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