Compare commits
17 commits
9f1d18f602
...
b3124948c4
Author | SHA1 | Date | |
---|---|---|---|
|
b3124948c4 | ||
|
d92a1cd617 | ||
|
e9b215c686 | ||
|
9c60ae2000 | ||
|
6bef5ada4d | ||
|
3a007f0093 | ||
|
985ce53a97 | ||
|
3c48698522 | ||
|
c2103071bc | ||
|
508cf528af | ||
|
fa9b2b6fc1 | ||
|
4c398a40f6 | ||
|
4fc00eaf23 | ||
|
204ed8ec41 | ||
|
db0783fe2e | ||
|
cf607ae345 | ||
|
2572fcbffd |
19 changed files with 747 additions and 272 deletions
|
@ -9,7 +9,7 @@
|
||||||
<title>FrostByteSolid</title>
|
<title>FrostByteSolid</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen">
|
<body>
|
||||||
<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>
|
||||||
|
|
92
client-solid/src/Login.tsx
Normal file
92
client-solid/src/Login.tsx
Normal file
|
@ -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 (
|
||||||
|
<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 "";
|
||||||
|
}
|
||||||
|
|
83
client-solid/src/Navbar.tsx
Normal file
83
client-solid/src/Navbar.tsx
Normal file
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
43
client-solid/src/Posts.tsx
Normal file
43
client-solid/src/Posts.tsx
Normal file
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
16
client-solid/src/Primary.tsx
Normal file
16
client-solid/src/Primary.tsx
Normal file
|
@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Posts />} />
|
||||||
|
<Route path="/post/:postid" element={<SinglePost />} />
|
||||||
|
<Route path="/new" element={<NewPostInputArea />} />
|
||||||
|
<Route path="*" element={<h1>404</h1>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,143 +1,111 @@
|
||||||
import { createSignal } from "solid-js";
|
import { Accessor, Show, createSignal, useContext } from "solid-js";
|
||||||
import { createContext } 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";
|
// Representing the state of varoious modals.
|
||||||
import { Post, NewPost } from "./api";
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
export const TestContext = createContext("Test123");
|
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>();
|
||||||
|
|
||||||
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 (
|
||||||
<>
|
<>
|
||||||
<TestContext.Provider value="Test123">
|
<ModalContext.Provider value={{ loginModalOpen, setLoginModalOpen }}>
|
||||||
<div class="flex flex-col items-center my-2">
|
<LoginContext.Provider
|
||||||
<Navbar />
|
value={{ token, setToken, username, setUsername }}
|
||||||
<div class="flex flex-col items-center md:w-96 space-y-2">
|
>
|
||||||
<Primary />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LoginContext.Provider>
|
||||||
</TestContext.Provider>
|
</ModalContext.Provider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Navbar() {
|
export function NewPostInputArea() {
|
||||||
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 [content, setContent] = createSignal("");
|
||||||
return (
|
const [waiting, setWaiting] = createSignal(false);
|
||||||
<div class="flex flex-col space-y-2">
|
const login_ctx = useContext(LoginContext);
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Posts() {
|
const nav = useNavigate();
|
||||||
const [posts, setPosts] = createSignal([] as Post[]);
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
|
|
||||||
getPosts().then((posts) => {
|
const sendPost = () => {
|
||||||
setPosts(posts as any);
|
setWaiting(true);
|
||||||
setLoading(false)
|
const response = createPost({
|
||||||
});
|
content: content(),
|
||||||
|
token: login_ctx?.token(),
|
||||||
|
} as NewPost);
|
||||||
|
if (response) {
|
||||||
|
response.then(() => {
|
||||||
|
setWaiting(false);
|
||||||
|
setContent("");
|
||||||
|
nav("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col space-y-2 w-full md:w-96">
|
<Show
|
||||||
{ loading() ? <span class="loading loading-spinner loading-lg self-center"></span> : <></> }
|
when={!waiting()}
|
||||||
{posts().map((post) => {
|
fallback={
|
||||||
if (post.content == "") return; // Filtering out empty posts, remove this later
|
<span class="loading loading-spinner loading-lg self-center"></span>
|
||||||
return <PostSegment post={post}></PostSegment>;
|
}
|
||||||
})}
|
>
|
||||||
</div>
|
<div class="flex flex-col space-y-2">
|
||||||
);
|
<textarea
|
||||||
}
|
class="textarea textarea-bordered"
|
||||||
|
placeholder="Speak your mind..."
|
||||||
function PostSegment({ post }: { post: Post }) {
|
maxLength={500}
|
||||||
return (
|
oninput={(input) => {
|
||||||
<div class="card bg-base-200 shadow-lg compact text-base-content w-full">
|
setContent(input.target.value);
|
||||||
<div class="card-body">
|
}}
|
||||||
<p class="text-base-content">{post.content}</p>
|
></textarea>
|
||||||
{/* <p>{post.votes.up}</p>
|
<button
|
||||||
<p>{post.votes.down}</p> */}
|
class={
|
||||||
|
"btn btn-primary self-end btn-sm" +
|
||||||
|
(content() == "" ? " btn-disabled" : "")
|
||||||
|
}
|
||||||
|
onclick={sendPost}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
19
client-solid/src/SinglePost.tsx
Normal file
19
client-solid/src/SinglePost.tsx
Normal file
|
@ -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 (
|
||||||
|
<Suspense fallback={<div>Some loading message</div>}>
|
||||||
|
<div>
|
||||||
|
<Show when={post()}>
|
||||||
|
<PostSegment post={post()!}></PostSegment>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,4 @@
|
||||||
// const PORT = 3000;
|
// This file contains types and functions related to interacting with the API.
|
||||||
// const API_URL = `http://localhost:${PORT}/api/`;
|
|
||||||
// const API_URL2 = new URL(API_URL);
|
|
||||||
|
|
||||||
export interface NewPost {
|
export interface NewPost {
|
||||||
content: string;
|
content: string;
|
||||||
|
@ -13,13 +11,12 @@ interface Votes {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post extends NewPost {
|
export interface Post extends NewPost {
|
||||||
uuid: string;
|
id: 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;
|
||||||
|
@ -27,7 +24,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
17
justfile
17
justfile
|
@ -4,14 +4,15 @@ 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."
|
||||||
|
|
||||||
# Builds the client with npm (result in client/dist
|
[private]
|
||||||
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: (npm-install "client-solid")
|
npm-build directory: (npm-install directory)
|
||||||
cd client && npm run build
|
cd {{directory}} && npm run build
|
||||||
|
@echo "Built client at {{directory}}/dist"
|
||||||
|
|
||||||
# Builds a debug container
|
# Builds a debug container
|
||||||
[private]
|
[private]
|
||||||
|
@ -31,7 +32,13 @@ 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 -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
|
# Removes and stops any containers related to the project
|
||||||
[private]
|
[private]
|
||||||
|
@ -56,6 +63,8 @@ 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
13
server/Cargo.lock
generated
|
@ -391,6 +391,18 @@ 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"
|
||||||
|
@ -1780,6 +1792,7 @@ dependencies = [
|
||||||
"actix-files",
|
"actix-files",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"biosvg",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
|
|
@ -9,6 +9,7 @@ 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"
|
||||||
|
|
|
@ -1,9 +1,31 @@
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE
|
||||||
id INTEGER PRIMARY KEY,
|
IF NOT EXISTS users (
|
||||||
username TEXT NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
password TEXT NOT NULL,
|
username TEXT NOT NULL UNIQUE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
password TEXT NOT NULL,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
);
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
create index users_username_index on users (username);
|
-- 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);
|
|
@ -1,12 +1,38 @@
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
IF NOT EXISTS posts (
|
||||||
user_id INTEGER NOT NULL,
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
content TEXT NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
upvotes INT NOT NULL DEFAULT 0,
|
content TEXT NOT NULL,
|
||||||
downvotes INT NOT NULL DEFAULT 0,
|
upvotes INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
downvotes INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
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);
|
|
||||||
|
-- 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);
|
134
server/src/db.rs
134
server/src/db.rs
|
@ -1,36 +1,138 @@
|
||||||
use crate::routes::{NewPost, Post};
|
use crate::routes::{Post, User};
|
||||||
use log::warn;
|
use argon2::{
|
||||||
use sqlx::{Row, SqlitePool};
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHasher, PasswordVerifier,
|
||||||
|
};
|
||||||
|
use log::{info, warn};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
// Gets all posts from the database
|
// Gets the latest posts from the database, ordered by created_at
|
||||||
pub async fn db_get_posts(pool: &SqlitePool) -> Vec<Post> {
|
pub async fn db_get_latest_posts(pool: &SqlitePool, limit: i64, offset: i64) -> Vec<Post> {
|
||||||
sqlx::query_as!(Post, "SELECT * FROM posts")
|
sqlx::query_as!(
|
||||||
.fetch_all(pool)
|
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)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts a new post to the database
|
// Inserts a new post to the database
|
||||||
pub async fn db_new_post(post: NewPost, pool: &SqlitePool) -> Option<Post> {
|
pub async fn db_new_post(userid: i64, content: &str, pool: &SqlitePool) -> Option<Post> {
|
||||||
let q2 = sqlx::query!(
|
info!("User with id {} submitted a post", userid);
|
||||||
"INSERT INTO posts (user_id, content) VALUES (1, ?)",
|
|
||||||
post.content
|
let insert_query = sqlx::query!(
|
||||||
|
"INSERT INTO posts (user_id, content) VALUES (?, ?)",
|
||||||
|
userid,
|
||||||
|
content
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if q2.is_err() {
|
if insert_query.is_err() {
|
||||||
let s = q2.err().unwrap();
|
let s = insert_query.err().unwrap();
|
||||||
warn!("Error inserting post into database: {}", s);
|
warn!("Error inserting post into database: {}", s);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let q = sqlx::query_as!(
|
// Dips into the database to get the post we just inserted
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
// 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,
|
||||||
};
|
};
|
||||||
|
@ -40,7 +36,8 @@ pub fn token_factory(user: &str) -> JwtResult<String> {
|
||||||
Ok(token)
|
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<Claims> {
|
pub fn validate_token(token: &str) -> JwtResult<Claims> {
|
||||||
let token_data = decode::<Claims>(
|
let token_data = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
|
|
|
@ -10,7 +10,8 @@ mod jwt;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
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;
|
use state::ServerState;
|
||||||
|
|
||||||
#[actix_web::main]
|
#[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();
|
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 || {
|
||||||
|
@ -29,9 +31,11 @@ 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"))
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 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 actix_web::{get, post, web::Json, HttpResponse, Responder, Result};
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
@ -27,6 +28,7 @@ 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,
|
||||||
|
@ -36,17 +38,55 @@ pub struct User {
|
||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/posts")]
|
// These look like /posts?limit=10&offset=20 in the URL
|
||||||
pub async fn get_posts(state: Data<ServerState>) -> Result<impl Responder> {
|
// Note that these are optional
|
||||||
let stream = sqlx::query_as!(Post, "SELECT * FROM posts");
|
/// Query parameters for the /posts endpoint
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
let posts = stream.fetch_all(&state.pool).await.unwrap();
|
pub struct QueryParams {
|
||||||
Ok(HttpResponse::Ok().json(posts))
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
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) => {
|
Some(post) => {
|
||||||
info!("Created post {:?}", post.id);
|
info!("Created post {:?}", post.id);
|
||||||
Ok(HttpResponse::Ok().json(post))
|
Ok(HttpResponse::Ok().json(post))
|
||||||
|
@ -54,3 +94,12 @@ 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
|
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::OsRng;
|
use argon2::password_hash::rand_core::RngCore;
|
||||||
use argon2::password_hash::SaltString;
|
|
||||||
use argon2::password_hash::*;
|
use argon2::password_hash::*;
|
||||||
use argon2::Argon2;
|
use biosvg::BiosvgBuilder;
|
||||||
use argon2::PasswordHasher;
|
|
||||||
use argon2::PasswordVerifier;
|
|
||||||
use log::*;
|
use log::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
@ -19,7 +18,7 @@ pub struct LoginData {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
username: String,
|
username: String,
|
||||||
token: String,
|
token: String,
|
||||||
}
|
}
|
||||||
|
@ -36,81 +35,82 @@ pub async fn register(
|
||||||
data: Json<RegisterData>,
|
data: Json<RegisterData>,
|
||||||
state: Data<ServerState>,
|
state: Data<ServerState>,
|
||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
// First check if the user already exists
|
db_new_user(data.username.clone(), data.password.clone(), &state.pool).await;
|
||||||
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 q = "SELECT password FROM users WHERE username = ?";
|
let result = db_user_login(data.username.clone(), data.password.clone(), &state.pool).await;
|
||||||
// let query = sqlx::query(q).bind(&data.username);
|
|
||||||
// let result = query.fetch_one(&state.pool).await.ok();
|
|
||||||
|
|
||||||
let uname = data.username.clone();
|
match result {
|
||||||
let q = sqlx::query!("SELECT password FROM users WHERE username = ?", uname)
|
Some(_) => {
|
||||||
.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,
|
token: token_factory(&data.username).unwrap(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
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()
|
||||||
|
}
|
||||||
|
|
|
@ -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::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>,
|
||||||
|
@ -13,6 +32,11 @@ 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)
|
||||||
|
@ -22,37 +46,45 @@ 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;
|
debug_setup(&pool).await.unwrap();
|
||||||
|
|
||||||
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) {
|
async fn debug_setup(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use sqlx::query;
|
use sqlx::query;
|
||||||
|
|
||||||
let now = NaiveDateTime::from_timestamp(0, 0);
|
use crate::db::db_new_user;
|
||||||
|
|
||||||
query!(
|
db_new_user("user".to_string(), "pass".to_string(), pool).await;
|
||||||
"INSERT INTO users (username, password, created_at, updated_at) VALUES ('test', 'test', ?, ?)",
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
query!(
|
// Check if the demo post already exists
|
||||||
"INSERT INTO posts (user_id, content, created_at, updated_at) VALUES (1, 'Hello world!', ?, ?)",
|
let posted = query!("SELECT * FROM posts WHERE id = 1",)
|
||||||
now,
|
.fetch_one(pool)
|
||||||
now
|
.await
|
||||||
)
|
.ok();
|
||||||
.execute(pool)
|
|
||||||
.await
|
// If the demo user already has a post, don't insert another one
|
||||||
.unwrap();
|
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(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue