diff --git a/client-solid/index.html b/client-solid/index.html index 35ca88f..363220e 100644 --- a/client-solid/index.html +++ b/client-solid/index.html @@ -9,7 +9,7 @@ FrostByteSolid - +
diff --git a/client-solid/src/Login.tsx b/client-solid/src/Login.tsx new file mode 100644 index 0000000..1af585c --- /dev/null +++ b/client-solid/src/Login.tsx @@ -0,0 +1,92 @@ +import { createSignal, useContext } from "solid-js"; +import { LoginContext, ModalContext } from "./Root"; + +export function LoginForm() { + const modal_ctx = useContext(ModalContext); + const login_ctx = useContext(LoginContext); + const [username, setUsername] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [waiting, setWaiting] = createSignal(false); + const [error, setError] = createSignal(false); + + async function loginFailed() { + setError(true); + setWaiting(false); + setTimeout(() => { + setError(false); + }, 1000); + } + + return ( +
+ + { + setUsername(e.target.value); + }} /> + + { + setPassword(e.target.value); + }} /> + +
+ ); +} + +// This function is responsible for sending the login request to the server +// and storing the token in localstorage +export async function submitLogin( + username: string, + password: string +): Promise { + const response = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.token && data.username) { + localStorage.setItem("token", data.token); + localStorage.setItem("username", data.username); + return data.token; + } + } + return ""; +} + diff --git a/client-solid/src/Navbar.tsx b/client-solid/src/Navbar.tsx new file mode 100644 index 0000000..ca5e4b8 --- /dev/null +++ b/client-solid/src/Navbar.tsx @@ -0,0 +1,83 @@ +import { useContext } from "solid-js"; +import { A } from "@solidjs/router"; +import { LoginContext } from "./Root"; +import { ModalContext } from "./Root"; +import { LoginForm } from "./Login"; + +function Menu() { + let login_ctx = useContext(LoginContext); + return ( + + ); +} + +export function Navbar() { + let modal_ctx = useContext(ModalContext); + let login_ctx = useContext(LoginContext); + + return ( + + ); +} + +// This is a modal +export function Login() { + const modal_ctx = useContext(ModalContext); + return ( + + + + + ); +} diff --git a/client-solid/src/Posts.tsx b/client-solid/src/Posts.tsx new file mode 100644 index 0000000..85e8dd1 --- /dev/null +++ b/client-solid/src/Posts.tsx @@ -0,0 +1,43 @@ +import { createSignal } from "solid-js"; +import { getPosts } from "./api"; +import { Post } from "./api"; +import { useNavigate } from "@solidjs/router"; + +export function Posts() { + const [posts, setPosts] = createSignal([] as Post[]); + const [loading, setLoading] = createSignal(true); + + getPosts().then((posts) => { + setPosts(posts as any); + setLoading(false); + }); + + return ( +
+ {loading() ? ( + + ) : ( + <> + )} + {posts().map((post) => { + if (post.content == "") return; // Filtering out empty posts, remove this later + return ; + })} +
+ ); +} + +// This is the card container for a post +export function PostSegment({ post }: { post: Post }) { + const nav = useNavigate(); + return ( +
nav("/post/" + post?.id)} + class="card bg-base-200 shadow-lg compact text-base-content w-full" + > +
+

{post?.content}

+
+
+ ); +} diff --git a/client-solid/src/Primary.tsx b/client-solid/src/Primary.tsx new file mode 100644 index 0000000..13d0eae --- /dev/null +++ b/client-solid/src/Primary.tsx @@ -0,0 +1,16 @@ +import { Route, Routes } from "@solidjs/router"; +import { Posts } from "./Posts"; +import { SinglePost } from "./SinglePost"; +import { NewPostInputArea } from "./Root"; + +// Primary is the section of the page that holds the main content +export function Primary() { + return ( + + } /> + } /> + } /> + 404} /> + + ); +} diff --git a/client-solid/src/Root.tsx b/client-solid/src/Root.tsx index 254424a..ef7aaa7 100644 --- a/client-solid/src/Root.tsx +++ b/client-solid/src/Root.tsx @@ -1,143 +1,111 @@ -import { createSignal } from "solid-js"; +import { Accessor, Show, createSignal, useContext } from "solid-js"; import { createContext } from "solid-js"; -import { Route, Routes, A } from "@solidjs/router"; +import { createPost } from "./api"; +import { NewPost } from "./api"; +import { Navbar } from "./Navbar"; +import { Primary } from "./Primary"; +import { Login } from "./Navbar"; +import { useNavigate } from "@solidjs/router"; -import { createPost, getPosts } from "./api"; -import { Post, NewPost } from "./api"; +// Representing the state of varoious modals. +// So far we only have one modal, but we can add more later +// by adding more fields to this interface, or maybe an enum +interface ModalContextType { + loginModalOpen: Accessor; + setLoginModalOpen: (value: boolean) => void; +} -export const TestContext = createContext("Test123"); +interface LoginContextType { + token: Accessor; + setToken: (value: string) => void; + username: Accessor; + setUsername: (value: string) => void; +} + +// It is unclear to me if this is the idiomatic way to do this in Solid +export const ModalContext = createContext(); +export const LoginContext = createContext(); function Root() { + // All of these are passed into context providers + const [loginModalOpen, setLoginModalOpen] = createSignal(false); + const [token, setToken] = createSignal(""); + const [username, setUsername] = createSignal(""); + + // This may not be the best place to do this. + localStorage.getItem("token") && setToken(localStorage.getItem("token")!); + localStorage.getItem("username") && + setUsername(localStorage.getItem("username")!); + return ( <> - -
- -
- + + +
+ + +
+ +
-
- + + ); } -function Navbar() { - return ( - - ); -} - -function Menu() { - return ( - - ); -} - -function NewPostInputArea() { +export function NewPostInputArea() { const [content, setContent] = createSignal(""); - return ( -
- - -
- ); -} + const [waiting, setWaiting] = createSignal(false); + const login_ctx = useContext(LoginContext); -function Posts() { - const [posts, setPosts] = createSignal([] as Post[]); - const [loading, setLoading] = createSignal(true); + const nav = useNavigate(); - getPosts().then((posts) => { - setPosts(posts as any); - setLoading(false) - }); + const sendPost = () => { + setWaiting(true); + const response = createPost({ + content: content(), + token: login_ctx?.token(), + } as NewPost); + if (response) { + response.then(() => { + setWaiting(false); + setContent(""); + nav("/"); + }); + } + }; return ( -
- { loading() ? : <> } - {posts().map((post) => { - if (post.content == "") return; // Filtering out empty posts, remove this later - return ; - })} -
- ); -} - -function PostSegment({ post }: { post: Post }) { - return ( -
-
-

{post.content}

- {/*

{post.votes.up}

-

{post.votes.down}

*/} + + } + > +
+ +
-
- ); -} - -function Primary() { - return ( - - } /> - } /> - Boards
} /> - } /> - - ); -} - -function Login() { - return ( -
- Login - -
+ ); } diff --git a/client-solid/src/SinglePost.tsx b/client-solid/src/SinglePost.tsx new file mode 100644 index 0000000..b4440be --- /dev/null +++ b/client-solid/src/SinglePost.tsx @@ -0,0 +1,19 @@ +import { useParams } from "@solidjs/router"; +import { Show, Suspense, createResource } from "solid-js"; +import { getPost } from "./api"; +import { PostSegment } from "./Posts"; + +export function SinglePost() { + const params = useParams(); + const [post] = createResource(params.postid, getPost); + + return ( + Some loading message
}> +
+ + + +
+ + ); +} diff --git a/client-solid/src/api.ts b/client-solid/src/api.ts index b1ed3a6..6b9660f 100644 --- a/client-solid/src/api.ts +++ b/client-solid/src/api.ts @@ -1,6 +1,4 @@ -// const PORT = 3000; -// const API_URL = `http://localhost:${PORT}/api/`; -// const API_URL2 = new URL(API_URL); +// This file contains types and functions related to interacting with the API. export interface NewPost { content: string; @@ -13,13 +11,12 @@ interface Votes { } export interface Post extends NewPost { - uuid: string; + id: string; createdAt: string; votes: Votes; } export async function getPosts(): Promise { -// const res = await fetch(`${API_URL}/posts`); const res = await fetch("/api/posts"); const data = await res.json(); return data; @@ -27,7 +24,9 @@ export async function getPosts(): Promise { export async function getPost(id: string): Promise { const res = await fetch(`/api/posts/${id}`); + console.log(res) const data = await res.json(); + console.log(data) return data; } diff --git a/justfile b/justfile index 5bce120..1ec67a7 100644 --- a/justfile +++ b/justfile @@ -4,14 +4,15 @@ runtime := "podman" dev: start-debug @echo "Cd into client and run 'npm run dev' to start the client in dev mode." -# Builds the client with npm (result in client/dist +[private] npm-install directory: cd {{directory}} && npm install # Builds the client with npm (result in client/dist) [private] -npm-build: (npm-install "client-solid") - cd client && npm run build +npm-build directory: (npm-install directory) + cd {{directory}} && npm run build + @echo "Built client at {{directory}}/dist" # Builds a debug container [private] @@ -31,7 +32,13 @@ build-container-release: # Builds a release container and runs it start-release: build-container-release remove-podman-containers - {{runtime}} run -d -p 8080:8080 --name frostbyte fb-server + {{runtime}} run -d -e DATABASE_URL=sqlite:release.db -p 8080:8080 --name frostbyte fb-server + +init-sqlx: + echo "DATABASE_URL=sqlite:debug.db" > server/.env + cd server && sqlx database create + cd server && sqlx migrate run + cd server && cargo sqlx prepare # Removes and stops any containers related to the project [private] @@ -56,6 +63,8 @@ clean: {{runtime}} image rm -f fb-server-debug rm -rf client/dist rm -rf client/node_modules + rm -rf client-solid/dist + rm -rf client-solid/node_modules rm -rf server/public rm -rf server/target @echo "Cleaned up! Make sure to run 'just nuke' to nuke everything podman related." diff --git a/server/Cargo.lock b/server/Cargo.lock index 93d4286..3dfcfc7 100755 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -391,6 +391,18 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "biosvg" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c50785b88aca88dc4417a3aede395dac83b5031286f02523ddf4839c59e7f8" +dependencies = [ + "once_cell", + "rand", + "regex", + "thiserror", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1780,6 +1792,7 @@ dependencies = [ "actix-files", "actix-web", "argon2", + "biosvg", "chrono", "clap", "dotenvy", diff --git a/server/Cargo.toml b/server/Cargo.toml index 8afda9d..f770f6e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" actix-files = "0.6.2" actix-web = "4.4.0" argon2 = { version = "0.5.2", features = ["zeroize"] } +biosvg = "0.1.3" chrono = { version = "0.4.31", features = ["serde"] } clap = { version = "4.4.5", features = ["derive"] } dotenvy = "0.15.7" diff --git a/server/migrations/0001_users_table.sql b/server/migrations/0001_users_table.sql index 76976d2..b9ccfdb 100644 --- a/server/migrations/0001_users_table.sql +++ b/server/migrations/0001_users_table.sql @@ -1,9 +1,31 @@ -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - username TEXT NOT NULL, - password TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); +CREATE TABLE + IF NOT EXISTS users ( + id INTEGER PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); -create index users_username_index on users (username); \ No newline at end of file +-- Create a trigger to set created_at and updated_at on INSERT +CREATE TRIGGER IF NOT EXISTS set_created_at AFTER INSERT ON users BEGIN +UPDATE users +SET + created_at = CURRENT_TIMESTAMP +WHERE + id = NEW.id; + +END; + +-- Create a trigger to set updated_at on UPDATE +CREATE TRIGGER IF NOT EXISTS set_updated_at AFTER +UPDATE ON users BEGIN +UPDATE users +SET + updated_at = CURRENT_TIMESTAMP +WHERE + id = NEW.id; + +END; + +CREATE INDEX users_username_index ON users (username); \ No newline at end of file diff --git a/server/migrations/0002_posts_table.sql b/server/migrations/0002_posts_table.sql index c5d05e5..1b9601b 100644 --- a/server/migrations/0002_posts_table.sql +++ b/server/migrations/0002_posts_table.sql @@ -1,12 +1,38 @@ -CREATE TABLE IF NOT EXISTS posts ( - id INTEGER PRIMARY KEY NOT NULL, - user_id INTEGER NOT NULL, - content TEXT NOT NULL, - upvotes INT NOT NULL DEFAULT 0, - downvotes INT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) -); -create index IF NOT EXISTS posts_user_id_index on posts (user_id); -create index IF NOT EXISTS posts_id_index on posts (id); \ No newline at end of file +CREATE TABLE + IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + upvotes INTEGER NOT NULL DEFAULT 0, + downvotes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + +-- Create a trigger to set created_at and updated_at on INSERT +CREATE TRIGGER IF NOT EXISTS set_created_at AFTER INSERT ON posts BEGIN +UPDATE posts +SET + created_at = CURRENT_TIMESTAMP +WHERE + id = NEW.id; + +END; + +-- Create a trigger to set updated_at on UPDATE +CREATE TRIGGER IF NOT EXISTS set_updated_at AFTER +UPDATE ON posts BEGIN +UPDATE posts +SET + updated_at = CURRENT_TIMESTAMP +WHERE + id = NEW.id; + +END; + +create INDEX IF NOT EXISTS posts_user_id_index ON posts (user_id); + +create INDEX IF NOT EXISTS posts_id_index ON posts (id); + +CREATE INDEX idx_created_at_desc ON posts (created_at DESC); \ No newline at end of file diff --git a/server/src/db.rs b/server/src/db.rs index c2f0f39..aa7e33d 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -1,36 +1,138 @@ -use crate::routes::{NewPost, Post}; -use log::warn; -use sqlx::{Row, SqlitePool}; +use crate::routes::{Post, User}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, PasswordVerifier, +}; +use log::{info, warn}; +use sqlx::SqlitePool; -// Gets all posts from the database -pub async fn db_get_posts(pool: &SqlitePool) -> Vec { - sqlx::query_as!(Post, "SELECT * FROM posts") - .fetch_all(pool) +// Gets the latest posts from the database, ordered by created_at +pub async fn db_get_latest_posts(pool: &SqlitePool, limit: i64, offset: i64) -> Vec { + sqlx::query_as!( + Post, + "SELECT * FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?", + limit, + offset + ) + .fetch_all(pool) + .await + .unwrap() +} + +// Gets the post with id from the database +pub async fn db_get_post(id: i64, pool: &SqlitePool) -> Option { + sqlx::query_as!(Post, "SELECT * FROM posts WHERE id = ?", id) + .fetch_one(pool) .await - .unwrap() + .ok() } // Inserts a new post to the database -pub async fn db_new_post(post: NewPost, pool: &SqlitePool) -> Option { - let q2 = sqlx::query!( - "INSERT INTO posts (user_id, content) VALUES (1, ?)", - post.content +pub async fn db_new_post(userid: i64, content: &str, pool: &SqlitePool) -> Option { + info!("User with id {} submitted a post", userid); + + let insert_query = sqlx::query!( + "INSERT INTO posts (user_id, content) VALUES (?, ?)", + userid, + content ) .execute(pool) .await; - if q2.is_err() { - let s = q2.err().unwrap(); + if insert_query.is_err() { + let s = insert_query.err().unwrap(); warn!("Error inserting post into database: {}", s); return None; } - let q = sqlx::query_as!( + // Dips into the database to get the post we just inserted + let post = sqlx::query_as!( Post, "SELECT * FROM posts WHERE id = (SELECT MAX(id) FROM posts)" ) .fetch_one(pool) .await .ok()?; - Some(q) + + Some(post) +} + +pub async fn db_user_exists(username: String, pool: &SqlitePool) -> bool { + let exists = sqlx::query!("SELECT username FROM users WHERE username = ?", username) + .fetch_one(pool) + .await + .ok() + .map(|row| row.username); + + exists.is_some() +} + +pub async fn db_user_login(username: String, password: String, pool: &SqlitePool) -> Option { + let username = username.clone(); + let user = sqlx::query_as!(User, "SELECT * FROM users WHERE username = ?", username) + .fetch_one(pool) + .await + .ok()?; + + let phc_password = user.password.clone(); + let phc_password = match argon2::PasswordHash::new(&phc_password) { + Ok(phc_password) => phc_password, + Err(_) => { + warn!( + "Invalid hash for user {} fetched from database (not a valid PHC string)", + username + ); + return None; + } + }; + + let argon2 = Argon2::default(); + let password = password.as_bytes(); + + match argon2.verify_password(password, &phc_password) { + Ok(_) => Some(user), + Err(_) => None, + } +} + +pub async fn db_new_user(username: String, password: String, pool: &SqlitePool) -> Option { + // First check if the user already exists + match db_user_exists(username.clone(), pool).await { + true => { + warn!("User \"{}\" already exists", username); + return None; + } + false => {} + } + + // Unwrapping here because if this fails, we have a serious problem + let phc_hash = Argon2::default() + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .to_string(); + + // Insert our new user into the database + let insert_query = sqlx::query!( + "INSERT INTO users (username, password) VALUES (?, ?)", + username, + phc_hash + ) + .execute(pool) + .await; + + match insert_query { + Ok(_) => { + info!("User: {} registered", username); + let user = sqlx::query_as!(User, "SELECT * FROM users WHERE username = ?", username) + .fetch_one(pool) + .await + .ok()?; + + Some(user) + } + Err(e) => { + warn!("Error inserting user into database: {}", e); + return None; + } + } } diff --git a/server/src/jwt.rs b/server/src/jwt.rs index 2e4bc44..d603c4d 100755 --- a/server/src/jwt.rs +++ b/server/src/jwt.rs @@ -1,7 +1,3 @@ -// use crate::{ -// config::{DAYS_VALID, JWT_SECRET}, -// Claims, -// }; use jsonwebtoken::{ decode, encode, errors::Result as JwtResult, DecodingKey, EncodingKey, Header, Validation, }; @@ -40,7 +36,8 @@ pub fn token_factory(user: &str) -> JwtResult { Ok(token) } -#[allow(dead_code)] +// JwtResult is just a predefined error from the jsonwebtoken crate +// This function is incomplete and should be expanded to check for more things pub fn validate_token(token: &str) -> JwtResult { let token_data = decode::( token, diff --git a/server/src/main.rs b/server/src/main.rs index 18e07d2..6d95008 100755 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,7 +10,8 @@ mod jwt; mod routes; mod state; -use routes::{get_posts, login, new_post, register}; +use routes::{get_posts, login, new_post, post_by_id, register}; +use state::CaptchaState; use state::ServerState; #[actix_web::main] @@ -18,6 +19,7 @@ async fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); let data = ServerState::new().await; + let capt_db = CaptchaState::new(); info!("Spinning up server on http://localhost:8080"); HttpServer::new(move || { @@ -29,9 +31,11 @@ async fn main() -> std::io::Result<()> { scope("/api") .service(get_posts) .service(new_post) + .service(post_by_id) .service(login) .service(register) - .app_data(Data::new(data.clone())), + .app_data(Data::new(data.clone())) + .app_data(Data::new(capt_db.clone())), ) .service(Files::new("/", "./public").index_file("index.html")) }) diff --git a/server/src/routes/post.rs b/server/src/routes/post.rs index cc7970c..42ef85e 100755 --- a/server/src/routes/post.rs +++ b/server/src/routes/post.rs @@ -1,7 +1,8 @@ -use crate::db::db_new_post; +use crate::db::{db_get_latest_posts, db_get_post, db_new_post}; +use crate::jwt::validate_token; use crate::ServerState; -use actix_web::web::Data; +use actix_web::web::{Data, Path, Query}; use actix_web::{get, post, web::Json, HttpResponse, Responder, Result}; use log::info; use serde::{Deserialize, Serialize}; @@ -27,6 +28,7 @@ pub struct Post { pub updated_at: chrono::NaiveDateTime, } +/// The user as it is stored in the database, with all the related metadata #[derive(Debug, Serialize, Deserialize, Clone, FromRow)] pub struct User { pub id: i64, @@ -36,17 +38,55 @@ pub struct User { pub updated_at: chrono::NaiveDateTime, } -#[get("/posts")] -pub async fn get_posts(state: Data) -> Result { - let stream = sqlx::query_as!(Post, "SELECT * FROM posts"); - - let posts = stream.fetch_all(&state.pool).await.unwrap(); - Ok(HttpResponse::Ok().json(posts)) +// These look like /posts?limit=10&offset=20 in the URL +// Note that these are optional +/// Query parameters for the /posts endpoint +#[derive(Debug, Serialize, Deserialize)] +pub struct QueryParams { + limit: Option, + offset: Option, } +/// Gets all posts from the database, query parameters are optional +/// If limit is not specified, it defaults to a sane value +#[get("/posts")] +pub async fn get_posts( + query: Query, + state: Data, +) -> Result { + if let (Some(lim), Some(ofs)) = (query.limit, query.offset) { + return Ok(HttpResponse::Ok() + .json(db_get_latest_posts(&state.pool, std::cmp::min(lim, 30), ofs).await)); + } + Ok(HttpResponse::Ok().json(db_get_latest_posts(&state.pool, 30, 0).await)) +} + +/// Creates a new post, requires a token in release mode #[post("/posts")] pub async fn new_post(new_post: Json, state: Data) -> Result { - return match db_new_post(new_post.into_inner(), &state.pool).await { + let user_claims = validate_token(&new_post.token); + + if let Err(e) = user_claims { + info!("Error validating token: {}", e); + return Ok(HttpResponse::BadRequest().json("Error")); + } + + // Bail if the token is invalid + let claims = user_claims.unwrap(); + info!("User {:?} created a new post", &claims.sub); + + let content = new_post.content.clone(); + let username = claims.sub.clone(); + + // This one is avoidable if we just store the user id in the token + let userid = sqlx::query!("SELECT id FROM users WHERE username = ?", username) + .fetch_one(&state.pool) + .await + .unwrap() + .id; + + // By now we know that the token is valid, so we can create the post + return match db_new_post(userid, &content, &state.pool).await { Some(post) => { info!("Created post {:?}", post.id); Ok(HttpResponse::Ok().json(post)) @@ -54,3 +94,12 @@ pub async fn new_post(new_post: Json, state: Data) -> Resu None => Ok(HttpResponse::InternalServerError().json("Error")), }; } + +#[get("posts/{id}")] +pub async fn post_by_id(path: Path, state: Data) -> Result { + let id = path.into_inner(); + match db_get_post(id, &state.pool).await { + Some(post) => Ok(HttpResponse::Ok().json(post)), + None => Ok(HttpResponse::NotFound().json("Error")), + } +} diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs index e5cdec8..5b7d999 100755 --- a/server/src/routes/users.rs +++ b/server/src/routes/users.rs @@ -1,14 +1,13 @@ +use crate::db::{db_new_user, db_user_login}; use crate::jwt::token_factory; +use crate::state::CaptchaState; use crate::ServerState; use actix_web::web::Data; use actix_web::{post, web::Json, HttpResponse, Responder, Result}; -use argon2::password_hash::rand_core::OsRng; -use argon2::password_hash::SaltString; +use argon2::password_hash::rand_core::RngCore; use argon2::password_hash::*; -use argon2::Argon2; -use argon2::PasswordHasher; -use argon2::PasswordVerifier; +use biosvg::BiosvgBuilder; use log::*; use serde::{Deserialize, Serialize}; @@ -19,7 +18,7 @@ pub struct LoginData { } #[derive(Debug, Serialize, Deserialize)] -struct LoginResponse { +pub struct LoginResponse { username: String, token: String, } @@ -36,81 +35,82 @@ pub async fn register( data: Json, state: Data, ) -> Result { - // First check if the user already exists - let exists = sqlx::query!( - "SELECT username FROM users WHERE username = ?", - data.username - ) - .fetch_one(&state.pool) - .await - .ok() - .map(|row| row.username); - - // Bail out if the user already exists - if exists.is_some() { - info!("User \"{}\" already exists", data.username); - return Ok(HttpResponse::BadRequest().json("Error")); - } - - // Unwrapping here because if this fails, we have a serious problem - let phc_hash = Argon2::default() - .hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng)) - .unwrap() - .to_string(); - - // Insert our new user into the database - sqlx::query!( - "INSERT INTO users (username, password) VALUES (?, ?)", - data.username, - phc_hash - ) - .execute(&state.pool) - .await - .unwrap(); - + db_new_user(data.username.clone(), data.password.clone(), &state.pool).await; info!("User: {} registered", data.username); Ok(HttpResponse::Ok().json("User registered")) } #[post("/login")] pub async fn login(data: Json, state: Data) -> Result { - // let q = "SELECT password FROM users WHERE username = ?"; - // let query = sqlx::query(q).bind(&data.username); - // let result = query.fetch_one(&state.pool).await.ok(); + let result = db_user_login(data.username.clone(), data.password.clone(), &state.pool).await; - let uname = data.username.clone(); - let q = sqlx::query!("SELECT password FROM users WHERE username = ?", uname) - .fetch_one(&state.pool) - .await - .ok(); - - if q.is_none() { - info!("User \"{}\" failed to log in", data.username); - return Ok(HttpResponse::BadRequest().json("Error")); - } - - let phc_password = q.unwrap().password; - let phc_password = PasswordHash::new(&phc_password).unwrap_or_else(|_| { - warn!( - "Invalid hash for user {} fetched from database (not a valid PHC string)", - data.username - ); - panic!(); - }); - - match Argon2::default().verify_password(data.password.as_bytes(), &phc_password) { - Ok(_) => { - info!("User {} logged in", data.username); - let token = token_factory(&data.username).unwrap(); - println!("{:?}", token); + match result { + Some(_) => { return Ok(HttpResponse::Ok().json(LoginResponse { username: data.username.clone(), - token: token, + token: token_factory(&data.username).unwrap(), })); } - Err(_) => { + None => { info!("User \"{}\" failed to log in", data.username); return Ok(HttpResponse::BadRequest().json("Error")); } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct CaptchaResponse { + captcha_svg: String, + captcha_id: i32, +} + +/// Request a captcha from the captcha service +#[post("/captcha")] +pub async fn captcha_request(cstate: Data) -> Result { + // This might block the thread a bit too long + let (answer, svg) = get_captcha(); + + let id = rand_core::OsRng.next_u32() as i32; + + let cresponse = CaptchaResponse { + captcha_svg: svg.clone(), + captcha_id: id, + }; + + // This is bad in about every way i can think of + // It might just be better to hit the database every time, and let the database + // handle rng and maybe set a trigger to delete old captchas + match cstate.capthca_db.lock() { + Ok(mut db) => { + if (db.len() as i32) > 100 { + // To prevent the database from growing too large + // Replace with a proper LRU cache or circular buffer + db.remove(&(id % 100)); // This is terrible + } + db.insert(id, answer.clone()); // We do not care about collisions + return Ok(HttpResponse::Ok().json(cresponse)); + } + Err(_) => { + // This shouldnt happen + error!("Failed to lock captcha database"); + return Ok(HttpResponse::InternalServerError().json("Error")); + } + } +} + +/// Returns a new captcha in the form of a tuple (answer, svg) +fn get_captcha() -> (String, String) { + BiosvgBuilder::new() + .length(4) + .difficulty(6) + .colors(vec![ + // Feel free to change these + "#0078D6".to_string(), + "#aa3333".to_string(), + "#f08012".to_string(), + "#33aa00".to_string(), + "#aa33aa".to_string(), + ]) + .build() + .unwrap() +} diff --git a/server/src/state.rs b/server/src/state.rs index 783b08d..b828927 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,8 +1,27 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::Mutex; + +use log::error; +use log::info; +use sqlx::migrate::MigrateDatabase; use sqlx::Pool; use sqlx::Sqlite; -use sqlx::SqlitePool; use sqlx::{self, sqlite}; +#[derive(Clone)] +pub struct CaptchaState { + pub capthca_db: Arc>>, +} + +impl CaptchaState { + pub fn new() -> Self { + Self { + capthca_db: Arc::new(Mutex::new(BTreeMap::new())), + } + } +} + #[derive(Clone)] pub struct ServerState { pub pool: Pool, @@ -13,6 +32,11 @@ impl ServerState { // This is almost certainly bad practice for more reasons than I can count dotenvy::dotenv().ok(); let db_url = dotenvy::var("DATABASE_URL").unwrap_or(":memory:".to_string()); + info!("Using db_url: {}", &db_url); + + if !sqlx::Sqlite::database_exists(&db_url).await.unwrap() { + sqlx::Sqlite::create_database(&db_url).await.unwrap(); + } let pool = sqlite::SqlitePoolOptions::new() .max_connections(5) @@ -22,37 +46,45 @@ impl ServerState { sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + match crate::db::db_new_user("imbus".to_string(), "kartellen1234".to_string(), &pool).await + { + Some(u) => info!("Created default user {}", u.username), + None => error!("Failed to create default user..."), + } + #[cfg(debug_assertions)] - debug_setup(&pool).await; + debug_setup(&pool).await.unwrap(); Self { pool } } } +#[cfg(debug_assertions)] +use sqlx::SqlitePool; + // Inserts a bunch of dummy data into the database // Mostly useful for debugging new posts, as we need to satisfy foreign key constraints. #[cfg(debug_assertions)] -async fn debug_setup(pool: &SqlitePool) { - use chrono::NaiveDateTime; +async fn debug_setup(pool: &SqlitePool) -> Result<(), sqlx::Error> { use sqlx::query; - let now = NaiveDateTime::from_timestamp(0, 0); + use crate::db::db_new_user; - query!( - "INSERT INTO users (username, password, created_at, updated_at) VALUES ('test', 'test', ?, ?)", - now, - now - ) - .execute(pool) - .await - .unwrap(); + db_new_user("user".to_string(), "pass".to_string(), pool).await; - query!( - "INSERT INTO posts (user_id, content, created_at, updated_at) VALUES (1, 'Hello world!', ?, ?)", - now, - now - ) - .execute(pool) - .await - .unwrap(); + // Check if the demo post already exists + let posted = query!("SELECT * FROM posts WHERE id = 1",) + .fetch_one(pool) + .await + .ok(); + + // If the demo user already has a post, don't insert another one + if !posted.is_some() { + // This requires that the user with id 1 exists in the user table + query!("INSERT INTO posts (user_id, content) VALUES (1, 'Hello world! The demo username is user and the password is pass.')",) + .execute(pool) + .await?; + } + + Ok(()) }