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>
</head>
<body>
<body class="min-h-screen">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</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,111 +1,143 @@
import { Accessor, Show, createSignal, useContext } from "solid-js";
import { createSignal } from "solid-js";
import { createContext } from "solid-js";
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 { Route, Routes, A } from "@solidjs/router";
// 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<boolean>;
setLoginModalOpen: (value: boolean) => void;
}
import { createPost, getPosts } from "./api";
import { Post, NewPost } from "./api";
interface LoginContextType {
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>();
export const TestContext = createContext("Test123");
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 (
<>
<ModalContext.Provider value={{ loginModalOpen, setLoginModalOpen }}>
<LoginContext.Provider
value={{ token, setToken, username, setUsername }}
>
<div class="flex flex-col items-center my-2">
<Navbar />
<Login />
<div class="flex flex-col items-center md:w-96 space-y-2">
<Primary />
</div>
<TestContext.Provider value="Test123">
<div class="flex flex-col items-center my-2">
<Navbar />
<div class="flex flex-col items-center md:w-96 space-y-2">
<Primary />
</div>
</LoginContext.Provider>
</ModalContext.Provider>
</div>
</TestContext.Provider>
</>
);
}
export function NewPostInputArea() {
function Navbar() {
return (
<div class="navbar bg-base-100 max-w-3xl max-w flex justify-evenly">
<a class="btn btn-ghost normal-case text-xl">hello</a>
<Menu />
<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("");
const [waiting, setWaiting] = createSignal(false);
const login_ctx = useContext(LoginContext);
return (
<div class="flex flex-col space-y-2">
<textarea
class="textarea textarea-bordered"
placeholder="Speak your mind..."
oninput={(input) => {
setContent(input.target.value);
}}
></textarea>
<button
class={
"btn btn-primary self-end btn-sm" +
(content() == "" ? " btn-disabled" : "")
}
onclick={() => {
if (content() == "") return;
createPost({ content: content(), token: "" } as NewPost);
}}
>
Submit
</button>
</div>
);
}
const nav = useNavigate();
function Posts() {
const [posts, setPosts] = createSignal([] as Post[]);
const [loading, setLoading] = createSignal(true);
const sendPost = () => {
setWaiting(true);
const response = createPost({
content: content(),
token: login_ctx?.token(),
} as NewPost);
if (response) {
response.then(() => {
setWaiting(false);
setContent("");
nav("/");
});
}
};
getPosts().then((posts) => {
setPosts(posts as any);
setLoading(false)
});
return (
<Show
when={!waiting()}
fallback={
<span class="loading loading-spinner loading-lg self-center"></span>
}
>
<div class="flex flex-col space-y-2">
<textarea
class="textarea textarea-bordered"
placeholder="Speak your mind..."
maxLength={500}
oninput={(input) => {
setContent(input.target.value);
}}
></textarea>
<button
class={
"btn btn-primary self-end btn-sm" +
(content() == "" ? " btn-disabled" : "")
}
onclick={sendPost}
>
Submit
</button>
<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>
</Show>
</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 {
content: string;
@ -11,12 +13,13 @@ interface Votes {
}
export interface Post extends NewPost {
id: string;
uuid: string;
createdAt: string;
votes: Votes;
}
export async function getPosts(): Promise<Post[]> {
// const res = await fetch(`${API_URL}/posts`);
const res = await fetch("/api/posts");
const data = await res.json();
return data;
@ -24,9 +27,7 @@ export async function getPosts(): Promise<Post[]> {
export async function getPost(id: string): Promise<Post> {
const res = await fetch(`/api/posts/${id}`);
console.log(res)
const data = await res.json();
console.log(data)
return data;
}

View file

@ -4,15 +4,14 @@ runtime := "podman"
dev: start-debug
@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:
cd {{directory}} && npm install
# Builds the client with npm (result in client/dist)
[private]
npm-build directory: (npm-install directory)
cd {{directory}} && npm run build
@echo "Built client at {{directory}}/dist"
npm-build: (npm-install "client-solid")
cd client && npm run build
# Builds a debug container
[private]
@ -32,13 +31,7 @@ build-container-release:
# Builds a release container and runs it
start-release: build-container-release remove-podman-containers
{{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
{{runtime}} run -d -p 8080:8080 --name frostbyte fb-server
# Removes and stops any containers related to the project
[private]
@ -63,8 +56,6 @@ 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."

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"
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"
@ -1792,7 +1780,6 @@ dependencies = [
"actix-files",
"actix-web",
"argon2",
"biosvg",
"chrono",
"clap",
"dotenvy",

View file

@ -9,7 +9,6 @@ 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"

View file

@ -1,31 +1,9 @@
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 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 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);
create index users_username_index on users (username);

View file

@ -1,38 +1,12 @@
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);
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);

View file

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

View file

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

View file

@ -10,8 +10,7 @@ mod jwt;
mod routes;
mod state;
use routes::{get_posts, login, new_post, post_by_id, register};
use state::CaptchaState;
use routes::{get_posts, login, new_post, register};
use state::ServerState;
#[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();
let data = ServerState::new().await;
let capt_db = CaptchaState::new();
info!("Spinning up server on http://localhost:8080");
HttpServer::new(move || {
@ -31,11 +29,9 @@ 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(capt_db.clone())),
.app_data(Data::new(data.clone())),
)
.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::jwt::validate_token;
use crate::db::db_new_post;
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 log::info;
use serde::{Deserialize, Serialize};
@ -28,7 +27,6 @@ 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,
@ -38,55 +36,17 @@ pub struct User {
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")]
pub async fn get_posts(
query: Query<QueryParams>,
state: Data<ServerState>,
) -> Result<impl Responder> {
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))
pub async fn get_posts(state: Data<ServerState>) -> Result<impl Responder> {
let stream = sqlx::query_as!(Post, "SELECT * FROM posts");
let posts = stream.fetch_all(&state.pool).await.unwrap();
Ok(HttpResponse::Ok().json(posts))
}
/// Creates a new post, requires a token in release mode
#[post("/posts")]
pub async fn new_post(new_post: Json<NewPost>, state: Data<ServerState>) -> Result<impl Responder> {
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 {
return match db_new_post(new_post.into_inner(), &state.pool).await {
Some(post) => {
info!("Created post {:?}", post.id);
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")),
};
}
#[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::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::RngCore;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::password_hash::*;
use biosvg::BiosvgBuilder;
use argon2::Argon2;
use argon2::PasswordHasher;
use argon2::PasswordVerifier;
use log::*;
use serde::{Deserialize, Serialize};
@ -18,7 +19,7 @@ pub struct LoginData {
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse {
struct LoginResponse {
username: String,
token: String,
}
@ -35,82 +36,81 @@ pub async fn register(
data: Json<RegisterData>,
state: Data<ServerState>,
) -> 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);
Ok(HttpResponse::Ok().json("User registered"))
}
#[post("/login")]
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 {
Some(_) => {
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);
return Ok(HttpResponse::Ok().json(LoginResponse {
username: data.username.clone(),
token: token_factory(&data.username).unwrap(),
token: token,
}));
}
None => {
Err(_) => {
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<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::Sqlite;
use sqlx::SqlitePool;
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)]
pub struct ServerState {
pub pool: Pool<Sqlite>,
@ -32,11 +13,6 @@ 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)
@ -46,45 +22,37 @@ 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.unwrap();
debug_setup(&pool).await;
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) -> Result<(), sqlx::Error> {
async fn debug_setup(pool: &SqlitePool) {
use chrono::NaiveDateTime;
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', ?, ?)",
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(())
query!(
"INSERT INTO posts (user_id, content, created_at, updated_at) VALUES (1, 'Hello world!', ?, ?)",
now,
now
)
.execute(pool)
.await
.unwrap();
}