├── migrations ├── .gitkeep ├── 2018-08-18-083245_create_users │ ├── down.sql │ └── up.sql └── 00000000000000_diesel_initial_setup │ ├── down.sql │ └── up.sql ├── .rustfmt.toml ├── .gitignore ├── diesel.toml ├── .travis.yml ├── src ├── build.rs ├── schema.rs ├── error.rs ├── models.rs ├── session.rs └── main.rs ├── templates ├── page.rs.html ├── footer.rs.html ├── login.rs.html ├── signup.rs.html ├── error.rs.html └── page_base.rs.html ├── .github └── workflows │ └── rust.yml ├── Cargo.toml ├── LICENSE ├── res └── style.scss └── README.md /migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 78 2 | 3 | reorder_imports = true 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | 3 | /Cargo.lock 4 | /target 5 | /.env 6 | **/*.rs.bk 7 | -------------------------------------------------------------------------------- /migrations/2018-08-18-083245_create_users/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE sessions; 2 | DROP TABLE users; 3 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/schema.rs" 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - beta 5 | cache: cargo 6 | before_script: 7 | - cargo update || true 8 | matrix: 9 | include: 10 | - rust: stable 11 | env: TASK=rustfmt 12 | before_script: rustup component add rustfmt-preview 13 | script: cargo fmt -- --check 14 | -------------------------------------------------------------------------------- /src/build.rs: -------------------------------------------------------------------------------- 1 | //! This job builds rust source from static files and templates, 2 | //! which can then be `include!`d in `main.rs`. 3 | use ructe::{Result, Ructe}; 4 | 5 | fn main() -> Result<()> { 6 | let mut ructe = Ructe::from_env()?; 7 | let mut statics = ructe.statics()?; 8 | statics.add_sass_file("res/style.scss")?; 9 | ructe.compile_templates("templates") 10 | } 11 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /templates/page.rs.html: -------------------------------------------------------------------------------- 1 | @use super::page_base; 2 | @use crate::Session; 3 | 4 | @(session: &Session, paras: &[(&str, usize)]) 5 | 6 | @:page_base(session, "Example", { 7 | 8 |

This is a simple sample page.

9 | 10 | @for (order, n) in paras { 11 |

This is a @order paragraph, with @n repeats. 12 | @for _ in 1..=*n { 13 | This is a @order paragraph. 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /src/schema.rs: -------------------------------------------------------------------------------- 1 | table! { 2 | sessions (id) { 3 | id -> Int4, 4 | cookie -> Varchar, 5 | user_id -> Int4, 6 | } 7 | } 8 | 9 | table! { 10 | users (id) { 11 | id -> Int4, 12 | username -> Varchar, 13 | realname -> Varchar, 14 | password -> Varchar, 15 | } 16 | } 17 | 18 | joinable!(sessions -> users (user_id)); 19 | 20 | allow_tables_to_appear_in_same_query!(sessions, users); 21 | -------------------------------------------------------------------------------- /templates/footer.rs.html: -------------------------------------------------------------------------------- 1 | @(frameworks: &[(&str, &str)]) 2 | 3 |

12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "warp-diesel-ructe-sample" 3 | version = "0.2.0" 4 | authors = ["Rasmus Kaj "] 5 | edition = "2018" 6 | 7 | build = "src/build.rs" 8 | 9 | [build-dependencies] 10 | ructe = { version = "0.14", features = ["sass", "warp03"] } 11 | 12 | [dependencies] 13 | warp = "0.3.0" 14 | mime = "0.3.0" 15 | env_logger = "0.9.0" 16 | log = "0.4.6" 17 | diesel = { version = "1.4.0", features = ["r2d2", "postgres"] } 18 | dotenv = "0.15.0" 19 | serde = { version = "1.0.0", features = ["derive"] } 20 | bcrypt = "0.13.0" 21 | rand = "0.8.3" 22 | tokio = { version = "1.4", features = ["macros", "rt-multi-thread"] } 23 | -------------------------------------------------------------------------------- /migrations/2018-08-18-083245_create_users/up.sql: -------------------------------------------------------------------------------- 1 | -- Create tables for users and sessions. 2 | 3 | CREATE TABLE users ( 4 | id SERIAL PRIMARY KEY, 5 | username VARCHAR UNIQUE NOT NULL, 6 | realname VARCHAR NOT NULL, 7 | password VARCHAR UNIQUE NOT NULL 8 | ); 9 | 10 | CREATE UNIQUE INDEX users_username_idx ON users (username); 11 | 12 | CREATE TABLE sessions ( 13 | id SERIAL PRIMARY KEY, 14 | cookie VARCHAR NOT NULL, 15 | user_id INTEGER NOT NULL REFERENCES users (id) 16 | -- TODO time created? time last accessed? both? 17 | -- Other "nice to have" fields may be added here or reference by id 18 | ); 19 | 20 | CREATE UNIQUE INDEX sessions_cookie_idx ON users (username); 21 | -------------------------------------------------------------------------------- /templates/login.rs.html: -------------------------------------------------------------------------------- 1 | @use super::page_base; 2 | @use crate::Session; 3 | 4 | @(session: &Session, next: Option, message: Option<&str>) 5 | 6 | @:page_base(session, "login", { 7 |
8 | @if let Some(message) = message {

@message

} 9 |

10 |

11 |

12 |

13 |

@if let Some(ref next) = next { 14 | 15 | } 16 | 17 |

18 |
19 | }) 20 | -------------------------------------------------------------------------------- /templates/signup.rs.html: -------------------------------------------------------------------------------- 1 | @use super::page_base; 2 | @use crate::Session; 3 | 4 | @(session: &Session, message: Option<&str>) 5 | 6 | @:page_base(session, "Sign up", { 7 |
8 | @if let Some(message) = message {

@message

} 9 |

10 |

11 |

12 |

13 |

14 |

15 |

[Cancel] 16 | 17 |

18 |
19 | }) 20 | -------------------------------------------------------------------------------- /templates/error.rs.html: -------------------------------------------------------------------------------- 1 | @use super::statics::*; 2 | @use crate::footer; 3 | @use warp::http::StatusCode; 4 | 5 | @(code: StatusCode, message: &str) 6 | 7 | 8 | 9 | 10 | Error @code.as_u16(): @code.canonical_reason().unwrap_or("error") 11 | 12 | 13 | 14 | 15 |
16 |

@code.canonical_reason().unwrap_or("error")

17 | 18 |

@message

19 |

We are sorry about this. 20 | In a real application, this would mention the incident having 21 | been logged, and giving contact details for further reporting.

22 |
23 | @:footer() 24 | 25 | 26 | -------------------------------------------------------------------------------- /templates/page_base.rs.html: -------------------------------------------------------------------------------- 1 | @use super::statics::style_css; 2 | @use crate::{footer, Session}; 3 | 4 | @(session: &Session, title: &str, content: Content) 5 | 6 | 7 | 8 | 9 | @title 10 | 11 | 12 | 13 | 14 | 15 |
16 | Example app 17 | @if let Some(u) = session.user() {@u 18 | (
)
} 19 | else { (log in or sign up) } 20 |
21 | 22 |
23 |

@title

24 | @:content() 25 |
26 | @:footer() 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::templates::{self, RenderError, RenderRucte}; 2 | use warp::http::{response::Builder, StatusCode}; 3 | use warp::reply::{Reply, Response}; 4 | 5 | #[derive(Debug)] 6 | pub enum Error { 7 | NotFound, 8 | InternalError, 9 | } 10 | 11 | impl Reply for Error { 12 | fn into_response(self) -> Response { 13 | match self { 14 | Error::NotFound => Builder::new() 15 | .status(StatusCode::NOT_FOUND) 16 | .html(|o| { 17 | templates::error( 18 | o, 19 | StatusCode::NOT_FOUND, 20 | "The resource you requested could not be located.", 21 | ) 22 | }) 23 | .unwrap(), 24 | Error::InternalError => { 25 | let code = StatusCode::INTERNAL_SERVER_ERROR; 26 | Builder::new() 27 | .status(code) 28 | .html(|o| { 29 | templates::error(o, code, "Something went wrong.") 30 | }) 31 | .unwrap() 32 | } 33 | } 34 | } 35 | } 36 | impl From for Error { 37 | fn from(_: RenderError) -> Self { 38 | Error::InternalError 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /src/models.rs: -------------------------------------------------------------------------------- 1 | use crate::templates::ToHtml; 2 | use diesel::pg::PgConnection; 3 | use diesel::prelude::*; 4 | use log::error; 5 | use std::io::{self, Write}; 6 | 7 | #[derive(Debug, Queryable)] 8 | pub struct User { 9 | pub id: i32, 10 | pub username: String, 11 | pub realname: String, 12 | } 13 | 14 | impl User { 15 | pub fn authenticate( 16 | db: &PgConnection, 17 | user: &str, 18 | pass: &str, 19 | ) -> Option { 20 | use crate::schema::users::dsl::*; 21 | let (user, hash) = match users 22 | .filter(username.eq(user)) 23 | .select(((id, username, realname), password)) 24 | .first::<(User, String)>(db) 25 | { 26 | Ok((user, hash)) => (user, hash), 27 | Err(e) => { 28 | error!("Failed to load hash for {:?}: {:?}", user, e); 29 | return None; 30 | } 31 | }; 32 | 33 | match bcrypt::verify(&pass, &hash) { 34 | Ok(true) => Some(user), 35 | Ok(false) => None, 36 | Err(e) => { 37 | error!("Verify failed for {:?}: {:?}", user, e); 38 | None 39 | } 40 | } 41 | } 42 | } 43 | 44 | // Implementing ToHtml for a type makes it possible to use that type 45 | // directly in templates. 46 | impl ToHtml for User { 47 | fn to_html(&self, out: &mut dyn Write) -> io::Result<()> { 48 | out.write_all(b"")?; 51 | self.realname.to_html(out)?; 52 | out.write_all(b"")?; 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /res/style.scss: -------------------------------------------------------------------------------- 1 | $border: 1px solid #333; 2 | 3 | html { 4 | height: 100%; 5 | &, body { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | } 10 | 11 | body { 12 | background: #eee; 13 | line-height: 1.6; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: space-between; 17 | min-height: 100%; 18 | } 19 | 20 | p { 21 | margin: 0; 22 | } 23 | 24 | p + p { 25 | margin-top: 1ex; 26 | } 27 | 28 | header { 29 | background-color: #eee; 30 | box-shadow: 0 0 1ex #444; 31 | position: sticky; 32 | top: 0; 33 | z-index: 9999; 34 | 35 | .user { 36 | form { 37 | display: inline; 38 | button { 39 | border: 0; 40 | font: inherit; 41 | background: transparent; 42 | padding: 0; 43 | color: #805; 44 | &:hover { 45 | text-decoration: underline; 46 | } 47 | } 48 | } 49 | } 50 | } 51 | 52 | footer { 53 | background: #999; 54 | color: white; 55 | :link, :visited { 56 | color: #edf; 57 | } 58 | p { margin: 0; } 59 | } 60 | 61 | header, footer { 62 | display: flex; 63 | 64 | & > span { 65 | flex-grow: 1; 66 | } 67 | & > :last-child { 68 | flex-grow: 5; 69 | padding-left: 1em; 70 | text-align: right; 71 | } 72 | a { 73 | text-decoration: none; 74 | &:focus, &:hover { 75 | text-decoration: underline; 76 | } 77 | } 78 | } 79 | 80 | main { 81 | flex-grow: 1; 82 | margin-bottom: 1em; 83 | 84 | form { 85 | border: $border; 86 | margin: auto; 87 | padding: 1em; 88 | width: -moz-fit-content; 89 | width: fit-content; 90 | 91 | p { 92 | display: flex; 93 | flex-flow: row wrap; 94 | justify-content: space-between; 95 | } 96 | label { 97 | padding: .2em 1em .2em 0; 98 | } 99 | } 100 | } 101 | 102 | header, footer, main { 103 | flex-wrap: wrap; 104 | padding: 0 1ex; 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web example: Login with warp, ructe, and diesel 2 | 3 | This application is intended as an example of a web service handling a login 4 | session. 5 | It uses the [warp](https://crates.io/crates/warp) web framework, 6 | the [ructe](https://crates.io/crates/ructe) template engine and 7 | the [diesel](https://diesel.rs/) database layer. 8 | 9 | [![Build Status](https://travis-ci.org/kaj/warp-diesel-ructe-sample.svg?branch=master)](https://travis-ci.org/kaj/warp-diesel-ructe-sample) 10 | 11 | A `Session` object is created for each request (except for static resources), 12 | containing a handle to a database connection pool and an `Option` that 13 | is set if the user is logged in. 14 | 15 | The authentication are done with bcrypt verification of hashed passwords (the 16 | hashes are stored in the database, passwords are never stored or logged in 17 | plain text). 18 | 19 | When authenticated, the user gets a cookie (httponly, strict samesite) 20 | containing a session key, which is used for authentication through the 21 | remainder of the session. 22 | 23 | ## Things that could use improvement: 24 | 25 | * The routing provieded by warp is very nice, 26 | but it would be nice to be able to define routers and subrouters in a more 27 | tree-like way. 28 | Perhaps it is possible, and I just havn't found out how yet? 29 | 30 | * I have probably missed something in how errors are supposed to be handled 31 | in warp. 32 | It feels like I am wrapping `Result`s in `Result`s, and I use more 33 | `.map_err(...)` than I like. 34 | 35 | * Database is not really handled asyncronously yet, so database accesses 36 | blocks the worker. 37 | See https://github.com/diesel-rs/diesel/issues/399 for information. 38 | 39 | ## Things that remains to be done: 40 | 41 | * Session keys should have a limited age. 42 | Maybe doing a request after half that time should generate a new session 43 | key? 44 | 45 | * The code that handles the authentication and sessions should be 46 | externalized to a separate crate, but the session data should remain 47 | application-specific. 48 | Generating and verifying the bcrypt hashes and session keys should be done 49 | by the external crate, but actually storing them in the database, including 50 | migrations to create the tables, should be done by the application. 51 | 52 | * CSRF protection is not yet implemented. 53 | 54 | This project was partially inspired by 55 | https://github.com/rust-lang-nursery/wg-net/issues/44 ; 56 | go there for more example projects. 57 | 58 | Issue reports and pull requests and welcomed. 59 | -------------------------------------------------------------------------------- /src/session.rs: -------------------------------------------------------------------------------- 1 | use crate::models::User; 2 | use diesel::pg::PgConnection; 3 | use diesel::prelude::*; 4 | use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; 5 | use log::{debug, error}; 6 | use rand::distributions::Alphanumeric; 7 | use rand::thread_rng; 8 | use rand::Rng; 9 | use warp::filters::{cookie, BoxedFilter}; 10 | use warp::{self, Filter}; 11 | 12 | type PooledPg = PooledConnection>; 13 | type PgPool = Pool>; 14 | 15 | #[derive(Debug)] 16 | pub struct NoDbReady; 17 | impl warp::reject::Reject for NoDbReady {} 18 | 19 | /// A Session object is sent to most handler methods. 20 | /// 21 | /// The content of the session object is application specific. 22 | /// My session contains a session pool for the database and an 23 | /// optional user (if logged in). 24 | /// It may also contain pools to other backend servers (e.g. memcache, 25 | /// redis, or application specific services) and/or other temporary 26 | /// user data (e.g. a shopping cart in a web shop). 27 | pub struct Session { 28 | db: PooledPg, 29 | id: Option, 30 | user: Option, 31 | } 32 | 33 | impl Session { 34 | /// Attempt to authenticate a user for this session. 35 | /// 36 | /// If the username and password is valid, create and return a session key. 37 | /// If authentication fails, simply return None. 38 | pub fn authenticate( 39 | &mut self, 40 | username: &str, 41 | password: &str, 42 | ) -> Option { 43 | if let Some(user) = User::authenticate(self.db(), username, password) 44 | { 45 | debug!("User {:?} authenticated", user); 46 | 47 | let secret = random_key(48); 48 | use crate::schema::sessions::dsl::*; 49 | let result = diesel::insert_into(sessions) 50 | .values((user_id.eq(user.id), cookie.eq(&secret))) 51 | .returning(id) 52 | .get_results(self.db()); 53 | if let Ok([a]) = result.as_ref().map(|v| &**v) { 54 | self.id = Some(*a); 55 | self.user = Some(user); 56 | return Some(secret); 57 | } else { 58 | error!( 59 | "Failed to create session for {}: {:?}", 60 | user.username, result, 61 | ); 62 | } 63 | } 64 | None 65 | } 66 | 67 | /// Get a Session from a database pool and a session key. 68 | /// 69 | /// The session key is checked against the database, and the 70 | /// matching session is loaded. 71 | /// The database pool handle is included in the session regardless 72 | /// of if the session key is a valid session or not. 73 | pub fn from_key(db: PooledPg, sessionkey: Option<&str>) -> Self { 74 | use crate::schema::sessions::dsl as s; 75 | use crate::schema::users::dsl as u; 76 | let (id, user) = sessionkey 77 | .and_then(|sessionkey| { 78 | u::users 79 | .inner_join(s::sessions) 80 | .select((s::id, (u::id, u::username, u::realname))) 81 | .filter(s::cookie.eq(&sessionkey)) 82 | .first::<(i32, User)>(&db) 83 | .ok() 84 | }) 85 | .map(|(i, u)| (Some(i), Some(u))) 86 | .unwrap_or((None, None)); 87 | 88 | debug!("Got: #{:?} {:?}", id, user); 89 | Session { db, id, user } 90 | } 91 | 92 | /// Clear the part of this session that is session-specific. 93 | /// 94 | /// In effect, the database pool will remain, but the user will be 95 | /// cleared, and the data in the sessions table for this session 96 | /// will be deleted. 97 | pub fn clear(&mut self) { 98 | use crate::schema::sessions::dsl as s; 99 | if let Some(session_id) = self.id { 100 | diesel::delete(s::sessions.filter(s::id.eq(session_id))) 101 | .execute(self.db()) 102 | .map_err(|e| { 103 | error!( 104 | "Failed to delete session {}: {:?}", 105 | session_id, e 106 | ); 107 | }) 108 | .ok(); 109 | } 110 | self.id = None; 111 | self.user = None; 112 | } 113 | 114 | pub fn user(&self) -> Option<&User> { 115 | self.user.as_ref() 116 | } 117 | pub fn db(&self) -> &PgConnection { 118 | &self.db 119 | } 120 | } 121 | 122 | fn random_key(len: usize) -> String { 123 | thread_rng() 124 | .sample_iter(&Alphanumeric) 125 | .map(char::from) 126 | .take(len) 127 | .collect() 128 | } 129 | 130 | pub fn create_session_filter(db_url: &str) -> BoxedFilter<(Session,)> { 131 | let pool = pg_pool(db_url); 132 | warp::any() 133 | .and(cookie::optional("EXAUTH")) 134 | .and_then(move |key: Option| { 135 | let pool = pool.clone(); 136 | async move { 137 | let key = key.as_ref().map(|s| &**s); 138 | match pool.get() { 139 | Ok(conn) => Ok(Session::from_key(conn, key)), 140 | Err(e) => { 141 | error!("Failed to get a db connection: {}", e); 142 | Err(warp::reject::custom(NoDbReady)) 143 | } 144 | } 145 | } 146 | }) 147 | .boxed() 148 | } 149 | 150 | fn pg_pool(database_url: &str) -> PgPool { 151 | let manager = ConnectionManager::::new(database_url); 152 | Pool::new(manager).expect("Postgres connection pool could not be created") 153 | } 154 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! An example web service using ructe with the warp framework. 2 | #![deny(warnings)] 3 | #[macro_use] 4 | extern crate diesel; 5 | 6 | mod error; 7 | mod models; 8 | mod schema; 9 | mod session; 10 | 11 | use diesel::insert_into; 12 | use diesel::prelude::*; 13 | use dotenv::dotenv; 14 | use error::Error; 15 | use log::info; 16 | use serde::Deserialize; 17 | use session::{create_session_filter, Session}; 18 | use std::env; 19 | use std::io::{self, Write}; 20 | use std::time::{Duration, SystemTime}; 21 | use templates::statics::StaticFile; 22 | use templates::RenderRucte; 23 | use warp::http::{header, response::Builder, StatusCode}; 24 | use warp::{reply::Response, Filter, Rejection, Reply}; 25 | 26 | /// Main program: Set up routes and start server. 27 | #[tokio::main] 28 | async fn main() { 29 | dotenv().ok(); 30 | env_logger::init(); 31 | 32 | // Get a filter that adds a session to each request. 33 | let pgsess = create_session_filter( 34 | &env::var("DATABASE_URL").expect("DATABASE_URL must be set"), 35 | ); 36 | let s = move || pgsess.clone(); 37 | 38 | use warp::{body, get, path, path::end, post}; 39 | let static_routes = get() 40 | .and(path("static")) 41 | .and(path::param()) 42 | .then(static_file); 43 | let routes = warp::any() 44 | .and(static_routes) 45 | .or(path("login").and(end()).and( 46 | get() 47 | .and(s()) 48 | .then(login_form) 49 | .or(post().and(s()).and(body::form()).then(do_login)) 50 | .unify(), 51 | )) 52 | .unify() 53 | .or(path("logout") 54 | .and(end()) 55 | .and(post()) 56 | .and(s()) 57 | .then(do_logout)) 58 | .unify() 59 | .or(path("signup").and(end()).and( 60 | get() 61 | .and(s()) 62 | .then(signup_form) 63 | .or(post().and(s()).and(body::form()).then(do_signup)) 64 | .unify(), 65 | )) 66 | .unify() 67 | .or(s().and(end()).and(get()).then(home_page)) 68 | .unify() 69 | .map(|result: Result| match result { 70 | Ok(reply) => reply, 71 | Err(err) => err.into_response(), 72 | }) 73 | .recover(customize_error); 74 | warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; 75 | } 76 | 77 | type Result = std::result::Result; 78 | 79 | /// Render a login form. 80 | async fn login_form(session: Session) -> Result { 81 | Ok(Builder::new().html(|o| templates::login(o, &session, None, None))?) 82 | } 83 | 84 | /// Verify a login attempt. 85 | /// 86 | /// If the credentials in the LoginForm are correct, redirect to the 87 | /// home page. 88 | /// Otherwise, show the login form again, but with a message. 89 | async fn do_login(mut session: Session, form: LoginForm) -> Result { 90 | if let Some(cookie) = session.authenticate(&form.user, &form.password) { 91 | Ok(Builder::new() 92 | .status(StatusCode::FOUND) 93 | .header(header::LOCATION, "/") 94 | .header( 95 | header::SET_COOKIE, 96 | format!("EXAUTH={}; SameSite=Strict; HttpOpnly", cookie), 97 | ) 98 | .html(|o| writeln!(o))?) 99 | } else { 100 | Ok(Builder::new().html(|o| { 101 | templates::login(o, &session, None, Some("Authentication failed")) 102 | })?) 103 | } 104 | } 105 | 106 | async fn do_logout(mut session: Session) -> Result { 107 | session.clear(); 108 | Ok(Builder::new() 109 | .status(StatusCode::FOUND) 110 | .header(header::LOCATION, "/") 111 | .header( 112 | header::SET_COOKIE, 113 | "EXAUTH=; Max-Age=0; SameSite=Strict; HttpOpnly", 114 | ) 115 | .html(|o| writeln!(o))?) 116 | } 117 | 118 | /// The data submitted by the login form. 119 | /// This does not derive Debug or Serialize, as the password is plain text. 120 | #[derive(Deserialize)] 121 | struct LoginForm { 122 | user: String, 123 | password: String, 124 | } 125 | 126 | /// Render a signup form. 127 | async fn signup_form(session: Session) -> Result { 128 | Ok(Builder::new().html(|o| templates::signup(o, &session, None))?) 129 | } 130 | 131 | /// Handle a submitted signup form. 132 | async fn do_signup(session: Session, form: SignupForm) -> Result { 133 | let result = form 134 | .validate() 135 | .map_err(|e| e.to_string()) 136 | .and_then(|form| { 137 | let hash = bcrypt::hash(&form.password, bcrypt::DEFAULT_COST) 138 | .map_err(|e| format!("Hash failed: {}", e))?; 139 | Ok((form, hash)) 140 | }) 141 | .and_then(|(form, hash)| { 142 | use schema::users::dsl::*; 143 | insert_into(users) 144 | .values(( 145 | username.eq(form.user), 146 | realname.eq(form.realname), 147 | password.eq(&hash), 148 | )) 149 | .execute(session.db()) 150 | .map_err(|e| format!("Oops: {}", e)) 151 | }); 152 | match result { 153 | Ok(_) => { 154 | Ok(Builder::new() 155 | .status(StatusCode::FOUND) 156 | .header(header::LOCATION, "/") 157 | // TODO: Set a session cookie? 158 | .html(|o| writeln!(o))?) 159 | } 160 | Err(msg) => Ok(Builder::new() 161 | .html(|o| templates::signup(o, &session, Some(&msg)))?), 162 | } 163 | } 164 | 165 | /// The data submitted by the login form. 166 | /// This does not derive Debug or Serialize, as the password is plain text. 167 | #[derive(Deserialize)] 168 | struct SignupForm { 169 | user: String, 170 | realname: String, 171 | password: String, 172 | } 173 | 174 | impl SignupForm { 175 | fn validate(self) -> Result { 176 | if self.user.len() < 2 { 177 | Err("Username must be at least two characters") 178 | } else if self.realname.is_empty() { 179 | Err("A real name (or pseudonym) must be given") 180 | } else if self.password.len() < 3 { 181 | Err("Please use a better password") 182 | } else { 183 | Ok(self) 184 | } 185 | } 186 | } 187 | 188 | /// Home page handler; just render a template with some arguments. 189 | async fn home_page(session: Session) -> Result { 190 | info!("Visiting home_page as {:?}", session.user()); 191 | Ok(Builder::new().html(|o| { 192 | templates::page(o, &session, &[("first", 3), ("second", 7)]) 193 | })?) 194 | } 195 | 196 | /// This method can be used as a "template tag", i.e. a method that 197 | /// can be called directly from a template. 198 | fn footer(out: &mut W) -> io::Result<()> { 199 | templates::footer( 200 | out, 201 | &[ 202 | ("warp", "https://crates.io/crates/warp"), 203 | ("diesel", "https://diesel.rs/"), 204 | ("ructe", "https://crates.io/crates/ructe"), 205 | ], 206 | ) 207 | } 208 | 209 | /// Handler for static files. 210 | /// Create a response from the file data with a correct content type 211 | /// and a far expires header (or a 404 if the file does not exist). 212 | async fn static_file(name: String) -> Result { 213 | if let Some(data) = StaticFile::get(&name) { 214 | let _far_expires = SystemTime::now() + FAR; 215 | Ok(Builder::new() 216 | .status(StatusCode::OK) 217 | .header("content-type", data.mime.as_ref()) 218 | // TODO .header("expires", _far_expires) 219 | .body(data.content.into()) 220 | .unwrap()) 221 | } else { 222 | println!("Static file {} not found", name); 223 | Err(Error::NotFound) 224 | } 225 | } 226 | 227 | /// A duration to add to current time for a far expires header. 228 | static FAR: Duration = Duration::from_secs(180 * 24 * 60 * 60); 229 | 230 | /// Create custom error pages. 231 | async fn customize_error(err: Rejection) -> Result { 232 | if err.is_not_found() { 233 | eprintln!("Got a 404: {:?}", err); 234 | // We have a custom 404 page! 235 | Ok(Error::NotFound.into_response()) 236 | } else { 237 | eprintln!("Got error: {:?}", err); 238 | Ok(Error::InternalError.into_response()) 239 | } 240 | } 241 | 242 | // And finally, include the generated code for templates and static files. 243 | include!(concat!(env!("OUT_DIR"), "/templates.rs")); 244 | --------------------------------------------------------------------------------