├── 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 |
4 | @if let Some(((last_name, last_href), prev)) = frameworks.split_last()
5 | {Made with
6 | @if let Some(((last_name, last_href), prev)) = prev.split_last()
7 | {@for (name, href) in prev {@name , }
8 | @last_name and }@last_name .}
9 |
10 | Sample app @env!("CARGO_PKG_VERSION").
11 |
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 |
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 |
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 | [](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 |
--------------------------------------------------------------------------------