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…
	
	Add table
		Add a link
		
	
		Reference in a new issue