Compare commits

..

No commits in common. "b3124948c4656542dc18509973f740cfed8a0588" and "9f1d18f6021eba911d56f52adf89bc876b2fadb0" have entirely different histories.

19 changed files with 271 additions and 746 deletions

View file

@ -9,7 +9,7 @@
<title>FrostByteSolid</title> <title>FrostByteSolid</title>
</head> </head>
<body> <body class="min-h-screen">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>

View file

@ -1,92 +0,0 @@
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 (
<form class="form-control">
<label class="label">
<span class="label-text">Username</span>
</label>
<input
type="text"
placeholder="username"
value={username()}
class="input input-bordered"
onChange={(e) => {
setUsername(e.target.value);
}} />
<label class="label">
<span class="label-text">Password</span>
</label>
<input
type="password"
placeholder="password"
value={password()}
class="input input-bordered"
onChange={(e) => {
setPassword(e.target.value);
}} />
<button
class={"btn btn-primary mt-4" + (error() ? " btn-error" : "")}
onClick={(b) => {
b.preventDefault();
setWaiting(true);
submitLogin(username(), password()).then((token) => {
if (token != "") {
setWaiting(false);
setError(false);
login_ctx?.setUsername(username());
setUsername("");
setPassword("");
login_ctx?.setToken(token);
modal_ctx?.setLoginModalOpen(false);
} else {
loginFailed();
}
});
}}
>
{waiting() ? "Logging in..." : "Login"}
</button>
</form>
);
}
// 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<string> {
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 "";
}

View file

@ -1,83 +0,0 @@
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 (
<ul class="menu menu-horizontal bg-base-200 rounded-box space-x-2">
<li>
<A href="/" end>
Home
</A>
</li>
{login_ctx?.token() != "" ? (
<li>
<A href="/new" end>
New
</A>
</li>
) : (
<></>
)}
</ul>
);
}
export function Navbar() {
let modal_ctx = useContext(ModalContext);
let login_ctx = useContext(LoginContext);
return (
<div class="navbar bg-base-100 max-w-3xl max-w flex justify-around">
<A href={"/"} class="btn btn-ghost normal-case text-xl">FrostByte</A>
<Menu />
<A
href="#"
class="btn btn-ghost normal-case text-sm"
onClick={(b) => {
b.preventDefault();
if (login_ctx?.token() != "") {
localStorage.setItem("token", "");
localStorage.setItem("username", "");
login_ctx?.setToken("");
login_ctx?.setUsername("");
return;
}
modal_ctx?.setLoginModalOpen(true);
}}
>
{login_ctx?.token() != "" ? login_ctx?.username() : "Login"}
</A>
</div>
);
}
// This is a modal
export function Login() {
const modal_ctx = useContext(ModalContext);
return (
<dialog id="login_modal" class="modal" open={modal_ctx?.loginModalOpen()}>
<div class="modal-box">
<h3 class="font-bold text-lg">Hello!</h3>
<p class="py-4">Login to your FrostByte account.</p>
<LoginForm />
</div>
<form
method="dialog"
// This backdrop renders choppy on my machine. Likely because of the blur filter or misuse of css transisions
class="modal-backdrop backdrop-brightness-50 backdrop-blur-sm backdrop-contrast-100 transition-all transition-300"
onsubmit={(e) => {
// This is just needed to set the state to false
// The modal will close itself without this code, but without setting the state
e.preventDefault();
modal_ctx?.setLoginModalOpen(false);
}}
>
<button>close</button>
</form>
</dialog>
);
}

View file

@ -1,43 +0,0 @@
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 (
<div class="flex flex-col space-y-2 w-full md:w-96">
{loading() ? (
<span class="loading loading-spinner loading-lg self-center"></span>
) : (
<></>
)}
{posts().map((post) => {
if (post.content == "") return; // Filtering out empty posts, remove this later
return <PostSegment post={post}></PostSegment>;
})}
</div>
);
}
// This is the card container for a post
export function PostSegment({ post }: { post: Post }) {
const nav = useNavigate();
return (
<div
onClick={() => nav("/post/" + post?.id)}
class="card bg-base-200 shadow-lg compact text-base-content w-full"
>
<div class="card-body">
<p class="text-base-content break-words">{post?.content}</p>
</div>
</div>
);
}

View file

@ -1,16 +0,0 @@
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 (
<Routes>
<Route path="/" element={<Posts />} />
<Route path="/post/:postid" element={<SinglePost />} />
<Route path="/new" element={<NewPostInputArea />} />
<Route path="*" element={<h1>404</h1>} />
</Routes>
);
}

View file

@ -1,96 +1,74 @@
import { Accessor, Show, createSignal, useContext } from "solid-js"; import { createSignal } from "solid-js";
import { createContext } from "solid-js"; import { createContext } from "solid-js";
import { createPost } from "./api"; import { Route, Routes, A } from "@solidjs/router";
import { NewPost } from "./api";
import { Navbar } from "./Navbar";
import { Primary } from "./Primary";
import { Login } from "./Navbar";
import { useNavigate } from "@solidjs/router";
// Representing the state of varoious modals. import { createPost, getPosts } from "./api";
// So far we only have one modal, but we can add more later import { Post, NewPost } from "./api";
// by adding more fields to this interface, or maybe an enum
interface ModalContextType {
loginModalOpen: Accessor<boolean>;
setLoginModalOpen: (value: boolean) => void;
}
interface LoginContextType { export const TestContext = createContext("Test123");
token: Accessor<string>;
setToken: (value: string) => void;
username: Accessor<string>;
setUsername: (value: string) => void;
}
// It is unclear to me if this is the idiomatic way to do this in Solid
export const ModalContext = createContext<ModalContextType>();
export const LoginContext = createContext<LoginContextType>();
function Root() { 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 ( return (
<> <>
<ModalContext.Provider value={{ loginModalOpen, setLoginModalOpen }}> <TestContext.Provider value="Test123">
<LoginContext.Provider
value={{ token, setToken, username, setUsername }}
>
<div class="flex flex-col items-center my-2"> <div class="flex flex-col items-center my-2">
<Navbar /> <Navbar />
<Login />
<div class="flex flex-col items-center md:w-96 space-y-2"> <div class="flex flex-col items-center md:w-96 space-y-2">
<Primary /> <Primary />
</div> </div>
</div> </div>
</LoginContext.Provider> </TestContext.Provider>
</ModalContext.Provider>
</> </>
); );
} }
export function NewPostInputArea() { function Navbar() {
const [content, setContent] = createSignal("");
const [waiting, setWaiting] = createSignal(false);
const login_ctx = useContext(LoginContext);
const nav = useNavigate();
const sendPost = () => {
setWaiting(true);
const response = createPost({
content: content(),
token: login_ctx?.token(),
} as NewPost);
if (response) {
response.then(() => {
setWaiting(false);
setContent("");
nav("/");
});
}
};
return ( return (
<Show <div class="navbar bg-base-100 max-w-3xl max-w flex justify-evenly">
when={!waiting()} <a class="btn btn-ghost normal-case text-xl">hello</a>
fallback={ <Menu />
<span class="loading loading-spinner loading-lg self-center"></span> <A href="/login" class="btn btn-ghost normal-case text-sm">
Login
</A>
</div>
);
} }
>
function Menu() {
return (
<ul class="menu menu-horizontal bg-base-200 rounded-box space-x-2 justify-end">
<li>
<A href="/" end>
Home
</A>
</li>
<li>
<A href="/new" end>
New
</A>
</li>
<li>
<A href="/boards" end>
Boards
</A>
</li>
<li>
<A href="/login" end>
Login
</A>
</li>
</ul>
);
}
function NewPostInputArea() {
const [content, setContent] = createSignal("");
return (
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<textarea <textarea
class="textarea textarea-bordered" class="textarea textarea-bordered"
placeholder="Speak your mind..." placeholder="Speak your mind..."
maxLength={500}
oninput={(input) => { oninput={(input) => {
setContent(input.target.value); setContent(input.target.value);
}} }}
@ -100,12 +78,66 @@ export function NewPostInputArea() {
"btn btn-primary self-end btn-sm" + "btn btn-primary self-end btn-sm" +
(content() == "" ? " btn-disabled" : "") (content() == "" ? " btn-disabled" : "")
} }
onclick={sendPost} onclick={() => {
if (content() == "") return;
createPost({ content: content(), token: "" } as NewPost);
}}
> >
Submit Submit
</button> </button>
</div> </div>
</Show> );
}
function Posts() {
const [posts, setPosts] = createSignal([] as Post[]);
const [loading, setLoading] = createSignal(true);
getPosts().then((posts) => {
setPosts(posts as any);
setLoading(false)
});
return (
<div class="flex flex-col space-y-2 w-full md:w-96">
{ loading() ? <span class="loading loading-spinner loading-lg self-center"></span> : <></> }
{posts().map((post) => {
if (post.content == "") return; // Filtering out empty posts, remove this later
return <PostSegment post={post}></PostSegment>;
})}
</div>
);
}
function PostSegment({ post }: { post: Post }) {
return (
<div class="card bg-base-200 shadow-lg compact text-base-content w-full">
<div class="card-body">
<p class="text-base-content">{post.content}</p>
{/* <p>{post.votes.up}</p>
<p>{post.votes.down}</p> */}
</div>
</div>
);
}
function Primary() {
return (
<Routes>
<Route path="/" element={<Posts />} />
<Route path="/new" element={<NewPostInputArea />} />
<Route path="/boards" element={<div>Boards</div>} />
<Route path="/login" element={<Login />} />
</Routes>
);
}
function Login() {
return (
<div>
Login
<input class="input input-bordered" type="text" />
</div>
); );
} }

View file

@ -1,19 +0,0 @@
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 (
<Suspense fallback={<div>Some loading message</div>}>
<div>
<Show when={post()}>
<PostSegment post={post()!}></PostSegment>
</Show>
</div>
</Suspense>
);
}

View file

@ -1,4 +1,6 @@
// This file contains types and functions related to interacting with the API. // const PORT = 3000;
// const API_URL = `http://localhost:${PORT}/api/`;
// const API_URL2 = new URL(API_URL);
export interface NewPost { export interface NewPost {
content: string; content: string;
@ -11,12 +13,13 @@ interface Votes {
} }
export interface Post extends NewPost { export interface Post extends NewPost {
id: string; uuid: string;
createdAt: string; createdAt: string;
votes: Votes; votes: Votes;
} }
export async function getPosts(): Promise<Post[]> { export async function getPosts(): Promise<Post[]> {
// const res = await fetch(`${API_URL}/posts`);
const res = await fetch("/api/posts"); const res = await fetch("/api/posts");
const data = await res.json(); const data = await res.json();
return data; return data;
@ -24,9 +27,7 @@ export async function getPosts(): Promise<Post[]> {
export async function getPost(id: string): Promise<Post> { export async function getPost(id: string): Promise<Post> {
const res = await fetch(`/api/posts/${id}`); const res = await fetch(`/api/posts/${id}`);
console.log(res)
const data = await res.json(); const data = await res.json();
console.log(data)
return data; return data;
} }

View file

@ -4,15 +4,14 @@ runtime := "podman"
dev: start-debug dev: start-debug
@echo "Cd into client and run 'npm run dev' to start the client in dev mode." @echo "Cd into client and run 'npm run dev' to start the client in dev mode."
[private] # Builds the client with npm (result in client/dist
npm-install directory: npm-install directory:
cd {{directory}} && npm install cd {{directory}} && npm install
# Builds the client with npm (result in client/dist) # Builds the client with npm (result in client/dist)
[private] [private]
npm-build directory: (npm-install directory) npm-build: (npm-install "client-solid")
cd {{directory}} && npm run build cd client && npm run build
@echo "Built client at {{directory}}/dist"
# Builds a debug container # Builds a debug container
[private] [private]
@ -32,13 +31,7 @@ build-container-release:
# Builds a release container and runs it # Builds a release container and runs it
start-release: build-container-release remove-podman-containers start-release: build-container-release remove-podman-containers
{{runtime}} run -d -e DATABASE_URL=sqlite:release.db -p 8080:8080 --name frostbyte fb-server {{runtime}} run -d -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 # Removes and stops any containers related to the project
[private] [private]
@ -63,8 +56,6 @@ clean:
{{runtime}} image rm -f fb-server-debug {{runtime}} image rm -f fb-server-debug
rm -rf client/dist rm -rf client/dist
rm -rf client/node_modules rm -rf client/node_modules
rm -rf client-solid/dist
rm -rf client-solid/node_modules
rm -rf server/public rm -rf server/public
rm -rf server/target rm -rf server/target
@echo "Cleaned up! Make sure to run 'just nuke' to nuke everything podman related." @echo "Cleaned up! Make sure to run 'just nuke' to nuke everything podman related."

13
server/Cargo.lock generated
View file

@ -391,18 +391,6 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -1792,7 +1780,6 @@ dependencies = [
"actix-files", "actix-files",
"actix-web", "actix-web",
"argon2", "argon2",
"biosvg",
"chrono", "chrono",
"clap", "clap",
"dotenvy", "dotenvy",

View file

@ -9,7 +9,6 @@ edition = "2021"
actix-files = "0.6.2" actix-files = "0.6.2"
actix-web = "4.4.0" actix-web = "4.4.0"
argon2 = { version = "0.5.2", features = ["zeroize"] } argon2 = { version = "0.5.2", features = ["zeroize"] }
biosvg = "0.1.3"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.4.5", features = ["derive"] } clap = { version = "4.4.5", features = ["derive"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"

View file

@ -1,31 +1,9 @@
CREATE TABLE CREATE TABLE IF NOT EXISTS users (
IF NOT EXISTS users ( id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY NOT NULL, username TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- Create a trigger to set created_at and updated_at on INSERT create index users_username_index on users (username);
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);

View file

@ -1,38 +1,12 @@
CREATE TABLE CREATE TABLE IF NOT EXISTS posts (
IF NOT EXISTS posts (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
upvotes INTEGER NOT NULL DEFAULT 0, upvotes INT NOT NULL DEFAULT 0,
downvotes INTEGER NOT NULL DEFAULT 0, downvotes INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) FOREIGN KEY (user_id) REFERENCES users (id)
); );
create index IF NOT EXISTS posts_user_id_index on posts (user_id);
-- Create a trigger to set created_at and updated_at on INSERT create index IF NOT EXISTS posts_id_index on posts (id);
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);

View file

@ -1,138 +1,36 @@
use crate::routes::{Post, User}; use crate::routes::{NewPost, Post};
use argon2::{ use log::warn;
password_hash::{rand_core::OsRng, SaltString}, use sqlx::{Row, SqlitePool};
Argon2, PasswordHasher, PasswordVerifier,
};
use log::{info, warn};
use sqlx::SqlitePool;
// Gets the latest posts from the database, ordered by created_at // Gets all posts from the database
pub async fn db_get_latest_posts(pool: &SqlitePool, limit: i64, offset: i64) -> Vec<Post> { pub async fn db_get_posts(pool: &SqlitePool) -> Vec<Post> {
sqlx::query_as!( sqlx::query_as!(Post, "SELECT * FROM posts")
Post,
"SELECT * FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?",
limit,
offset
)
.fetch_all(pool) .fetch_all(pool)
.await .await
.unwrap() .unwrap()
} }
// Gets the post with id from the database
pub async fn db_get_post(id: i64, pool: &SqlitePool) -> Option<Post> {
sqlx::query_as!(Post, "SELECT * FROM posts WHERE id = ?", id)
.fetch_one(pool)
.await
.ok()
}
// Inserts a new post to the database // Inserts a new post to the database
pub async fn db_new_post(userid: i64, content: &str, pool: &SqlitePool) -> Option<Post> { pub async fn db_new_post(post: NewPost, pool: &SqlitePool) -> Option<Post> {
info!("User with id {} submitted a post", userid); let q2 = sqlx::query!(
"INSERT INTO posts (user_id, content) VALUES (1, ?)",
let insert_query = sqlx::query!( post.content
"INSERT INTO posts (user_id, content) VALUES (?, ?)",
userid,
content
) )
.execute(pool) .execute(pool)
.await; .await;
if insert_query.is_err() { if q2.is_err() {
let s = insert_query.err().unwrap(); let s = q2.err().unwrap();
warn!("Error inserting post into database: {}", s); warn!("Error inserting post into database: {}", s);
return None; return None;
} }
// Dips into the database to get the post we just inserted let q = sqlx::query_as!(
let post = sqlx::query_as!(
Post, Post,
"SELECT * FROM posts WHERE id = (SELECT MAX(id) FROM posts)" "SELECT * FROM posts WHERE id = (SELECT MAX(id) FROM posts)"
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
.ok()?; .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<User> {
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<User> {
// 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;
}
}
} }

View file

@ -1,3 +1,7 @@
// use crate::{
// config::{DAYS_VALID, JWT_SECRET},
// Claims,
// };
use jsonwebtoken::{ use jsonwebtoken::{
decode, encode, errors::Result as JwtResult, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::Result as JwtResult, DecodingKey, EncodingKey, Header, Validation,
}; };
@ -36,8 +40,7 @@ pub fn token_factory(user: &str) -> JwtResult<String> {
Ok(token) Ok(token)
} }
// JwtResult is just a predefined error from the jsonwebtoken crate #[allow(dead_code)]
// This function is incomplete and should be expanded to check for more things
pub fn validate_token(token: &str) -> JwtResult<Claims> { pub fn validate_token(token: &str) -> JwtResult<Claims> {
let token_data = decode::<Claims>( let token_data = decode::<Claims>(
token, token,

View file

@ -10,8 +10,7 @@ mod jwt;
mod routes; mod routes;
mod state; mod state;
use routes::{get_posts, login, new_post, post_by_id, register}; use routes::{get_posts, login, new_post, register};
use state::CaptchaState;
use state::ServerState; use state::ServerState;
#[actix_web::main] #[actix_web::main]
@ -19,7 +18,6 @@ async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init();
let data = ServerState::new().await; let data = ServerState::new().await;
let capt_db = CaptchaState::new();
info!("Spinning up server on http://localhost:8080"); info!("Spinning up server on http://localhost:8080");
HttpServer::new(move || { HttpServer::new(move || {
@ -31,11 +29,9 @@ async fn main() -> std::io::Result<()> {
scope("/api") scope("/api")
.service(get_posts) .service(get_posts)
.service(new_post) .service(new_post)
.service(post_by_id)
.service(login) .service(login)
.service(register) .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")) .service(Files::new("/", "./public").index_file("index.html"))
}) })

View file

@ -1,8 +1,7 @@
use crate::db::{db_get_latest_posts, db_get_post, db_new_post}; use crate::db::db_new_post;
use crate::jwt::validate_token;
use crate::ServerState; use crate::ServerState;
use actix_web::web::{Data, Path, Query}; use actix_web::web::Data;
use actix_web::{get, post, web::Json, HttpResponse, Responder, Result}; use actix_web::{get, post, web::Json, HttpResponse, Responder, Result};
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -28,7 +27,6 @@ pub struct Post {
pub updated_at: chrono::NaiveDateTime, 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)] #[derive(Debug, Serialize, Deserialize, Clone, FromRow)]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
@ -38,55 +36,17 @@ pub struct User {
pub updated_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime,
} }
// 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<i64>,
offset: Option<i64>,
}
/// Gets all posts from the database, query parameters are optional
/// If limit is not specified, it defaults to a sane value
#[get("/posts")] #[get("/posts")]
pub async fn get_posts( pub async fn get_posts(state: Data<ServerState>) -> Result<impl Responder> {
query: Query<QueryParams>, let stream = sqlx::query_as!(Post, "SELECT * FROM posts");
state: Data<ServerState>,
) -> Result<impl Responder> { let posts = stream.fetch_all(&state.pool).await.unwrap();
if let (Some(lim), Some(ofs)) = (query.limit, query.offset) { Ok(HttpResponse::Ok().json(posts))
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")] #[post("/posts")]
pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Result<impl Responder> { pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Result<impl Responder> {
let user_claims = validate_token(&new_post.token); return match db_new_post(new_post.into_inner(), &state.pool).await {
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) => { Some(post) => {
info!("Created post {:?}", post.id); info!("Created post {:?}", post.id);
Ok(HttpResponse::Ok().json(post)) Ok(HttpResponse::Ok().json(post))
@ -94,12 +54,3 @@ pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Resu
None => Ok(HttpResponse::InternalServerError().json("Error")), None => Ok(HttpResponse::InternalServerError().json("Error")),
}; };
} }
#[get("posts/{id}")]
pub async fn post_by_id(path: Path<i64>, state: Data<ServerState>) -> Result<impl Responder> {
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")),
}
}

View file

@ -1,13 +1,14 @@
use crate::db::{db_new_user, db_user_login};
use crate::jwt::token_factory; use crate::jwt::token_factory;
use crate::state::CaptchaState;
use crate::ServerState; use crate::ServerState;
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{post, web::Json, HttpResponse, Responder, Result}; use actix_web::{post, web::Json, HttpResponse, Responder, Result};
use argon2::password_hash::rand_core::RngCore; use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::password_hash::*; use argon2::password_hash::*;
use biosvg::BiosvgBuilder; use argon2::Argon2;
use argon2::PasswordHasher;
use argon2::PasswordVerifier;
use log::*; use log::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -18,7 +19,7 @@ pub struct LoginData {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse { struct LoginResponse {
username: String, username: String,
token: String, token: String,
} }
@ -35,82 +36,81 @@ pub async fn register(
data: Json<RegisterData>, data: Json<RegisterData>,
state: Data<ServerState>, state: Data<ServerState>,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
db_new_user(data.username.clone(), data.password.clone(), &state.pool).await; // 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();
info!("User: {} registered", data.username); info!("User: {} registered", data.username);
Ok(HttpResponse::Ok().json("User registered")) Ok(HttpResponse::Ok().json("User registered"))
} }
#[post("/login")] #[post("/login")]
pub async fn login(data: Json<LoginData>, state: Data<ServerState>) -> Result<impl Responder> { pub async fn login(data: Json<LoginData>, state: Data<ServerState>) -> Result<impl Responder> {
let result = db_user_login(data.username.clone(), data.password.clone(), &state.pool).await; // 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();
match result { let uname = data.username.clone();
Some(_) => { 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);
return Ok(HttpResponse::Ok().json(LoginResponse { return Ok(HttpResponse::Ok().json(LoginResponse {
username: data.username.clone(), username: data.username.clone(),
token: token_factory(&data.username).unwrap(), token: token,
})); }));
} }
None => { Err(_) => {
info!("User \"{}\" failed to log in", data.username); info!("User \"{}\" failed to log in", data.username);
return Ok(HttpResponse::BadRequest().json("Error")); 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<CaptchaState>) -> Result<impl Responder> {
// 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()
}

View file

@ -1,27 +1,8 @@
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::Pool;
use sqlx::Sqlite; use sqlx::Sqlite;
use sqlx::SqlitePool;
use sqlx::{self, sqlite}; use sqlx::{self, sqlite};
#[derive(Clone)]
pub struct CaptchaState {
pub capthca_db: Arc<Mutex<BTreeMap<i32, String>>>,
}
impl CaptchaState {
pub fn new() -> Self {
Self {
capthca_db: Arc::new(Mutex::new(BTreeMap::new())),
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ServerState { pub struct ServerState {
pub pool: Pool<Sqlite>, pub pool: Pool<Sqlite>,
@ -32,11 +13,6 @@ impl ServerState {
// This is almost certainly bad practice for more reasons than I can count // This is almost certainly bad practice for more reasons than I can count
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let db_url = dotenvy::var("DATABASE_URL").unwrap_or(":memory:".to_string()); 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() let pool = sqlite::SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
@ -46,45 +22,37 @@ impl ServerState {
sqlx::migrate!("./migrations").run(&pool).await.unwrap(); 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)] #[cfg(debug_assertions)]
debug_setup(&pool).await.unwrap(); debug_setup(&pool).await;
Self { pool } Self { pool }
} }
} }
#[cfg(debug_assertions)]
use sqlx::SqlitePool;
// Inserts a bunch of dummy data into the database // Inserts a bunch of dummy data into the database
// Mostly useful for debugging new posts, as we need to satisfy foreign key constraints. // Mostly useful for debugging new posts, as we need to satisfy foreign key constraints.
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
async fn debug_setup(pool: &SqlitePool) -> Result<(), sqlx::Error> { async fn debug_setup(pool: &SqlitePool) {
use chrono::NaiveDateTime;
use sqlx::query; use sqlx::query;
use crate::db::db_new_user; let now = NaiveDateTime::from_timestamp(0, 0);
db_new_user("user".to_string(), "pass".to_string(), pool).await; query!(
"INSERT INTO users (username, password, created_at, updated_at) VALUES ('test', 'test', ?, ?)",
// Check if the demo post already exists now,
let posted = query!("SELECT * FROM posts WHERE id = 1",) now
.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) .execute(pool)
.await?; .await
} .unwrap();
Ok(()) query!(
"INSERT INTO posts (user_id, content, created_at, updated_at) VALUES (1, 'Hello world!', ?, ?)",
now,
now
)
.execute(pool)
.await
.unwrap();
} }