Initial commit after extraction
This commit is contained in:
commit
80ba54e4ef
43 changed files with 3865 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
/target
|
||||
**/*.rs.bk
|
||||
2716
Cargo.lock
generated
Normal file
2716
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal 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
12
LICENSE
Normal 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
12
build.rs
Normal 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
5
diesel.toml
Normal 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
6
example.env
Normal 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
0
migrations/.gitkeep
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal 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();
|
||||
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal file
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal 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;
|
||||
1
migrations/2018-07-17-173341_create_users/down.sql
Normal file
1
migrations/2018-07-17-173341_create_users/down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
drop table users;
|
||||
21
migrations/2018-07-17-173341_create_users/up.sql
Normal file
21
migrations/2018-07-17-173341_create_users/up.sql
Normal 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
64
readme.md
Normal 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
9
src/emails/mod.rs
Normal 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};
|
||||
79
src/emails/postmark/mod.rs
Normal file
79
src/emails/postmark/mod.rs
Normal 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
88
src/main.rs
Normal 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
76
src/pages/mod.rs
Normal 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
27
src/schema.rs
Normal 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
98
src/users/middleware.rs
Normal 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
41
src/users/mod.rs
Normal 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
93
src/users/models.rs
Normal 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
85
src/users/views/login.rs
Normal 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
19
src/users/views/logout.rs
Normal 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
18
src/users/views/mod.rs
Normal 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;
|
||||
44
src/users/views/reset_password.rs
Normal file
44
src/users/views/reset_password.rs
Normal 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
81
src/users/views/signup.rs
Normal 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
26
src/util/database.rs
Normal 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
22
src/util/forms.rs
Normal 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
10
src/util/mod.rs
Normal 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
38
src/util/responses.rs
Normal 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
1
templates/app/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
LOGGED IN!!!!
|
||||
3
templates/index.html
Normal file
3
templates/index.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
19
templates/layout.html
Normal file
19
templates/layout.html
Normal 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>
|
||||
3
templates/pages/about.html
Normal file
3
templates/pages/about.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
3
templates/pages/cookies.html
Normal file
3
templates/pages/cookies.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
3
templates/pages/privacy.html
Normal file
3
templates/pages/privacy.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
3
templates/pages/team.html
Normal file
3
templates/pages/team.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
3
templates/pages/terms.html
Normal file
3
templates/pages/terms.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}HELLO!{% endblock %}
|
||||
13
templates/users/forgot_password.html
Normal file
13
templates/users/forgot_password.html
Normal 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 %}
|
||||
8
templates/users/forgot_password_sent.html
Normal file
8
templates/users/forgot_password_sent.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Signup{% endblock %}
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
13
templates/users/login.html
Normal file
13
templates/users/login.html
Normal 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 %}
|
||||
1
templates/users/reset_password.html
Normal file
1
templates/users/reset_password.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<h1></h1>
|
||||
23
templates/users/signup.html
Normal file
23
templates/users/signup.html
Normal 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 %}
|
||||
Reference in a new issue