wip: move to axum

This commit is contained in:
emilis 2022-09-13 01:35:05 +01:00
parent 1c5c9caf2a
commit a5eef831c7
16 changed files with 436 additions and 290 deletions

119
Cargo.lock generated
View File

@ -51,6 +51,53 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "axum"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043"
dependencies = [
"async-trait",
"axum-core",
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"hyper",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-http",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"mime",
"tower-layer",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.66"
@ -245,6 +292,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"argon2",
"axum",
"base-62",
"handlebars",
"jsonwebtoken",
@ -257,7 +305,6 @@ dependencies = [
"tokio",
"tokio-postgres",
"warp",
"warp-embed",
]
[[package]]
@ -460,6 +507,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29"
[[package]]
name = "httparse"
version = "1.8.0"
@ -579,6 +632,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "matchit"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cbba799671b762df5a175adf59ce145165747bb891505c43d09aefbbf38beb"
[[package]]
name = "md-5"
version = "0.10.4"
@ -1257,6 +1316,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
[[package]]
name = "synstructure"
version = "0.12.6"
@ -1444,6 +1509,47 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-range-header",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62"
[[package]]
name = "tower-service"
version = "0.3.2"
@ -1633,17 +1739,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "warp-embed"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b958139e25f097e0ebde85342a3a2dbb728983ca893ba96b7fb8f448337110af"
dependencies = [
"mime_guess",
"rust-embed",
"warp",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"

View File

@ -8,6 +8,7 @@ edition = "2021"
[dependencies]
anyhow = "1.0.64"
argon2 = "0.4.1"
axum = "0.5.16"
base-62 = "0.1.1"
handlebars = "4.3.3"
jsonwebtoken = "8.1.1"
@ -20,4 +21,3 @@ serde_json = "1.0.85"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7.7", features = ["with-serde_json-1"] }
warp = { version = "0.3.2" }
warp-embed = "0.4.0"

View File

@ -5,6 +5,7 @@ CREATE TABLE users (
username TEXT NOT NULL,
host TEXT,
display_name TEXT,
email TEXT NOT NULL,
password_hash TEXT NOT NULL
);

View File

@ -17,8 +17,8 @@ impl Users {
pub async fn create_user(&self, u: User) -> Result<User, db::DBError> {
let row = self.0.lock().await.query_one(
"insert into users (id, username, host, display_name, password_hash) values ($1, $2, $3, $4, $5) returning id",
&[&sec::new_id(), &u.username, &u.host, &u.display_name, &u.password_hash],
"insert into users (id, username, host, display_name, password_hash, email) values ($1, $2, $3, $4, $5, $6) returning id",
&[&sec::new_id(), &u.username, &u.host, &u.display_name, &u.password_hash, &u.email],
).await?;
Ok(User {
id: row.get("id"),
@ -26,6 +26,7 @@ impl Users {
host: u.host,
display_name: u.display_name,
password_hash: u.password_hash,
email: u.email,
})
}
@ -73,6 +74,7 @@ pub struct User {
pub host: Option<String>,
pub display_name: Option<String>,
pub password_hash: String,
pub email: String,
}
impl From<&Row> for User {
@ -83,6 +85,7 @@ impl From<&Row> for User {
host: row.get("host"),
display_name: row.get("display_name"),
password_hash: row.get("password_hash"),
email: row.get("email"),
}
}
}

View File

@ -1,12 +1 @@
use serde::Serialize;
#[derive(Clone, Serialize)]
pub struct Error<'a> {
pub error: &'a str,
}
impl<'a> Error<'a> {
pub fn error(error: &'a str) -> Self {
Self { error }
}
}

View File

@ -1,18 +1,20 @@
use std::{collections::HashMap, str::FromStr};
use warp::{
http::HeaderValue,
hyper::Uri,
path::Tail,
reply::{Html, Response},
Filter, Rejection, Reply,
use axum::{
body::Full,
extract::Path,
handler::Handler,
http::{header, StatusCode},
response::{self, Html, IntoResponse, Redirect, Response},
routing, Extension, Form, Json, Router,
};
use warp::{http::HeaderValue, hyper::Uri, path::Tail, Filter, Rejection, Reply};
use crate::svc::auth::AuthError;
use super::{
servek::{Server, ServerError},
CreateProfileRequest, ErrorTemplate,
CreateProfileRequest, LoginRequest, Notification,
};
use rust_embed::RustEmbed;
@ -21,147 +23,190 @@ use rust_embed::RustEmbed;
struct StaticData;
impl Server {
pub(super) async fn html(
&self,
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
Self::index()
.or(self.profile())
.or(self.create_profile())
.or(Server::static_files())
.or(self.login_page())
.or(self.login())
.or(Self::handler_404())
}
fn index() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::get().and(warp::path::end().map(move || {
warp::reply::html(include_str!("../../templates/html/index.html").to_owned())
}))
}
fn handler_404() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::get().and(warp::path::end().map(move || {
warp::reply::html(include_str!("../../templates/html/404.html").to_owned())
}))
}
fn login_with_error(&self, error: String) -> Result<Html<String>, Rejection> {
self.hb
.render("login-error", &serde_json::json!(ErrorTemplate { error }))
.map(|html| warp::reply::html(html))
.map_err(|e| ServerError::from(e).rejection())
}
fn login_page(
&self,
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::get().and(warp::path::path("login").map(move || {
warp::reply::html(include_str!("../../templates/html/login.html").to_owned())
}))
}
fn login(&self) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::post().and(
warp::body::content_length_limit(8192).and(
warp::path::path("login")
.and(Self::with_server(self.clone()))
.and(warp::body::form())
.and_then(|srv: Server, body: HashMap<String, String>| async move {
let user = body.get("username").ok_or(
ServerError::BadRequest("no username provided".to_owned()).rejection(),
)?;
let pass = body.get("password").ok_or(
ServerError::BadRequest("no password provided".to_owned()).rejection(),
)?;
let token = srv
.auth
.login(user.clone(), pass.clone())
.await
.map(|html| warp::reply::html(html));
if let Err(e) = &token {
if let AuthError::InvalidCredentials = e {
return srv.login_with_error("invalid credentials".to_owned());
}
}
token.map_err(|e| ServerError::from(e).rejection())
}),
),
async fn index() -> impl IntoResponse {
(
StatusCode::OK,
response::Html(include_str!("../../templates/html/index.html")),
)
}
pub(super) fn register_html(&self, router: &Router) -> Router {
router
.clone()
.route("/", routing::get(Self::index))
.route("/login", routing::get(Self::login_page))
.route(
"/signup",
routing::get(Self::signup_page).post(Self::create_user),
)
.route("/static/*file", routing::get(Self::static_handler))
.fallback(routing::get(Self::handler_404))
}
async fn handler_404() -> impl IntoResponse {
(
StatusCode::OK,
response::Html(include_str!("../../templates/html/404.html")),
)
}
fn login_page_with(
&self,
tag_name: String,
message: String,
) -> Result<response::Html<String>, ServerError> {
Ok(self
.hb
.render(
"login",
&serde_json::json!(Notification { message, tag_name }),
)
.map(|html| response::Html(html))?)
}
async fn login_page(
Extension(srv): Extension<Server>,
) -> Result<impl IntoResponse, ServerError> {
Ok((
StatusCode::OK,
response::Html(srv.hb.render("login", &serde_json::json!(()))?),
))
}
// async fn login(
// Extension(srv): Extension<Server>,
// Form(login): Form<LoginRequest>,
// ) -> Result<impl IntoResponse, ServerError> {
// if login.username == "" || login.password == "" {
// return srv.login_page_with(
// "error-partial".to_owned(),
// "credentials required".to_owned(),
// );
// }
// let token = srv.auth.login(login.username, login.password).await?;
// }
// async fn login(
// srv: Server,
// Form(body): Form<HashMap<String, String>>,
// ) -> Result<response::Html<String>, ServerError> {
// let user = body
// .get("username")
// .ok_or(ServerError::BadRequest("no username provided".to_owned()))?;
// let pass = body
// .get("password")
// .ok_or(ServerError::BadRequest("no password provided".to_owned()))?;
// let token = srv
// .auth
// .login(user.clone(), pass.clone())
// .await
// .map(|html| response::Html(html));
// if let Err(e) = &token {
// if let AuthError::InvalidCredentials = e {
// return srv.login_with_error("invalid credentials".to_owned());
// }
// }
// Ok(token?)
// }
fn with_server(
srv: Server,
) -> impl Filter<Extract = (Server,), Error = std::convert::Infallible> + Clone {
warp::any().map(move || srv.clone())
}
fn profile(&self) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::get().and(
warp::path!("@" / String)
.and(Self::with_server(self.clone()))
.and_then(|username: String, srv: Server| async move {
srv.hb
.render(
"profile",
&serde_json::json!(srv
.profiler
.profile(username)
.await
.map_err(|e| ServerError::from(e))?),
)
.map(|html| warp::reply::html(html))
.map_err(|e| ServerError::from(e).rejection())
}),
)
// fn profile(&self) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
// warp::get().and(
// warp::path!("@" / String)
// .and(Self::with_server(self.clone()))
// .and_then(|username: String, srv: Server| async move {
// srv.hb
// .render(
// "profile",
// &serde_json::json!(srv
// .profiler
// .profile(username)
// .await
// .map_err(|e| ServerError::from(e))?),
// )
// .map(|html| warp::reply::html(html))
// .map_err(|e| ServerError::from(e).rejection())
// }),
// )
// }
async fn create_user(
Extension(srv): Extension<Server>,
Form(body): Form<CreateProfileRequest>,
) -> Result<impl IntoResponse, ServerError> {
if body.username == "" {
return srv.signup_page_with("error-partial".to_owned(), "empty username".to_owned());
}
if body.password == "" {
return srv.signup_page_with("error-partial".to_owned(), "empty password".to_owned());
}
if body.email == "" {
return srv.signup_page_with("error-partial".to_owned(), "empty email".to_owned());
}
srv.profiler
.create_user(body.username, body.password, body.email)
.await?;
srv.signup_page_with("success-partial".to_owned(), "signup successful".to_owned())
}
fn create_profile(&self) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::post().and(
warp::body::content_length_limit(8192).and(
warp::path!("@" / String)
.and(Self::with_server(self.clone()))
.and(warp::body::form())
.and_then(
|username: String, srv: Server, body: CreateProfileRequest| async move {
if body.password_hash.len() == 0 {
return Err(ServerError::BadRequest(
"cannot have an empty password".to_owned(),
)
.rejection());
}
let user = srv
.profiler
.create_user(username, body.password_hash)
.await
.map_err(|e| ServerError::from(e));
match user {
Ok(u) => Ok(warp::redirect(
Uri::from_str(format!("/@/{}", u.username).as_str()).unwrap(),
)),
Err(e) => Err(e.rejection()),
}
},
),
),
)
}
fn static_files() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::get().and(warp::path("static").and(warp::path::tail()).and_then(
|path: Tail| async move {
let asset = match StaticData::get(path.as_str()) {
Some(a) => a,
None => return Err(ServerError::NotFound.rejection()),
};
let mime = mime_guess::from_path(path.as_str()).first_or_octet_stream();
let mut res = Response::new(asset.data.into());
res.headers_mut().insert(
"Content-Type",
HeaderValue::from_str(mime.as_ref()).unwrap(),
);
Ok(res)
},
async fn signup_page(
Extension(srv): Extension<Server>,
) -> Result<impl IntoResponse, ServerError> {
Ok((
StatusCode::OK,
response::Html(srv.hb.render("signup", &serde_json::json!(()))?),
))
}
fn signup_page_with(
&self,
tag_name: String,
message: String,
) -> Result<impl IntoResponse, ServerError> {
Ok((
StatusCode::OK,
response::Html(self.hb.render(
"signup",
&serde_json::json!(Notification { message, tag_name }),
)?),
))
}
async fn static_handler(Path(mut path): Path<String>) -> impl IntoResponse {
path.remove(0);
println!("getting path: {}", path);
StaticFile(path)
}
}
pub struct StaticFile<T>(pub T);
impl<T> IntoResponse for StaticFile<T>
where
T: Into<String>,
{
fn into_response(self) -> axum::response::Response {
let path = self.0.into();
match StaticData::get(path.as_str()) {
Some(content) => {
let body = axum::body::boxed(Full::from(content.data));
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.body(body)
.unwrap()
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(axum::body::boxed(Full::from("404")))
.unwrap(),
}
}
}

View File

@ -4,13 +4,22 @@ mod html;
pub mod servek;
#[derive(Debug, Clone, Serialize)]
struct ErrorTemplate {
pub error: String,
struct Notification {
pub message: String,
pub tag_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateProfileRequest {
pub password_hash: String,
pub username: String,
pub password: String,
pub email: String,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -1,6 +1,11 @@
use core::panic;
use std::{convert::Infallible, fmt::Display};
use std::{convert::Infallible, fmt::Display, net::SocketAddr};
use axum::{
http::uri::InvalidUri,
response::{self, IntoResponse, Response},
routing, Extension, Router,
};
use handlebars::{Handlebars, RenderError};
use warp::{
hyper::StatusCode,
@ -28,62 +33,33 @@ impl Server {
let mut hb = Handlebars::new();
hb.register_template_string("profile", include_str!("../../templates/html/profile.html"))
.expect("profile template");
hb.register_template_string(
"login-error",
include_str!("../../templates/html/login-error.html"),
)
.expect("login-error template");
hb.register_template_string("login", include_str!("../../templates/html/login.html"))
.expect("login template");
hb.register_template_string("signup", include_str!("../../templates/html/signup.html"))
.expect("login template");
hb.register_template_string("error-partial", r#"<h2 class="error">{{message}}</h2>"#)
.expect("error-partial");
hb.register_template_string("success-partial", r#"<h2 class="success">{{message}}</h2>"#)
.expect("success-partial");
Self { hb, profiler, auth }
}
pub async fn listen_and_serve(self, port: u16) -> ! {
println!("starting server on port {}", port);
warp::serve(self.html().await.recover(Self::handle_rejection))
.run(([127, 0, 0, 1], port))
.await;
let router = Router::new();
let router = self
.register_html(&router)
.layer(Extension::<Server>(self.clone()));
let addr = SocketAddr::from(([127, 0, 0, 1], port));
println!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(router.into_make_service())
.await
.unwrap();
panic!("server stopped prematurely")
}
async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
let code;
let message;
if err.is_not_found() {
code = StatusCode::NOT_FOUND;
message = "not found";
} else if let Some(err) = err.find::<ServerError>() {
match err {
ServerError::Internal(err) => {
println!("internal server error: {}", err);
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "internal server error";
}
ServerError::NotFound => {
code = StatusCode::NOT_FOUND;
message = "not found";
}
ServerError::BadRequest(err) => {
code = StatusCode::BAD_REQUEST;
message = err;
}
}
} else if let Some(err) = err.find::<MethodNotAllowed>() {
println!("MethodNotAllowed: {:#?}", err);
code = StatusCode::NOT_FOUND;
message = "not found";
} else {
// We should have expected this... Just log and say its a 500
println!("FIXME: unhandled rejection: {:?}", err);
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "internal server error"
}
Ok(warp::reply::with_status(
warp::reply::json(&model::Error::error(message)),
code,
))
}
}
#[derive(Clone, Debug)]
@ -93,12 +69,6 @@ pub(super) enum ServerError {
BadRequest(String),
}
impl ServerError {
pub(super) fn rejection(self) -> Rejection {
warp::reject::custom(self)
}
}
impl Reject for ServerError {}
impl From<RenderError> for ServerError {
@ -133,3 +103,13 @@ impl Display for ServerError {
write!(f, "{}", self)
}
}
impl IntoResponse for ServerError {
fn into_response(self) -> axum::response::Response {
match self {
ServerError::Internal(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
ServerError::NotFound => (StatusCode::NOT_FOUND, "").into_response(),
ServerError::BadRequest(err) => (StatusCode::BAD_REQUEST, err).into_response(),
}
}
}

View File

@ -28,7 +28,12 @@ impl Profiler {
Ok(User::from(self.db.user(select).await?))
}
pub async fn create_user(&self, username: String, password: String) -> Result<User, UserError> {
pub async fn create_user(
&self,
username: String,
password: String,
email: String,
) -> Result<User, UserError> {
let result = self
.db
.create_user(users::User {
@ -37,6 +42,7 @@ impl Profiler {
host: None,
display_name: None,
password_hash: sec::hash(password),
email,
})
.await?;
@ -70,12 +76,6 @@ pub enum UserError {
Other(String),
}
impl From<anyhow::Error> for UserError {
fn from(err: anyhow::Error) -> Self {
Self::Other(format!("UserError: {}", err))
}
}
impl From<db::DBError> for UserError {
fn from(err: db::DBError) -> Self {
match err {

View File

@ -1 +0,0 @@

View File

@ -14,9 +14,39 @@ input {
border-color: rebeccapurple;
}
form>div {
width: 80%;
}
.error {
color: rgba(255, 0, 0, 0.7);
font-weight: bold;
border: 5px solid rgba(99, 0, 0, 0.2);
background-color: rgba(95, 0, 0, 0.5);
}
.success {
color: rgba(0, 202, 0, 0.7);
font-weight: bold;
border: 5px solid rgba(0, 99, 0, 0.2);
background-color: rgba(0, 99, 0, 0.5);
}
#central {
width: 30%;
border: 5px solid rebeccapurple;
height: 30vw;
padding: 10px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(13, 6, 19, 0.4);
font-size: large;
font-weight: bold;
}
a {
color: rebeccapurple;
}

View File

@ -9,6 +9,7 @@
<body>
<h1>hi</h1>
<h1><a href="/login">login</a></h1>
<h1><a href="/signup">sign up</a></h1>
</body>
</html>

View File

@ -1,40 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>flabk - login error</title>
<link rel="stylesheet" href="/static/style/main.css">
<style>
#login-main {
width: 30%;
border: 5px solid rebeccapurple;
height: 30vw;
padding: 10px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(13, 6, 19, 0.4);
font-size: large;
font-weight: bold;
}
</style>
</head>
<body>
<h1>login</h1>
<div id="login-main">
<p>Login form</p>
<h2 class="error">error: {{error}}</h2>
<form id="login" method="post" action="/login">
<input type="text" name="username">
<input type="password" name="password">
<input type="submit" value="Submit" hidden>
</form>
</div>
</body>
</html>

View File

@ -4,29 +4,13 @@
<head>
<title>flabk - login</title>
<link rel="stylesheet" href="/static/style/main.css">
<style>
#login-main {
width: 30%;
border: 5px solid rebeccapurple;
height: 30vw;
padding: 10px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(13, 6, 19, 0.4);
font-size: large;
font-weight: bold;
}
</style>
</head>
<body>
<h1>login</h1>
<div id="login-main">
<p>Login form</p>
<div id="central">
<p>login form</p>
{{> (lookup this "tag_name")}}
<form id="login" method="post" action="/login">
<input type="text" name="username">
<input type="password" name="password">

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>logging in</title>
</head>
<body>
<script>
window.localStorage.setItem('token', '{{token}}');
document.location.href = "/";
</script>
</body>
</html>

View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<title>flabk - signup</title>
<link rel="stylesheet" href="/static/style/main.css">
</head>
<body>
<h1>signup</h1>
<div id="central">
<p>signup form</p>
{{> (lookup this "tag_name")}}
<form id="signup" method="post" action="/signup">
<div>
<label for="username">username</label>
<input type="text" name="username">
</div>
<br />
<div>
<label for="email">email</label>
<input type="email" name="email">
</div>
<br />
<div>
<label for="password">password</label>
<input type="password" name="password">
</div>
<input type="submit" value="Submit" hidden>
</form>
</div>
</body>
</html>