commit e44d120ba72bc7332c21f60f2c3f9c8eb9c5cb93 Author: saw <> Date: Fri Jun 4 08:57:14 2021 -0400 Initial commit v1.0.0 release of Boarders Version 1.0 release of Boarders. `overflow.tar.gz` is an archive of the project where I discovered a stack overflow due to a bug in Actix, or Rust itself, more investigation is needed. In the future `src/api/` needs to be rewritten to be more reusable so code isn't copy/pasted from there to `src/html/` for the frontend. In v1.1.0 page generation should be handled by a `PageBuilder` that is instantiated once for each thread. This is to dramatically reduce the number of HTML blobs scattered around in `src/html/` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c3b49d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/target + +# old data directory +/boards + +# new data directory, specified in default config file +/data + +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3822acd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "board_server" +version = "1.0.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "3.3.2" +actix-files = "0.5.0" +toml = "0.5" +serde = "1.0.125" +serde_json = "1.0" +bcrypt-bsd = "0.1.3" +rand = "0.8.3" +colored = "2.0.0" diff --git a/overflow.tar.gz b/overflow.tar.gz new file mode 100644 index 0000000..3e8bf50 Binary files /dev/null and b/overflow.tar.gz differ diff --git a/src/api/boards.rs b/src/api/boards.rs new file mode 100644 index 0000000..2595a25 --- /dev/null +++ b/src/api/boards.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use actix_web::{ + web, get, post, + Responder, HttpResponse, HttpRequest, HttpMessage +}; + +use crate::lib::{ + boards::*, + users::UserCookie +}; +use crate::api::types::{AppState, BoardForm}; +//use types::{AppState, BoardForm}; + +#[get("/boards/list")] +pub async fn list( + data: web::Data +) -> impl Responder { + HttpResponse::Ok().body(serde_json::to_string_pretty(&data.boards.lock().unwrap().clone()).unwrap()) +} + +#[post("/boards/new")] +pub async fn new( + req: HttpRequest, + data: web::Data, + form: web::Form +) -> impl Responder { + let cookie = req.cookie("auth"); + if cookie.is_none() { + return HttpResponse::Unauthorized().body("Unauthorized: You are not logged in"); + } + + let cookie = match UserCookie::parse(cookie.unwrap().value()) { + Ok(a) => a, + Err(_e) => { return HttpResponse::BadRequest().body("Bad Request: Could not parse user cookie") } + }; + + let um = data.user_manager.lock().unwrap(); + + if let Some(u) = um.get_user_by_id(cookie.id) { + if cookie.secret != u.secret { + return HttpResponse::BadRequest().body("Bad Request: Could not authenticate user"); + } + } else { + return HttpResponse::BadRequest().body("Bad Request: User does not exist"); + } + + let mut bf = data.board_factory.lock().unwrap(); + let mut path = PathBuf::from( + format!( + "{}/board.json", bf.next_id + ) + ); + path.push(&form.name); + if let Ok(_a) = Board::open(path) { + return HttpResponse::Conflict().body("Conflict: Board already exists"); + } + match bf.create_board(&form.name, &form.description, cookie.id) { + Ok(a) => { + if form.name.contains(' ') { + return HttpResponse::BadRequest().body("Bad Request: Name cannot contain spaces"); + } + println!("new board"); + data.boards.lock().unwrap().push(a.clone()); + HttpResponse::Created().body(serde_json::to_string(&a).unwrap()) + }, + Err(e) => HttpResponse::InternalServerError().body(format!("Internal Server Error: {}", e)) + } +} diff --git a/src/api/messages.rs b/src/api/messages.rs new file mode 100644 index 0000000..8f93b8c --- /dev/null +++ b/src/api/messages.rs @@ -0,0 +1,69 @@ +use actix_web::{ + web, post, + HttpResponse, HttpRequest, HttpMessage//, Responder +}; + +use super::types::{ + AppState, MessageForm +}; + +use crate::lib::users::{ + User, UserCookie//, UserCookieParseError +}; + +/* +impl From for HttpResponse { + fn from(ucpe: UserCookieParseError) -> HttpResponse { + ucpe.into() + } +} + +//@ line 40: let cookie = UserCookie::parse(cookie.unwrap().value())?; +*/ +/* + * For some reason the compiler lets me implicitly convert from UserCookieParseError + * even though (as far as I know) have no real way of (implicitly) converting between the two + * + * Down the road this causes a core dump following a stack overflow. + */ + +#[post("/messages/new")] +pub async fn new( + req: HttpRequest, + form: web::Form, + data: web::Data +) -> Result { + let cookie = req.cookie("auth"); + if cookie.is_none() { + return Err(HttpResponse::Unauthorized().body("Unauthorized: You are not logged in")); + } + let cookie = match UserCookie::parse(cookie.unwrap().value()) { + Ok(a) => a, + Err(e) => { + println!("{}", e); + return Err(HttpResponse::BadRequest().body("Bad Request: Could not parse cookie data")); + } + }; + let mut mf = data.msg_factory.lock().unwrap(); + let b = data.boards.lock().unwrap(); + let board = b.iter().find(|x| (x.id == form.board_id)); + let um = data.user_manager.lock().unwrap(); + match board { + Some(a) => { + let msg = mf.create_message({ + let user = um.get_user_by_id(form.user_id).ok_or_else(|| { + HttpResponse::NotFound().body("Not Found: User does not exist") + })?; + if user.secret == cookie.secret { + User::from(user) + } else { + return Err(HttpResponse::Unauthorized().body("Unauthorized: Bad login attempt, invalid auth token")); + } + }, form.content.clone()); + println!("new message"); + a.add_message(msg.clone()); + Ok(HttpResponse::Created().body(serde_json::to_string(&msg).unwrap())) + }, + None => Err(HttpResponse::BadRequest().body("Bad Request: Board does not exist")) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..5644fb2 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod boards; +pub mod messages; +pub mod types; +pub mod users; diff --git a/src/api/types.rs b/src/api/types.rs new file mode 100644 index 0000000..4f2688e --- /dev/null +++ b/src/api/types.rs @@ -0,0 +1,55 @@ +use std::sync::Mutex; + +use serde::{ + Deserialize, Serialize +}; + +use crate::lib::{ + boards::{BoardFactory, Board}, + messages::{MessageFactory}, + users::UserManager +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct AppState { + pub boards: Mutex>, + pub board_factory: Mutex, + pub msg_factory: Mutex, + pub user_manager: Mutex, +} + +#[derive(Serialize, Deserialize)] +pub struct BoardForm { + pub name: String, + pub description: String, + pub author: Option +} + +#[derive(Deserialize)] +pub struct PageForm { + pub page: u16 +} + +#[derive(Serialize, Deserialize)] +pub struct MessageForm { + pub content: String, + pub board_id: u32, + pub user_id: u32 +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserForm { + pub nick: Option, + pub id: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CryptoUserForm { + pub username: String, + pub password: String +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NickForm { + pub nickname: String +} diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..f17addb --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,114 @@ +//use std::fmt::Display; +use crate::lib::users::{User, Account}; + +use super::types::{AppState, UserForm, CryptoUserForm};//, NickForm}; + +use actix_web::{ + web, get, post, + http::{ + header::ContentType, + Cookie, + }, + HttpResponse//, HttpRequest, HttpMessage +}; + +#[post("/users/new")] +pub async fn new( + data: web::Data, + form: web::Form +) -> HttpResponse { + println!("new user"); + let mut um = data.user_manager.lock().unwrap(); + let user = { + if let Some(_a) = um.get_user_by_uname(form.username.clone()) { + return HttpResponse::Conflict().body("Conflict: user with that username already exists"); + } else { + let new_id = um.assign_id(); + User::new(new_id, &form.username).encrypt(form.password.clone()) + } + }; + //HttpResponse::Created().body("Created: Created user") + um.add_user(user.clone()); + HttpResponse::Created().body(serde_json::to_string(&User::from(user)).unwrap()) +} + +#[get("/users/list")] +pub async fn list( + data: web::Data +) -> HttpResponse { + let v: Vec = data.user_manager.lock().unwrap().users.clone().into_iter().map(User::from).collect(); + HttpResponse::Ok().set(ContentType::json()).body( + serde_json::to_string(&v).unwrap() + ) +} + +#[get("/users/get")] +pub async fn get( + data: web::Data, + form: web::Form +) -> HttpResponse { + let um = data.user_manager.lock().unwrap(); + if let Some(id) = form.id { + match um.get_user_by_id(id) { + Some(a) => return HttpResponse::Found() + .set(ContentType::json()) + .body(serde_json::to_string_pretty(&User::from(a)).unwrap()), + None => return HttpResponse::NotFound().body("Not Found: That user could not be found") + } + } else if let Some(nick) = &form.nick { + match um.get_user_by_nick(nick.to_string()) { + Some(a) => return HttpResponse::Found() + .set(ContentType::json()) + .body(serde_json::to_string_pretty(&User::from(a)).unwrap()), + None => return HttpResponse::NotFound().body("Not Found: That user could not be found") + } + } else { + HttpResponse::BadRequest().body("Bad Request: Bad form data") + } +} + +#[post("/users/auth")] +pub async fn auth( + data: web::Data, + form: web::Form +) -> HttpResponse { + let user = data.user_manager.lock().unwrap().get_user_by_uname(form.username.clone()); + if let Some(u) = user { + match u.verify(form.password.clone()) { + Ok(a) => if a { + let mut response = HttpResponse::Ok() + .body(serde_json::to_string(&User::from(u.clone())).unwrap()); + response.add_cookie( + &Cookie::build("auth", format!("{}&{}", u.user_id, u.secret)) + .domain("boards.solow.xyz") + //.domain("localhost") + .path("/") + .same_site(actix_web::cookie::SameSite::Strict) + //.secure(true) + .finish() + ).unwrap(); + response + } else { + HttpResponse::Forbidden().body("Forbidden: Could not authenticate user") + } + Err(e) => HttpResponse::BadRequest().body(format!("Bad Request: {}", e)) + } + } else { + HttpResponse::BadRequest().body("Bad Request: User does not exist") + } +} + +/* +#[post("/users/set_nick")] +pub async fn set_nick( + req: HttpRequest, + data: web::Data, + form: web::Form +) -> HttpResponse { + if let Some(cookie) = req.cookie("auth") { + HttpResponse::Ok().body("") + } else { + HttpResponse::Unauthorized().body("Unauthorized: You are not logged in") + } +} +*/ diff --git a/src/html/accounts.rs b/src/html/accounts.rs new file mode 100644 index 0000000..be41803 --- /dev/null +++ b/src/html/accounts.rs @@ -0,0 +1,136 @@ +use actix_web::{ + get, post, web, + HttpResponse, Responder, + cookie::Cookie +}; + +use crate::api::types::*; +use crate::lib::users::{User, Account}; + +#[post("/account/auth")] +pub async fn auth_html( + data: web::Data, + form: web::Form +) -> HttpResponse { + let user = data.user_manager.lock().unwrap().get_user_by_uname(form.username.clone()); + if let Some(u) = user { + match u.verify(form.password.clone()) { + Ok(a) => if a { + let mut response = HttpResponse::Ok() + .body(format!(r#" + + + + +
+

Hello, {0}

+
+

+Logged in as {0}, return to index +

+
+"#, u.username)); + response.add_cookie( + &Cookie::build("auth", format!("{}&{}", u.user_id, u.secret)) + .domain("boards.solow.xyz") + //.domain("localhost") + .path("/") + .same_site(actix_web::cookie::SameSite::Strict) + //.secure(true) + .finish() + ).unwrap(); + response + } else { + HttpResponse::Forbidden().body("Forbidden: Could not authenticate user") + } + Err(e) => HttpResponse::BadRequest().body(format!("Bad Request: {}", e)) + } + } else { + HttpResponse::BadRequest().body("Bad Request: User does not exist") + } +} + +#[get("/account/login/")] +pub async fn login() -> impl Responder { + HttpResponse::Ok().body(r#" + + + + +
+

Login

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+"#) +} + +#[post("/account/new")] +pub async fn sign_up_result( + data: web::Data, + form: web::Form +) -> HttpResponse { + println!("new user"); + let mut um = data.user_manager.lock().unwrap(); + let user = { + if let Some(_a) = um.get_user_by_uname(form.username.clone()) { + return HttpResponse::Conflict().body("Conflict: user with that username already exists"); + } else { + let new_id = um.assign_id(); + User::new(new_id, &form.username).encrypt(form.password.clone()) + } + }; + um.add_user(user.clone()); + HttpResponse::Created().body(format!(r#" + + + + +
+

Hello, {0}

+
+

+Hooray! You can log into you new account here +

+
+"#, user.username)) +} + +#[get("/account/new/")] +pub async fn sign_up() -> impl Responder { + HttpResponse::Ok().body(r#" + + + + +
+

Sign Up

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+"#) +} diff --git a/src/html/boards.rs b/src/html/boards.rs new file mode 100644 index 0000000..935797f --- /dev/null +++ b/src/html/boards.rs @@ -0,0 +1,264 @@ +use std::{ + time, + time::Duration, + path::PathBuf +}; + +use actix_web::{ + get, post, web, + HttpResponse, Responder, HttpRequest, HttpMessage +}; + +use crate::{ + lib::{ + boards::Board, + users::{UserCookie, User} + }, + api::types::* +}; + +#[get("/boards/")] +pub async fn list_boards( + data: web::Data, +) -> impl Responder { + let now = time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap(); + let mut ab = false; + let boards = (*data.boards.lock().unwrap()).clone(); + HttpResponse::Ok().body(format!( + r#" + + + + +
+
+

Board Index

+
+ +
+New Board [+] + + +{} +
NameDescriptionLast Active
+"#, + boards.iter().map(|b| { + ab = !ab; + format!( + "{}{}{}", + if ab { "a" } else { "b" }, b.id, b.title, b.desc, + if let Some(a) = b.messages.borrow().last() { + format!("{:.2} minutes", now.checked_sub(Duration::from_secs(a.timestamp)).unwrap().as_secs()/60) + } else { + String::from("Never") + } + ) + }).collect::() + )) +} + +#[get("/board/{board_id}/")] +pub async fn get_board( + req: HttpRequest, + data: web::Data, + web::Path(board_id): web::Path +) -> HttpResponse { + let mut ab = false; + let now = time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap(); + let boards = data.boards.lock().unwrap(); + let cookie = req.cookie("auth"); + let uid = if cookie.is_some() { + UserCookie::parse(cookie.unwrap().value()) + } else { + UserCookie::parse("null") + }; + if let Some(a) = Board::search(boards.iter(), board_id) { + HttpResponse::Ok().body(format!( + r#" + + + + + + +
+ +{} +
+
+
+ + + +
+
+
+"#, + a.title, + a.messages.borrow().iter().map(|x| { + ab = !ab; + format!( + "{}{}{}", + if ab { "a" } else { "b" }, + { + let author = x.author.clone().into_inner(); + author.nick.clone().or_else(|| Some(author.username.clone())).unwrap() + }, + x.text.replace('\n', "
"), + format!("{:.2} min.", now.checked_sub(Duration::from_secs(x.timestamp)).unwrap().as_secs()/60)//x.timestamp + ) + }).collect::(), + board_id, + match uid { + Ok(a) => a.id.to_string(), + Err(_e) => "null".to_string() + } + )) + } else { + HttpResponse::NotFound().body("That board could not be found") + } +} + +#[post("/boards/post")] +pub async fn board_post( + req: HttpRequest, + form: web::Form, + data: web::Data +) -> Result { + let cookie = req.cookie("auth"); + if cookie.is_none() { + return Err(HttpResponse::Unauthorized().body("Unauthorized: You are not logged in")); + } + let cookie = match UserCookie::parse(cookie.unwrap().value()) { + Ok(a) => a, + Err(e) => { + println!("{}", e); + return Err(HttpResponse::BadRequest().body("Bad Request: Could not parse cookie data")); + } + }; + let mut mf = data.msg_factory.lock().unwrap(); + let b = data.boards.lock().unwrap(); + let board = b.iter().find(|x| (x.id == form.board_id)); + let um = data.user_manager.lock().unwrap(); + match board { + Some(a) => { + let msg = mf.create_message({ + let user = um.get_user_by_id(form.user_id).ok_or_else(|| { + HttpResponse::NotFound().body("Not Found: User does not exist") + })?; + if user.secret == cookie.secret { + User::from(user) + } else { + return Err(HttpResponse::Unauthorized().body("Unauthorized: Bad login attempt, invalid auth token")); + } + }, form.content.clone()); + println!("new message"); + a.add_message(msg); + Ok(HttpResponse::SeeOther().header("location", format!("/board/{}/", a.id)).finish()) + }, + None => Err(HttpResponse::BadRequest().body("Bad Request: Board does not exist")) + } +} + +#[get("/boards/new/")] +pub async fn new_board( + req: HttpRequest, + data: web::Data, +) -> HttpResponse { + let cookie = req.cookie("auth"); + if cookie.is_none() { + return HttpResponse::Unauthorized().body("Unauthorized: You are not logged in"); + } + + let cookie = match UserCookie::parse(cookie.unwrap().value()) { + Ok(a) => a, + Err(_e) => { return HttpResponse::BadRequest().body("Bad Request: Could not parse user cookie") } + }; + + let um = data.user_manager.lock().unwrap(); + + if let Some(u) = um.get_user_by_id(cookie.id) { + if cookie.secret != u.secret { + return HttpResponse::BadRequest().body("Bad Request: Could not authenticate user"); + } + } else { + return HttpResponse::BadRequest().body("Bad Request: User does not exist"); + } + + HttpResponse::Ok().body(r#" + + + + +
+

New Board

+
+
+
+
+
+ +
+
+ + +
+ +
+
+
+"#) +} + +#[post("/boards/new")] +pub async fn new_board_result( + req: HttpRequest, + data: web::Data, + form: web::Form +) -> impl Responder { + let cookie = req.cookie("auth"); + if cookie.is_none() { + return HttpResponse::Unauthorized().body("Unauthorized: You are not logged in"); + } + + let cookie = match UserCookie::parse(cookie.unwrap().value()) { + Ok(a) => a, + Err(_e) => { return HttpResponse::BadRequest().body("Bad Request: Could not parse user cookie") } + }; + + let um = data.user_manager.lock().unwrap(); + + if let Some(u) = um.get_user_by_id(cookie.id) { + if cookie.secret != u.secret { + return HttpResponse::BadRequest().body("Bad Request: Could not authenticate user"); + } + } else { + return HttpResponse::BadRequest().body("Bad Request: User does not exist"); + } + + let mut bf = data.board_factory.lock().unwrap(); + let mut path = PathBuf::from( + format!( + "{}/board.json", bf.next_id + ) + ); + path.push(&form.name); + if let Ok(_a) = Board::open(path) { + return HttpResponse::Conflict().body("Conflict: Board already exists"); + } + match bf.create_board(&form.name, &form.description, cookie.id) { + Ok(a) => { + if form.name.contains(' ') { + return HttpResponse::BadRequest().body("Bad Request: Name cannot contain spaces"); + } + println!("new board"); + data.boards.lock().unwrap().push(a.clone()); + HttpResponse::SeeOther().header("location", format!("/board/{}/", a.id)).finish() + }, + Err(e) => HttpResponse::InternalServerError().body(format!("Internal Server Error: {}", e)) + } +} diff --git a/src/html/index.css b/src/html/index.css new file mode 100644 index 0000000..b141292 --- /dev/null +++ b/src/html/index.css @@ -0,0 +1,154 @@ +body { + margin: unset !important; + padding: unset; + max-width: unset; +} + +.bar { + padding: 0.5em; + background-color: #111111; + z-index: 1; + display: flex; + flex-direction: row; +} + +.bar > div { + flex-grow: 1; + align-self: center; +} + +.bar > div > h1 { + font-size: xxx-large; + margin: unset; + width: fit-content; + height: fit-content; +} + +.bar > div > p { + width: fit-content; + height: fit-content; + margin-left: auto; + margin-right: 0; + margin-right: 2em; +} + +.bar > h1 { + width: fit-content; + height: fit-content; +} + +.bar > h1 > a { + font-size: 0.5em; + vertical-align: middle; +} + +#title { + width: 100%; + height: 3em; + position: inherit; + top: 0; + left: 0; + margin-bottom: 1rem; + padding-left: 1em; +} + +#title > h1 { + font-size: 2.5em !important; +} + +#nav { + width: 3em; + position: fixed; + right: 0; + top: 0; + margin-left: 3rem; + height: 100%; +} + +.arrow { + width: 2.5rem; + background-color: #07a6ea; + color: white; + padding: 0.25rem; + border-radius: 0.5em; +} + +.orange-arrow { + width: 2.5rem; + color: orange; + padding: 0.25rem; + border-radius: 0.5em; +} + +.down { + transform: rotate(180deg); + -webkit-transform:rotate(180deg); + -moz-transform: rotate(180deg); + -ms-transform: rotate(180deg); + -o-transform: rotate(180deg); +} + +#navbar > img.arrow.down { + unset: top; + bottom: 0; + margin-bottom: 0.5em; +} + +#navbar > img.arrow { + position: relative; +} + +.message { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.message.right { +} +.message.left { +} + +.vertical-center { + margin: 0; + position: absolute; + top: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +table { + font-size: 1em; +} + +table, th, td { + border-collapse: collapse; +} + +th { + text-align: left; + margin-bottom: 1em; +} + +tr.a { + background-color: #3A3A3A; +} +tr.b { + background-color: inherit; +} + +.auth { + margin: auto; + width: 20em; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + border: 2px solid white; + border-radius: 5px; + background-color: #3A3A3A; +} + +#index { + padding: 3em; + padding-top: 0; +} diff --git a/src/html/mod.rs b/src/html/mod.rs new file mode 100644 index 0000000..e11dea5 --- /dev/null +++ b/src/html/mod.rs @@ -0,0 +1,2 @@ +pub mod accounts; +pub mod boards; diff --git a/src/html/navbar.html b/src/html/navbar.html new file mode 100644 index 0000000..a42e963 --- /dev/null +++ b/src/html/navbar.html @@ -0,0 +1,17 @@ + diff --git a/src/lib/boards.rs b/src/lib/boards.rs new file mode 100644 index 0000000..a74b8c2 --- /dev/null +++ b/src/lib/boards.rs @@ -0,0 +1,114 @@ +use serde::{Serialize, Deserialize}; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + fs::File, + time::SystemTime, + io::Result, + iter::Iterator +}; + +use super::{ + Archiveable, + messages::Message +}; + +//use colored::*; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Board { + pub title: String, + pub desc: String, + pub messages: RefCell>, + pub creation: u64, + pub id: u32, + pub path: String, + pub owner: u32 +} + +impl Board { + pub fn open

(path: P) -> Result where P: AsRef { + match File::open(&path) { + Ok(a) => Ok(serde_json::from_reader(a).unwrap()), + Err(e) => Err(e) + } + } + + pub fn search<'a, I>( + mut iter: I, + id: u32 + ) -> Option<&'a Board> + where + I: Iterator + { + iter.find(|x| x.id == id) + } + + pub fn add_message(&self, msg: Message) { + self.messages.borrow_mut().push(msg); + } +} + +impl Archiveable for Board { + fn get_root(&self) -> &Path { + Path::new(&self.path) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BoardFactory { + pub next_id: u32, + pub data_dir: String +} + +impl BoardFactory { + pub fn new

(path: P) -> BoardFactory where P: AsRef { + #[allow(clippy::redundant_field_names)] + BoardFactory { + next_id: 0, + data_dir: path.as_ref().to_str().unwrap().into() + } + } + + pub fn assign_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + pub fn set_data_dir

(&mut self, path: P) where P: AsRef { + self.data_dir = path.as_ref().to_str().unwrap().into(); + } + pub fn create_board(&mut self, name: S, descript: S, creator: u32) -> Result where S: AsRef { + let mut path: PathBuf = PathBuf::from(self.data_dir.clone()); + path.push(self.next_id.to_string()); + #[allow(clippy::redundant_field_names)] + let b = Board { + title: String::from(name.as_ref()), + desc: String::from(descript.as_ref()), + messages: RefCell::new(Vec::new()), + creation: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(), + id: self.assign_id(), + path: path.to_str().unwrap().to_string(), + owner: creator + }; + Ok(b) + /* + println!("board path: {:?}", path); + fs::create_dir_all(&path).unwrap(); + path.push("board.json"); + Board::open(&path).or_else(|_e| -> Result { + if !path.exists() { + let mut file = File::create(&path).unwrap(); + file.write_all(serde_json::to_string(&b).unwrap().as_bytes())?; + } + Board::open(path.parent().unwrap()) + }) + */ + } +} + +impl Archiveable for BoardFactory { + fn get_root(&self) -> &Path { + Path::new(&self.data_dir) + } +} diff --git a/src/lib/messages.rs b/src/lib/messages.rs new file mode 100644 index 0000000..b9e661f --- /dev/null +++ b/src/lib/messages.rs @@ -0,0 +1,53 @@ +use serde::{Serialize, Deserialize}; +use std::{ + cell::RefCell, + time::SystemTime, + path::Path +}; + +use super::{ + Archiveable, + users::User +}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Message { + pub text: String, + pub id: u32, + pub timestamp: u64, + pub author: RefCell +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageFactory { + pub next_id: u32, + pub last_write: Option, + pub data_dir: String +} + +impl MessageFactory { + pub fn new

(path: P) -> MessageFactory where P: AsRef { + MessageFactory { + next_id: 0, + last_write: None, + data_dir: path.as_ref().to_str().unwrap().into() + } + } + + pub fn create_message(&mut self, user: User, content: S) -> Message where S: AsRef { + self.next_id += 1; + let time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + Message { + text: String::from(content.as_ref()), + id: self.next_id-1, + timestamp: time, + author: RefCell::new(user) + } + } +} + +impl Archiveable for MessageFactory { + fn get_root(&self) -> &Path { + Path::new(&self.data_dir) + } +} diff --git a/src/lib/mod.rs b/src/lib/mod.rs new file mode 100644 index 0000000..0bc428b --- /dev/null +++ b/src/lib/mod.rs @@ -0,0 +1,68 @@ +pub mod boards; +pub mod messages; +pub mod users; + +use boards::BoardFactory; +use messages::MessageFactory; +use users::UserManager; + +use std::{ + path::Path +}; + +use serde::{de::{DeserializeOwned}, Serialize}; + +pub trait Archiveable: Serialize where T: DeserializeOwned { + fn get_json(&self) -> String where Self: Serialize { + serde_json::to_string(self).unwrap() + } + + fn get_root(&self) -> &Path; + + fn save_state

( + &self, path: P + ) -> Result<(), std::io::Error> where + Self: Serialize, + P: AsRef + { + std::fs::write({ + let mut p = self.get_root().to_path_buf(); + p.push(path); + p + }, self.get_json()) + } + + fn load_state

( + path: P + ) -> Result + where + P: AsRef, + { + let t = std::fs::read_to_string(path)?; + let a = serde_json::from_str::(&t); + Ok(a.unwrap()) + //Err(std::io::Error::new(std::io::ErrorKind::NotFound, "asd")) + } +} + +pub struct FactoryFactory { + data_dir: String +} + +impl FactoryFactory { + pub fn new

(dir: P) -> FactoryFactory where P: AsRef { + FactoryFactory { + data_dir: dir.as_ref().to_str().unwrap().to_string() + } + } + + pub fn build(&self) -> (BoardFactory, MessageFactory, UserManager) { + (BoardFactory::new(self.data_dir.clone()), + MessageFactory { + next_id: 0, + last_write: None, + data_dir: self.data_dir.clone() + }, + UserManager::new(self.data_dir.clone())) + } +} diff --git a/src/lib/users.rs b/src/lib/users.rs new file mode 100644 index 0000000..1cb36ee --- /dev/null +++ b/src/lib/users.rs @@ -0,0 +1,235 @@ +use std::{ + result::Result, + iter, + path::Path, + fmt::Display +}; +use serde::{Serialize, Deserialize}; +use bcrypt_bsd::{gen_salt, hash, to_str}; +use rand::{self, Rng}; +use super::Archiveable; + +pub trait Account { + fn encrypt(&self, password: String) -> CryptoUser; +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct User { + pub user_id: u32, + pub nick: Option, + pub username: String, +} + +impl User { + pub fn new(id: u32, username: S) -> User where S: AsRef { + User { + user_id: id, + nick: None, + username: username.as_ref().to_string(), + } + } +} + +impl Account for User { + fn encrypt(&self, mut password: String) -> CryptoUser { + let new_salt = gen_salt(12).unwrap(); + #[allow(unused_assignments)] + CryptoUser { + user_id: self.user_id, + nick: self.nick.clone(), + username: self.username.clone(), + pwh: { + let s = to_str(&hash(&password, &new_salt).unwrap()).unwrap().to_string(); + password = "\0".repeat(password.len()); + s + }, + salt: to_str(&new_salt).unwrap().to_string(), + secret: CryptoUser::gen_secret() + } + } +} + +impl From for User { + fn from(item: CryptoUser) -> Self { + User { + user_id: item.user_id, + nick: item.nick.clone(), + username: item.username + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CryptoUser { + pub user_id: u32, + nick: Option, + pub username: String, + pwh: String, + salt: String, + pub secret: String +} + +impl CryptoUser { + pub fn gen_secret() -> String { + let mut rng = rand::thread_rng(); + let s: String = iter::repeat(()) + .map(|()| rng.sample(rand::distributions::Alphanumeric)) + .map(char::from) + .take(32) + .collect(); + s + } + + #[allow(unused_assignments)] + pub fn verify(&self, mut password: String) -> Result { + let h = hash( + &password, + std::ffi::CString::new(self.salt.clone()).unwrap().as_bytes_with_nul() + )?; + let new_hash = to_str(&h).unwrap(); + password = "\0".repeat(password.len()); + Ok(new_hash == self.pwh) + } +} + +impl Account for CryptoUser { + fn encrypt(&self, mut password: String) -> CryptoUser { + let new_salt = gen_salt(16).unwrap(); + #[allow(unused_assignments)] + CryptoUser { + user_id: self.user_id, + nick: self.nick.clone(), + username: self.username.clone(), + pwh: { + let s = to_str(&hash(&password, &new_salt).unwrap()).unwrap().to_string(); + password = "\0".repeat(password.len()); + s + }, + salt: to_str(&new_salt).unwrap().to_string(), + secret: self.secret.clone() + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct UserManager { + pub next_id: u32, + pub(in super) data_dir: String, + pub users: Vec +} + +impl UserManager { + pub fn new

(path: P) -> UserManager where P: AsRef { + UserManager { + next_id: 0, + data_dir: path.as_ref().to_str().unwrap().to_string(), + users: Vec::new() + } + } + + pub fn assign_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + pub fn add_user(&mut self, user: CryptoUser) { + self.users.push(user); + } + + pub fn get_user_by_id(&self, uid: u32) -> Option { + self.users.clone().into_iter().find(|a| a.user_id == uid) + } + pub fn get_user_by_nick(&self, nick: String) -> Option { + self.users.clone().into_iter().find(|a| { + if let Some(an) = a.nick.clone() { + an == nick + } else { + false + } + }) + } + pub fn get_user_by_uname(&self, username: String) -> Option { + self.users.clone().into_iter().find(|a| a.username == username) + } +} + +impl Archiveable for UserManager { + fn get_root(&self) -> &Path { + Path::new(&self.data_dir) + } +} + +#[derive(Debug)] +pub struct UserCookie { + pub id: u32, + pub secret: String +} + +#[derive(Debug)] +pub struct UserCookieParseError { + msg: String +} + +impl UserCookieParseError { + pub fn new(s: S) -> UserCookieParseError where S: AsRef { + UserCookieParseError { + msg: s.as_ref().to_string() + } + } +} + +impl From<&'static str> for UserCookieParseError { + fn from(s: &'static str) -> UserCookieParseError { + UserCookieParseError::new(s) + } +} + +impl From for UserCookieParseError { + fn from(err: std::num::ParseIntError) -> UserCookieParseError { + UserCookieParseError::new(format!("{}", err)) + } +} + +impl From for String { + fn from(s: UserCookieParseError) -> String { + s.into() + } +} + +impl Display for UserCookieParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.msg) + } +} + +impl UserCookie { + /* + * Gross amounts of error handling because + * we don't want a poisoned mutex if someone + * decided to mess with cookie data + */ + pub fn parse(s: S) -> Result + where + S: AsRef + { + let s = s.as_ref().to_string(); + let mut ss = s.splitn(2, '&'); + Ok(UserCookie::new( + ss.next().ok_or_else(|| UserCookieParseError::new("Not enough fields"))?.parse::()?, + ss.next().ok_or_else(|| UserCookieParseError::new("Not enough fields"))?.to_string(), + )) + } + pub fn new(i: u32, s: S) -> UserCookie where S: AsRef { + UserCookie { + id: i, + secret: s.as_ref().to_string() + } + } +} + +impl Display for UserCookie { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}&{}", self.id, self.secret) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..680fad3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,241 @@ +use std::{ + env, fs, + sync::Mutex, + fs::{ + ReadDir + }, + path::Path, +}; + +use actix_web::{ + get, web, + App, HttpResponse, HttpServer, Responder, +}; + +mod lib; +use lib::{ + *, + Archiveable, + boards::{ + Board, + BoardFactory + }, + users::UserManager, + messages::MessageFactory +}; + +mod api; +use api::types::AppState; + +mod html; +use html::{ + accounts::*, + boards::* +}; + +/* +struct PageBuilder { + body: &'static str, + style: Option, + table_labels: Option +} + +impl PageBuilder { + fn new(text: &'static str) -> PageBuilder { + PageBuilder { + body: text, + style: None, + table_labels: None + } + } + + fn build(self, style: S, table_labels: S) -> String where S: AsRef { + String::from(self.body) + .replacen("{}", style.as_ref(), 1) + .replacen("{}", table_labels.as_ref(), 1) + } +} +*/ + +#[get("/")] +async fn index() -> impl Responder { + HttpResponse::Ok().body(r#" + + + + +

Boarders v1.0.0

+

+

What is it?

+Boarders is an easily deployable message board written in Rust. +It is extremely efficient on both the frontend and backend. +On the frontent all pages are static and (at this time) have no JS, only CSS/HTML. +The backend uses the 5th fastest web framework, Actix, according to techempower.com. +Which ensures that response times will always be snappy. +

+

+


+

Rules

+
    +
  1. No NSFW
    +At least until NSFW channels are implemented
  2. +
  3. Don't spam boards or user accounts
    +It's just anoying, and if it gets too bad I may have to hard reset everything
    +Don't ruin the fun for everyone
  4. +
  5. Have Fun!
    Pretty much anything goes, but if people don't like you, that's your fault
  6. +
+

+

------[Get Started]------

+ + +"#) +} + +#[get("/debug/")] +async fn debug( + data: web::Data +) -> impl Responder { + HttpResponse::Ok().body(serde_json::to_string_pretty(data.as_ref()).unwrap()) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + println!("Initializing..."); + let data_dir = Path::new("data/"); + let mut pwd = env::current_dir().unwrap(); + + let board_files: ReadDir = match fs::read_dir({ + pwd.push(data_dir); + pwd.push("boards/"); + &pwd + }) { + Ok(a) => a, + Err(_e) => { + fs::create_dir_all(&pwd).unwrap(); + fs::read_dir(pwd).unwrap() + } + }; + + let mut boards: Vec = Vec::new(); + + for file in board_files.map(|x| x.unwrap()) { + let mut path = file.path().to_path_buf(); + //let meta = fs::metadata(&path).unwrap(); + let ftype = file.file_type().unwrap(); + if ftype.is_dir() { + path.push("board.json"); + println!("loading board: {:?}", path); + boards.push( + Board::open(&path).unwrap_or_else(|e| { + panic!("Could not open board {:?}\n{}", path, e) + }) + ); + } + } + + boards.sort_by(|a,b| a.id.cmp(&b.id)); + + let mut board_data = std::path::PathBuf::new(); + board_data.push(&data_dir); + let ff = FactoryFactory::new(data_dir); + let (mut bf, mf, um) = ff.build(); + board_data.push("boards/"); + bf.set_data_dir(board_data); + + let web_data = web::Data::new(AppState { + boards: Mutex::new(boards.clone()), + board_factory: Mutex::new(BoardFactory::load_state({ + println!("Loading board factory file..."); + let mut dd = data_dir.to_path_buf(); + dd.push("boards/factory.json"); + dd + }).unwrap_or(bf)), + msg_factory: Mutex::new(MessageFactory::load_state({ + println!("Loading message factory file..."); + let mut dd = data_dir.to_path_buf(); + dd.push("message_factory.json"); + dd + }).unwrap_or(mf)), + user_manager: Mutex::new(UserManager::load_state({ + println!("Loading user manager file..."); + let mut dd = data_dir.to_path_buf(); + dd.push("users.json"); + dd + }).unwrap_or(um)) + }); + let wb = web_data.clone(); + + println!("Finished initialization"); + println!("Starting server..."); + + let server = HttpServer::new(move || { + App::new() + .app_data(web_data.clone()) + .service(index) + .service(debug) + .service(list_boards) + .service(login) + .service(auth_html) + .service(sign_up) + .service(sign_up_result) + .service(new_board) + .service(new_board_result) + .service(get_board) + .service(board_post) + .service( + web::scope("/api") + .service(api::boards::new) + .service(api::boards::list) + .service(api::messages::new) + .service(api::users::new) + .service(api::users::list) + .service(api::users::get) + .service(api::users::auth)) + .service(actix_files::Files::new("/static/", "./static/").show_files_listing()) + }).bind("127.0.0.1:8080")? + .run(); + println!("Started server"); + println!("{}", include_str!("res/banner")); + let res = server.await; + println!("\nExiting..."); + /* + * Note to self: handle all errors as much as possible + * Use unwrap() as little as possible + * If anything goes wrong while saving data data WILL get lost + */ + println!("Saving application state..."); + for b in wb.boards.lock().unwrap().iter() { + fs::create_dir_all(&b.path).unwrap_or_else( + |x| { println!("Could not create directory {:?}: {}", b.path, x) } + ); + b.save_state("board.json").unwrap_or_else( + |x| { println!("Could not save board {:?}: {}", b.title, x) } + ); + } + + wb.board_factory + .lock() + .unwrap() + .save_state("factory.json") + .unwrap_or_else( + |x| { println!("Could not save user factory state: {}", x) } + ); + + wb.msg_factory + .lock() + .unwrap() + .save_state("message_factory.json") + .unwrap_or_else( + |x| { println!("Could not save message factory state: {}", x) } + ); + + wb.user_manager + .lock() + .unwrap() + .save_state("users.json") + .unwrap_or_else( + |x| { println!("Could not save user data: {}", x) } + ); + println!("Done!"); + res +} diff --git a/src/res/banner b/src/res/banner new file mode 100644 index 0000000..a9e185f --- /dev/null +++ b/src/res/banner @@ -0,0 +1,5 @@ + __ __ + / /_ ____ ____ _ _____ ____/ / _____ _____ + / __ \ / __ \ / __ `// ___// __ / / ___// ___/ + / /_/ // /_/ // /_/ // / / /_/ /_ / / (__ ) +/_.___/ \____/ \__,_//_/ \__,_/(_)/_/ /____/ diff --git a/static/index.css b/static/index.css new file mode 100644 index 0000000..b141292 --- /dev/null +++ b/static/index.css @@ -0,0 +1,154 @@ +body { + margin: unset !important; + padding: unset; + max-width: unset; +} + +.bar { + padding: 0.5em; + background-color: #111111; + z-index: 1; + display: flex; + flex-direction: row; +} + +.bar > div { + flex-grow: 1; + align-self: center; +} + +.bar > div > h1 { + font-size: xxx-large; + margin: unset; + width: fit-content; + height: fit-content; +} + +.bar > div > p { + width: fit-content; + height: fit-content; + margin-left: auto; + margin-right: 0; + margin-right: 2em; +} + +.bar > h1 { + width: fit-content; + height: fit-content; +} + +.bar > h1 > a { + font-size: 0.5em; + vertical-align: middle; +} + +#title { + width: 100%; + height: 3em; + position: inherit; + top: 0; + left: 0; + margin-bottom: 1rem; + padding-left: 1em; +} + +#title > h1 { + font-size: 2.5em !important; +} + +#nav { + width: 3em; + position: fixed; + right: 0; + top: 0; + margin-left: 3rem; + height: 100%; +} + +.arrow { + width: 2.5rem; + background-color: #07a6ea; + color: white; + padding: 0.25rem; + border-radius: 0.5em; +} + +.orange-arrow { + width: 2.5rem; + color: orange; + padding: 0.25rem; + border-radius: 0.5em; +} + +.down { + transform: rotate(180deg); + -webkit-transform:rotate(180deg); + -moz-transform: rotate(180deg); + -ms-transform: rotate(180deg); + -o-transform: rotate(180deg); +} + +#navbar > img.arrow.down { + unset: top; + bottom: 0; + margin-bottom: 0.5em; +} + +#navbar > img.arrow { + position: relative; +} + +.message { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.message.right { +} +.message.left { +} + +.vertical-center { + margin: 0; + position: absolute; + top: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +table { + font-size: 1em; +} + +table, th, td { + border-collapse: collapse; +} + +th { + text-align: left; + margin-bottom: 1em; +} + +tr.a { + background-color: #3A3A3A; +} +tr.b { + background-color: inherit; +} + +.auth { + margin: auto; + width: 20em; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + border: 2px solid white; + border-radius: 5px; + background-color: #3A3A3A; +} + +#index { + padding: 3em; + padding-top: 0; +} diff --git a/static/navbar.html b/static/navbar.html new file mode 100644 index 0000000..a42e963 --- /dev/null +++ b/static/navbar.html @@ -0,0 +1,17 @@ +