diff --git a/.gitignore b/.gitignore index ea97930..10fcc03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# --> sqlite3 +*.db + # ---> Rust # Generated by Cargo # will have compiled files and executables diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a1bb35f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hole-thu" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = ["json"] } +diesel = { version = "1.4.8", features= ["sqlite", "chrono"] } +chrono = { version="0.4", features=["serde"] } +rand = "0.8.5" +dotenv = "0.15.0" diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..92267c8 --- /dev/null +++ b/diesel.toml @@ -0,0 +1,5 @@ +# For documentation on how to configure this file, +# see diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/2022-03-11-065048_create_posts/down.sql b/migrations/2022-03-11-065048_create_posts/down.sql new file mode 100644 index 0000000..a8c711c --- /dev/null +++ b/migrations/2022-03-11-065048_create_posts/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE posts diff --git a/migrations/2022-03-11-065048_create_posts/up.sql b/migrations/2022-03-11-065048_create_posts/up.sql new file mode 100644 index 0000000..9560e62 --- /dev/null +++ b/migrations/2022-03-11-065048_create_posts/up.sql @@ -0,0 +1,20 @@ +-- Your SQL goes here + +CREATE TABLE posts ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + author_hash VARCHAR NOT NULL, + content TEXT NOT NULL, + cw VARCHAR NOT NULL DEFAULT '', + author_title VARCHAR NOT NULL DEFAULT '', + n_likes INTEGER NOT NULL DEFAULT 0, + n_comments INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_comment_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + is_reported BOOLEAN NOT NULL DEFAULT FALSE, + hot_score INTEGER NOT NULL DEFAULT 0, + allow_search BOOLEAN NOT NULL DEFAULT '' +); +CREATE INDEX posts_last_comment_time_idx ON posts (`last_comment_time`); +CREATE INDEX posts_hot_idx ON posts (`hot_score`) + diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..ba11f30 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,80 @@ +use crate::random_hasher::RandomHasher; +use rocket::http::Status; +use rocket::request::{self, FromRequest, Request}; +use rocket::serde::json::{Value, json}; +use rocket::response::{self, Responder}; + +#[catch(401)] +pub fn catch_401_error() -> Value { + json!({ + "code": -1, + "msg": "未登录或token过期" + }) +} + +pub struct CurrentUser { + namehash: String, + is_admin: bool, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for CurrentUser { + type Error = (); + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let token = request.headers().get_one("User-Token"); + match token { + Some(t) => request::Outcome::Success(CurrentUser { + namehash: request + .rocket() + .state::() + .unwrap() + .hash_with_salt(t), + is_admin: t == "admin", // TODO + }), + None => request::Outcome::Failure((Status::Unauthorized, ())), + } + } +} + +pub enum PolicyError { + IsReported, + IsDeleted, + NotAllowed +} + + +pub enum APIError { + DbError(diesel::result::Error), + PcError(PolicyError) +} + +impl APIError { + fn from_db(err: diesel::result::Error) -> APIError { + APIError::DbError(err) + } +} + +impl<'r> Responder<'r, 'static> for APIError { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + match self { + APIError::DbError(e) => + json!({ + "code": -1, + "msg": e.to_string() + }).respond_to(req), + APIError::PcError(e) => + json!({ + "code": -1, + "msg": match e { + PolicyError::IsReported => "内容被举报,处理中", + PolicyError::IsDeleted => "内容被删除", + PolicyError::NotAllowed => "不允许的操作", + } + }).respond_to(req), + } + } +} + +pub type API = Result; + +pub mod post; diff --git a/src/api/post.rs b/src/api/post.rs new file mode 100644 index 0000000..4238e37 --- /dev/null +++ b/src/api/post.rs @@ -0,0 +1,106 @@ +use crate::api::{APIError, CurrentUser, PolicyError::*, API}; +use crate::models::*; +use chrono::NaiveDateTime; +use rocket::serde::{ + json::{json, Json, Value}, + Deserialize, Serialize, +}; + +#[derive(Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct PostInput<'r> { + content: &'r str, + cw: &'r str, +} + +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub struct PostOutput { + id: i32, + content: String, + cw: String, + author_title: String, + n_likes: i32, + n_comments: i32, + create_time: NaiveDateTime, + last_comment_time: NaiveDateTime, + allow_search: bool, + is_reported: Option, + // for old version frontend + timestamp: NaiveDateTime, +} + +fn p2output(p: &Post, user: &CurrentUser) -> PostOutput { + PostOutput { + id: p.id, + content: p.content.to_string(), + cw: p.cw.to_string(), + author_title: p.author_title.to_string(), + n_likes: p.n_likes, + n_comments: p.n_comments, + create_time: p.create_time, + last_comment_time: p.last_comment_time, + allow_search: p.allow_search, + is_reported: if user.is_admin { + Some(p.is_reported) + } else { + None + }, + // for old version frontend + timestamp: p.create_time, + } +} + +#[get("/post/")] +pub fn get_one(pid: i32, user: CurrentUser) -> API { + let conn = establish_connection(); + let p = Post::get(&conn, pid).map_err(APIError::from_db)?; + if !user.is_admin { + if p.is_reported { + return Err(APIError::PcError(IsReported)); + } + if p.is_deleted { + return Err(APIError::PcError(IsDeleted)); + } + } + Ok(json!({ + "data": p2output(&p, &user), + "code": 0, + })) +} + +#[get("/getlist?

&")] +pub fn get_list(p: Option, order_mode: u8, user: CurrentUser) -> API { + let page = p.unwrap_or(1); + let conn = establish_connection(); + let ps = Post::gets_by_page(&conn, order_mode, page, 25, user.is_admin) + .map_err(APIError::from_db)?; + let ps_data = ps + .iter() + .map(|p| p2output(p, &user)) + .collect::>(); + Ok(json!({ + "data": ps_data, + "count": ps_data.len(), + "code": 0 + })) +} + +#[post("/dopost", format = "json", data = "")] +pub fn publish_post(poi: Json, user: CurrentUser) -> API { + let conn = establish_connection(); + let r = Post::create( + &conn, + NewPost { + content: &poi.content, + cw: &poi.cw, + author_hash: &user.namehash, + author_title: "", + }, + ) + .map_err(APIError::from_db)?; + Ok(json!({ + "data": r, + "code": 0 + })) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..db81e5e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,35 @@ +#[macro_use] +extern crate rocket; + +#[macro_use] +extern crate diesel; + +mod random_hasher; +mod models; +mod schema; +mod api; + +use random_hasher::RandomHasher; + +#[launch] +fn rocket() -> _ { + load_env(); + rocket::build() + .mount("/_api", routes![ + api::post::get_list, + api::post::get_one, + api::post::publish_post + ]) + .register("/_api", catchers![ + api::catch_401_error + ]) + .manage(RandomHasher::get_random_one()) +} + +fn load_env() { + match dotenv::dotenv() { + Ok(path) => eprintln!("Configuration read from {}", path.display()), + Err(ref e) if e.not_found() => eprintln!("Warning: no .env was found"), + e => e.map(|_| ()).unwrap(), + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..458f5f7 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,82 @@ +#![allow(clippy::all)] + +use chrono::NaiveDateTime; +use diesel::{insert_into, Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; +use std::env; + +use crate::schema::posts; + +type MR = Result; + +no_arg_sql_function!(RANDOM, (), "Represents the sql RANDOM() function"); + +pub fn establish_connection() -> SqliteConnection { + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + SqliteConnection::establish(&database_url) + .expect(&format!("Error connecting to {}", database_url)) +} + +#[derive(Queryable, Debug)] +pub struct Post { + pub id: i32, + pub author_hash: String, + pub content: String, + pub cw: String, + pub author_title: String, + pub n_likes: i32, + pub n_comments: i32, + pub create_time: NaiveDateTime, + pub last_comment_time: NaiveDateTime, + pub is_deleted: bool, + pub is_reported: bool, + pub hot_score: i32, + pub allow_search: bool, +} + +#[derive(Insertable)] +#[table_name = "posts"] +pub struct NewPost<'a> { + pub content: &'a str, + pub cw: &'a str, + pub author_hash: &'a str, + pub author_title: &'a str, + // TODO: tags +} + +impl Post { + pub fn get(conn: &SqliteConnection, id: i32) -> MR { + posts::table.find(id).first(conn) + } + + pub fn gets_by_page( + conn: &SqliteConnection, + order_mode: u8, + page: u32, + page_size: u32, + is_admin: bool, + ) -> MR> { + let mut query = posts::table.into_boxed(); + if !is_admin { + query = query.filter(posts::is_deleted.eq(false)); + if order_mode > 0 { + query = query.filter(posts::is_reported.eq(false)) + } + } + + match order_mode { + 1 => query = query.order(posts::last_comment_time.desc()), + 2 => query = query.order(posts::hot_score.desc()), + 3 => query = query.order(RANDOM), + _ => query = query.order(posts::id.desc()), + } + query + .offset(((page - 1) * page_size).into()) + .limit(page_size.into()) + .load(conn) + } + + pub fn create(conn: &SqliteConnection, new_post: NewPost) -> MR { + // TODO: tags + insert_into(posts::table).values(&new_post).execute(conn) + } +} diff --git a/src/random_hasher.rs b/src/random_hasher.rs new file mode 100644 index 0000000..e88c51e --- /dev/null +++ b/src/random_hasher.rs @@ -0,0 +1,22 @@ +use rand::{distributions::Alphanumeric, thread_rng, Rng}; + +pub struct RandomHasher { + salt: String, +} + +impl RandomHasher { + pub fn get_random_one() -> RandomHasher { + RandomHasher { + salt: thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(), + } + } + + pub fn hash_with_salt(&self, text: &str) -> String { + // TODO + format!("hash({}+{})", self.salt, text) + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..0e9cc3a --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,17 @@ +table! { + posts (id) { + id -> Integer, + author_hash -> Text, + content -> Text, + cw -> Text, + author_title -> Text, + n_likes -> Integer, + n_comments -> Integer, + create_time -> Timestamp, + last_comment_time -> Timestamp, + is_deleted -> Bool, + is_reported -> Bool, + hot_score -> Integer, + allow_search -> Bool, + } +} diff --git a/tools/migdb.py b/tools/migdb.py new file mode 100644 index 0000000..c605717 --- /dev/null +++ b/tools/migdb.py @@ -0,0 +1,37 @@ +import sqlite3 +from datetime import datetime + +def mig_post(db_old, db_new): + c_old = db_old.cursor() + c_new = db_new.cursor() + rs = c_old.execute( + 'SELECT id, name_hash, content, cw, author_title, ' + 'likenum, n_comments, timestamp, comment_timestamp, ' + 'deleted, is_reported, hot_score, allow_search ' + 'FROM post WHERE deleted = false' + ) + + for r in rs: + r = list(r) + r[3] = r[3] or '' # cw + r[4] = r[4] or '' # author_title + r[8] = r[8] or r[7] # comment_timestamp + r[7] = datetime.fromtimestamp(r[7]) + r[8] = datetime.fromtimestamp(r[8]) + r[10] = r[10] or False # comment + c_new.execute( + 'INSERT OR REPLACE INTO posts VALUES({})'.format(','.join(['?'] * 13)), + r + ) + db_new.commit() + + c_old.close() + c_new.close() + + +if __name__ == '__main__': + db_old = sqlite3.connect('hole.db') + db_new = sqlite3.connect('hole_v2.db') + + mig_post(db_old, db_new) +