13 changed files with 423 additions and 0 deletions
@ -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" |
@ -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" |
@ -0,0 +1,2 @@ |
|||||||
|
-- This file should undo anything in `up.sql` |
||||||
|
DROP TABLE posts |
@ -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`) |
||||||
|
|
@ -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<Self, Self::Error> { |
||||||
|
let token = request.headers().get_one("User-Token"); |
||||||
|
match token { |
||||||
|
Some(t) => request::Outcome::Success(CurrentUser { |
||||||
|
namehash: request |
||||||
|
.rocket() |
||||||
|
.state::<RandomHasher>() |
||||||
|
.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<T> = Result<T, APIError>; |
||||||
|
|
||||||
|
pub mod post; |
@ -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<bool>, |
||||||
|
// 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/<pid>")] |
||||||
|
pub fn get_one(pid: i32, user: CurrentUser) -> API<Value> { |
||||||
|
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?<p>&<order_mode>")] |
||||||
|
pub fn get_list(p: Option<u32>, order_mode: u8, user: CurrentUser) -> API<Value> { |
||||||
|
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::<Vec<PostOutput>>(); |
||||||
|
Ok(json!({ |
||||||
|
"data": ps_data, |
||||||
|
"count": ps_data.len(), |
||||||
|
"code": 0 |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
#[post("/dopost", format = "json", data = "<poi>")] |
||||||
|
pub fn publish_post(poi: Json<PostInput>, user: CurrentUser) -> API<Value> { |
||||||
|
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 |
||||||
|
})) |
||||||
|
} |
@ -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(), |
||||||
|
} |
||||||
|
} |
@ -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<T> = Result<T, diesel::result::Error>; |
||||||
|
|
||||||
|
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<Self> { |
||||||
|
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<Vec<Self>> { |
||||||
|
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<usize> { |
||||||
|
// TODO: tags
|
||||||
|
insert_into(posts::table).values(&new_post).execute(conn) |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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, |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
|
Loading…
Reference in new issue