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/`
This commit is contained in:
saw 2021-06-04 08:57:14 -04:00
commit e44d120ba7
21 changed files with 1796 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
/target
# old data directory
/boards
# new data directory, specified in default config file
/data
Cargo.lock

16
Cargo.toml Normal file
View File

@ -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"

BIN
overflow.tar.gz Normal file

Binary file not shown.

69
src/api/boards.rs Normal file
View File

@ -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<AppState>
) -> 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<AppState>,
form: web::Form<BoardForm>
) -> 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))
}
}

69
src/api/messages.rs Normal file
View File

@ -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<UserCookieParseError> 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<MessageForm>,
data: web::Data<AppState>
) -> Result<HttpResponse, HttpResponse> {
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"))
}
}

4
src/api/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod boards;
pub mod messages;
pub mod types;
pub mod users;

55
src/api/types.rs Normal file
View File

@ -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<Vec<Board>>,
pub board_factory: Mutex<BoardFactory>,
pub msg_factory: Mutex<MessageFactory>,
pub user_manager: Mutex<UserManager>,
}
#[derive(Serialize, Deserialize)]
pub struct BoardForm {
pub name: String,
pub description: String,
pub author: Option<u32>
}
#[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<String>,
pub id: Option<u32>
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CryptoUserForm {
pub username: String,
pub password: String
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NickForm {
pub nickname: String
}

114
src/api/users.rs Normal file
View File

@ -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<AppState>,
form: web::Form<CryptoUserForm>
) -> 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<AppState>
) -> HttpResponse {
let v: Vec<User> = 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<AppState>,
form: web::Form<UserForm>
) -> 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<AppState>,
form: web::Form<CryptoUserForm>
) -> 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<AppState>,
form: web::Form<NickForm>
) -> HttpResponse {
if let Some(cookie) = req.cookie("auth") {
HttpResponse::Ok().body("")
} else {
HttpResponse::Unauthorized().body("Unauthorized: You are not logged in")
}
}
*/

136
src/html/accounts.rs Normal file
View File

@ -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<AppState>,
form: web::Form<CryptoUserForm>
) -> 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<h1>Hello, {0}</h1>
</div><div id="index">
<p>
Logged in as {0}, return to <a href="/boards/">index</a>
</p>
</body></html></div></body></html>
"#, 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<h1>Login</h1>
</div><div id="index">
<div class="auth">
<form action="/account/auth" method="post">
<div>
<label for="username">Username: </label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password: </label>
<input type="password" id="password" name="password" required>
</div>
<input type="submit" value="Login">
</form>
</div>
</body></html></div></body></html>
"#)
}
#[post("/account/new")]
pub async fn sign_up_result(
data: web::Data<AppState>,
form: web::Form<CryptoUserForm>
) -> 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<h1>Hello, {0}</h1>
</div><div id="index">
<p>
Hooray! You can log into you new account <a href="/account/login/">here</a>
</p>
</body></html></div></body></html>
"#, user.username))
}
#[get("/account/new/")]
pub async fn sign_up() -> impl Responder {
HttpResponse::Ok().body(r#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<h1>Sign Up</h1>
</div><div id="index">
<div class="auth">
<form action="/account/new" method="post">
<div>
<label for="username">Username: </label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password: </label>
<input type="password" id="password" name="password" required>
</div>
<input type="submit" value="Sign Up">
</form>
</div>
</body></html></div></body></html>
"#)
}

264
src/html/boards.rs Normal file
View File

@ -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<AppState>,
) -> 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<div>
<h1>Board Index</h1>
</div>
<div>
<p><a href="/account/login/">Login</a>/<a href="/account/new/">Sign Up</a></p>
</div>
</div><div id="index">
<a href="/boards/new/" style="margin-bottom: 0.5em;">New Board [+]</a>
<table style="width: 100%;" cellpadding=8px>
<tr><th>Name</th><th>Description</th><th>Last Active</th></tr>
{}
</table></body></html></div></body></html>
"#,
boards.iter().map(|b| {
ab = !ab;
format!(
"<tr class=\"{}\"><td><a href=\"/board/{}/\">{}</a></td><td>{}</td><td>{}</td></tr>",
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::<String>()
))
}
#[get("/board/{board_id}/")]
pub async fn get_board(
req: HttpRequest,
data: web::Data<AppState>,
web::Path(board_id): web::Path<u32>
) -> 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head>
<body>
<div id="title" class="bar"><h1>
{} | <a href="/boards/"><- Back to index</a>
</h1></div>
<div id="index">
<table style="width: 100%;" cellpadding=8px>
{}
</table>
<form action="/boards/post" method="post">
<label for="content">Content:</label><br/>
<input type="hidden" id="board_id" name="board_id" value="{}">
<input type="hidden" id="user_id" name="user_id" value="{}">
<textarea name="content" id="content" rows=12 cols=50 placeholder="Enter message here..."></textarea>
<br/><input type="submit" value="Post Message" style="margin: 0.5em;">
</form>
</div></body></html>
"#,
a.title,
a.messages.borrow().iter().map(|x| {
ab = !ab;
format!(
"<tr class=\"message {}\"><td>{}</td><td>{}</td><td>{}</td></tr>",
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', "<br>"),
format!("{:.2} min.", now.checked_sub(Duration::from_secs(x.timestamp)).unwrap().as_secs()/60)//x.timestamp
)
}).collect::<String>(),
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<MessageForm>,
data: web::Data<AppState>
) -> Result<HttpResponse, HttpResponse> {
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<AppState>,
) -> 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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
<link rel="stylesheet" type="text/css" href="/static/index.css">
</head><body>
<div id="title" class="bar">
<h1>New Board</h1>
</div><div id="index">
<div class="auth">
<form action="/boards/new" method="post">
<div>
<label for="name">Board Title: </label><br>
<input type="text" id="name" name="name" required>
</div>
<div>
<label for="description">Board Description: </label>
<input type="text" id="description" name="description" required>
</div>
<input type="submit" value="Create Board">
</form>
</div>
</body></html></div></body></html>
"#)
}
#[post("/boards/new")]
pub async fn new_board_result(
req: HttpRequest,
data: web::Data<AppState>,
form: web::Form<BoardForm>
) -> 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))
}
}

154
src/html/index.css Normal file
View File

@ -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;
}

2
src/html/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod accounts;
pub mod boards;

17
src/html/navbar.html Normal file
View File

@ -0,0 +1,17 @@
<div id="nav" class="bar">
<img class="arrow up" src="https://static.solow.xyz/boards/arrow.svg">
<img class="arrow down" style="bottom: 0; position: absolute; right: 0; margin-right: 9; margin-bottom: 28;" src="https://static.solow.xyz/boards/arrow.svg">
<div class="vertical-center">
<!--<img class="orange-arrow right" src="https://static.solow.xyz/boards/arrow.svg">-->
<a href="../">
Next<br>
Page
</a>
<hr>
<a href="../">
Prev<br>
Page
</a>
<!--<img class="orange-arrow left" src="https://static.solow.xyz/boards/arrow.svg">-->
</div>
</div>

114
src/lib/boards.rs Normal file
View File

@ -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<Vec<Message>>,
pub creation: u64,
pub id: u32,
pub path: String,
pub owner: u32
}
impl Board {
pub fn open<P>(path: P) -> Result<Board> where P: AsRef<Path> {
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<Item = &'a Board>
{
iter.find(|x| x.id == id)
}
pub fn add_message(&self, msg: Message) {
self.messages.borrow_mut().push(msg);
}
}
impl Archiveable<Self> 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<P>(path: P) -> BoardFactory where P: AsRef<Path> {
#[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<P>(&mut self, path: P) where P: AsRef<Path> {
self.data_dir = path.as_ref().to_str().unwrap().into();
}
pub fn create_board<S>(&mut self, name: S, descript: S, creator: u32) -> Result<Board> where S: AsRef<str> {
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<Board> {
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<Self> for BoardFactory {
fn get_root(&self) -> &Path {
Path::new(&self.data_dir)
}
}

53
src/lib/messages.rs Normal file
View File

@ -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<User>
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MessageFactory {
pub next_id: u32,
pub last_write: Option<u64>,
pub data_dir: String
}
impl MessageFactory {
pub fn new<P>(path: P) -> MessageFactory where P: AsRef<Path> {
MessageFactory {
next_id: 0,
last_write: None,
data_dir: path.as_ref().to_str().unwrap().into()
}
}
pub fn create_message<S>(&mut self, user: User, content: S) -> Message where S: AsRef<str> {
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<Self> for MessageFactory {
fn get_root(&self) -> &Path {
Path::new(&self.data_dir)
}
}

68
src/lib/mod.rs Normal file
View File

@ -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<T>: 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<P>(
&self, path: P
) -> Result<(), std::io::Error> where
Self: Serialize,
P: AsRef<Path>
{
std::fs::write({
let mut p = self.get_root().to_path_buf();
p.push(path);
p
}, self.get_json())
}
fn load_state<P>(
path: P
) -> Result<T, std::io::Error>
where
P: AsRef<Path>,
{
let t = std::fs::read_to_string(path)?;
let a = serde_json::from_str::<T>(&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<P>(dir: P) -> FactoryFactory where P: AsRef<Path> {
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()))
}
}

235
src/lib/users.rs Normal file
View File

@ -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<String>,
pub username: String,
}
impl User {
pub fn new<S>(id: u32, username: S) -> User where S: AsRef<str> {
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<CryptoUser> 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<String>,
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<bool, bcrypt_bsd::CryptError> {
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<CryptoUser>
}
impl UserManager {
pub fn new<P>(path: P) -> UserManager where P: AsRef<Path> {
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<CryptoUser> {
self.users.clone().into_iter().find(|a| a.user_id == uid)
}
pub fn get_user_by_nick(&self, nick: String) -> Option<CryptoUser> {
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<CryptoUser> {
self.users.clone().into_iter().find(|a| a.username == username)
}
}
impl Archiveable<Self> 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: S) -> UserCookieParseError where S: AsRef<str> {
UserCookieParseError {
msg: s.as_ref().to_string()
}
}
}
impl From<&'static str> for UserCookieParseError {
fn from(s: &'static str) -> UserCookieParseError {
UserCookieParseError::new(s)
}
}
impl From<std::num::ParseIntError> for UserCookieParseError {
fn from(err: std::num::ParseIntError) -> UserCookieParseError {
UserCookieParseError::new(format!("{}", err))
}
}
impl From<UserCookieParseError> 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: S) -> Result<UserCookie, UserCookieParseError>
where
S: AsRef<str>
{
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::<u32>()?,
ss.next().ok_or_else(|| UserCookieParseError::new("Not enough fields"))?.to_string(),
))
}
pub fn new<S>(i: u32, s: S) -> UserCookie where S: AsRef<str> {
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)
}
}

241
src/main.rs Normal file
View File

@ -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<String>,
table_labels: Option<String>
}
impl PageBuilder {
fn new(text: &'static str) -> PageBuilder {
PageBuilder {
body: text,
style: None,
table_labels: None
}
}
fn build<S>(self, style: S, table_labels: S) -> String where S: AsRef<str> {
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#"
<html><head>
<link rel="stylesheet" type="text/css" href="https://orbitalfox.us/Music/index.css">
</head>
<body>
<h1>Boarders v1.0.0</h1>
<p>
<h2>What is it?</h2>
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 <a href="https://www.techempower.com/benchmarks/">techempower.com</a>.
Which ensures that response times will always be snappy.
</p>
<p>
<hr>
<h2>Rules</h2>
<ol>
<li>No NSFW<br>
At least until NSFW channels are implemented</li>
<li>Don't spam boards or user accounts<br>
It's just anoying, and if it gets too bad I may have to hard reset everything<br>
Don't ruin the fun for everyone</li>
<li>Have Fun!<br>Pretty much anything goes, but if people don't like you, that's your fault</li>
</ol>
</p>
<h3 style="text-align: center;">------<a href="/boards/">[Get Started]</a>------</h3>
</body>
</html>
"#)
}
#[get("/debug/")]
async fn debug(
data: web::Data<AppState>
) -> 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<Board> = 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
}

5
src/res/banner Normal file
View File

@ -0,0 +1,5 @@
__ __
/ /_ ____ ____ _ _____ ____/ / _____ _____
/ __ \ / __ \ / __ `// ___// __ / / ___// ___/
/ /_/ // /_/ // /_/ // / / /_/ /_ / / (__ )
/_.___/ \____/ \__,_//_/ \__,_/(_)/_/ /____/

154
static/index.css Normal file
View File

@ -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;
}

17
static/navbar.html Normal file
View File

@ -0,0 +1,17 @@
<div id="nav" class="bar">
<img class="arrow up" src="https://static.solow.xyz/boards/arrow.svg">
<img class="arrow down" style="bottom: 0; position: absolute; right: 0; margin-right: 9; margin-bottom: 28;" src="https://static.solow.xyz/boards/arrow.svg">
<div class="vertical-center">
<!--<img class="orange-arrow right" src="https://static.solow.xyz/boards/arrow.svg">-->
<a href="../">
Next<br>
Page
</a>
<hr>
<a href="../">
Prev<br>
Page
</a>
<!--<img class="orange-arrow left" src="https://static.solow.xyz/boards/arrow.svg">-->
</div>
</div>