61 Commits

Author SHA1 Message Date
9003cefbc4 update Dockerfile 2026-03-24 04:11:33 +08:00
0487427342 fix some warnings 2026-03-24 03:19:49 +08:00
8e386d98d0 cache in memory 2026-03-24 03:05:33 +08:00
fae30ed97a fix deprecated macro sql_function 2026-03-23 18:10:41 +08:00
bdb3bc49a6 support alumni email 2024-09-23 03:42:03 +08:00
d3d9f30b2a basic rate limit 2024-01-02 20:25:05 +08:00
84943a3965 rocket 5.0 & diesel 2.1 2024-01-02 17:40:05 +08:00
7621f56b34 fix lock rc version 2023-12-30 23:11:57 +08:00
289a19d4da fix: not put new post into reply timeline cache 2023-05-17 03:28:20 +08:00
50df8eda36 auth backend from host and default frontend/backend 2023-05-05 23:02:07 +08:00
01f56ea0a6 support sort by n_attention 2023-05-05 22:06:19 +08:00
bbed041253 add user_count in systemlog 2023-05-04 04:10:42 +08:00
959e6caa1d random hash username and clear users when restart 2023-05-04 00:33:56 +08:00
6911038f56 feat: tmp post always room 0 2022-11-30 21:57:44 +08:00
104ccb030d fix: not show deleted post to admin 2022-11-30 21:53:07 +08:00
54fa0e1cbd change username format 2022-11-13 12:46:09 +08:00
f5005faedc support github login 2022-11-13 03:17:55 +08:00
e531f0fed8 support sha256 token 2022-11-12 23:57:34 +08:00
dd88bbb868 tmp use cannot reply others 2022-11-12 23:51:59 +08:00
0a5af9f35f stop use 114514 2022-10-20 23:02:23 +08:00
1a04c41846 filter private in getlist, getmulti and attention 2022-10-20 22:54:36 +08:00
553b46b504 allow get popular private post 2022-10-20 22:32:49 +08:00
b70b6b2e7f Revert "update set token url"
This reverts commit d98e7fb653.
2022-10-16 03:56:45 +08:00
3b8efc0c0b fix: not use title for report 2022-10-16 02:32:13 +08:00
fcf6b33aa7 can't get one private post 2022-10-16 02:31:26 +08:00
d98e7fb653 update set token url 2022-10-16 02:24:36 +08:00
04a0d1084f feat: upvote & downvote 2022-10-16 02:23:37 +08:00
e0d6720433 use custom_title in syslog 2022-10-05 10:57:48 +08:00
8fabca785c allow any char in title 2022-09-26 01:57:00 +08:00
cbf24d6eec admin based on title 2022-09-26 01:44:42 +08:00
503a8a3b9b use 404 for old api 2022-09-25 23:56:58 +08:00
7ab60c9975 keep title for 7 days and random title secret 2022-09-25 23:48:56 +08:00
791d1f526d rocket 0.5-rc2 & rust 1.64 2022-09-23 13:10:11 +08:00
2bae66099d not return blocked_count 2022-08-31 23:10:23 +08:00
d5a3c3d7d7 disable no room warning 2022-08-31 21:29:55 +08:00
8639ed0c88 is_candidate and is_admin 2022-08-31 00:53:17 +08:00
dd110be8c7 feat: filter invisible chars in title 2022-08-30 23:36:44 +08:00
86b736dadb feat: support announcement 2022-08-29 23:48:41 +08:00
18262a62e0 提醒旧版前端分区问题 2022-08-21 17:00:03 +08:00
0e1a088575 feat: room(分区) 2022-08-19 09:59:44 +08:00
dbb5732938 feat: prepare for push notification II 2022-08-17 00:59:06 +08:00
f2fa7bb6f8 feat: fix docker and docs 2022-08-11 23:07:14 +08:00
241034ca9b fix: cors 2022-08-01 10:05:11 +08:00
7016718673 stop use ipfs 2022-07-31 23:12:57 +08:00
999ca30448 use api v2 for add comment 2022-07-31 14:53:26 +08:00
273073ba11 举报时手动选择是否隐藏 2022-07-21 14:05:47 +08:00
58794a479d update README 2022-07-10 02:07:59 +08:00
4c4ddca82b 支持Docker部署 2022-07-10 01:45:06 +08:00
25649e6280 fix clippy & rustfmt 2022-07-06 18:29:57 +08:00
1aa86b0963 lock rocket version to rc.1 2022-06-17 02:15:55 +08:00
dbea8f435f feat: 有评论后允许删除主楼内容 2022-06-04 14:24:47 +08:00
9a33ef5a88 禁止举报标准改为10 2022-06-02 23:53:04 +08:00
9937bc0dc7 fix bugs 2022-06-02 23:52:16 +08:00
fcc8ee3b15 调整热度算法 & 举报自动发洞 2022-05-28 17:30:54 +08:00
9ec106872b fix: check jump_to_url 2022-05-03 01:46:17 +08:00
462071da54 fix: handle preflight 2022-05-03 01:11:54 +08:00
a42ef851be feat: fixed login backend url 2022-05-02 23:10:44 +08:00
0fcb56c8a3 feat: cors 2022-05-02 22:27:27 +08:00
ff4b983ac3 feat: clear outdate attention data 2022-04-29 20:23:08 +08:00
340d6da45c feat: clear cache of random list 2022-04-29 20:02:09 +08:00
c8dcaab936 fix: search 2022-04-17 23:41:03 +08:00
38 changed files with 1628 additions and 888 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
/target
Cargo.lock
/user_files
*.db
.env

View File

@@ -3,6 +3,11 @@ MAST_CLIENT="<your client id>"
MAST_SECRET="<your client key>"
MAST_SCOPE="read:accounts"
AUTH_BACKEND_URL="http://hole.localhost"
FRONTEND_WHITELIST="https://thuhollow.github.io"
UPLOAD_DIR="user_files"
DATABASE_URL="postgres://hole:hole_pass@localhost/hole_v2"
MIGRATION_DIRECTORY=migrations/postgres
REDIS_URL="redis://127.0.0.1:6379"

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# private key
keys
# --> sqlite3
*.db

View File

@@ -7,21 +7,26 @@ license = "WTFPL-2.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
mastlogin = ["url", "reqwest"]
default = ["mastlogin"]
mastlogin = ["reqwest"]
[dependencies]
rocket = { version = "0.5.0-rc.1", features = ["json"] }
rocket_sync_db_pools = { version = "0.1.0-cr.1", features = ["diesel_postgres_pool"] }
diesel = { version = "1.4.8", features = ["postgres", "chrono"] }
diesel_migrations = "1.4.0"
tokio = "1.17.0"
redis = { version="0.21.5", features = ["aio", "tokio-comp"] }
rocket = { version = "0.5.0", features = ["json"] }
rocket_sync_db_pools = { version = "0.1.0", features = ["diesel_postgres_pool"] }
diesel = { version = "2.1", features = ["postgres", "chrono"] }
diesel_migrations = "2.1"
redis = { version="0.24.0", features = ["aio", "tokio-comp"] }
chrono = { version="0.4.19", features = ["serde"] }
rand = "0.8.5"
dotenv = "0.15.0"
sha2 = "0.10.2"
log = "0.4.16"
env_logger = "0.9.0"
web-push = "0.9.2"
url = "2.2.2"
futures = "0.3.24"
futures-util = "0.3.24"
lru = "0.11"
url = { version="2.2.2",optional = true }
reqwest = { version = "0.11.10", features = ["json"], optional = true }
moka = { version = "0.12.15", features = ["future"] }

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM rust:1-bookworm as builder
WORKDIR /usr/src/
RUN cargo new myapp --vcs none
WORKDIR /usr/src/myapp
COPY Cargo.toml ./
COPY rust-toolchain ./
RUN cargo build --release
# 为了充分利用docker的缓存
COPY src ./src
COPY migrations ./migrations
RUN touch src/main.rs && cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install libpq5 -y
COPY --from=builder /usr/src/myapp/target/release/hole-thu /usr/local/bin/hole-thu
COPY Rocket.toml /usr/local/bin/
CMD ["hole-thu"]

118
README.md
View File

@@ -1,25 +1,94 @@
# hole-backend-rust v1.2.0
# hole-backend-rust v1.3.0
## 部署
### 使用docker
+ 安装最新版docker与docker-compose-plugin
以Ubuntu为例: https://docs.docker.com/engine/install/ubuntu/
检查docker版本:
```shell
$ docker --version
Docker version 20.10.17, build 100c701
```
+ 执行
```shell
mkdir hole
cd hole
# 下载docker-compose.yml
wget https://git.thu.monster/newthuhole/hole-backend-rust/raw/branch/master/docker-compose.yml
# 下载add_pg_trgm.sh
mkdir psql-docker-init
wget https://git.thu.monster/newthuhole/hole-backend-rust/raw/branch/master/psql-docker-init/add_pg_trgm.sh -O psql-docker-init/add_pg_trgm.sh
# 下载镜像
docker compose pull
# 初始化postgres
docker compose up -d postgres
# 建表
docker compose run --rm hole-thu hole-thu --init-database
# 全部跑起来
docker compose up -d
```
现在树洞后端应该已经运行在8000端口了
停止运行:
```shell
docker compose stop
```
如需创建管理员账户,执行:
```shell
docker compose exec postgres psql --username hole --dbname hole_v2
```
进入数据库执行需要的SQL命令。
+ 修改`docker-compose.yml`的情况:
+ 编辑services.hole-thu.environmen填入你的后端地址(用于登陆时的回调跳转)和前端地址(用于允许跨域)
+ 如果希望使用其他端口而非8000编辑services.hole-thu.ports
+ 如果需要使用闭社登陆请在services.hole-thu.environment中添加需要用到的更多环境变量(参考`.env.sample`)
### 使用源码编译
*以下内容假设你使用 Ubuntu 20.04*
目前只支持postgresql对支持sqlite的追踪见 issue #1
安装rust与cargo环境 (略)
clone 代码 (略)
安装postgresql (略)
安装redis (略)
安装redis/valkey (略)
### 准备数据库
#### 准备数据库
进入:
```
```shell
sudo -u postgres psql
```
执行 (替换`'hole_pass'`为实际希望使用的密码):
执行 (替换`hole_pass`为实际希望使用的密码):
```postgresql
postgres=# CREATE USER hole WITH PASSWORD 'hole_pass';
@@ -32,39 +101,36 @@ hole_v2=# CREATE EXTENSION pg_trgm;
CREATE EXTENSION
hole_v2=# \q
```
### 运行
#### 编译&运行
创建 .env 文件,写入必要的环境变量。可参考 .env.sample。
#### 基于二进制文件
从[release](https://git.thu.monster/newthuhole/hole-backend-rust/releases)直接下载二进制文件
```
./hole-thu --init-database
./hole-thu
```
#### 基于源码
安装rust与cargo环境 (略)
clone 代码 (略)
```
```shell
cargo run --release -- --init-database
cargo run --release
```
或安装`diesel_cli`后
```
```shell
diesel migration run
cargo run --release
```
### 关于账号系统
### 基于二进制文件
+ 如果你希望使用自己的登录系统,将 `/_login/` 路径交由另外的后端处理只需最终将用户名和token写入users表并跳转到 `/?token=<token>`
安装与准备数据库同
从[release](https://git.thu.monster/newthuhole/hole-backend-rust/releases)直接下载二进制文件
```shell
./hole-thu --init-database
./hole-thu
```
## 关于账号系统
+ 如果你希望使用自己的登录系统在Nginx或Apache中将 `/_login/` 路径交由另外的后端处理只需最终将用户名和token写入users表并跳转到 `/###token=<token>`。
+ 如果你希望也使用闭社提供的授权来维护账号系统,使用 `https://thu.closed.social/api/v1/apps` 接口创建应用,并在.env或环境变量中填入client与secret。此操作不需要闭社账号。详情见[文档](https://docs.joinmastodon.org/client/token/#app)。编译运行时,增加`--features mastlogin`: `cargo run --release --features mastlogin`

View File

@@ -1,3 +1,2 @@
[default]
limits = { file = "200MB" }
temp_dir = "user_files"

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: '1'
services:
postgres:
image: postgres:14.3
restart: unless-stopped
volumes:
- "./data/postgres:/var/lib/postgresql/data"
- "./psql-docker-init:/docker-entrypoint-initdb.d"
environment:
POSTGRES_PASSWORD: hole_pass
POSTGRES_USER: hole
POSTGRES_DB: hole_v2
redis:
image: redis:7.0.2
restart: unless-stopped
hole-thu:
image: holethu/hole-backend-rust:1.2.0
restart: unless-stopped
ports:
- "127.0.0.1:8000:8863"
volumes:
- "./data/user_files:/user_files"
environment:
DATABASE_URL: "postgres://hole:hole_pass@postgres/hole_v2"
REDIS_URL: "redis://redis:6379"
ROCKET_DATABASES: '{pg_v2={url="postgres://hole:hole_pass@postgres/hole_v2"}}'
ROCKET_ADDRESS: "0.0.0.0"
ROCKET_PORT: 8863
AUTH_BACKEND_URL: "<你的后端地址>"
FRONTEND_WHITELIST: "<你的前端地址1>,<你的前端地址2>"
UPLOAD_DIR: "/user_files"

View File

@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE posts
DROP COLUMN room_id

View File

@@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE posts
ADD COLUMN room_id INTEGER NOT NULL DEFAULT 0

View File

@@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
ALTER TABLE posts
DROP COLUMN up_votes,
DROP COLUMN down_votes

View File

@@ -0,0 +1,4 @@
-- Your SQL goes here
ALTER TABLE posts
ADD COLUMN up_votes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN down_votes INTEGER NOT NULL DEFAULT 0

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP INDEX posts_attention_idx

View File

@@ -0,0 +1,2 @@
-- Your SQL goes here
CREATE INDEX posts_attention_idx ON posts (n_attentions)

View File

@@ -0,0 +1,5 @@
#!/bin/bash
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
create extension pg_trgm;
EOSQL

1
rust-toolchain Normal file
View File

@@ -0,0 +1 @@
stable

View File

@@ -1,14 +1,18 @@
use crate::api::post::ps2outputs;
use crate::api::{CurrentUser, JsonAPI, PolicyError::*, UGC};
use crate::api::{CurrentUser, JsonApi, PolicyError::*, Ugc};
use crate::db_conn::Db;
use crate::libs::diesel_logger::LoggingConnection;
use crate::models::*;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use crate::schema;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use rocket::form::Form;
use rocket::serde::json::json;
use rocket::serde::json::serde_json;
use rocket::serde::Serialize;
use std::fs::File;
use url::Url;
use web_push::{
ContentEncoding, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
};
#[derive(FromForm)]
pub struct AttentionInput {
@@ -23,11 +27,11 @@ pub async fn attention_post(
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonAPI {
) -> JsonApi {
// 临时用户不允许手动关注
user.id.ok_or_else(|| YouAreTmp)?;
user.id.ok_or(YouAreTmp)?;
let mut p = Post::get(&db, &rconn, ai.pid).await?;
let mut p = Post::get(&db, ai.pid).await?;
p.check_permission(&user, "r")?;
let mut att = Attention::init(&user.namehash, &rconn);
let switch_to = ai.switch == 1;
@@ -40,17 +44,22 @@ pub async fn attention_post(
att.remove(ai.pid).await?;
delta = -1;
}
let hot_delta = if p.n_attentions <= 3 * p.n_comments {
delta * 2
} else {
0
};
update!(
p,
posts,
&db,
{ n_attentions, add delta },
{ hot_score, add delta * 2 }
{ hot_score, add hot_delta }
);
if switch_to && user.is_admin {
update!(p, posts, &db, { is_reported, to false });
}
p.refresh_cache(&rconn, false).await;
p.refresh_cache(false).await;
}
Ok(json!({
@@ -63,11 +72,72 @@ pub async fn attention_post(
}
#[get("/getattention")]
pub async fn get_attention(user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
pub async fn get_attention(user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
let mut ids = Attention::init(&user.namehash, &rconn).all().await?;
ids.sort_by_key(|x| -x);
let ps = Post::get_multi(&db, &rconn, &ids).await?;
let ps: Vec<Post> = Post::get_multi(&db, &ids)
.await?
.into_iter()
.filter(|post| {
!post.get_is_private()
|| chrono::offset::Utc::now() - post.create_time < chrono::Duration::days(30)
})
.collect();
let ps_data = ps2outputs(&ps, &user, &db, &rconn).await?;
code0!(ps_data)
}
#[derive(FromForm)]
pub struct NotificatinInput {
enable: bool,
endpoint: String,
auth: String,
p256dh: String,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
struct PushData {
title: String,
pid: i32,
text: String,
}
#[post("/post/<pid>/notification", data = "<ni>")]
pub async fn set_notification(pid: i32, ni: Form<NotificatinInput>, _user: CurrentUser) -> JsonApi {
let url_host = Url::parse(&ni.endpoint)
.map_err(|_| UnknownPushEndpoint)?
.host()
.ok_or(UnknownPushEndpoint)?
.to_string();
(url_host.ends_with("googleapis.com") || url_host.ends_with("mozilla.com"))
.then_some(())
.ok_or(UnknownPushEndpoint)?;
if ni.enable {
let subscription_info = SubscriptionInfo::new(&ni.endpoint, &ni.p256dh, &ni.auth);
let file = File::open("keys/private.pem").unwrap();
let sig_builder = VapidSignatureBuilder::from_pem(file, &subscription_info)
.unwrap()
.build()
.unwrap();
let mut builder = WebPushMessageBuilder::new(&subscription_info).unwrap();
let data = PushData {
title: "测试".to_owned(),
pid,
text: format!("#{} 开启提醒测试成功,消息提醒功能即将正式上线", &pid),
};
let content = serde_json::to_string(&data).unwrap();
builder.set_payload(ContentEncoding::Aes128Gcm, content.as_bytes());
builder.set_vapid_signature(sig_builder);
let client = WebPushClient::new()?;
client.send(builder.build()?).await?;
}
code0!()
}

View File

@@ -1,21 +1,16 @@
use crate::api::{APIError, CurrentUser, JsonAPI, PolicyError::*, UGC};
use crate::api::{ApiError, CurrentUser, JsonApi, PolicyError::*, Ugc};
use crate::cache::BlockDictCache;
use crate::db_conn::Db;
use crate::libs::diesel_logger::LoggingConnection;
use crate::models::*;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use crate::schema;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use futures::{future, join};
use rocket::form::Form;
use rocket::futures::future;
use rocket::futures::join;
use rocket::serde::{json::json, Serialize};
use std::collections::HashMap;
#[derive(FromForm)]
pub struct CommentInput {
pid: i32,
#[field(validate = len(1..12289))]
text: String,
use_title: Option<i8>,
@@ -32,18 +27,18 @@ pub struct CommentOutput {
is_tmp: bool,
create_time: i64,
is_blocked: bool,
blocked_count: Option<i32>,
//blocked_count: Option<i32>,
// for old version frontend
timestamp: i64,
blocked: bool,
}
pub async fn c2output<'r>(
p: &'r Post,
cs: &Vec<Comment>,
pub async fn c2output(
p: &Post,
cs: &[Comment],
user: &CurrentUser,
cached_block_dict: &HashMap<String, bool>,
rconn: &RdsConn,
//rconn: &RdsConn,
) -> Vec<CommentOutput> {
let mut hash2id = HashMap::<&String, i32>::from([(&p.author_hash, 0)]);
let name_ids_iter = cs.iter().map(|c| match hash2id.get(&c.author_hash) {
@@ -66,10 +61,11 @@ pub async fn c2output<'r>(
text: (if can_view { &c.content } else { "" }).to_string(),
author_title: c.author_title.to_string(),
can_del: c.check_permission(user, "wd").is_ok(),
name_id: name_id,
name_id,
is_tmp: c.is_tmp,
create_time: c.create_time.timestamp(),
is_blocked: is_blocked,
is_blocked,
/*
blocked_count: if user.is_admin {
BlockCounter::get_count(rconn, &c.author_hash)
.await
@@ -78,6 +74,7 @@ pub async fn c2output<'r>(
} else {
None
},
*/
timestamp: c.create_time.timestamp(),
blocked: is_blocked,
})
@@ -85,22 +82,22 @@ pub async fn c2output<'r>(
}))
.await
.into_iter()
.filter_map(|x| x)
.flatten()
.collect()
}
#[get("/getcomment?<pid>")]
pub async fn get_comment(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
let p = Post::get(&db, &rconn, pid).await?;
pub async fn get_comment(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
let p = Post::get(&db, pid).await?;
if p.is_deleted {
return Err(APIError::PcError(IsDeleted));
return Err(ApiError::Pc(IsDeleted));
}
let cs = p.get_comments(&db, &rconn).await?;
let hash_list = cs.iter().map(|c| &c.author_hash).collect();
let cached_block_dict = BlockDictCache::init(&user.namehash, p.id, &rconn)
.get_or_create(&user, &hash_list)
let cs = p.get_comments(&db).await?;
let hash_list = cs.iter().map(|c| &c.author_hash).collect::<Vec<_>>();
let cached_block_dict = BlockDictCache::init(&user.namehash, p.id)
.get_or_create(&user, &hash_list, &rconn)
.await?;
let data = c2output(&p, &cs, &user, &cached_block_dict, &rconn).await;
let data = c2output(&p, &cs, &user, &cached_block_dict).await;
Ok(json!({
"code": 0,
@@ -112,27 +109,30 @@ pub async fn get_comment(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) ->
}))
}
#[post("/docomment", data = "<ci>")]
#[post("/post/<pid>/comment", data = "<ci>")]
pub async fn add_comment(
pid: i32,
ci: Form<CommentInput>,
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonAPI {
let mut p = Post::get(&db, &rconn, ci.pid).await?;
) -> JsonApi {
let mut p = Post::get(&db, pid).await?;
if p.author_hash != user.namehash {
user.id.ok_or(YouAreTmp)?;
}
let use_title = ci.use_title.is_some() || user.is_admin || user.is_candidate;
let c = Comment::create(
&db,
NewComment {
content: ci.text.to_string(),
author_hash: user.namehash.to_string(),
author_title: (if ci.use_title.is_some() {
CustomTitle::get(&rconn, &user.namehash).await?
} else {
None
})
.unwrap_or_default(),
author_title: user
.custom_title
.and_then(|title| use_title.then_some(title))
.unwrap_or_default(),
is_tmp: user.id.is_none(),
post_id: ci.pid,
post_id: pid,
},
)
.await?;
@@ -146,7 +146,7 @@ pub async fn add_comment(
at_delta = 1;
att.add(p.id).await?;
} else {
hs_delta = 1;
hs_delta = (p.n_comments < 3 * p.n_attentions) as i32;
at_delta = 0;
}
@@ -160,10 +160,7 @@ pub async fn add_comment(
{ hot_score, add hs_delta }
);
join!(
p.refresh_cache(&rconn, false),
p.clear_comments_cache(&rconn),
);
join!(p.refresh_cache(false), p.clear_comments_cache(),);
Ok(json!({
"code": 0

View File

@@ -1,12 +1,12 @@
#![allow(clippy::unnecessary_lazy_evaluations)]
use crate::db_conn::Db;
use crate::libs::diesel_logger::LoggingConnection;
use crate::models::*;
use crate::random_hasher::RandomHasher;
use crate::rate_limit::MainLimiters;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use crate::schema;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use rocket::http::Status;
use rocket::http::{Method, Status};
use rocket::outcome::try_outcome;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::{self, Responder};
@@ -25,6 +25,7 @@ macro_rules! code0 {
);
}
/*
macro_rules! code1 {
($msg:expr) => (
Ok(json!({
@@ -33,6 +34,7 @@ macro_rules! code1 {
}))
);
}
*/
macro_rules! e2s {
($e:expr) => (json!({
@@ -51,59 +53,88 @@ pub fn catch_403_error() -> &'static str {
"可能被封禁了,等下次重置吧"
}
#[catch(404)]
pub fn catch_404_error() -> &'static str {
"请更新前端版本"
}
pub struct CurrentUser {
pub id: Option<i32>, // tmp user has no id, only for block
namehash: String,
is_admin: bool,
custom_title: String,
is_candidate: bool,
custom_title: Option<String>,
title_secret: Option<String>,
pub auto_block_rank: u8,
}
impl CurrentUser {
pub async fn from_hash(rconn: &RdsConn, namehash: String) -> Self {
let (custom_title, title_secret) = CustomTitle::get(rconn, &namehash)
.await
.ok()
.unwrap_or((None, None));
Self {
id: None,
is_admin: false,
is_candidate: false,
custom_title,
title_secret,
auto_block_rank: AutoBlockRank::get(rconn, &namehash).await.unwrap_or(2),
namehash,
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CurrentUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let rh = request.rocket().state::<RandomHasher>().unwrap();
let rconn = try_outcome!(request.guard::<RdsConn>().await);
let limiters = request.rocket().state::<MainLimiters>().unwrap();
let mut id = None;
let mut namehash = None;
let mut is_admin = false;
if let Some(token) = request.headers().get_one("User-Token") {
let sp = token.split('_').collect::<Vec<&str>>();
if sp.len() == 2 && sp[0] == rh.get_tmp_token() {
namehash = Some(rh.hash_with_salt(sp[1]));
id = None;
is_admin = false;
} else {
let db = try_outcome!(request.guard::<Db>().await);
if let Some(u) = User::get_by_token(&db, &rconn, token).await {
id = Some(u.id);
namehash = Some(rh.hash_with_salt(&u.name));
is_admin = u.is_admin;
}
}
}
match namehash {
Some(nh) => {
if BannedUsers::has(&rconn, &nh).await.unwrap() {
Outcome::Failure((Status::Forbidden, ()))
if let Some(user) = {
if let Some(token) = request.headers().get_one("User-Token") {
let sp = token.split('_').collect::<Vec<&str>>();
if sp.len() == 2 && sp[0] == rh.get_tmp_token() {
Some(CurrentUser::from_hash(&rconn, rh.hash_with_salt(sp[1])).await)
} else {
Outcome::Success(CurrentUser {
id: id,
custom_title: CustomTitle::get(&rconn, &nh)
.await
.ok()
.flatten()
.unwrap_or_default(),
auto_block_rank: AutoBlockRank::get(&rconn, &nh).await.unwrap_or(2),
namehash: nh,
is_admin: is_admin,
})
let db = try_outcome!(request.guard::<Db>().await);
if let Some(u) = User::get_by_token(&db, token).await {
let namehash = rh.hash_with_salt(&u.name);
let user_base = CurrentUser::from_hash(&rconn, namehash).await;
Some(CurrentUser {
id: Some(u.id),
is_admin: u.is_admin
|| is_elected_admin(&rconn, &user_base.custom_title)
.await
.unwrap(),
is_candidate: is_elected_candidate(&rconn, &user_base.custom_title)
.await
.unwrap(),
..user_base
})
} else {
None
}
}
} else {
None
}
None => Outcome::Failure((Status::Unauthorized, ())),
} {
if BannedUsers::has(&rconn, &user.namehash).await.unwrap() {
Outcome::Error((Status::Forbidden, ()))
} else if !limiters.check(
request.method() == Method::Post,
user.id.unwrap_or_default(),
) {
Outcome::Error((Status::TooManyRequests, ()))
} else {
Outcome::Success(user)
}
} else {
Outcome::Error((Status::Unauthorized, ()))
}
}
}
@@ -111,34 +142,46 @@ impl<'r> FromRequest<'r> for CurrentUser {
#[derive(Debug)]
pub enum PolicyError {
IsReported,
IsPrivate,
IsDeleted,
NotAllowed,
TitleUsed,
TitleProtected,
InvalidTitle,
YouAreTmp,
NoReason,
UnknownPushEndpoint,
}
#[derive(Debug)]
pub enum APIError {
DbError(diesel::result::Error),
RdsError(redis::RedisError),
PcError(PolicyError),
IoError(std::io::Error),
pub enum ApiError {
Db(diesel::result::Error),
Rds(redis::RedisError),
WebPush(web_push::WebPushError),
Pc(PolicyError),
IO(std::io::Error),
}
impl<'r> Responder<'r, 'static> for APIError {
impl<'r> Responder<'r, 'static> for ApiError {
fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
match self {
APIError::DbError(e) => e2s!(e).respond_to(req),
APIError::RdsError(e) => e2s!(e).respond_to(req),
APIError::IoError(e) => e2s!(e).respond_to(req),
APIError::PcError(e) => json!({
ApiError::Db(e) => e2s!(e).respond_to(req),
ApiError::Rds(e) => e2s!(e).respond_to(req),
ApiError::WebPush(e) => e2s!(e).respond_to(req),
ApiError::IO(e) => e2s!(e).respond_to(req),
ApiError::Pc(e) => json!({
"code": -1,
"msg": match e {
PolicyError::IsReported => "内容被举报,处理中",
PolicyError::IsPrivate => "未被设置为公开",
PolicyError::IsDeleted => "内容被删除",
PolicyError::NotAllowed => "不允许的操作",
PolicyError::TitleUsed => "头衔已被使用",
PolicyError::YouAreTmp => "临时用户只可发布内容和进入单个洞"
PolicyError::TitleProtected => "头衔处于保护期",
PolicyError::InvalidTitle => "头衔包含不允许的符号",
PolicyError::YouAreTmp => "临时用户只可发布内容",
PolicyError::NoReason => "未填写理由",
PolicyError::UnknownPushEndpoint => "未知的浏览器推送地址",
}
})
.respond_to(req),
@@ -146,60 +189,70 @@ impl<'r> Responder<'r, 'static> for APIError {
}
}
impl From<diesel::result::Error> for APIError {
fn from(err: diesel::result::Error) -> APIError {
APIError::DbError(err)
impl From<web_push::WebPushError> for ApiError {
fn from(err: web_push::WebPushError) -> ApiError {
ApiError::WebPush(err)
}
}
impl From<redis::RedisError> for APIError {
fn from(err: redis::RedisError) -> APIError {
APIError::RdsError(err)
impl From<diesel::result::Error> for ApiError {
fn from(err: diesel::result::Error) -> ApiError {
ApiError::Db(err)
}
}
impl From<std::io::Error> for APIError {
fn from(err: std::io::Error) -> APIError {
APIError::IoError(err)
impl From<redis::RedisError> for ApiError {
fn from(err: redis::RedisError) -> ApiError {
ApiError::Rds(err)
}
}
impl From<PolicyError> for APIError {
fn from(err: PolicyError) -> APIError {
APIError::PcError(err)
impl From<std::io::Error> for ApiError {
fn from(err: std::io::Error) -> ApiError {
ApiError::IO(err)
}
}
pub type API<T> = Result<T, APIError>;
pub type JsonAPI = API<Value>;
impl From<PolicyError> for ApiError {
fn from(err: PolicyError) -> ApiError {
ApiError::Pc(err)
}
}
pub type Api<T> = Result<T, ApiError>;
pub type JsonApi = Api<Value>;
#[rocket::async_trait]
pub trait UGC {
pub trait Ugc {
fn get_author_hash(&self) -> &str;
fn get_is_deleted(&self) -> bool;
fn get_is_reported(&self) -> bool;
fn get_is_private(&self) -> bool;
fn extra_delete_condition(&self) -> bool;
async fn do_set_deleted(&mut self, db: &Db) -> API<()>;
fn check_permission(&self, user: &CurrentUser, mode: &str) -> API<()> {
async fn do_set_deleted(&mut self, db: &Db) -> Api<()>;
fn check_permission(&self, user: &CurrentUser, mode: &str) -> Api<()> {
if mode.contains('r') && self.get_is_deleted() {
return Err(ApiError::Pc(PolicyError::IsDeleted));
}
if user.is_admin {
return Ok(());
}
if mode.contains('r') && self.get_is_deleted() {
return Err(APIError::PcError(PolicyError::IsDeleted));
}
if mode.contains('o') && self.get_is_reported() {
return Err(APIError::PcError(PolicyError::IsReported));
return Err(ApiError::Pc(PolicyError::IsReported));
}
if mode.contains('o') && self.get_is_private() {
return Err(ApiError::Pc(PolicyError::IsPrivate));
}
if mode.contains('w') && self.get_author_hash() != user.namehash {
return Err(APIError::PcError(PolicyError::NotAllowed));
return Err(ApiError::Pc(PolicyError::NotAllowed));
}
if mode.contains('d') && !self.extra_delete_condition() {
return Err(APIError::PcError(PolicyError::NotAllowed));
return Err(ApiError::Pc(PolicyError::NotAllowed));
}
Ok(())
}
async fn soft_delete(&mut self, user: &CurrentUser, db: &Db) -> API<()> {
async fn soft_delete(&mut self, user: &CurrentUser, db: &Db) -> Api<()> {
self.check_permission(user, "rwd")?;
self.do_set_deleted(db).await?;
@@ -208,40 +261,46 @@ pub trait UGC {
}
#[rocket::async_trait]
impl UGC for Post {
impl Ugc for Post {
fn get_author_hash(&self) -> &str {
&self.author_hash
}
fn get_is_reported(&self) -> bool {
self.is_reported
}
fn get_is_private(&self) -> bool {
!(self.allow_search || self.n_attentions > 20)
}
fn get_is_deleted(&self) -> bool {
self.is_deleted
}
fn extra_delete_condition(&self) -> bool {
self.n_comments == 0
self.room_id != 42
}
async fn do_set_deleted(&mut self, db: &Db) -> API<()> {
async fn do_set_deleted(&mut self, db: &Db) -> Api<()> {
update!(*self, posts, db, { is_deleted, to true });
Ok(())
}
}
#[rocket::async_trait]
impl UGC for Comment {
impl Ugc for Comment {
fn get_author_hash(&self) -> &str {
&self.author_hash
}
fn get_is_reported(&self) -> bool {
false
}
fn get_is_private(&self) -> bool {
false
}
fn get_is_deleted(&self) -> bool {
self.is_deleted
}
fn extra_delete_condition(&self) -> bool {
true
}
async fn do_set_deleted(&mut self, db: &Db) -> API<()> {
async fn do_set_deleted(&mut self, db: &Db) -> Api<()> {
update!(*self, comments, db, { is_deleted, to true });
Ok(())
}
@@ -257,6 +316,7 @@ pub mod attention;
pub mod comment;
pub mod operation;
pub mod post;
pub mod reaction;
pub mod search;
pub mod systemlog;
pub mod upload;

View File

@@ -1,13 +1,10 @@
use crate::api::{APIError, CurrentUser, JsonAPI, PolicyError::*, UGC};
use crate::api::{ApiError, CurrentUser, JsonApi, PolicyError::*, Ugc};
use crate::cache::*;
use crate::db_conn::Db;
use crate::libs::diesel_logger::LoggingConnection;
use crate::models::*;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use crate::schema;
use chrono::offset::Local;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use rocket::form::Form;
use rocket::serde::json::json;
@@ -20,12 +17,12 @@ pub struct DeleteInput {
}
#[post("/delete", data = "<di>")]
pub async fn delete(di: Form<DeleteInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
pub async fn delete(di: Form<DeleteInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
let (author_hash, p) = match di.id_type.as_str() {
"cid" => {
let mut c = Comment::get(&db, di.id).await?;
c.soft_delete(&user, &db).await?;
let mut p = Post::get(&db, &rconn, c.post_id).await?;
let mut p = Post::get(&db, c.post_id).await?;
update!(
p,
posts,
@@ -34,17 +31,28 @@ pub async fn delete(di: Form<DeleteInput>, user: CurrentUser, db: Db, rconn: Rds
{ hot_score, add -1 }
);
p.refresh_cache(&rconn, false).await;
p.clear_comments_cache(&rconn).await;
p.refresh_cache(false).await;
p.clear_comments_cache().await;
(c.author_hash.clone(), p)
}
"pid" => {
let mut p = Post::get(&db, &rconn, di.id).await?;
p.soft_delete(&user, &db).await?;
let mut p = Post::get(&db, di.id).await?;
// 有评论:清空主楼而非删除
if p.author_hash == user.namehash && p.n_comments > 0 {
update! {
p,
posts,
&db,
{ content, to "[洞主已删除]" }
}
} else {
p.soft_delete(&user, &db).await?;
}
// 如果是删除需要也从0号缓存队列中去掉
p.refresh_cache(&rconn, true).await;
p.refresh_cache(true).await;
(p.author_hash.clone(), p)
}
@@ -53,7 +61,7 @@ pub async fn delete(di: Form<DeleteInput>, user: CurrentUser, db: Db, rconn: Rds
if user.is_admin && !user.namehash.eq(&author_hash) {
Systemlog {
user_hash: user.namehash.clone(),
user_hash: user.custom_title.clone().unwrap_or(look!(user.namehash)),
action_type: LogType::AdminDelete,
target: format!("#{}, {}={}", p.id, di.id_type, di.id),
detail: di.note.clone(),
@@ -64,7 +72,7 @@ pub async fn delete(di: Form<DeleteInput>, user: CurrentUser, db: Db, rconn: Rds
if di.note.starts_with("!ban ") {
Systemlog {
user_hash: user.namehash.clone(),
user_hash: user.custom_title.unwrap_or(look!(user.namehash)),
action_type: LogType::Ban,
target: look!(author_hash),
detail: di.note.clone(),
@@ -84,34 +92,58 @@ pub struct ReportInput {
pid: i32,
#[field(validate = len(0..1000))]
reason: String,
should_hide: Option<u8>,
}
#[post("/report", data = "<ri>")]
pub async fn report(ri: Form<ReportInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
pub async fn report(ri: Form<ReportInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
// 临时用户不允许举报
user.id.ok_or_else(|| NotAllowed)?;
user.id.ok_or(NotAllowed)?;
// 被拉黑10次不允许举报
(BlockCounter::get_count(&rconn, &user.namehash)
.await?
.unwrap_or(0)
< 10)
.then_some(())
.ok_or(NotAllowed)?;
(!ri.reason.is_empty()).then_some(()).ok_or(NoReason)?;
let mut p = Post::get(&db, ri.pid).await?;
if ri.should_hide.is_some() {
update!(p, posts, &db, { is_reported, to true });
p.refresh_cache(false).await;
}
let mut p = Post::get(&db, &rconn, ri.pid).await?;
update!(p, posts, &db, { is_reported, to true });
p.refresh_cache(&rconn, false).await;
Systemlog {
user_hash: user.namehash,
user_hash: look!(user.namehash),
action_type: LogType::Report,
target: format!(
"#{} {}",
ri.pid,
if ri.reason.starts_with("评论区") {
"评论区"
} else {
""
}
),
target: format!("#{}", ri.pid),
detail: ri.reason.clone(),
time: Local::now(),
}
.create(&rconn)
.await?;
// 自动发布一条洞
let p = Post::create(
&db,
NewPost {
content: format!("[系统自动代发]\n我举报了 #{}\n理由: {}", &p.id, &ri.reason),
cw: "举报".to_string(),
author_hash: user.namehash.clone(),
author_title: String::default(),
is_tmp: false,
n_attentions: 1,
allow_search: true,
room_id: 42,
},
)
.await?;
Attention::init(&user.namehash, &rconn).add(p.id).await?;
p.refresh_cache(true).await;
code0!()
}
@@ -123,15 +155,15 @@ pub struct BlockInput {
}
#[post("/block", data = "<bi>")]
pub async fn block(bi: Form<BlockInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
user.id.ok_or_else(|| NotAllowed)?;
pub async fn block(bi: Form<BlockInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
user.id.ok_or(NotAllowed)?;
let mut blk = BlockedUsers::init(user.id.ok_or_else(|| NotAllowed)?, &rconn);
let mut blk = BlockedUsers::init(user.id.ok_or(NotAllowed)?, &rconn);
let pid;
let nh_to_block = match bi.content_type.as_str() {
"post" => {
let p = Post::get(&db, &rconn, bi.id).await?;
let p = Post::get(&db, bi.id).await?;
pid = p.id;
p.author_hash
}
@@ -140,7 +172,7 @@ pub async fn block(bi: Form<BlockInput>, user: CurrentUser, db: Db, rconn: RdsCo
pid = c.post_id;
c.author_hash
}
_ => return Err(APIError::PcError(NotAllowed)),
_ => return Err(ApiError::Pc(NotAllowed)),
};
if nh_to_block.eq(&user.namehash) {
@@ -150,12 +182,12 @@ pub async fn block(bi: Form<BlockInput>, user: CurrentUser, db: Db, rconn: RdsCo
let curr = if blk.add(&nh_to_block).await? > 0 {
BlockCounter::count_incr(&rconn, &nh_to_block).await?
} else {
114514
BlockCounter::get_count(&rconn, &nh_to_block)
.await?
.unwrap_or_default()
};
BlockDictCache::init(&user.namehash, pid, &rconn)
.clear()
.await?;
BlockDictCache::init(&user.namehash, pid).clear().await;
Ok(json!({
"code": 0,
@@ -169,15 +201,24 @@ pub async fn block(bi: Form<BlockInput>, user: CurrentUser, db: Db, rconn: RdsCo
pub struct TitleInput {
#[field(validate = len(1..31))]
title: String,
secret: String,
}
#[post("/title", data = "<ti>")]
pub async fn set_title(ti: Form<TitleInput>, user: CurrentUser, rconn: RdsConn) -> JsonAPI {
if CustomTitle::set(&rconn, &user.namehash, &ti.title).await? {
code0!()
} else {
Err(TitleUsed)?
#[post("/set-title", data = "<ti>")]
pub async fn set_title(ti: Form<TitleInput>, user: CurrentUser, rconn: RdsConn) -> JsonApi {
if ti.title.is_empty() {
Err(InvalidTitle)?
}
/*
ti.title
.chars()
.map(|c| c.is_alphanumeric().then_some(()).ok_or(InvalidTitle))
.collect::<Result<Vec<()>, PolicyError>>()?;
*/
let secret = CustomTitle::set(&rconn, &user.namehash, &ti.title, &ti.secret).await?;
code0!(secret)
}
#[derive(FromForm)]
@@ -190,7 +231,7 @@ pub async fn set_auto_block(
ai: Form<AutoBlockInput>,
user: CurrentUser,
rconn: RdsConn,
) -> JsonAPI {
) -> JsonApi {
AutoBlockRank::set(&rconn, &user.namehash, ai.rank).await?;
code0!()
}

View File

@@ -1,14 +1,11 @@
use crate::api::comment::{c2output, CommentOutput};
use crate::api::vote::get_poll_dict;
use crate::api::{CurrentUser, JsonAPI, PolicyError::*, API, UGC};
use crate::api::{Api, CurrentUser, JsonApi, PolicyError::*, Ugc};
use crate::cache::*;
use crate::db_conn::Db;
use crate::libs::diesel_logger::LoggingConnection;
use crate::models::*;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use crate::schema;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use rocket::form::Form;
use rocket::futures::future::{self, OptionFuture};
use rocket::serde::{
@@ -26,12 +23,14 @@ pub struct PostInput {
use_title: Option<i8>,
#[field(validate = len(0..97))]
poll_options: Vec<String>,
room_id: Option<i32>,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct PostOutput {
pid: i32,
room_id: i32,
text: String,
cw: Option<String>,
author_title: Option<String>,
@@ -47,8 +46,11 @@ pub struct PostOutput {
attention: bool,
hot_score: Option<i32>,
is_blocked: bool,
blocked_count: Option<i32>,
//blocked_count: Option<i32>,
poll: Option<Value>,
up_votes: i32,
down_votes: i32,
reaction_status: i32, // -1, 0, 1
// for old version frontend
timestamp: i64,
likenum: i32,
@@ -63,9 +65,9 @@ pub struct CwInput {
cw: String,
}
async fn p2output(p: &Post, user: &CurrentUser, db: &Db, rconn: &RdsConn) -> API<PostOutput> {
async fn p2output(p: &Post, user: &CurrentUser, db: &Db, rconn: &RdsConn) -> Api<PostOutput> {
let comments: Option<Vec<Comment>> = if p.n_comments < 5 {
Some(p.get_comments(db, rconn).await?)
Some(p.get_comments(db).await?)
} else {
None
};
@@ -74,45 +76,54 @@ async fn p2output(p: &Post, user: &CurrentUser, db: &Db, rconn: &RdsConn) -> API
.flatten()
.map(|c| &c.author_hash)
.chain(std::iter::once(&p.author_hash))
.collect();
.collect::<Vec<_>>();
//dbg!(&hash_list);
let cached_block_dict = BlockDictCache::init(&user.namehash, p.id, rconn)
.get_or_create(&user, &hash_list)
let cached_block_dict = BlockDictCache::init(&user.namehash, p.id)
.get_or_create(user, &hash_list, rconn)
.await?;
let is_blocked = cached_block_dict[&p.author_hash];
let can_view =
user.is_admin || (!is_blocked && user.id.is_some() || user.namehash.eq(&p.author_hash));
Ok(PostOutput {
pid: p.id,
text: can_view.then(|| p.content.clone()).unwrap_or_default(),
cw: (!p.cw.is_empty()).then(|| p.cw.clone()),
room_id: p.room_id,
text: if can_view {
p.content.clone()
} else {
Default::default()
},
cw: (!p.cw.is_empty()).then_some(p.cw.clone()),
n_attentions: p.n_attentions,
n_comments: p.n_comments,
create_time: p.create_time.timestamp(),
last_comment_time: p.last_comment_time.timestamp(),
allow_search: p.allow_search,
author_title: (!p.author_title.is_empty()).then(|| p.author_title.clone()),
author_title: (!p.author_title.is_empty()).then_some(p.author_title.clone()),
is_tmp: p.is_tmp,
is_reported: user.is_admin.then(|| p.is_reported),
is_reported: user.is_admin.then_some(p.is_reported),
comments: OptionFuture::from(
comments
.map(|cs| async move { c2output(p, &cs, user, &cached_block_dict, rconn).await }),
comments.map(|cs| async move { c2output(p, &cs, user, &cached_block_dict).await }),
)
.await,
can_del: p.check_permission(user, "wd").is_ok(),
attention: Attention::init(&user.namehash, &rconn).has(p.id).await?,
hot_score: user.is_admin.then(|| p.hot_score),
is_blocked: is_blocked,
attention: Attention::init(&user.namehash, rconn).has(p.id).await?,
hot_score: user.is_admin.then_some(p.hot_score),
is_blocked,
/*
blocked_count: if user.is_admin {
BlockCounter::get_count(rconn, &p.author_hash).await?
} else {
None
},
*/
poll: if can_view {
get_poll_dict(p.id, rconn, &user.namehash).await
} else {
None
},
up_votes: p.up_votes,
down_votes: p.down_votes,
reaction_status: get_user_post_reaction_status(rconn, p.id, &user.namehash).await?,
// for old version frontend
timestamp: p.create_time.timestamp(),
likenum: p.n_attentions,
@@ -122,21 +133,22 @@ async fn p2output(p: &Post, user: &CurrentUser, db: &Db, rconn: &RdsConn) -> API
}
pub async fn ps2outputs(
ps: &Vec<Post>,
ps: &[Post],
user: &CurrentUser,
db: &Db,
rconn: &RdsConn,
) -> API<Vec<PostOutput>> {
) -> Api<Vec<PostOutput>> {
future::try_join_all(
ps.iter()
.map(|p| async { Ok(p2output(p, &user, &db, &rconn).await?) }),
.map(|p| async { p2output(p, user, db, rconn).await }),
)
.await
}
#[get("/getone?<pid>")]
pub async fn get_one(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
let p = Post::get(&db, &rconn, pid).await?;
pub async fn get_one(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
user.id.ok_or(YouAreTmp)?;
let p = Post::get(&db, pid).await?;
p.check_permission(&user, "ro")?;
Ok(json!({
"data": p2output(&p, &user,&db, &rconn).await?,
@@ -144,25 +156,37 @@ pub async fn get_one(pid: i32, user: CurrentUser, db: Db, rconn: RdsConn) -> Jso
}))
}
#[get("/getlist?<p>&<order_mode>")]
#[get("/getlist?<p>&<order_mode>&<room_id>")]
pub async fn get_list(
p: Option<u32>,
order_mode: u8,
room_id: Option<i32>,
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonAPI {
user.id.ok_or_else(|| YouAreTmp)?;
) -> JsonApi {
user.id.ok_or(YouAreTmp)?;
let page = p.unwrap_or(1);
let page_size = 25;
let start = (page - 1) * page_size;
let ps = Post::gets_by_page(&db, &rconn, order_mode, start.into(), page_size.into()).await?;
let ps: Vec<Post> =
Post::gets_by_page(&db, room_id, order_mode, start as usize, page_size as usize)
.await?
.into_iter()
.filter(|post| page < 40 || !post.get_is_private())
.collect();
let ps_data = ps2outputs(&ps, &user, &db, &rconn).await?;
Ok(json!({
"data": ps_data,
"count": ps_data.len(),
"custom_title": user.custom_title,
"title_secret": user.title_secret,
"is_admin": user.is_admin,
"is_candidate": user.is_candidate,
"auto_block_rank": user.auto_block_rank,
"announcement": get_announcement(&rconn).await?,
"code": 0
}))
}
@@ -173,22 +197,35 @@ pub async fn publish_post(
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonAPI {
) -> JsonApi {
let use_title = poi.use_title.is_some() || user.is_admin || user.is_candidate;
let is_tmp = user.id.is_none();
let room_id = if is_tmp {
0
} else {
poi.room_id.unwrap_or_default()
};
let p = Post::create(
&db,
NewPost {
content: poi.text.to_string(),
cw: poi.cw.to_string(),
author_hash: user.namehash.to_string(),
author_title: poi.use_title.map(|_| user.custom_title).unwrap_or_default(),
is_tmp: user.id.is_none(),
author_title: user
.custom_title
.and_then(|title| use_title.then_some(title))
.unwrap_or_default(),
is_tmp,
n_attentions: 1,
allow_search: poi.allow_search.is_some(),
room_id,
},
)
.await?;
Attention::init(&user.namehash, &rconn).add(p.id).await?;
p.refresh_cache(&rconn, true).await;
p.refresh_cache(true).await;
if !poi.poll_options.is_empty() {
PollOption::init(p.id, &rconn)
@@ -199,18 +236,25 @@ pub async fn publish_post(
}
#[post("/editcw", data = "<cwi>")]
pub async fn edit_cw(cwi: Form<CwInput>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
let mut p = Post::get(&db, &rconn, cwi.pid).await?;
pub async fn edit_cw(cwi: Form<CwInput>, user: CurrentUser, db: Db) -> JsonApi {
let mut p = Post::get(&db, cwi.pid).await?;
p.check_permission(&user, "w")?;
update!(p, posts, &db, { cw, to cwi.cw.to_string() });
p.refresh_cache(&rconn, false).await;
p.refresh_cache(false).await;
code0!()
}
#[get("/getmulti?<pids>")]
pub async fn get_multi(pids: Vec<i32>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonAPI {
user.id.ok_or_else(|| YouAreTmp)?;
let ps = Post::get_multi(&db, &rconn, &pids).await?;
pub async fn get_multi(pids: Vec<i32>, user: CurrentUser, db: Db, rconn: RdsConn) -> JsonApi {
user.id.ok_or(YouAreTmp)?;
let ps: Vec<Post> = Post::get_multi(&db, &pids)
.await?
.into_iter()
.filter(|post| {
!post.get_is_private()
|| chrono::offset::Utc::now() - post.create_time < chrono::Duration::days(30)
})
.collect();
let ps_data = ps2outputs(&ps, &user, &db, &rconn).await?;
Ok(json!({

65
src/api/reaction.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::api::{CurrentUser, JsonApi, PolicyError::*, Ugc};
use crate::db_conn::Db;
use crate::models::*;
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use rocket::form::Form;
use rocket::serde::json::json;
#[derive(FromForm)]
pub struct ReactionInput {
#[field(validate = range(-1..2))]
status: i32,
}
#[post("/post/<pid>/reaction", data = "<ri>")]
pub async fn reaction(
pid: i32,
ri: Form<ReactionInput>,
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonApi {
user.id.ok_or(YouAreTmp)?;
let mut p = Post::get(&db, pid).await?;
p.check_permission(&user, "r")?;
let mut r_up = Reaction::init(pid, 1, &rconn);
let mut r_down = Reaction::init(pid, -1, &rconn);
let (delta_up, delta_down): (i32, i32) = match ri.status {
1 => (
r_up.add(&user.namehash).await? as i32,
-(r_down.rem(&user.namehash).await? as i32),
),
-1 => (
-(r_up.rem(&user.namehash).await? as i32),
r_down.add(&user.namehash).await? as i32,
),
_ => (
-(r_up.rem(&user.namehash).await? as i32),
-(r_down.rem(&user.namehash).await? as i32),
),
};
if delta_up != 0 || delta_down != 0 {
update!(
p,
posts,
&db,
{ up_votes, add delta_up },
{ down_votes, add delta_down }
);
p.refresh_cache(false).await;
}
Ok(json!({
"code": 0,
"data": {
"up_votes": p.up_votes,
"down_votes": p.down_votes,
"reaction_status": ri.status,
},
}))
}

View File

@@ -1,34 +1,31 @@
use crate::api::post::ps2outputs;
use crate::api::{CurrentUser, JsonAPI, PolicyError::*};
use crate::api::{CurrentUser, JsonApi, PolicyError::*};
use crate::db_conn::Db;
use crate::models::*;
use crate::rds_conn::RdsConn;
use rocket::serde::json::json;
#[get("/search?<search_mode>&<page>&<keywords>")]
#[get("/search?<search_mode>&<page>&<keywords>&<room_id>")]
pub async fn search(
room_id: Option<i32>,
keywords: String,
search_mode: u8,
page: i32,
user: CurrentUser,
db: Db,
rconn: RdsConn,
) -> JsonAPI {
user.id.ok_or_else(|| YouAreTmp)?;
) -> JsonApi {
user.id.ok_or(YouAreTmp)?;
let page_size = 25;
let start = (page - 1) * page_size;
let kws = keywords
.split(" ")
.filter(|x| !x.is_empty())
.collect::<Vec<&str>>();
let ps = if kws.is_empty() {
let ps = if !keywords.chars().any(|c| !c.eq(&' ')) {
vec![]
} else {
Post::search(
&db,
&rconn,
room_id,
search_mode,
keywords.to_string(),
start.into(),

View File

@@ -1,25 +1,35 @@
use crate::api::{CurrentUser, JsonAPI};
use crate::api::{CurrentUser, JsonApi};
use crate::cache::cached_user_count;
use crate::db_conn::Db;
use crate::random_hasher::RandomHasher;
use crate::rds_conn::RdsConn;
use crate::rds_models::{Systemlog};
use crate::rds_models::{get_admin_list, get_candidate_list, Systemlog};
use rocket::serde::json::{json, Value};
use rocket::State;
#[get("/systemlog")]
pub async fn get_systemlog(user: CurrentUser, rh: &State<RandomHasher>, rconn: RdsConn) -> JsonAPI {
pub async fn get_systemlog(
user: CurrentUser,
rh: &State<RandomHasher>,
db: Db,
rconn: RdsConn,
) -> JsonApi {
let logs = Systemlog::get_list(&rconn, 50).await?;
Ok(json!({
"tmp_token": rh.get_tmp_token(),
"salt": look!(rh.salt),
"start_time": rh.start_time.timestamp(),
"user_count": cached_user_count(&db).await?,
"custom_title": user.custom_title,
"admin_list": get_admin_list(&rconn).await?,
"candidate_list": get_candidate_list(&rconn).await?,
"data": logs.into_iter().map(|log|
json!({
"type": log.action_type,
"user": look!(log.user_hash),
"user": log.user_hash,
"timestamp": log.time.timestamp(),
"detail": format!("{}\n{}", &log.target, if user.is_admin || !log.action_type.contains_ugc() { &log.detail } else { "" })
"detail": format!("{}\n{}", &log.target, &log.detail),
})
).collect::<Vec<Value>>(),
}))

View File

@@ -1,37 +1,20 @@
use crate::api::{CurrentUser, JsonAPI};
use super::{CurrentUser, JsonApi};
use rocket::fs::TempFile;
use rocket::serde::json::json;
use std::process::Command;
use std::env::var;
#[post("/upload", data = "<file>")]
pub async fn ipfs_upload(_user: CurrentUser, file: TempFile<'_>) -> JsonAPI {
// dbg!(&file);
pub async fn local_upload(_user: CurrentUser, mut file: TempFile<'_>) -> JsonApi {
let filename: String = format!(
"file{}.{}",
file.path().unwrap().file_name().unwrap().to_str().unwrap(),
file.content_type()
.map(|ct| ct.extension().unwrap_or_else(|| ct.sub()).as_str())
.unwrap_or("unknown")
);
// dbg!(&file.path());
if let Some(filepath) = file.path() {
let output = Command::new("ipfs")
.args([
"add",
"-q",
"-r",
"-cid-version=1",
filepath.to_str().unwrap(),
])
.output()?;
// dbg!(&output);
let hash = std::str::from_utf8(&output.stdout)
.unwrap()
.split_terminator("\n")
.last()
.unwrap_or_else(|| {
dbg!(&output);
dbg!(&file.path());
panic!("get ipfs output error");
});
code0!(json!({
"hash": hash,
}))
} else {
code1!("文件丢失")
}
file.copy_to(format!("{}/{}", var("UPLOAD_DIR").unwrap(), filename))
.await?;
code0!(json!({ "path": filename }))
}

View File

@@ -1,4 +1,4 @@
use crate::api::{CurrentUser, JsonAPI, PolicyError::*};
use crate::api::{CurrentUser, JsonApi, PolicyError::*};
use crate::rds_conn::RdsConn;
use crate::rds_models::*;
use rocket::form::Form;
@@ -18,11 +18,11 @@ pub async fn get_poll_dict(pid: i32, rconn: &RdsConn, namehash: &str) -> Option<
.has(namehash)
.await
.unwrap_or_default()
.then(|| opt)
.then_some(opt)
}))
.await
.into_iter()
.filter_map(|x| x)
.flatten()
.collect::<Vec<&String>>()
.pop();
Some(json!({
@@ -46,8 +46,8 @@ pub struct VoteInput {
}
#[post("/vote", data = "<vi>")]
pub async fn vote(vi: Form<VoteInput>, user: CurrentUser, rconn: RdsConn) -> JsonAPI {
user.id.ok_or_else(|| NotAllowed)?;
pub async fn vote(vi: Form<VoteInput>, user: CurrentUser, rconn: RdsConn) -> JsonApi {
user.id.ok_or(NotAllowed)?;
let pid = vi.pid;
let opts = PollOption::init(pid, &rconn).get_list().await?;
@@ -61,10 +61,7 @@ pub async fn vote(vi: Form<VoteInput>, user: CurrentUser, rconn: RdsConn) -> Jso
}
}
let idx: usize = opts
.iter()
.position(|x| x.eq(&vi.vote))
.ok_or_else(|| NotAllowed)?;
let idx: usize = opts.iter().position(|x| x.eq(&vi.vote)).ok_or(NotAllowed)?;
PollVote::init(pid, idx, &rconn).add(&user.namehash).await?;

View File

@@ -1,221 +1,164 @@
use crate::api::CurrentUser;
use crate::api::{Api, CurrentUser};
use crate::db_conn::Db;
use crate::models::{Comment, Post, User};
use crate::rds_conn::RdsConn;
use crate::rds_models::{init, BlockedUsers};
use crate::rds_models::BlockedUsers;
use diesel::result::{Error as DieselError, QueryResult};
use moka::future::Cache;
use rand::Rng;
use redis::{AsyncCommands, RedisError, RedisResult};
use rocket::serde::json::serde_json;
// can use rocket::serde::json::to_string in master version
use redis::RedisResult;
use rocket::futures::future;
use rocket::tokio::sync::RwLock;
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
const INSTANCE_EXPIRE_TIME: usize = 60 * 60;
const USER_COUNT_EXPIRE_TIME: u64 = 60;
const INSTANCE_EXPIRE_TIME: u64 = 60 * 60;
const MIN_LENGTH: isize = 200;
const MAX_LENGTH: isize = 900;
const CUT_LENGTH: isize = 100;
macro_rules! post_cache_key {
($id: expr) => {
format!("hole_v2:cache:post:{}", $id)
};
// Global cache getters using OnceLock
fn post_cache() -> &'static Cache<i32, Post> {
static CACHE: OnceLock<Cache<i32, Post>> = OnceLock::new();
CACHE.get_or_init(|| Cache::builder().max_capacity(10_000).build())
}
pub struct PostCache {
rconn: RdsConn,
fn post_comment_cache() -> &'static Cache<String, Vec<Comment>> {
static CACHE: OnceLock<Cache<String, Vec<Comment>>> = OnceLock::new();
CACHE.get_or_init(|| {
Cache::builder()
.time_to_idle(Duration::from_secs(INSTANCE_EXPIRE_TIME))
.build()
})
}
// Each list in post_list_cache, keyed by room_id and mode, is a sorted list. The element is a pair of numbers. The first one is the weight used to sort, the second one is the post id.
fn post_list_cache() -> &'static Cache<String, Arc<RwLock<Vec<(i64, i32)>>>> {
static CACHE: OnceLock<Cache<String, Arc<RwLock<Vec<(i64, i32)>>>>> = OnceLock::new();
CACHE.get_or_init(|| Cache::builder().build())
}
fn user_cache() -> &'static Cache<String, User> {
static CACHE: OnceLock<Cache<String, User>> = OnceLock::new();
CACHE.get_or_init(|| {
Cache::builder()
.time_to_idle(Duration::from_secs(INSTANCE_EXPIRE_TIME))
.build()
})
}
fn block_dict_cache() -> &'static Cache<String, Arc<RwLock<HashMap<String, bool>>>> {
static CACHE: OnceLock<Cache<String, Arc<RwLock<HashMap<String, bool>>>>> = OnceLock::new();
CACHE.get_or_init(|| {
Cache::builder()
.time_to_idle(Duration::from_secs(INSTANCE_EXPIRE_TIME))
.build()
})
}
fn user_count_cache() -> &'static Cache<String, i64> {
static CACHE: OnceLock<Cache<String, i64>> = OnceLock::new();
CACHE.get_or_init(|| {
Cache::builder()
.time_to_live(Duration::from_secs(USER_COUNT_EXPIRE_TIME))
.build()
})
}
fn map_shared_diesel_error(err: Arc<DieselError>) -> DieselError {
match err.as_ref() {
DieselError::NotFound => DieselError::NotFound,
DieselError::RollbackTransaction => DieselError::RollbackTransaction,
DieselError::AlreadyInTransaction => DieselError::AlreadyInTransaction,
DieselError::NotInTransaction => DieselError::NotInTransaction,
DieselError::BrokenTransactionManager => DieselError::BrokenTransactionManager,
_ => DieselError::QueryBuilderError(Box::new(io::Error::other(err.to_string()))),
}
}
pub struct PostCache;
impl PostCache {
init!();
pub async fn sets(&mut self, ps: &Vec<&Post>) {
pub async fn sets(ps: &[&Post]) {
if ps.is_empty() {
return;
}
let kvs: Vec<(String, String)> = ps
.iter()
.map(|p| (post_cache_key!(p.id), serde_json::to_string(p).unwrap()))
.collect();
self.rconn.set_multiple(&kvs).await.unwrap_or_else(|e| {
warn!("set post cache failed: {}", e);
dbg!(&kvs);
});
}
pub async fn get(&mut self, pid: &i32) -> Option<Post> {
let key = post_cache_key!(pid);
let rds_result: Option<String> = self
.rconn
.get::<String, Option<String>>(key)
.await
.unwrap_or_else(|e| {
warn!("try to get post cache, connect rds failed, {}", e);
None
});
rds_result.and_then(|s| {
serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("get post cache, decode failed {}, {}", e, s);
None
})
})
}
pub async fn gets(&mut self, pids: &Vec<i32>) -> Vec<Option<Post>> {
// 长度为1时会走GET而非MGET返回值格式不兼容。愚蠢的设计。
match pids.len() {
0 => vec![],
1 => vec![self.get(&pids[0]).await],
_ => {
let ks: Vec<String> = pids.iter().map(|pid| post_cache_key!(pid)).collect();
// dbg!(&ks);
// Vec is single arg, while &Vec is not. Seems a bug.
let rds_result: Vec<Option<String>> = self
.rconn
.get::<Vec<String>, Vec<Option<String>>>(ks)
.await
.unwrap_or_else(|e| {
warn!("try to get posts cache, connect rds failed, {}", e);
vec![None; pids.len()]
});
// dbg!(&rds_result);
// 定期热度衰减的时候会清空缓存,这里设不设置过期时间影响不大
rds_result
.into_iter()
.map(|x| {
// dbg!(&x);
x.and_then(|s| {
serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("get post cache, decode failed {}, {}", e, s);
None
})
})
})
.collect()
}
for p in ps {
post_cache().insert(p.id, (*p).clone()).await;
}
}
pub async fn clear_all(&mut self) {
let mut keys = self
.rconn
.scan_match::<String, String>(post_cache_key!("*"))
pub async fn get(pid: &i32) -> Option<Post> {
post_cache().get(pid).await
}
pub async fn get_with<F>(pid: i32, init: F) -> QueryResult<Post>
where
F: Future<Output = QueryResult<Post>>,
{
post_cache()
.try_get_with(pid, init)
.await
.unwrap(); //.collect::<Vec<String>>().await;
// colllect() does not work
// also see: https://github.com/mitsuhiko/redis-rs/issues/583
let mut ks_for_del = Vec::new();
while let Some(key) = keys.next_item().await {
ks_for_del.push(key);
}
if ks_for_del.is_empty() {
return;
}
self.rconn
.del(ks_for_del)
.await
.unwrap_or_else(|e| warn!("clear all post cache fail, {}", e));
.map_err(map_shared_diesel_error)
}
pub async fn gets(pids: &[i32]) -> Vec<Option<Post>> {
future::join_all(pids.iter().map(Self::get)).await
}
pub async fn clear_all() {
post_cache().invalidate_all();
}
}
pub struct PostCommentCache {
key: String,
rconn: RdsConn,
}
impl PostCommentCache {
init!(i32, "hole_v2:cache:post_comments:{}");
pub async fn set(&mut self, cs: &Vec<Comment>) {
self.rconn
.set_ex(
&self.key,
serde_json::to_string(cs).unwrap(),
INSTANCE_EXPIRE_TIME,
)
.await
.unwrap_or_else(|e| {
warn!("set comments cache failed: {}", e);
dbg!(cs);
})
pub fn init(post_id: i32) -> Self {
Self {
key: format!("hole_v2:cache:post_comments:{}", post_id),
}
}
pub async fn get(&mut self) -> Option<Vec<Comment>> {
let rds_result = self.rconn.get::<&String, String>(&self.key).await;
// dbg!(&rds_result);
if let Ok(s) = rds_result {
self.rconn
.expire::<&String, bool>(&self.key, INSTANCE_EXPIRE_TIME)
.await
.unwrap_or_else(|e| {
warn!(
"get comments cache, set new expire failed: {}, {}, {} ",
e, &self.key, &s
);
false
});
serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("get comments cache, decode failed {}, {}", e, s);
None
})
} else {
None
}
pub async fn get_with<F>(&self, init: F) -> QueryResult<Vec<Comment>>
where
F: Future<Output = QueryResult<Vec<Comment>>>,
{
post_comment_cache()
.try_get_with(self.key.clone(), init)
.await
.map_err(map_shared_diesel_error)
}
pub async fn clear(&mut self) {
self.rconn.del(&self.key).await.unwrap_or_else(|e| {
warn!("clear commenrs cache fail, {}", e);
});
post_comment_cache().invalidate(&self.key).await;
}
}
pub struct PostListCommentCache {
pub struct PostListCache {
key: String,
mode: u8,
rconn: RdsConn,
length: isize,
}
impl PostListCommentCache {
pub fn init(mode: u8, rconn: &RdsConn) -> Self {
impl PostListCache {
pub const MAX_LENGTH: usize = 900;
// pub const MIN_LENGTH: usize = 200;
pub const CUT_LENGTH: usize = 100;
pub fn init(room_id: Option<i32>, mode: u8) -> Self {
Self {
key: format!("hole_v2:cache:post_list:{}", &mode),
mode: mode,
rconn: rconn.clone(),
length: 0,
key: format!(
"hole_v2:cache:post_list:{}:{}",
room_id.map_or_else(String::new, |i| i.to_string()),
&mode
),
mode,
}
}
async fn set_and_check_length(&mut self) {
let mut l = self.rconn.zcard(&self.key).await.unwrap();
if l > MAX_LENGTH {
self.rconn
.zremrangebyrank::<&String, ()>(&self.key, MAX_LENGTH - CUT_LENGTH, -1)
.await
.unwrap_or_else(|e| {
warn!("cut list cache failed, {}, {}", e, &self.key);
});
l = MIN_LENGTH;
}
self.length = l;
}
pub async fn need_fill(&mut self) -> bool {
self.set_and_check_length().await;
self.length < MIN_LENGTH
}
pub fn i64_len(&self) -> i64 {
self.length.try_into().unwrap()
}
pub fn i64_minlen(&self) -> i64 {
MIN_LENGTH.try_into().unwrap()
}
fn p2pair(&self, p: &Post) -> (i64, i32) {
(
match self.mode {
@@ -223,152 +166,162 @@ impl PostListCommentCache {
1 => -p.last_comment_time.timestamp(),
2 => (-p.hot_score).into(),
3 => rand::thread_rng().gen_range(0..i64::MAX),
4 => (-p.n_attentions).into(),
_ => panic!("wrong mode"),
},
p.id,
)
}
pub async fn fill(&mut self, ps: &Vec<Post>) {
let items: Vec<(i64, i32)> = ps.iter().map(|p| self.p2pair(p)).collect();
self.rconn
.zadd_multiple(&self.key, &items)
pub async fn fill_with<F>(&mut self, query_posts: F) -> QueryResult<usize>
where
F: Future<Output = QueryResult<Vec<Post>>>,
{
let list_ref = post_list_cache()
.try_get_with(self.key.clone(), async {
let mut items: Vec<(i64, i32)> =
query_posts.await?.iter().map(|p| self.p2pair(p)).collect();
items.sort_by(|a, b| a.0.cmp(&b.0));
Ok(Arc::new(RwLock::new(items)))
})
.await
.unwrap_or_else(|e| {
warn!("fill list cache failed, {} {}", e, &self.key);
});
.map_err(map_shared_diesel_error)?;
let list = list_ref.read().await;
self.set_and_check_length().await;
}
// Double-Checked Locking
if list.len() <= Self::MAX_LENGTH {
return Ok(list.len());
}
drop(list);
let mut list = list_ref.write().await;
pub async fn put(&mut self, p: &Post) {
// 其他都是加到最前面的但热榜不是。可能导致MIN_LENGTH到MAX_LENGTH之间的数据不可靠
// 影响不大,先不管了
if p.is_deleted || (self.mode > 0 && p.is_reported) {
self.rconn.zrem(&self.key, p.id).await.unwrap_or_else(|e| {
warn!(
"remove from list cache failed, {} {} {}",
e, &self.key, p.id
);
});
if list.len() <= Self::MAX_LENGTH {
Ok(list.len())
} else {
let (s, m) = self.p2pair(p);
self.rconn.zadd(&self.key, m, s).await.unwrap_or_else(|e| {
warn!(
"put into list cache failed, {} {} {} {}",
e, &self.key, m, s
);
});
list.truncate(Self::MAX_LENGTH - Self::CUT_LENGTH);
Ok(list.len())
}
}
pub async fn get_pids(&mut self, start: i64, limit: i64) -> Vec<i32> {
self.rconn
.zrange(
&self.key,
start.try_into().unwrap(),
(start + limit - 1).try_into().unwrap(),
)
.await
.unwrap()
pub async fn put(&mut self, p: &Post) {
// Don't put is there is no cache. Let fill_with handle it.
if let Some(list_ref) = post_list_cache().get(&self.key).await {
let mut list = list_ref.write().await;
// Remove any existing entry for this post_id
if let Some(pos) = list.iter().position(|(_, pid)| *pid == p.id) {
list.remove(pos);
}
if p.is_deleted || (self.mode > 0 && p.is_reported) {
return;
}
list.push(self.p2pair(p));
list.sort_by(|a, b| a.0.cmp(&b.0));
}
}
pub async fn get_pids(&mut self, start: usize, limit: usize) -> Vec<i32> {
if let Some(list_ref) = post_list_cache().get(&self.key).await {
let list = list_ref.read().await;
list.iter()
.skip(start)
.take(limit)
.map(|(_, pid)| *pid)
.collect()
} else {
vec![]
}
}
pub async fn clear(&mut self) {
self.rconn.del(&self.key).await.unwrap_or_else(|e| {
warn!("clear post list cache failed, {}", e);
});
post_list_cache().invalidate(&self.key).await;
}
}
pub struct UserCache {
key: String,
rconn: RdsConn,
}
impl UserCache {
init!(&str, "hole_v2:cache:user:{}");
pub async fn set(&mut self, u: &User) {
self.rconn
.set_ex(
&self.key,
serde_json::to_string(u).unwrap(),
INSTANCE_EXPIRE_TIME,
)
.await
.unwrap_or_else(|e| {
warn!("set user cache failed: {}", e);
dbg!(u);
})
pub fn init(user_id: &str) -> Self {
Self {
key: format!("hole_v2:cache:user:{}", user_id),
}
}
pub async fn get(&mut self) -> Option<User> {
let rds_result = self.rconn.get::<&String, String>(&self.key).await;
if let Ok(s) = rds_result {
self.rconn
.expire::<&String, bool>(&self.key, INSTANCE_EXPIRE_TIME)
.await
.unwrap_or_else(|e| {
warn!(
"get user cache, set new expire failed: {}, {}, {} ",
e, &self.key, &s
);
false
});
serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("get user cache, decode failed {}, {}", e, s);
None
})
} else {
None
}
// No need to use get_with for User. Just check and set separately.
pub async fn set(&self, u: &User) {
user_cache().insert(self.key.clone(), u.clone()).await;
}
pub async fn get(&self) -> Option<User> {
user_cache().get(&self.key).await
}
pub async fn clear_all() {
user_cache().invalidate_all();
}
}
pub struct BlockDictCache {
key: String,
rconn: RdsConn,
}
impl BlockDictCache {
// namehash, pid
init!(&str, i32, "hole_v2:cache:block_dict:{}:{}");
pub fn init(namehash: &str, post_id: i32) -> Self {
Self {
key: format!("hole_v2:cache:block_dict:{}:{}", namehash, post_id),
}
}
pub async fn get_or_create(
&mut self,
user: &CurrentUser,
hash_list: &Vec<&String>,
hash_list: &[&String],
rconn: &RdsConn,
) -> RedisResult<HashMap<String, bool>> {
let mut block_dict = self
.rconn
.hgetall::<&String, HashMap<String, bool>>(&self.key)
.await?;
let dict_ref = block_dict_cache()
.get_with(self.key.clone(), async move {
Arc::new(RwLock::new(HashMap::new()))
})
.await;
//dbg!(&self.key, &block_dict);
let missing: Vec<(String, bool)> =
future::try_join_all(hash_list.iter().filter_map(|hash| {
(!block_dict.contains_key(&hash.to_string())).then(|| async {
Ok::<(String, bool), RedisError>((
hash.to_string(),
BlockedUsers::check_if_block(&self.rconn, user, hash).await?,
))
})
}))
.await?;
if !missing.is_empty() {
self.rconn.hset_multiple(&self.key, &missing).await?;
self.rconn.expire(&self.key, INSTANCE_EXPIRE_TIME).await?;
block_dict.extend(missing.into_iter());
// Find missing hashes
let mut missing_keys: Vec<String> = Vec::new();
{
let block_dict = dict_ref.read().await;
for hash in hash_list {
if !block_dict.contains_key(hash.as_str()) {
missing_keys.push((*hash).clone());
}
}
}
//dbg!(&block_dict);
if !missing_keys.is_empty() {
let mut missing: Vec<(String, bool)> = Vec::with_capacity(missing_keys.len());
for hash in missing_keys {
let is_blocked = BlockedUsers::check_if_block(rconn, user, &hash).await?;
missing.push((hash, is_blocked));
}
Ok(block_dict)
let mut block_dict = dict_ref.write().await;
for (hash, is_blocked) in missing {
block_dict.entry(hash).or_insert(is_blocked);
}
}
let out = dict_ref.read().await.clone();
Ok(out)
}
pub async fn clear(&mut self) -> RedisResult<()> {
self.rconn.del(&self.key).await
pub async fn clear(&mut self) {
block_dict_cache().invalidate(&self.key).await;
}
}
pub async fn cached_user_count(db: &Db) -> Api<i64> {
let key = "hole_v2:cache:user_count";
Ok(user_count_cache()
.try_get_with(key.to_string(), async { User::get_count(db).await })
.await
.map_err(map_shared_diesel_error)?)
}

42
src/cors.rs Normal file
View File

@@ -0,0 +1,42 @@
#![allow(clippy::let_unit_value)]
use rocket::fairing::{Fairing, Info, Kind};
use rocket::http::Header;
use rocket::{Request, Response};
use std::path::PathBuf;
pub struct Cors {
pub whitelist: Vec<String>,
}
#[rocket::async_trait]
impl Fairing for Cors {
fn info(&self) -> Info {
Info {
name: "Add CORS headers to responses",
kind: Kind::Response,
}
}
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
if let Some(origin) = request.headers().get_one("Origin").and_then(|origin| {
self.whitelist
.contains(&origin.to_string())
.then_some(origin)
}) {
response.set_header(Header::new("Access-Control-Allow-Origin", origin));
response.set_header(Header::new(
"Access-Control-Allow-Methods",
"POST, GET, OPTIONS",
));
response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
response.set_header(Header::new(
"Access-Control-Allow-Headers",
"User-Token, Content-Type",
));
}
}
}
#[options("/<_path..>")]
pub async fn options_handler(_path: PathBuf) {}

View File

@@ -1,5 +1,5 @@
use rocket_sync_db_pools::{database, diesel};
use diesel::Connection;
use rocket_sync_db_pools::{database, diesel};
use std::env;
pub type Conn = diesel::pg::PgConnection;
@@ -7,11 +7,9 @@ pub type Conn = diesel::pg::PgConnection;
#[database("pg_v2")]
pub struct Db(Conn);
// get sync connection, only for annealing
pub fn establish_connection() -> Conn {
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
Conn::establish(&database_url)
.expect(&format!("Error connecting to {}", database_url))
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

View File

@@ -1,148 +0,0 @@
/*
* from https://github.com/shssoichiro/diesel-logger
* change Connection to &mut Connection
*/
use std::ops::Deref;
use std::time::{Duration, Instant};
use diesel::backend::{Backend, UsesAnsiSavepointSyntax};
use diesel::connection::{AnsiTransactionManager, SimpleConnection};
use diesel::debug_query;
use diesel::deserialize::QueryableByName;
use diesel::prelude::*;
use diesel::query_builder::{AsQuery, QueryFragment, QueryId};
use diesel::sql_types::HasSqlType;
/// Wraps a diesel `Connection` to time and log each query using
/// the configured logger for the `log` crate.
///
/// Currently, this produces a `debug` log on every query,
/// an `info` on queries that take longer than 1 second,
/// and a `warn`ing on queries that take longer than 5 seconds.
/// These thresholds will be configurable in a future version.
pub struct LoggingConnection<'r, C: Connection>(&'r mut C);
impl<'r, C: Connection> LoggingConnection<'r, C> {
pub fn new(conn: &'r mut C) -> Self {
LoggingConnection(conn)
}
}
impl<'r, C: Connection> Deref for LoggingConnection<'r, C> {
type Target = C;
fn deref(&self) -> &Self::Target {
self.0
}
}
impl<'r, C> SimpleConnection for LoggingConnection<'r, C>
where
C: Connection + Send + 'static,
{
fn batch_execute(&self, query: &str) -> QueryResult<()> {
let start_time = Instant::now();
let result = self.0.batch_execute(query);
let duration = start_time.elapsed();
log_query(query, duration);
result
}
}
impl<C: Connection> Connection for LoggingConnection<'_, C>
where
C: Connection<TransactionManager = AnsiTransactionManager> + Send + 'static,
C::Backend: UsesAnsiSavepointSyntax,
<C::Backend as Backend>::QueryBuilder: Default,
{
type Backend = C::Backend;
type TransactionManager = C::TransactionManager;
fn establish(_: &str) -> ConnectionResult<Self> {
Err(ConnectionError::__Nonexhaustive)
//Ok(LoggingConnection(C::establish(database_url)?))
}
fn execute(&self, query: &str) -> QueryResult<usize> {
let start_time = Instant::now();
let result = self.0.execute(query);
let duration = start_time.elapsed();
log_query(query, duration);
result
}
fn query_by_index<T, U>(&self, source: T) -> QueryResult<Vec<U>>
where
T: AsQuery,
T::Query: QueryFragment<Self::Backend> + QueryId,
Self::Backend: HasSqlType<T::SqlType>,
U: Queryable<T::SqlType, Self::Backend>,
{
let query = source.as_query();
let debug_query = debug_query::<Self::Backend, _>(&query).to_string();
let start_time = Instant::now();
let result = self.0.query_by_index(query);
let duration = start_time.elapsed();
log_query(&debug_query, duration);
result
}
fn query_by_name<T, U>(&self, source: &T) -> QueryResult<Vec<U>>
where
T: QueryFragment<Self::Backend> + QueryId,
U: QueryableByName<Self::Backend>,
{
let debug_query = debug_query::<Self::Backend, _>(&source).to_string();
let start_time = Instant::now();
let result = self.0.query_by_name(source);
let duration = start_time.elapsed();
log_query(&debug_query, duration);
result
}
fn execute_returning_count<T>(&self, source: &T) -> QueryResult<usize>
where
T: QueryFragment<Self::Backend> + QueryId,
{
let debug_query = debug_query::<Self::Backend, _>(&source).to_string();
let start_time = Instant::now();
let result = self.0.execute_returning_count(source);
let duration = start_time.elapsed();
log_query(&debug_query, duration);
result
}
fn transaction_manager(&self) -> &Self::TransactionManager {
self.0.transaction_manager()
}
}
fn log_query(query: &str, duration: Duration) {
if duration.as_secs() >= 5 {
warn!(
"Slow query ran in {:.2} seconds: {}",
duration_to_secs(duration),
query
);
} else if duration.as_secs() >= 1 {
info!(
"Slow query ran in {:.2} seconds: {}",
duration_to_secs(duration),
query
);
} else {
debug!("Query ran in {:.1} ms: {}", duration_to_ms(duration), query);
}
}
const NANOS_PER_MILLI: u32 = 1_000_000;
const MILLIS_PER_SEC: u32 = 1_000;
fn duration_to_secs(duration: Duration) -> f32 {
duration_to_ms(duration) / MILLIS_PER_SEC as f32
}
fn duration_to_ms(duration: Duration) -> f32 {
(duration.as_secs() as u32 * 1000) as f32
+ (duration.subsec_nanos() as f32 / NANOS_PER_MILLI as f32)
}

View File

@@ -1 +0,0 @@
pub mod diesel_logger;

View File

@@ -1,39 +1,68 @@
#![allow(clippy::unused_unit)]
use crate::db_conn::Db;
use crate::models::User;
use crate::random_hasher::RandomHasher;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::response::Redirect;
use rocket::serde::Deserialize;
use rocket::State;
use std::env;
use url::Url;
pub struct RefHeader(pub String);
#[derive(Debug)]
pub struct FrontendAddr(pub String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for RefHeader {
impl<'r> FromRequest<'r> for FrontendAddr {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.headers().get_one("Referer") {
Some(h) => Outcome::Success(RefHeader(h.to_string())),
None => Outcome::Forward(()),
}
Outcome::Success(Self(
request
.headers()
.get_one("Referer")
.map(|s| s.to_string())
.unwrap_or_else(|| env::var("DEFAULT_FRONTEND").unwrap()),
))
}
}
#[derive(Debug)]
pub struct BackendAddr(pub String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for BackendAddr {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
Outcome::Success(Self(
request
.headers()
.get_one("Host")
.map(|s| format!("https://{}", s))
.unwrap_or_else(|| env::var("DEFAULT_BACKEND").unwrap()),
))
}
}
#[get("/?p=cs")]
pub fn cs_login(r: RefHeader) -> Redirect {
pub fn cs_login(r: FrontendAddr, h: BackendAddr) -> Redirect {
let mast_url = env::var("MAST_BASE_URL").unwrap();
let mast_cli = env::var("MAST_CLIENT").unwrap();
let mast_scope = env::var("MAST_SCOPE").unwrap();
let mut redirect_url = Url::parse(&r.0).unwrap();
let jump_to_url = Url::parse(&r.0).unwrap();
let mut redirect_url = Url::parse(&h.0).unwrap();
redirect_url.set_path("/_login/cs/auth");
redirect_url.set_query(None);
redirect_url = Url::parse_with_params(
redirect_url.as_str(),
&[("redirect_url", redirect_url.as_str())],
&[
("redirect_url", redirect_url.as_str()),
("jump_to_url", jump_to_url.as_str()),
],
)
.unwrap();
let url = Url::parse_with_params(
&format!("{}oauth/authorize", mast_url),
&[
@@ -59,8 +88,22 @@ struct Token {
struct Account {
pub id: String,
}
#[get("/cs/auth?<code>&<redirect_url>")]
pub async fn cs_auth(code: String, redirect_url: String, db: Db) -> Redirect {
#[get("/cs/auth?<code>&<redirect_url>&<jump_to_url>")]
pub async fn cs_auth(
code: String,
redirect_url: String,
jump_to_url: String,
db: Db,
rh: &State<RandomHasher>,
) -> Result<Redirect, &'static str> {
if !env::var("FRONTEND_WHITELIST")
.unwrap_or_default()
.split(',')
.any(|url| jump_to_url.starts_with(url))
{
return Err("前端地址不在白名单内");
}
let mast_url = env::var("MAST_BASE_URL").unwrap();
let mast_cli = env::var("MAST_CLIENT").unwrap();
let mast_sec = env::var("MAST_SECRET").unwrap();
@@ -69,12 +112,15 @@ pub async fn cs_auth(code: String, redirect_url: String, db: Db) -> Redirect {
// to keep same
let redirect_url = Url::parse_with_params(
redirect_url.as_str(),
&[("redirect_url", redirect_url.as_str())],
&[
("redirect_url", redirect_url.as_str()),
("jump_to_url", jump_to_url.as_str()),
],
)
.unwrap();
let client = reqwest::Client::new();
let token: Token = client
let r = client
.post(format!("{}oauth/token", &mast_url))
.form(&[
("client_id", mast_cli.as_str()),
@@ -86,11 +132,10 @@ pub async fn cs_auth(code: String, redirect_url: String, db: Db) -> Redirect {
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
//dbg!(&r);
let token: Token = r.json().await.unwrap();
//dbg!(&token);
let client = reqwest::Client::new();
@@ -106,9 +151,124 @@ pub async fn cs_auth(code: String, redirect_url: String, db: Db) -> Redirect {
//dbg!(&account);
let tk = User::find_or_create_token(&db, &format!("cs_{}", &account.id), false)
let tk = User::find_or_create_token(
&db,
&rh.hash_with_salt(&format!("cs_{}", &account.id)),
false,
)
.await
.unwrap();
Ok(Redirect::to(format!("{}?token={}", &jump_to_url, &tk)))
}
#[get("/gh")]
pub fn gh_login(r: FrontendAddr, h: BackendAddr) -> Redirect {
let gh_url = "https://github.com/login/oauth/authorize";
let gh_cli = env::var("GH_CLIENT").unwrap();
let gh_scope = "user:email";
let jump_to_url = Url::parse(&r.0).unwrap();
let mut redirect_url = Url::parse(&h.0).unwrap();
redirect_url.set_path("/_login/gh/auth");
redirect_url = Url::parse_with_params(
redirect_url.as_str(),
&[("jump_to_url", jump_to_url.as_str())],
)
.unwrap();
let url = Url::parse_with_params(
gh_url,
&[
("redirect_uri", redirect_url.as_str()),
("client_id", &gh_cli),
("scope", gh_scope),
],
)
.unwrap();
Redirect::to(url.to_string())
}
#[derive(Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
struct GithubEmail {
pub email: String,
pub verified: bool,
}
#[get("/gh/auth?<code>&<jump_to_url>")]
pub async fn gh_auth(
code: String,
jump_to_url: String,
db: Db,
rh: &State<RandomHasher>,
) -> Result<Redirect, &'static str> {
if !env::var("FRONTEND_WHITELIST")
.unwrap_or_default()
.split(',')
.any(|url| jump_to_url.starts_with(url))
{
return Err("前端地址不在白名单内");
}
let gh_cli = env::var("GH_CLIENT").unwrap();
let gh_sec = env::var("GH_SECRET").unwrap();
let client = reqwest::Client::new();
let r = client
.post("https://github.com/login/oauth/access_token")
.header(reqwest::header::ACCEPT, "application/json")
.form(&[
("client_id", gh_cli.as_str()),
("client_secret", gh_sec.as_str()),
("code", code.as_str()),
])
.send()
.await
.unwrap();
Redirect::to(format!("/?token={}", tk))
//let token: rocket::serde::json::Value = r.json().await.unwrap();
let token: Token = r.json().await.unwrap();
dbg!(&token);
let client = reqwest::Client::new();
let r = client
.get("https://api.github.com/user/emails")
.bearer_auth(token.access_token)
.header(reqwest::header::USER_AGENT, "hole_thu LoginBot")
.send()
.await
.unwrap();
// dbg!(&r);
let emails = r
.json::<Vec<GithubEmail>>()
//.json::<rocket::serde::json::Value>()
.await
.unwrap();
//dbg!(&emails);
let name = emails
.iter()
.filter(|email| email.verified)
.find_map(
|email| match email.email.split('@').collect::<Vec<&str>>()[..] {
[name, "mails.tsinghua.edu.cn"] | [name, "tsinghua.org.cn"] => Some(name),
_ => None,
},
);
if let Some(name) = name {
let tk =
User::find_or_create_token(&db, &rh.hash_with_salt(&format!("email_{}", name)), false)
.await
.unwrap();
Ok(Redirect::to(format!("{}?token={}", &jump_to_url, &tk)))
} else {
Err("没有找到已验证的清华邮箱/校友邮箱")
}
}

View File

@@ -12,50 +12,66 @@ extern crate log;
mod api;
mod cache;
mod cors;
mod db_conn;
mod libs;
#[cfg(feature = "mastlogin")]
mod login;
mod models;
mod random_hasher;
mod rate_limit;
mod rds_conn;
mod rds_models;
mod schema;
use db_conn::{establish_connection, Conn, Db};
use std::env;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
use rocket::tokio;
use rocket::tokio::time::{sleep, Duration};
use db_conn::{establish_connection, Conn, Db};
use random_hasher::RandomHasher;
use rate_limit::MainLimiters;
use rds_conn::{init_rds_client, RdsConn};
use rds_models::clear_outdate_redis_data;
use std::env;
use tokio::time::{sleep, Duration};
embed_migrations!("migrations/postgres");
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgres");
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
async fn main() {
load_env();
if env::args().any(|arg| arg.eq("--init-database")) {
init_database();
return Ok(());
return;
}
env_logger::init();
let rmc = init_rds_client().await;
let rconn = RdsConn(rmc.clone());
clear_outdate_redis_data(&rconn.clone()).await;
let mut rconn = RdsConn(rmc.clone());
let mut c_start = establish_connection();
models::User::clear_non_admin_users(&mut c_start).await;
clear_outdate_redis_data(&mut rconn).await;
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(4 * 60 * 60)).await;
models::Post::annealing(establish_connection(), &rconn).await;
sleep(Duration::from_secs(3 * 60 * 60)).await;
models::Post::annealing(&mut c_start).await;
}
});
rocket::build()
tokio::spawn(async move {
loop {
for room_id in (0..5).map(Some).chain([None, Some(42)]) {
cache::PostListCache::init(room_id, 3).clear().await;
}
sleep(Duration::from_secs(5 * 60)).await;
}
});
let _ = rocket::build()
.mount(
"/_api/v1",
routes![
api::comment::get_comment,
api::comment::add_comment,
api::post::get_list,
api::post::get_one,
api::post::publish_post,
@@ -67,31 +83,58 @@ async fn main() -> Result<(), rocket::Error> {
api::systemlog::get_systemlog,
api::operation::delete,
api::operation::report,
api::operation::set_title,
api::operation::block,
api::operation::set_auto_block,
api::vote::vote,
api::upload::ipfs_upload,
cors::options_handler,
],
)
.mount(
"/_api/v2",
routes![
api::attention::set_notification,
api::reaction::reaction,
api::comment::add_comment,
api::operation::set_title,
api::upload::local_upload,
cors::options_handler,
],
)
.mount(
"/_login",
[
#[cfg(feature = "mastlogin")]
routes![login::cs_login, login::cs_auth],
routes![
login::cs_login,
login::cs_auth,
login::gh_login,
login::gh_auth
],
routes![],
]
.concat(),
)
.register(
"/_api",
catchers![api::catch_401_error, api::catch_403_error,],
catchers![
api::catch_401_error,
api::catch_403_error,
api::catch_404_error
],
)
.manage(MainLimiters::init())
.manage(RandomHasher::get_random_one())
.manage(rmc)
.attach(Db::fairing())
.attach(cors::Cors {
whitelist: env::var("FRONTEND_WHITELIST")
.unwrap_or_default()
.split(',')
.map(|s| s.to_string())
.collect::<Vec<String>>(),
})
.launch()
.await
.await;
}
fn load_env() {
@@ -104,6 +147,6 @@ fn load_env() {
fn init_database() {
let database_url = env::var("DATABASE_URL").unwrap();
let conn = Conn::establish(&database_url).unwrap();
embedded_migrations::run(&conn).unwrap();
let mut conn = Conn::establish(&database_url).unwrap();
conn.run_pending_migrations(MIGRATIONS).unwrap();
}

View File

@@ -1,13 +1,10 @@
#![allow(clippy::all)]
// #![allow(clippy::all)]
use crate::cache::*;
use crate::db_conn::{Conn, Db};
use crate::libs::diesel_logger::LoggingConnection;
use crate::random_hasher::random_string;
use crate::rds_conn::RdsConn;
use crate::schema::*;
use chrono::{offset::Utc, DateTime};
use diesel::dsl::any;
use diesel::sql_types::*;
use diesel::{
insert_into, BoolExpressionMethods, ExpressionMethods, QueryDsl, QueryResult, RunQueryDsl,
@@ -15,11 +12,15 @@ use diesel::{
};
use rocket::futures::{future, join};
use rocket::serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
no_arg_sql_function!(RANDOM, (), "Represents the sql RANDOM() function");
sql_function!(fn floor(x: Float) -> Int4);
sql_function!(fn float4(x: Int4) -> Float);
#[declare_sql_function]
extern "SQL" {
fn random() -> Text;
fn floor(x: Float) -> Int4;
fn float4(x: Int4) -> Float;
}
macro_rules! _get {
($table:ident) => {
@@ -40,7 +41,7 @@ macro_rules! _get_multi {
// eq(any()) is only for postgres
db.run(move |c| {
$table::table
.filter($table::id.eq(any(ids)))
.filter($table::id.eq_any(ids))
.filter($table::is_deleted.eq(false))
.load(with_log!(c))
})
@@ -60,6 +61,8 @@ macro_rules! op_to_col_expr {
macro_rules! update {
($obj:expr, $table:ident, $db:expr, $({ $col:ident, $op:ident $v:expr }), + ) => {{
use crate::schema;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
let id = $obj.id;
$obj = $db
.run(move |c| {
@@ -82,13 +85,14 @@ macro_rules! base_query {
};
}
// TODO: log sql query
macro_rules! with_log {
($c: expr) => {
&LoggingConnection::new($c)
$c
};
}
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug)]
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug, Clone)]
#[serde(crate = "rocket::serde")]
pub struct Comment {
pub id: i32,
@@ -102,7 +106,7 @@ pub struct Comment {
pub post_id: i32,
}
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug)]
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug, Clone)]
#[serde(crate = "rocket::serde")]
pub struct Post {
pub id: i32,
@@ -119,9 +123,12 @@ pub struct Post {
pub is_reported: bool,
pub hot_score: i32,
pub allow_search: bool,
pub room_id: i32,
pub up_votes: i32,
pub down_votes: i32,
}
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug)]
#[derive(Queryable, Insertable, Serialize, Deserialize, Debug, Clone)]
#[serde(crate = "rocket::serde")]
pub struct User {
pub id: i32,
@@ -131,7 +138,7 @@ pub struct User {
}
#[derive(Insertable)]
#[table_name = "posts"]
#[diesel(table_name = posts)]
pub struct NewPost {
pub content: String,
pub cw: String,
@@ -140,6 +147,7 @@ pub struct NewPost {
pub is_tmp: bool,
pub n_attentions: i32,
pub allow_search: bool,
pub room_id: i32,
}
impl Post {
@@ -147,9 +155,8 @@ impl Post {
_get_multi!(posts);
pub async fn get_multi(db: &Db, rconn: &RdsConn, ids: &Vec<i32>) -> QueryResult<Vec<Self>> {
let mut cacher = PostCache::init(&rconn);
let mut cached_posts = cacher.gets(ids).await;
pub async fn get_multi(db: &Db, ids: &[i32]) -> QueryResult<Vec<Self>> {
let mut cached_posts = PostCache::gets(ids).await;
let mut id2po = HashMap::<i32, &mut Option<Post>>::new();
// dbg!(&cached_posts);
@@ -159,7 +166,7 @@ impl Post {
.zip(cached_posts.iter_mut())
.filter_map(|(pid, p)| match p {
None => {
id2po.insert(pid.clone(), p);
id2po.insert(*pid, p);
Some(pid)
}
_ => None,
@@ -171,7 +178,7 @@ impl Post {
let missing_ps = Self::_get_multi(db, missing_ids).await?;
// dbg!(&missing_ps);
cacher.sets(&missing_ps.iter().collect()).await;
PostCache::sets(&missing_ps.iter().collect::<Vec<_>>()).await;
for p in missing_ps.into_iter() {
if let Some(op) = id2po.get_mut(&p.id) {
@@ -185,57 +192,56 @@ impl Post {
.collect())
}
pub async fn get(db: &Db, rconn: &RdsConn, id: i32) -> QueryResult<Self> {
pub async fn get(db: &Db, id: i32) -> QueryResult<Self> {
// 注意即使is_deleted也应该缓存和返回
let mut cacher = PostCache::init(&rconn);
if let Some(p) = cacher.get(&id).await {
Ok(p)
} else {
let p = Self::_get(db, id).await?;
cacher.sets(&vec![&p]).await;
Ok(p)
}
PostCache::get_with(id, async move { Self::_get(db, id).await }).await
}
pub async fn get_comments(&self, db: &Db, rconn: &RdsConn) -> QueryResult<Vec<Comment>> {
let mut cacher = PostCommentCache::init(self.id, rconn);
if let Some(cs) = cacher.get().await {
Ok(cs)
} else {
let cs = Comment::gets_by_post_id(db, self.id).await?;
cacher.set(&cs).await;
Ok(cs)
}
pub async fn get_comments(&self, db: &Db) -> QueryResult<Vec<Comment>> {
let cacher = PostCommentCache::init(self.id);
cacher
.get_with(async move { Comment::gets_by_post_id(db, self.id).await })
.await
}
pub async fn clear_comments_cache(&self, rconn: &RdsConn) {
PostCommentCache::init(self.id, rconn).clear().await;
pub async fn clear_comments_cache(&self) {
PostCommentCache::init(self.id).clear().await;
}
pub async fn gets_by_page(
db: &Db,
rconn: &RdsConn,
room_id: Option<i32>,
order_mode: u8,
start: i64,
limit: i64,
start: usize,
limit: usize,
) -> QueryResult<Vec<Self>> {
let mut cacher = PostListCommentCache::init(order_mode, &rconn);
if cacher.need_fill().await {
let pids =
Self::_get_ids_by_page(db, order_mode.clone(), 0, cacher.i64_minlen()).await?;
let ps = Self::get_multi(db, rconn, &pids).await?;
cacher.fill(&ps).await;
}
let pids = if start + limit > cacher.i64_len() {
Self::_get_ids_by_page(db, order_mode, start, limit).await?
let mut cacher = PostListCache::init(room_id, order_mode);
let current_len = cacher
.fill_with(async move {
let pids = Self::_get_ids_by_page(
db,
room_id,
order_mode,
0,
PostListCache::MAX_LENGTH as i64,
)
.await?;
Self::get_multi(db, &pids).await
})
.await?;
let pids = if start + limit > current_len {
Self::_get_ids_by_page(db, room_id, order_mode, start as i64, limit as i64).await?
} else {
cacher.get_pids(start, limit).await
};
Self::get_multi(db, rconn, &pids).await
Self::get_multi(db, &pids).await
}
async fn _get_ids_by_page(
db: &Db,
room_id: Option<i32>,
order_mode: u8,
start: i64,
limit: i64,
@@ -243,14 +249,23 @@ impl Post {
db.run(move |c| {
let mut query = base_query!(posts).select(posts::id);
if order_mode > 0 {
query = query.filter(posts::is_reported.eq(false))
query = query.filter(posts::is_reported.eq(false));
}
if order_mode == 1 {
query = query.filter(posts::n_comments.gt(0));
}
if let Some(ri) = room_id {
query = query.filter(posts::room_id.eq(ri));
}
query = match order_mode {
0 => query.order(posts::id.desc()),
1 => query.order(posts::last_comment_time.desc()),
2 => query.order(posts::hot_score.desc()),
3 => query.order(RANDOM),
3 => query.order(random()),
4 => query.order(posts::n_attentions.desc()),
_ => panic!("Wrong order mode!"),
};
@@ -261,13 +276,13 @@ impl Post {
pub async fn search(
db: &Db,
rconn: &RdsConn,
room_id: Option<i32>,
search_mode: u8,
search_text: String,
start: i64,
limit: i64,
) -> QueryResult<Vec<Self>> {
let search_text2 = search_text.replace("%", "\\%");
let search_text2 = search_text.replace('%', "\\%");
let pids = db
.run(move |c| {
let pat;
@@ -276,29 +291,38 @@ impl Post {
.distinct()
.left_join(comments::table)
.filter(posts::is_reported.eq(false));
if let Some(ri) = room_id {
query = query.filter(posts::room_id.eq(ri));
}
// 先用搜索+缓存性能有问题了再真的做tag表
query = match search_mode {
0 => {
pat = format!("%#{}%", &search_text2);
query
.filter(posts::cw.eq(&search_text))
.or_filter(posts::cw.eq(format!("#{}", &search_text)))
.or_filter(posts::content.like(&pat))
.or_filter(
comments::content
query.filter(
posts::cw
.eq(&search_text)
.or(posts::cw.eq(format!("#{}", &search_text)))
.or(posts::content.like(&pat))
.or(comments::content
.like(&pat)
.and(comments::is_deleted.eq(false)),
)
.and(comments::is_deleted.eq(false))),
)
}
1 => {
pat = format!("%{}%", search_text2.replace(" ", "%"));
pat = format!("%{}%", search_text2.replace(' ', "%"));
query
.filter(posts::content.like(&pat).or(comments::content.like(&pat)))
.filter(
posts::content.like(&pat).or(comments::content
.like(&pat)
.and(comments::is_deleted.eq(false))),
)
.filter(posts::allow_search.eq(true))
}
2 => query
.filter(posts::author_title.eq(&search_text))
.or_filter(comments::author_title.eq(&search_text)),
2 => query.filter(
posts::author_title
.eq(&search_text)
.or(comments::author_title.eq(&search_text)),
),
_ => panic!("Wrong search mode!"),
};
@@ -309,7 +333,7 @@ impl Post {
.load(with_log!(c))
})
.await?;
Self::get_multi(db, rconn, &pids).await
Self::get_multi(db, &pids).await
}
pub async fn create(db: &Db, new_post: NewPost) -> QueryResult<Self> {
@@ -321,29 +345,34 @@ impl Post {
.await
}
pub async fn set_instance_cache(&self, rconn: &RdsConn) {
PostCache::init(rconn).sets(&vec![self]).await;
pub async fn set_instance_cache(&self) {
PostCache::sets(&[self]).await;
}
pub async fn refresh_cache(&self, rconn: &RdsConn, is_new: bool) {
pub async fn refresh_cache(&self, is_new: bool) {
join!(
self.set_instance_cache(rconn),
future::join_all((if is_new { 0..4 } else { 1..4 }).map(|mode| async move {
PostListCommentCache::init(mode, &rconn.clone())
.put(self)
.await
})),
self.set_instance_cache(),
future::join_all((if is_new { [0, 2, 3, 4] } else { [1, 2, 3, 4] }).map(
|mode| async move {
PostListCache::init(None, mode).put(self).await;
PostListCache::init(Some(self.room_id), mode)
.put(self)
.await;
}
)),
);
}
pub async fn annealing(mut c: Conn, rconn: &RdsConn) {
pub async fn annealing(c: &mut Conn) {
info!("Time for annealing!");
diesel::update(posts::table.filter(posts::hot_score.gt(10)))
.set(posts::hot_score.eq(floor(float4(posts::hot_score) * 0.9)))
.execute(with_log!(&mut c))
.execute(with_log!(c))
.unwrap();
PostCache::init(&rconn).clear_all().await;
PostListCommentCache::init(2, rconn).clear().await
PostCache::clear_all().await;
for room_id in (0..5).map(Some).chain([None, Some(42)]) {
PostListCache::init(room_id, 2).clear().await;
}
}
}
@@ -359,8 +388,25 @@ impl User {
.ok()
}
pub async fn get_by_token(db: &Db, rconn: &RdsConn, token: &str) -> Option<Self> {
let mut cacher = UserCache::init(token, &rconn);
pub async fn get_by_token(db: &Db, token: &str) -> Option<Self> {
let cacher = UserCache::init(token);
if let Some(u) = cacher.get().await {
return Some(u);
}
let real_token;
let token = match &token.split(':').collect::<Vec<&str>>()[..] {
["sha256", tk] => {
let mut h = Sha256::new();
h.update(tk);
h.update("hole");
real_token = format!("{:x}", h.finalize())[0..16].to_string();
&real_token
}
_ => token,
};
// dbg!(token);
let cacher = UserCache::init(token);
if let Some(u) = cacher.get().await {
Some(u)
} else {
@@ -401,10 +447,22 @@ impl User {
})
.await
}
pub async fn clear_non_admin_users(c: &mut Conn) {
diesel::delete(users::table.filter(users::is_admin.eq(false)))
.execute(c)
.unwrap();
UserCache::clear_all().await;
}
pub async fn get_count(db: &Db) -> QueryResult<i64> {
db.run(move |c| users::table.count().get_result(with_log!(c)))
.await
}
}
#[derive(Insertable)]
#[table_name = "comments"]
#[diesel(table_name = comments)]
pub struct NewComment {
pub content: String,
pub author_hash: String,

67
src/rate_limit.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::iter;
use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::SystemTime;
use lru::LruCache;
pub struct Limiter {
record: Mutex<LruCache<i32, Vec<u64>>>,
amount: u64,
interval: u64,
}
impl Limiter {
pub fn init(amount: u64, interval: u64) -> Self {
Self {
record: Mutex::new(LruCache::new(NonZeroUsize::new(2000).unwrap())),
amount,
interval,
}
}
pub fn check(&self, uid: i32) -> bool {
let t = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut r = self.record.lock().unwrap();
if let Some(ts) = r.pop(&uid) {
let new_ts: Vec<u64> = ts
.into_iter()
.chain(iter::once(t))
.filter(|&tt| tt + self.interval > t)
.collect();
let len = new_ts.len() as u64;
r.put(uid, new_ts);
len < self.amount
} else {
r.put(uid, vec![t]);
true
}
}
}
pub struct MainLimiters {
post_min: Limiter,
post_hour: Limiter,
get_hour: Limiter,
}
impl MainLimiters {
pub fn init() -> Self {
Self {
post_min: Limiter::init(6, 60),
post_hour: Limiter::init(50, 3600),
get_hour: Limiter::init(1000, 3600),
}
}
pub fn check(&self, is_post: bool, uid: i32) -> bool {
if is_post {
self.post_hour.check(uid) && self.post_min.check(uid)
} else {
self.get_hour.check(uid)
}
}
}

View File

@@ -1,7 +1,7 @@
use redis::aio::MultiplexedConnection;
use rocket::request::{FromRequest, Outcome, Request};
use std::ops::{Deref, DerefMut};
use std::env;
use std::ops::{Deref, DerefMut};
pub struct RdsConn(pub MultiplexedConnection);
@@ -34,7 +34,6 @@ impl DerefMut for RdsConn {
}
}
pub async fn init_rds_client() -> MultiplexedConnection {
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
let client = redis::Client::open(redis_url).expect("connect to redis fail");

View File

@@ -1,6 +1,8 @@
use crate::api::CurrentUser;
use crate::api::{Api, CurrentUser, PolicyError};
use crate::random_hasher::random_string;
use crate::rds_conn::RdsConn;
use chrono::{offset::Local, DateTime};
use futures_util::stream::StreamExt;
use redis::{AsyncCommands, RedisResult};
use rocket::serde::json::serde_json;
use rocket::serde::{Deserialize, Serialize};
@@ -13,7 +15,7 @@ macro_rules! init {
}
}
};
($ktype:ty, $formatter:expr) => {
($ktype:ty, $formatter:literal) => {
pub fn init(k: $ktype, rconn: &RdsConn) -> Self {
Self {
key: format!($formatter, k),
@@ -21,7 +23,7 @@ macro_rules! init {
}
}
};
($k1type:ty, $k2type:ty, $formatter:expr) => {
($k1type:ty, $k2type:ty, $formatter:literal) => {
pub fn init(k1: $k1type, k2: $k2type, rconn: &RdsConn) -> Self {
Self {
key: format!($formatter, k1, k2),
@@ -47,11 +49,46 @@ macro_rules! add {
};
}
macro_rules! rem {
($vtype:ty) => {
pub async fn rem(&mut self, v: $vtype) -> RedisResult<usize> {
self.rconn.srem(&self.key, v).await
}
};
}
macro_rules! clear_all {
($pattern:literal) => {
pub async fn clear_all(rconn: &mut RdsConn) {
let keys: Vec<String> = rconn
.scan_match::<&str, String>($pattern)
.await
.unwrap()
.collect::<Vec<String>>()
.await;
rconn
.del(keys)
.await
.unwrap_or_else(|e| warn!("clear all fail, pattern: {} , {}", $pattern, e));
}
};
}
const KEY_SYSTEMLOG: &str = "hole_v2:systemlog_list";
const KEY_BANNED_USERS: &str = "hole_v2:banned_user_hash_list";
const KEY_BLOCKED_COUNTER: &str = "hole_v2:blocked_counter";
const KEY_CUSTOM_TITLE: &str = "hole_v2:title";
const CUSTOM_TITLE_KEEP_TIME: u64 = 7 * 24 * 60 * 60;
macro_rules! KEY_TITLE_SECRET {
($title: expr) => {
format!("hole_v2:title_secret:{}", $title)
};
}
const KEY_AUTO_BLOCK_RANK: &str = "hole_v2:auto_block_rank"; // rank * 5: 自动过滤的拉黑数阈值
const KEY_ANNOUNCEMENT: &str = "hole_v2:announcement";
const KEY_CANDIDATE: &str = "hole_v2:candidate";
const KEY_ADMIN: &str = "hole_v2:admin";
const SYSTEMLOG_MAX_LEN: isize = 1000;
@@ -67,6 +104,8 @@ impl Attention {
has!(i32);
clear_all!("hole_v2:attention:*");
pub async fn remove(&mut self, pid: i32) -> RedisResult<()> {
self.rconn.srem(&self.key, pid).await
}
@@ -74,8 +113,34 @@ impl Attention {
pub async fn all(&mut self) -> RedisResult<Vec<i32>> {
self.rconn.smembers(&self.key).await
}
}
// TODO: clear all
pub struct Reaction {
key: String,
rconn: RdsConn,
}
impl Reaction {
init!(i32, i32, "hole_v2:reaction:{}:{}");
add!(&str);
rem!(&str);
has!(&str);
}
pub async fn get_user_post_reaction_status(
rconn: &RdsConn,
pid: i32,
namehash: &str,
) -> RedisResult<i32> {
for rt in [-1, 1] {
if Reaction::init(pid, rt, rconn).has(namehash).await? {
return Ok(rt);
}
}
Ok(0)
}
#[derive(Serialize, Deserialize, Debug)]
@@ -86,6 +151,7 @@ pub enum LogType {
Ban,
}
/*
impl LogType {
pub fn contains_ugc(&self) -> bool {
match self {
@@ -94,6 +160,7 @@ impl LogType {
}
}
}
*/
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
@@ -109,7 +176,9 @@ impl Systemlog {
pub async fn create(&self, rconn: &RdsConn) -> RedisResult<()> {
let mut rconn = rconn.clone();
if rconn.llen::<&str, isize>(KEY_SYSTEMLOG).await? > SYSTEMLOG_MAX_LEN {
rconn.ltrim(KEY_SYSTEMLOG, 0, SYSTEMLOG_MAX_LEN - 1).await?;
rconn
.ltrim::<_, ()>(KEY_SYSTEMLOG, 0, SYSTEMLOG_MAX_LEN - 1)
.await?;
}
rconn
.lpush(KEY_SYSTEMLOG, serde_json::to_string(&self).unwrap())
@@ -142,8 +211,8 @@ impl BannedUsers {
rconn.clone().sismember(KEY_BANNED_USERS, namehash).await
}
pub async fn clear(rconn: &RdsConn) -> RedisResult<()> {
rconn.clone().del(KEY_BANNED_USERS).await
pub async fn clear(rconn: &mut RdsConn) -> RedisResult<()> {
rconn.del(KEY_BANNED_USERS).await
}
}
@@ -159,6 +228,8 @@ impl BlockedUsers {
has!(&str);
clear_all!("hole_v2:blocked_users:*");
pub async fn check_if_block(
rconn: &RdsConn,
user: &CurrentUser,
@@ -175,7 +246,7 @@ impl BlockedUsers {
pub struct BlockCounter;
impl BlockCounter {
pub async fn count_incr(rconn: &RdsConn, namehash: &str) -> RedisResult<usize> {
pub async fn count_incr(rconn: &RdsConn, namehash: &str) -> RedisResult<i32> {
rconn.clone().hincr(KEY_BLOCKED_COUNTER, namehash, 1).await
}
@@ -187,24 +258,63 @@ impl BlockCounter {
pub struct CustomTitle;
impl CustomTitle {
async fn gen_and_set_secret(rconn: &RdsConn, title: &str) -> RedisResult<String> {
let secret = random_string(8);
() = rconn
.clone()
.set_ex::<_, _, ()>(KEY_TITLE_SECRET!(&title), &secret, CUSTOM_TITLE_KEEP_TIME)
.await?;
Ok(secret)
}
// return false if title exits
pub async fn set(rconn: &RdsConn, namehash: &str, title: &str) -> RedisResult<bool> {
pub async fn set(rconn: &RdsConn, namehash: &str, title: &str, secret: &str) -> Api<String> {
let mut rconn = rconn.clone();
if rconn.hexists(KEY_CUSTOM_TITLE, title).await? {
Ok(false)
Err(PolicyError::TitleUsed)?
} else {
rconn.hset(KEY_CUSTOM_TITLE, namehash, title).await?;
rconn.hset(KEY_CUSTOM_TITLE, title, namehash).await?;
Ok(true)
let ori_secret: Option<String> = rconn.get(KEY_TITLE_SECRET!(title)).await?;
if ori_secret.is_none() {
clear_title_from_admins(&rconn, title).await?;
}
ori_secret
.map_or(Some(()), |s| (s.eq(&secret).then_some(())))
.ok_or(PolicyError::TitleProtected)?;
let old_title: Option<String> = rconn.hget(KEY_CUSTOM_TITLE, namehash).await?;
if let Some(t) = old_title {
clear_title_from_admins(&rconn, &t).await?;
}
() = rconn.hset(KEY_CUSTOM_TITLE, namehash, title).await?;
() = rconn.hset(KEY_CUSTOM_TITLE, title, namehash).await?;
Ok(Self::gen_and_set_secret(&rconn, title).await?)
}
}
pub async fn get(rconn: &RdsConn, namehash: &str) -> RedisResult<Option<String>> {
rconn.clone().hget(KEY_CUSTOM_TITLE, namehash).await
pub async fn get(
rconn: &RdsConn,
namehash: &str,
) -> RedisResult<(Option<String>, Option<String>)> {
let t: Option<String> = rconn.clone().hget(KEY_CUSTOM_TITLE, namehash).await?;
Ok(if let Some(title) = t {
let s: Option<String> = rconn.clone().get(KEY_TITLE_SECRET!(title)).await?;
let secret = if let Some(ss) = s {
() = rconn
.clone()
.expire(KEY_TITLE_SECRET!(title), CUSTOM_TITLE_KEEP_TIME as i64)
.await?;
ss
} else {
Self::gen_and_set_secret(rconn, &title).await?
};
(Some(title), Some(secret))
} else {
(None, None)
})
}
pub async fn clear(rconn: &RdsConn) -> RedisResult<()> {
rconn.clone().del(KEY_CUSTOM_TITLE).await
pub async fn clear(rconn: &mut RdsConn) -> RedisResult<()> {
rconn.del(KEY_CUSTOM_TITLE).await
}
}
@@ -220,11 +330,11 @@ impl AutoBlockRank {
pub async fn get(rconn: &RdsConn, namehash: &str) -> RedisResult<u8> {
let rank: Option<u8> = rconn.clone().hget(KEY_AUTO_BLOCK_RANK, namehash).await?;
Ok(rank.unwrap_or(2))
Ok(rank.unwrap_or(4))
}
pub async fn clear(rconn: &RdsConn) -> RedisResult<()> {
rconn.clone().del(KEY_AUTO_BLOCK_RANK).await
pub async fn clear(rconn: &mut RdsConn) -> RedisResult<()> {
rconn.del(KEY_AUTO_BLOCK_RANK).await
}
}
@@ -237,7 +347,7 @@ impl PollOption {
init!(i32, "hole_thu:poll_opts:{}");
pub async fn set_list(&mut self, v: &Vec<String>) -> RedisResult<()> {
self.rconn.del(&self.key).await?;
() = self.rconn.del(&self.key).await?;
self.rconn.rpush(&self.key, v).await
}
@@ -263,10 +373,47 @@ impl PollVote {
}
}
pub async fn clear_outdate_redis_data(rconn: &RdsConn) {
BannedUsers::clear(&rconn).await.unwrap();
CustomTitle::clear(&rconn).await.unwrap();
AutoBlockRank::clear(&rconn).await.unwrap();
pub async fn clear_outdate_redis_data(rconn: &mut RdsConn) {
BannedUsers::clear(rconn).await.unwrap();
CustomTitle::clear(rconn).await.unwrap();
AutoBlockRank::clear(rconn).await.unwrap();
Attention::clear_all(rconn).await;
BlockedUsers::clear_all(rconn).await;
}
pub async fn get_announcement(rconn: &RdsConn) -> RedisResult<Option<String>> {
rconn.clone().get(KEY_ANNOUNCEMENT).await
}
pub async fn is_elected_candidate(rconn: &RdsConn, title: &Option<String>) -> RedisResult<bool> {
if let Some(t) = title {
rconn.clone().sismember(KEY_CANDIDATE, t).await
} else {
Ok(false)
}
}
pub async fn is_elected_admin(rconn: &RdsConn, title: &Option<String>) -> RedisResult<bool> {
if let Some(t) = title {
rconn.clone().sismember(KEY_ADMIN, t).await
} else {
Ok(false)
}
}
pub async fn get_admin_list(rconn: &RdsConn) -> RedisResult<Vec<String>> {
rconn.clone().smembers(KEY_ADMIN).await
}
pub async fn get_candidate_list(rconn: &RdsConn) -> RedisResult<Vec<String>> {
rconn.clone().smembers(KEY_CANDIDATE).await
}
pub async fn clear_title_from_admins(rconn: &RdsConn, title: &str) -> RedisResult<()> {
let mut rconn = rconn.clone();
() = rconn.srem(KEY_CANDIDATE, title).await?;
rconn.srem(KEY_ADMIN, title).await
}
pub(crate) use clear_all;
pub(crate) use init;

View File

@@ -28,6 +28,9 @@ table! {
is_reported -> Bool,
hot_score -> Int4,
allow_search -> Bool,
room_id -> Int4,
up_votes -> Int4,
down_votes -> Int4,
}
}
@@ -42,8 +45,4 @@ table! {
joinable!(comments -> posts (post_id));
allow_tables_to_appear_in_same_query!(
comments,
posts,
users,
);
allow_tables_to_appear_in_same_query!(comments, posts, users,);