Initial commit after extraction

This commit is contained in:
Ryan McGrath 2018-07-20 15:42:02 -07:00
commit 80ba54e4ef
No known key found for this signature in database
GPG key ID: 811674B62B666830
43 changed files with 3865 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
/target
**/*.rs.bk

2716
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

33
Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "jelly"
version = "0.1.0"
authors = ["Ryan McGrath <ryan@rymc.io>"]
[dependencies]
actix = "0.5"
actix-web = { version = "0.6.15", features = ["alpn", "session"] }
actix-redis = { git = "https://github.com/ryanmcgrath/actix-redis.git", rev = "6c8801dc8caebe901257e8ea41e465e62db94c3f" }
sentry = "0.6.1"
sentry-actix = "0.1.0"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
chrono = "0.4.4"
diesel = { version = "<= 1.3.2", features = ["postgres", "chrono", "serde_json", "r2d2"] }
dotenv = "0.13.0"
askama = "0.7.0"
num_cpus = "1.0"
futures = "0.1.22"
log = "0.4.0"
env_logger = "0.5.10"
djangohashers = { version = "^0.3", features = ["fpbkdf2"] }
validator = { version = "0.7.0", features = [] }
validator_derive = "0.7.1"
uuid = { version = "0.6", features = ["v4"] }
zxcvbn = "1.0.0"
[build-dependencies]
askama = "0.7"
[profile.release]
lto = true

12
LICENSE Normal file
View file

@ -0,0 +1,12 @@
DO WHAT YOU WANT TO PUBLIC LICENSE
Version 1, July 2018
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed. Sub-projects... well, respect their licenses and
attribute accordingly.
DO WHAT YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT YOU WANT TO.

12
build.rs Normal file
View file

@ -0,0 +1,12 @@
// build.rs
//
// A build script. What, you wanna fight about it? Here's your paycheck.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
extern crate askama;
fn main() {
askama::rerun_if_templates_changed();
}

5
diesel.toml Normal file
View file

@ -0,0 +1,5 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"

6
example.env Normal file
View file

@ -0,0 +1,6 @@
# SENTRY_DSN=""
# BIND_TO="127.0.0.1:17000"
# SECRET_KEY="generate w/ openssl rand -base64 32"
# DATABASE_URL=""
# REDIS="localhost:6379"
# POSTMARK=""

0
migrations/.gitkeep Normal file
View file

View file

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View file

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1 @@
drop table users;

View file

@ -0,0 +1,21 @@
CREATE OR REPLACE FUNCTION update_timestamp() RETURNS TRIGGER AS $$
BEGIN
NEW.updated = now();
RETURN NEW;
END;
$$ language 'plpgsql';
create table if not exists users (
id serial primary key,
name text,
email text not null unique,
password text not null,
avatar text,
is_verified bool not null default false,
has_verified_email bool not null default false,
created timestamp with time zone not null default now(),
updated timestamp with time zone not null default now()
);
CREATE TRIGGER user_updated BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE PROCEDURE update_timestamp();

64
readme.md Normal file
View file

@ -0,0 +1,64 @@
# Jelly
This is a sample repository showcasing a rather straightforward way to handle user sessions, signup, and authentication in an [actix-web](https://actix.rs) project. I extracted it from something I'm working on as I realized that it can be otherwise tricky to figure out at a glance how all of this fits together (actix-web is still fairly fast moving, and the docs can be... rough).
You might be interested in this project if:
- You want a sample Rust/actix-web project to kick off that has (mostly) sane defaults, and built-in user accounts.
- You're unsure about how to structure an actix-web project, and want an opinionated (not even necessarily correct) starter.
- You're not interested in putting a puzzle together for something as basic as user authentication, and just want it to work.
You might also not be interested in this, and that's cool too. It's licensed as a "do whatever you want" type deal, so... clone away and have fun. Some extra notes are below.
## Setup
- Clone the repo
- `mv example.env .env`, and fill in the values in there
- `diesel migration run` to create the user database table
- `cargo run` to... well, run it. Depending on whether you have `diesel_cli` installed you might need that too.
## Notes
This is probably still a bit rough around the edges, since I ripped it out of an existing project of mine, but the key things I wanted to solve were:
- User signup/login, with mostly secure cookie defaults
- An easy way to check the current active user/session on each request
- Figuring out how the hell to shove Redis in here - sessions are stored in there instead of the built-in `CookieSessionBackend` you'll find that ships with actix-web.
There's some "middleware" here (`src/users/middleware.rs`) that makes it easy to check the authentication status for the request, and load the associated `User` record. The first one, `request.is_authentication()`, simply checks the session to see if we have anything indicating a `User` is set. The second one, `request.user()`, returns a future that'll provide the actual `User` object.
`FutureResponse` and `future_redirect` are some wrappers around `actix-web` response formats to make the ergonomics of all of this more readable. You can take 'em or leave 'em.
``` rust
use users::middleware::UserAuthentication;
use utils::responses::{FutureHttpResponse, future_redirect};
fn view(request: HttpRequest) -> FutureResponse {
// Check the session is valid, without a database hit to load the user
if let Err(e) = request.is_authenticated() {
return future_redirect("http://www.mozilla.com/");
}
// Call over to Postgres and get that there user
request.user().then(|a| match a {
Ok(user) => {
future_redirect("http://www.duckduckgo.com/")
},
Err(_) => {
future_redirect("http://www.google.com/")
}
}).responder()
}
```
If I was the kind to use Rust nightly in a project, I'd be interested in a derive-esque macro to check auth, ala Django's `@login_required` decorator.
Also, as you read the code, you may notice a lot of this is influenced by Django. I think they got the user model stuff right at some point over the years. Thanks to the `djangohashers` package, this even matches the password hashing Django does.
Oh, and randomly, this includes a simple library for sending emails via [Postmark](https://postmarkapp.com/), since I enjoy their service.
## Questions, Comments, Etc?
- Email: [ryan@rymc.io](mailto:ryan@rymc.io)
- Twitter: [@ryanmcgrath](https://twitter.com/ryanmcgrath/)
- Web: [rymc.io](https://rymc.io/)
## License
Do what you want. Read the license, it'll say the same.

9
src/emails/mod.rs Normal file
View file

@ -0,0 +1,9 @@
// emails/mod.rs
//
// A module for dealing with emails - e.g, templates, etc.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
pub mod postmark;
pub use self::postmark::{Postmark, Email};

View file

@ -0,0 +1,79 @@
// postmark.rs
//
// A basic API client library for utilizing Postmark (https://postmarkapp.com/),
// a great service for transactional emails.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use actix_web::client;
use std::collections::HashMap;
#[derive(Debug, Default, Serialize)]
pub struct Email<'a> {
#[serde(rename = "Subject")]
pub subject: &'a str,
#[serde(rename = "TextBody")]
pub body: &'a str,
#[serde(rename = "HtmlBody")]
pub html_body: Option<&'a str>,
#[serde(rename = "From")]
pub from: &'a str,
#[serde(rename = "To")]
pub to: &'a str,
#[serde(rename = "ReplyTo")]
pub reply_to: Option<&'a str>,
#[serde(rename = "Cc")]
pub cc: Option<&'a str>,
#[serde(rename = "Bcc")]
pub bcc: Option<&'a str>,
#[serde(rename = "Tag")]
pub tag: Option<&'a str>,
#[serde(rename = "Metadata")]
pub metadata: Option<HashMap<&'a str, &'a str>>,
#[serde(rename = "Headers")]
pub headers: Option<Vec<HashMap<&'a str, &'a str>>>,
#[serde(rename = "TrackOpens")]
pub track_opens: Option<bool>,
#[serde(rename = "TrackLinks")]
pub track_links: Option<&'a str>
}
#[derive(Debug)]
pub struct Postmark {
api_token: String
}
impl Postmark {
pub fn new(api_token: &str) -> Self {
Postmark { api_token: api_token.into() }
}
pub fn send(&self, subject: &str, body: &str, from: &str, to: &str) -> client::SendRequest {
let email = Email {
from: from,
to: to,
subject: subject,
body: body,
..Default::default()
};
client::post("https://api.postmarkapp.com/email")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("X-Postmark-Server-Token", self.api_token.as_str())
.json(email).unwrap().send()
}
}

88
src/main.rs Normal file
View file

@ -0,0 +1,88 @@
// main.rs
//
// Main linker for all the external crates and such. Server logic
// is handled over in app.rs for brevity's sake.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/02/2018
extern crate uuid;
extern crate futures;
extern crate dotenv;
extern crate sentry;
extern crate chrono;
extern crate actix;
extern crate num_cpus;
extern crate actix_web;
extern crate validator;
extern crate env_logger;
extern crate actix_redis;
extern crate sentry_actix;
extern crate djangohashers;
#[macro_use] extern crate log;
#[macro_use] extern crate askama;
extern crate serde_json;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate validator_derive;
#[macro_use] extern crate diesel;
use std::env;
use dotenv::dotenv;
use actix::prelude::*;
use actix_web::{server, App, middleware};
use sentry_actix::SentryMiddleware;
pub mod schema;
pub mod users;
pub mod util;
pub mod pages;
pub mod emails;
pub struct State {
pub db: Addr<Syn, util::database::Database>
}
fn main() {
dotenv().ok();
env::set_var("RUST_BACKTRACE", "1");
env::set_var("RUST_LOG", "actix_web=debug,info,warn");
env_logger::init();
let _sentry;
if let Ok(dsn) = env::var("SENTRY_DSN") {
_sentry = sentry::init(dsn);
sentry::integrations::panic::register_panic_handler();
}
let address = env::var("BIND_TO").expect("BIND_TO not set!");
let sys = System::new("user-auth-demo");
/*actix::Arbiter::handle().spawn({
let postmark = emails::Postmark::new(env::var("POSTMARK").expect("No Postmark API key set!");
postmark.send("Testing", "123?", "ryan@rymc.io", "ryan@rymc.io").map_err(|e| {
println!("Error? {:?}", e);
}).and_then(|response| {
println!("Response: {:?}", response);
Ok(())
})
});*/
let pool = util::database::pool();
//let addr = SyncArbiter::start(num_cpus::get() * 3, move || database::Database(pool.clone()));
let addr = SyncArbiter::start(12, move || util::database::Database(pool.clone()));
server::new(move || {
let mut app = App::with_state(State {
db: addr.clone()
});
app = app.middleware(SentryMiddleware::new());
app = app.middleware(middleware::Logger::default());
app = users::configure(app);
app = pages::configure(app);
app
}).backlog(8192).workers(4).bind(&address).unwrap().start();
let _ = sys.run();
}

76
src/pages/mod.rs Normal file
View file

@ -0,0 +1,76 @@
// pages/mod.rs
//
// Basic pages that don't really belong anywhere else, that
// I want included but really don't want to deal with separate
// repos/etc.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use futures::Future;
use askama::Template;
use actix_web::{http::Method, App};
use actix_web::{AsyncResponder, HttpRequest};
use State;
use users::middleware::UserAuthentication;
use users::models::User;
use util::responses::{FutureResponse, render, redirect};
#[derive(Template)]
#[template(path = "index.html")]
pub struct Homepage {}
#[derive(Template)]
#[template(path = "pages/terms.html")]
pub struct TermsOfService {}
#[derive(Template)]
#[template(path = "pages/privacy.html")]
pub struct PrivacyPolicy {}
#[derive(Template)]
#[template(path = "pages/cookies.html")]
pub struct CookiesPolicy {}
#[derive(Template)]
#[template(path = "pages/about.html")]
pub struct About {}
#[derive(Template)]
#[template(path = "pages/team.html")]
pub struct TeamBreakdown {}
#[derive(Template)]
#[template(path = "app/index.html")]
pub struct AppRoot {
user: User
}
fn render_root(request: HttpRequest<State>) -> FutureResponse {
// If the session is blank, or has no UID for the current user, this will
// Err() immediately without a database hit.
request.user().then(|res| match res {
Ok(user) => Ok(render(&AppRoot {
user: user
})),
Err(_e) => {
Ok(render(&Homepage {}))
}
}).responder()
}
pub fn configure(application: App<State>) -> App<State> {
application
.resource("/", |r| {
r.name("homepage");
r.method(Method::GET).f(render_root);
r.method(Method::POST).f(render_root)
})
.resource("/terms/", |r| r.method(Method::GET).f(|_req| render(&TermsOfService {})))
.resource("/privacy/", |r| r.method(Method::GET).f(|_req| render(&PrivacyPolicy {})))
.resource("/cookies/", |r| r.method(Method::GET).f(|_req| render(&CookiesPolicy {})))
.resource("/about/", |r| r.method(Method::GET).f(|_req| render(&About {})))
.resource("/team/", |r| r.method(Method::GET).f(|_req| render(&TeamBreakdown{})))
}

27
src/schema.rs Normal file
View file

@ -0,0 +1,27 @@
table! {
news (id) {
id -> Int4,
title -> Text,
url -> Text,
added -> Timestamptz,
}
}
table! {
users (id) {
id -> Int4,
name -> Nullable<Text>,
email -> Text,
password -> Text,
avatar -> Nullable<Text>,
is_verified -> Bool,
has_verified_email -> Bool,
created -> Timestamptz,
updated -> Timestamptz,
}
}
allow_tables_to_appear_in_same_query!(
news,
users,
);

98
src/users/middleware.rs Normal file
View file

@ -0,0 +1,98 @@
// src/users/middleware.rs
//
// Middleware that handles loading current User for a given
// request, along with any Session data they may have. This
// specifically enables Anonymous users with Session data, ala
// Django's approach.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use std::io::{Error as IoError, ErrorKind};
use futures::Future;
use futures::future;
use actix_web::{HttpRequest, Error};
use actix_web::middleware::session::RequestSession;
use State;
use users::models::{UserLookup, User};
pub type UserAuthenticationResult = Box<Future<Item = User, Error = Error>>;
/// UserAuthentication is kind of a request guard - it returns a Future which will resolve
/// with either the current authenticated user, or "error" out if the user has no session data
/// that'd tie them to a user profile, or if the session cache can't be read, or if the database
/// has issues, or... pick your poison I guess.
///
/// It enables your views to be as simple as:
///
/// # Example
///
/// ```
/// use users::middleware::UserAuthentication;
/// use utils::responses::{AsyncHttpResponse, async_redirect};
///
/// fn view(request: HttpRequest) -> AsyncHttpResponse {
/// request.check_authentication().then(move |a| match a {
/// Ok(user) => {
/// async_redirect("http://www.duckduckgo.com/")
/// },
///
/// Err(_) => {
/// async_redirect("http://www.google.com/")
/// }
/// })
/// }
/// ```
pub trait UserAuthentication {
fn is_authenticated(&self) -> bool;
fn user(&self) -> UserAuthenticationResult;
}
impl UserAuthentication for HttpRequest<State> {
#[inline(always)]
fn is_authenticated(&self) -> bool {
match self.session().get::<i32>("uid") {
Ok(session) => {
match session {
Some(_session_id) => true,
None => false
}
},
Err(e) => {
error!("Error'd when attempting to fetch session data: {:?}", e);
false
}
}
}
fn user(&self) -> UserAuthenticationResult {
match self.session().get::<i32>("uid") {
Ok(session) => { match session {
Some(session_id) => {
Box::new(self.state().db.send(UserLookup {
id: session_id
}).from_err().and_then(|res| match res {
Ok(user) => Ok(user),
Err(err) => {
// Temporary because screw matching all these error types
let e = IoError::new(ErrorKind::NotFound, format!("{}", err));
Err(e.into())
}
}))
},
None => {
let e = IoError::new(ErrorKind::NotFound, "User has no session data.");
Box::new(future::err(e.into()))
}
}},
Err(e) => {
error!("Error'd when attempting to fetch session data: {:?}", e);
Box::new(future::err(e.into()))
}
}
}
}

41
src/users/mod.rs Normal file
View file

@ -0,0 +1,41 @@
// users/mod.rs
//
// URL dispatcher for user account related API endpoints.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/15/2018
use std::env;
use actix_redis::{RedisSessionBackend, SameSite};
use actix_web::{http::Method, App, middleware::session::SessionStorage};
use State;
pub mod views;
pub mod models;
pub mod middleware;
pub fn configure(application: App<State>) -> App<State> {
let key = env::var("SECRET_KEY").expect("SECRET_KEY not set!");
application.middleware(SessionStorage::new(
RedisSessionBackend::new(env::var("REDIS").expect("REDIS not set!"), key.as_bytes())
.cookie_name("sessionid")
.cookie_secure(true)
//.cookie_domain("your domain here")
.cookie_path("/")
.cookie_same_site(SameSite::Lax)
)).scope("/users", |scope| {
scope.resource("/signup/", |r| {
r.method(Method::GET).with(views::Signup::get);
r.method(Method::POST).with(views::Signup::post)
}).resource("/login/", |r| {
r.method(Method::GET).with(views::Login::get);
r.method(Method::POST).with(views::Login::post)
}).resource("/logout/", |r| {
r.method(Method::POST).with(views::logout)
}).resource("/forgot_password/", |r| {
r.method(Method::GET).with(views::ResetPassword::get);
r.method(Method::POST).with(views::ResetPassword::post)
})
})
}

93
src/users/models.rs Normal file
View file

@ -0,0 +1,93 @@
// src/users/models.rs
//
// Implements a basic User model, with support for creating/updating/deleting
// users, along with welcome email and verification.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use chrono;
use diesel;
use diesel::prelude::*;
use actix::prelude::*;
use validator::Validate;
use schema::users;
use util::database::Database;
#[derive(Queryable, Serialize, Deserialize, Debug)]
pub struct User {
pub id: i32,
pub name: Option<String>,
pub email: String,
pub password: String,
pub avatar: Option<String>,
pub is_verified: bool,
pub has_verified_email: bool,
pub created: chrono::NaiveDateTime,
pub updated: chrono::NaiveDateTime
}
#[derive(Insertable, Validate, Deserialize, Serialize, Debug)]
#[table_name="users"]
pub struct NewUser {
#[validate(email(message="Hmmm, invalid email provided."))]
pub email: String,
pub password: String
}
impl Message for NewUser {
type Result = Result<User, diesel::result::Error>;
}
impl Handler<NewUser> for Database {
type Result = Result<User, diesel::result::Error>;
fn handle(&mut self, msg: NewUser, _: &mut Self::Context) -> Self::Result {
use schema::users::dsl::*;
let conn = self.0.get().unwrap();
diesel::insert_into(users).values(&msg).get_result::<User>(&conn)
}
}
#[derive(Deserialize, Debug)]
pub struct UserLookup {
pub id: i32
}
impl Message for UserLookup {
type Result = Result<User, diesel::result::Error>;
}
impl Handler<UserLookup> for Database {
type Result = Result<User, diesel::result::Error>;
fn handle(&mut self, msg: UserLookup, _: &mut Self::Context) -> Self::Result {
use schema::users::dsl::*;
let conn = self.0.get().unwrap();
users.filter(id.eq(msg.id)).get_result::<User>(&conn)
}
}
#[derive(Deserialize, Validate, Serialize, Debug)]
pub struct UserLogin {
#[validate(email(message="Hmmm, invalid email provided."))]
pub email: String,
pub password: String
}
impl Message for UserLogin {
type Result = Result<User, diesel::result::Error>;
}
impl Handler<UserLogin> for Database {
type Result = Result<User, diesel::result::Error>;
fn handle(&mut self, msg: UserLogin, _: &mut Self::Context) -> Self::Result {
use schema::users::dsl::*;
let conn = self.0.get().unwrap();
users.filter(email.eq(msg.email)).get_result::<User>(&conn)
}
}

85
src/users/views/login.rs Normal file
View file

@ -0,0 +1,85 @@
// src/users/views/login.rs
//
// Views for user authentication.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use askama::Template;
use validator::Validate;
use futures::future::Future;
use djangohashers::{check_password};
use actix_web::{Form, AsyncResponder, HttpRequest, HttpResponse};
use actix_web::middleware::session::RequestSession;
use State;
use users::models::{UserLogin};
use users::middleware::UserAuthentication;
use util::forms::collect_validation_errors;
use util::responses::{FutureResponse, future_render, future_redirect, redirect, render};
#[derive(Template)]
#[template(path = "users/login.html")]
pub struct Login {
pub errors: Option<Vec<String>>
}
impl Login {
pub fn get(request: HttpRequest<State>) -> HttpResponse {
if request.is_authenticated() {
let url = request.url_for("homepage", &[""; 0]).unwrap();
return redirect(url.as_str());
}
render(&Self {
errors: None
})
}
pub fn post((request, login): (HttpRequest<State>, Form<UserLogin>)) -> FutureResponse {
if request.is_authenticated() {
let url = request.url_for("homepage", &[""; 0]).unwrap();
return future_redirect(url.as_str());
}
// No sense in wasting a database call if they didn't pass in an actual email address.
// *shrug*
let login = login.into_inner();
if let Err(e) = login.validate() {
return future_render(&Self {
errors: Some(collect_validation_errors(e))
});
}
let password = login.password.clone();
request.state().db.send(login).from_err().and_then(move |res| match res {
Ok(user) => {
// Yo, so I know you're coming here and looking at this .unwrap(), and probably
// going "man what is this guy doing?". This should only ever give a Err() result
// if we're trying to use an unknown algorithm... which isn't the case here.
if check_password(&password, &user.password).unwrap() {
if let Err(e) = request.session().set("uid", user.id) {
error!("Could not set UID for user session! {:?}", e);
return Ok(render(&Self {
errors: Some(vec!["An internal error occurred while attempting to sign you in. Please try again in a bit.".into()])
}));
}
let url = request.url_for("homepage", &[""; 0]).unwrap();
Ok(redirect(url.as_str()))
} else {
Ok(render(&Self {
errors: Some(vec!["Email or password is incorrect.".into()])
}))
}
},
Err(e) => {
warn!("Error locating user: {:?}", e);
Ok(render(&Self {
errors: Some(vec!["Email or password is incorrect.".into()])
}))
}
}).responder()
}
}

19
src/users/views/logout.rs Normal file
View file

@ -0,0 +1,19 @@
// src/users/views/signup.rs
//
// Endpoint for logging a user out. Nothing particularly special,
// just obliterates their session entry + cookie.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use actix_web::{HttpRequest, HttpResponse};
use actix_web::middleware::session::RequestSession;
use State;
use util::responses::redirect;
pub fn logout(request: HttpRequest<State>) -> HttpResponse {
request.session().clear();
let url = request.url_for("homepage", &[""; 0]).unwrap();
redirect(url.as_str())
}

18
src/users/views/mod.rs Normal file
View file

@ -0,0 +1,18 @@
// src/users/views/mod.rs
//
// View hoisting. *shrug*
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
pub mod signup;
pub use self::signup::Signup;
pub mod login;
pub use self::login::Login;
pub mod logout;
pub use self::logout::logout;
pub mod reset_password;
pub use self::reset_password::ResetPassword;

View file

@ -0,0 +1,44 @@
// src/users/views/reset_password.rs
//
// Views for user registration.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use askama::Template;
use futures::future::Future;
use djangohashers::{make_password};
use actix_web::{Form, AsyncResponder, HttpRequest, HttpResponse};
use State;
use users::models::{NewUser};
use util::responses::{FutureResponse, render, redirect};
#[derive(Template)]
#[template(path = "users/reset_password.html")]
pub struct ResetPassword<'a> {
pub error: Option<&'a str>
}
impl<'a> ResetPassword<'a> {
pub fn get(_req: HttpRequest<State>) -> HttpResponse {
render(&ResetPassword {
error: None
})
}
pub fn post((req, item): (HttpRequest<State>, Form<NewUser>)) -> FutureResponse {
let mut item = item.into_inner();
item.password = make_password(&item.password);
req.state().db.send(item).from_err().and_then(|res| match res {
Ok(_) => Ok(redirect("/")),
Err(e) => {
warn!("Error creating new user: {:?}", e);
Ok(render(&ResetPassword {
error: Some("An error occurred!")
}))
}
}).responder()
}
}

81
src/users/views/signup.rs Normal file
View file

@ -0,0 +1,81 @@
// src/users/views/signup.rs
//
// Views for user registration.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use askama::Template;
use validator::Validate;
use futures::future::Future;
use djangohashers::{make_password};
use actix_web::{Form, AsyncResponder, HttpRequest, HttpResponse};
use actix_web::middleware::session::RequestSession;
use State;
use users::middleware::UserAuthentication;
use users::models::{NewUser};
use util::forms::collect_validation_errors;
use util::responses::{FutureResponse, render, future_render, redirect, future_redirect};
#[derive(Template)]
#[template(path = "users/signup.html")]
pub struct Signup {
pub errors: Option<Vec<String>>
}
impl Signup {
pub fn get(request: HttpRequest<State>) -> HttpResponse {
if request.is_authenticated() {
let url = request.url_for("homepage", &[""; 0]).unwrap();
return redirect(url.as_str());
}
render(&Self {
errors: None
})
}
pub fn post((request, user): (HttpRequest<State>, Form<NewUser>)) -> FutureResponse {
if request.is_authenticated() {
let url = request.url_for("homepage", &[""; 0]).unwrap();
return future_redirect(url.as_str());
}
let mut user = user.into_inner();
if let Err(e) = user.validate() {
return future_render(&Self {
errors: Some(collect_validation_errors(e))
});
}
user.password = make_password(&user.password);
request.state().db.send(user).from_err().and_then(move |res| match res {
Ok(user) => {
if let Err(e) = request.session().set("uid", user.id) {
error!("Could not set UID for user session! {:?}", e);
return Ok(render(&Self {
errors: Some(vec![
"Your account was created, but an internal error happened while \
attempting to sign you in. Try again in a bit!".into()
])
}))
}
let url = request.url_for("homepage", &[""; 0]).unwrap();
Ok(redirect(url.as_str()))
},
Err(e) => {
error!("Error creating new user: {:?}", e);
Ok(render(&Self {
errors: Some(vec![
"An error occurred while trying to create your account. We've \
notified the engineering team and are looking into it - feel \
free to contact us for more information, or if you continue to \
see the issue after a short period.".into()
])
}))
}
}).responder()
}
}

26
src/util/database.rs Normal file
View file

@ -0,0 +1,26 @@
// database.rs
//
// Handles setting up database routines, state, and such
// to work within actix-web.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/16/2018
use std::env;
use actix::prelude::*;
use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool};
pub fn pool() -> Pool<ConnectionManager<PgConnection>> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL not set!");
let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::new(manager).expect("Error creating Postgres connection pool!")
}
pub struct Database(pub Pool<ConnectionManager<PgConnection>>);
unsafe impl Send for Database {}
impl Actor for Database {
type Context = SyncContext<Self>;
}

22
src/util/forms.rs Normal file
View file

@ -0,0 +1,22 @@
// src/util/forms.rs
//
// Helper methods for dealing with certain form-related things, like... validation.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use validator::ValidationErrors;
/// Handles collecting validation errors from Form-ish structs.
/// This has to be done this way to work with Askama templates, which... well, I'm
/// not sold on, but it gets the job done for now.
///
/// @TODO: See about String -> &str?
#[inline(always)]
pub fn collect_validation_errors(e: ValidationErrors) -> Vec<String> {
e.inner().into_iter().map(|(_k, v)| {
v.into_iter().map(|a| {
a.message.unwrap().to_string()
}).collect()
}).collect()
}

10
src/util/mod.rs Normal file
View file

@ -0,0 +1,10 @@
// util/mod.rs
//
// Various utility methods and helper libs that persist throughout the project.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
pub mod forms;
pub mod responses;
pub mod database;

38
src/util/responses.rs Normal file
View file

@ -0,0 +1,38 @@
// src/util/responses.rs
//
// Basic response objects that are commonly thrown about in API calls.
//
// @author Ryan McGrath <ryan@rymc.io>
// @created 06/18/2018
use askama::Template;
use futures::future::{Future, result};
use actix_web::{HttpResponse, Error, AsyncResponder};
pub type FutureResponse = Box<Future<Item = HttpResponse, Error = Error>>;
#[derive(Deserialize, Serialize)]
pub struct OperationResponse<'a> {
pub success: bool,
pub message: Option<&'a str>
}
#[inline(always)]
pub fn render(template: &Template) -> HttpResponse {
HttpResponse::Ok().content_type("text/html").body(template.render().unwrap())
}
#[inline(always)]
pub fn future_render(template: &Template) -> FutureResponse {
result(Ok(HttpResponse::Ok().content_type("text/html").body(template.render().unwrap()))).responder()
}
#[inline(always)]
pub fn redirect(location: &str) -> HttpResponse {
HttpResponse::TemporaryRedirect().header("Location", location).finish()
}
#[inline(always)]
pub fn future_redirect(location: &str) -> FutureResponse {
result(Ok(HttpResponse::TemporaryRedirect().header("Location", location).finish())).responder()
}

1
templates/app/index.html Normal file
View file

@ -0,0 +1 @@
LOGGED IN!!!!

3
templates/index.html Normal file
View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

19
templates/layout.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<!--[if IE 8]><html class="lt-ie9"><![endif]-->
<!--[if gt IE 8]><!--><html><!--<![endif]-->
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">
<title>{% block title %}{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
<div id="nav">
<a href="/users/signup/">Signup</a>
<a href="/users/login/">Login</a>
</div>
<div id="content">
{% block content %}{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

View file

@ -0,0 +1,3 @@
{% extends "layout.html" %}
{% block content %}HELLO!{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% block title %}Signup{% endblock %}
{% block head %}{% endblock %}
{% block content %}
<form method="POST" action="/signup">
<input type="text" name="name">
<input type="password" name="password">
<input type="submit" value="Go">
</form>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "layout.html" %}
{% block title %}Signup{% endblock %}
{% block head %}{% endblock %}
{% block content %}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% block title %}Signup{% endblock %}
{% block head %}{% endblock %}
{% block content %}
<form method="POST" action="/users/login/">
<input type="text" name="name">
<input type="password" name="password">
<input type="submit" value="Go">
</form>
{% endblock %}

View file

@ -0,0 +1 @@
<h1></h1>

View file

@ -0,0 +1,23 @@
{% extends "layout.html" %}
{% block title %}Signup{% endblock %}
{% block head %}{% endblock %}
{% block content %}
{% match errors %}
{% when Some with (errs) %}
<ul>
{% for e in errs %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% when None %}
{% endmatch %}
<form method="POST" action="/users/signup/">
<input type="text" name="email">
<input type="password" name="password">
<input type="submit" value="Go">
</form>
{% endblock %}