feat: basic api & model for post
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# --> sqlite3
|
||||
*.db
|
||||
|
||||
# ---> Rust
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
|
||||
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -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"
|
||||
5
diesel.toml
Normal file
5
diesel.toml
Normal file
@@ -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
migrations/.gitkeep
Normal file
0
migrations/.gitkeep
Normal file
2
migrations/2022-03-11-065048_create_posts/down.sql
Normal file
2
migrations/2022-03-11-065048_create_posts/down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
DROP TABLE posts
|
||||
20
migrations/2022-03-11-065048_create_posts/up.sql
Normal file
20
migrations/2022-03-11-065048_create_posts/up.sql
Normal file
@@ -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`)
|
||||
|
||||
80
src/api/mod.rs
Normal file
80
src/api/mod.rs
Normal file
@@ -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;
|
||||
106
src/api/post.rs
Normal file
106
src/api/post.rs
Normal file
@@ -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
|
||||
}))
|
||||
}
|
||||
35
src/main.rs
Normal file
35
src/main.rs
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
82
src/models.rs
Normal file
82
src/models.rs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
22
src/random_hasher.rs
Normal file
22
src/random_hasher.rs
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
17
src/schema.rs
Normal file
17
src/schema.rs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
37
tools/migdb.py
Normal file
37
tools/migdb.py
Normal file
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user