gourami

[UNMAINTAINED] Activitypub server in Rust
Log | Files | Refs | README | LICENSE

commit 0a428eacef6d0dde8bdf95b67d4c3d2b7ae7bc9c
parent 101f9db6eac5f85d30620366fb51b42fbb0e294f
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat, 18 Apr 2020 19:15:45 -0500

Very basic user authentication

Messy, inescure, etc. Just working on proof of concept

Diffstat:
MCargo.lock | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 17++++++++++-------
MTODO | 4++++
Aauth.rs | 1+
Mmigrations/2020-04-13-014917_initialize/up.sql | 20+++++++++++++-------
Asession.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/db/mod.rs | 1+
Msrc/db/schema.rs | 23++++++++++++++++++++++-
Msrc/db/status.rs | 14++------------
Asrc/db/user.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 296++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 145++-----------------------------------------------------------------------------
Asrc/routes.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/session.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/base.html | 11++++++-----
Dtemplates/head.html | 5-----
Mtemplates/login.html | 21+++++++++++++++++++++
Atemplates/register.html | 20++++++++++++++++++++
Mtemplates/timeline.html | 2++
19 files changed, 859 insertions(+), 184 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -103,6 +103,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" [[package]] +name = "base-x" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1" + +[[package]] name = "base64" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -115,6 +121,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5ca2cd0adc3f48f9e9ea5a6bbdf9ccc0bfade884847e484d452414c7ccffb3" [[package]] +name = "bcrypt" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02d7d008a57bcb2251ba115b803934e02315edbde9a861c88713493e381b63" +dependencies = [ + "base64 0.12.0", + "blowfish", + "byteorder", + "lazy_static", + "rand 0.7.3", +] + +[[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -133,6 +152,15 @@ dependencies = [ ] [[package]] +name = "block-cipher-trait" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c924d49bd09e7c06003acda26cd9742e796e34282ec6c1189404dee0c1f4774" +dependencies = [ + "generic-array", +] + +[[package]] name = "block-padding" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -142,6 +170,17 @@ dependencies = [ ] [[package]] +name = "blowfish" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeb80d00f2688459b8542068abd974cfb101e7a82182414a99b5026c0d85cc3" +dependencies = [ + "block-cipher-trait", + "byteorder", + "opaque-debug", +] + +[[package]] name = "buf_redux" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -195,7 +234,7 @@ checksum = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2" dependencies = [ "num-integer", "num-traits", - "time", + "time 0.1.42", ] [[package]] @@ -208,6 +247,15 @@ dependencies = [ ] [[package]] +name = "cookie" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c60ef6d0bbf56ad2674249b6bb74f2c6aeb98b98dd57b5d3e37cace33011d69" +dependencies = [ + "time 0.2.9", +] + +[[package]] name = "core-foundation" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -265,6 +313,12 @@ dependencies = [ ] [[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + +[[package]] name = "dtoa" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -478,11 +532,15 @@ version = "0.1.0" dependencies = [ "activitystreams", "askama", + "bcrypt", "chrono", + "cookie", "diesel", "env_logger", + "hyper", "lazy_static", "log 0.4.8", + "rand 0.7.3", "reqwest", "serde", "serde_json", @@ -522,7 +580,7 @@ dependencies = [ "http", "mime 0.3.16", "sha-1", - "time", + "time 0.1.42", ] [[package]] @@ -603,7 +661,7 @@ dependencies = [ "log 0.4.8", "net2", "pin-project", - "time", + "time 0.1.42", "tokio", "tower-service", "want", @@ -1299,7 +1357,7 @@ dependencies = [ "pin-project-lite", "serde", "serde_urlencoded", - "time", + "time 0.1.42", "tokio", "tokio-tls", "url", @@ -1310,6 +1368,26 @@ dependencies = [ ] [[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bba175698996010c4f6dce5e7f173b6eb781fce25d2cfc45e27091ce0b79f6" +dependencies = [ + "proc-macro2 1.0.10", + "quote 1.0.3", + "syn 1.0.17", +] + +[[package]] name = "ryu" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1361,6 +1439,21 @@ dependencies = [ ] [[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] name = "serde" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1416,6 +1509,12 @@ dependencies = [ ] [[package]] +name = "sha1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" + +[[package]] name = "siphasher" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1434,6 +1533,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05720e22615919e4734f6a99ceae50d00226c3c5aca406e102ebc33298214e0a" [[package]] +name = "standback" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee531c64ad0f80d289504bd32fb047f42a9e957cda584276ab96eb587e9abac3" + +[[package]] +name = "stdweb" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" +dependencies = [ + "discard", + "rustc_version", + "stdweb-derive", + "stdweb-internal-macros", + "stdweb-internal-runtime", + "wasm-bindgen", +] + +[[package]] +name = "stdweb-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" +dependencies = [ + "proc-macro2 1.0.10", + "quote 1.0.3", + "serde", + "serde_derive", + "syn 1.0.17", +] + +[[package]] +name = "stdweb-internal-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" +dependencies = [ + "base-x", + "proc-macro2 1.0.10", + "quote 1.0.3", + "serde", + "serde_derive", + "serde_json", + "sha1", + "syn 1.0.17", +] + +[[package]] +name = "stdweb-internal-runtime" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" + +[[package]] name = "syn" version = "0.15.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1519,6 +1673,43 @@ dependencies = [ ] [[package]] +name = "time" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6329a7835505d46f5f3a9a2c237f8d6bf5ca6f0015decb3698ba57fcdbb609ba" +dependencies = [ + "cfg-if", + "libc", + "rustversion", + "standback", + "stdweb", + "time-macros", + "winapi 0.3.8", +] + +[[package]] +name = "time-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9b6e9f095bc105e183e3cd493d72579be3181ad4004fceb01adbe9eecab2d" +dependencies = [ + "proc-macro-hack", + "time-macros-impl", +] + +[[package]] +name = "time-macros-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e987cfe0537f575b5fc99909de6185f6c19c3ad8889e2275e686a873d0869ba1" +dependencies = [ + "proc-macro-hack", + "proc-macro2 1.0.10", + "quote 1.0.3", + "syn 1.0.17", +] + +[[package]] name = "tokio" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -5,18 +5,21 @@ authors = ["alex wennerberg <alex@alexwennerberg.com>"] edition = "2018" [dependencies] +activitystreams = "0.4.0" askama = "0.8" +bcrypt = "0.7" +cookie = "0.13" +chrono = "*" diesel = { version = "1.0.0", features = ["sqlite"] } -tokio = { version = "0.2", features = ["macros"] } -log = "0.4" env_logger = "0.7" -warp = "0.2" -reqwest = "0.10" -activitystreams = "0.4.0" -chrono = "*" lazy_static = "1.4.0" +log = "0.4" +rand = "0.7" +reqwest = "0.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" - +tokio = { version = "0.2", features = ["macros"] } +warp = "0.2" +hyper = "*" [dev-dependencies] diff --git a/TODO b/TODO @@ -1,5 +1,7 @@ Plain text statuses +understand fn vs async fn in tokio + marketing tagline: "An intentionally small, lightweight activitypub community" @@ -38,6 +40,8 @@ Note -- Represents a short written work typically less than a single paragraph Object properties: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object +used https://github.com/kaj/warp-diesel-ructe-sample + Webfinger Profiles: diff --git a/auth.rs b/auth.rs @@ -0,0 +1 @@ +// login, registration, etc diff --git a/migrations/2020-04-13-014917_initialize/up.sql b/migrations/2020-04-13-014917_initialize/up.sql @@ -1,22 +1,28 @@ -- Your SQL goes here -CREATE TABLE user ( +CREATE TABLE users ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, username VARCHAR(255), - email VARCHAR(255), - created_at TEXT, - private_key TEXT, - public_key TEXT + password VARCHAR(255), + email VARCHAR(255) ); -CREATE TABLE activity ( +CREATE UNIQUE INDEX users_username_idx ON users (username); + +CREATE TABLE activities ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, json_text TEXT ); +CREATE TABLE sessions ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + cookie VARCHAR NOT NULL, + user_id INTEGER NOT NULL REFERENCES users (id) +); + -- media_attachments -CREATE TABLE note ( +CREATE TABLE notes ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, creator_id INTEGER, parent_id INTEGER, diff --git a/session.rs b/session.rs @@ -0,0 +1,90 @@ +use db::user::User; +use log::{debug, error}; +use rand::thread_rng; +use warp::filters::{cookie, BoxedFilter}; + +pub struct Session { + id: Option<i32>, + user: Option<User>, +} + +// TODO -- figure out if database pooling is strictly necessary for security + +impl Session { + /// Attempt to authenticate a user for this session. + /// + /// If the username and password is valid, create and return a session key. + /// If authentication fails, simply return None. + pub fn authenticate(&mut self, conn: &SqliteConnection, username: &str, password: &str) -> Some(String) { + if let Some(user) = User::authenticate(self.db(), username, password) { + debug!("User authenticated"); + let random_key = thread_rng().sample_iter(&Alphanumeric).take(48).collect() + use crate::schema::sessions::dsl::*; + let result = diesel::insert_into(sessions) + .values((user_id.eq(user.id), cookie.eq(&secret))) + .returning(id) + .get_results(conn); + if let Ok([a]) = result.as_ref().map(|v| &**v) { + self.id = Some(*a); + self.user = Some(user); + return Some(secret); + } else { + error!( + "Failed to create session for {}: {:?}", + user.username, result, + ); + } + } + None + } + /// Get a Session from a database pool and a session key. + /// + /// The session key is checked against the database, and the + /// matching session is loaded. + pub fn from_key(conn: &SqliteConnection, sessionkey: Option<&str>) -> Self { + use crate::schema::sessions::dsl as s; + use crate::schema::users::dsl as u; + let (id, user) = sessionkey + .and_then(|sessionkey| { + u::users + .inner_join(s::sessions) + .select((s::id, (u::id, u::username, u::realname))) + .filter(s::cookie.eq(&sessionkey)) + .first::<(i32, User)>(conn) + .ok() + }) + .map(|(i, u)| (Some(i), Some(u))) + .unwrap_or((None, None)); + + debug!("Got: #{:?} {:?}", id, user); + Session { db, id, user } + } + /// Clear the part of this session that is session-specific. + pub fn clear(conn: &SqliteConnection) { + use crate::schema::sessions::dsl as s; + if let Some(session_id) = self.id { + diesel::delete(s::sessions.filter(s::id.eq(session_id))) + .execute(self.db()) + .map_err(|e| { + error!( + "Failed to delete session {}: {:?}", + session_id, e + ); + }) + .ok(); + } + self.id = None; + self.user = None; + } +} + +pub fn create_session_filter(conn: &SqliteConnection) -> BoxedFilter<(Session,)> { + warp::any() + .and(cookie::optional("EXAUTH")) + .and_then(move |key: Option<String>| { + let key = key.as_ref().map(|s| &**s); + Ok(Session::from_key(conn, key)), + } + }) + .boxed() +} diff --git a/src/db/mod.rs b/src/db/mod.rs @@ -1,2 +1,3 @@ pub mod status; pub mod schema; +pub mod user; diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -1,5 +1,5 @@ table! { - note (id) { + notes (id) { id -> Integer, creator_id -> Integer, parent_id -> Nullable<Integer>, @@ -7,3 +7,24 @@ table! { published -> Timestamp, } } + +table! { + users (id) { + id -> Integer, + username -> Text, + password -> Text, + email -> Text, + } +} + +table! { + sessions (id) { + id -> Integer, + cookie -> Text, + user_id -> Integer, + } +} + +joinable!(sessions -> users (user_id)); + +allow_tables_to_appear_in_same_query!(sessions, users); diff --git a/src/db/status.rs b/src/db/status.rs @@ -2,8 +2,7 @@ use chrono; use activitystreams::object::streams; use diesel::sqlite::SqliteConnection; use diesel::deserialize::{Queryable}; -use super::schema::note; -use super::schema::note::dsl::*; +use super::schema::notes; use diesel::prelude::*; use serde::{Deserialize, Serialize}; @@ -18,17 +17,8 @@ pub struct Note { pub published: String, } -impl Note { - pub fn get_for_user(conn: &SqliteConnection, user_id: i32) -> Vec<Self> { - let results = note - .filter(creator_id.eq(user_id)) - .load::<Self>(conn) - .expect("Error loading posts"); - results - } -} #[derive(Insertable, Clone)] -#[table_name = "note"] +#[table_name = "notes"] pub struct NoteInput { //pub id: i32, //unsigned? pub creator_id: i32, diff --git a/src/db/user.rs b/src/db/user.rs @@ -0,0 +1,60 @@ +use activitystreams::object::streams; +use diesel::sqlite::SqliteConnection; +use diesel::deserialize::{Queryable}; +use super::schema::users; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use bcrypt; + +#[derive(Debug, Queryable, Deserialize)] +pub struct User { + pub id: i32, + pub username: String, + pub email: String, + // created at, updated at +} + + +impl User { + pub fn authenticate( + conn: &SqliteConnection, + user: &str, + pass: &str, + ) -> Option<Self> { + use crate::db::schema::users::dsl::*; + let (user, hash) = match users + .filter(username.eq(user)) + .select(((id, username, email), password)) + .first::<(User, String)>(conn) + { + Ok((user, hash)) => (user, hash), + Err(e) => { + error!("Failed to load hash for {:?}: {:?}", user, e); + return None; + } + }; + + match bcrypt::verify(&pass, &hash) { + Ok(true) => Some(user), + Ok(false) => None, + Err(e) => { + error!("Verify failed for {:?}: {:?}", user, e); + None + } + } + } +} + +#[derive(Insertable, Deserialize)] +#[table_name="users"] +pub struct NewUser<'a> { + pub username: &'a str, + pub password: &'a str, + pub email: &'a str, +} + +// impl<'a> NewUser<'a> { +// fn validate_and_insert() -> Result<Ok(()), Err> { +// } +// +// } diff --git a/src/lib.rs b/src/lib.rs @@ -1,2 +1,296 @@ +#[macro_use] +extern crate diesel; +#[macro_use] extern crate log; -// TODO move stuff here? +use warp::{Reply, Filter, Rejection}; +use warp::http; +use warp::hyper::Body; +use warp::reply::{Response}; +use warp::reject::{custom, not_found}; + +use hyper; +use askama::Template; +use env_logger; +use db::status::{NoteInput, Note}; +use db::user::{User, NewUser}; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use diesel::insert_into; +use serde::{Deserialize, Serialize}; +use session::{Session}; + +mod db; +mod session; + +fn establish_connection() -> SqliteConnection { + let url = ::std::env::var("DATABASE_URL").unwrap(); + let conn = SqliteConnection::establish(&url).unwrap(); + conn +} +// TODO split into separate templates. not sure how +#[derive(Template)] +#[template(path = "timeline.html")] +struct TimelineTemplate<'a>{ + global: Global<'a>, + page: &'a str, + notes: Vec<Note>, +} + +struct Global<'a> { + title: &'a str, + username: String, + logged_in: bool, +} + +impl<'a> Global<'a> { + fn from_user(user: Option<User>) -> Self { + match user { + Some(u) => Global { + logged_in: true, + title: "gourami", + username: u.username.clone(), + }, + None => Global { + logged_in: false, + title: "gourami", + username: String::from("anonymous"), + } + } + } +} +// impl default + +#[derive(Template)] +#[template(path = "notifications.html")] +struct NotificationTemplate<'a>{ + name: &'a str, +} + +pub fn render_template<T: askama::Template>(t: &T) -> http::Response<hyper::body::Body> { + match t.render() { + Ok(body) => http::Response::builder() + .status(http::StatusCode::OK) + // TODO add headers etc + .body(body.into()), + Err(_) => http::Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()), + } + .unwrap() +} + +fn delete_note(note_id: i32) -> impl Reply { + use db::schema::notes::dsl::*; + let conn = establish_connection(); + diesel::delete(notes.filter(id.eq(note_id))).execute(&conn).unwrap(); + warp::redirect::redirect(warp::http::Uri::from_static("/")) +} + +#[derive(Deserialize)] +struct NewNoteRequest { + note_input: String, // has to be String +} + +fn new_note(req: &NewNoteRequest) -> impl Reply { + use db::schema::notes::dsl::*; + // create activitypub activity object + // TODO -- micropub? + let conn = establish_connection(); + let new_note = NoteInput{ + creator_id: 1, + parent_id: None, + published: String::from("now"), + content: req.note_input.clone(), // how to avoid clone here? + }; + insert_into(notes).values(new_note).execute(&conn).unwrap(); + + // generate activitypub object from post request + // send to outbox + // if request made from web form + warp::redirect::redirect(warp::http::Uri::from_static("/")) +} + +// ActivityPub outbox +fn send_to_outbox(activity: bool) { // activitystreams object + // fetch/store from db. + // db objects need to serialize/deserialize this object + // if get -> fetch from db + // if post -> put to db, send to inbox of followers + // send to inbox of followers +} + + +#[derive(Template)] +#[template(path = "register.html")] +struct RegisterTemplate<'a>{ + page: &'a str, + global: Global<'a>, +} + +fn register_page() -> impl Reply { + let global = Global::from_user(None); + render_template(&RegisterTemplate{page: "register", global:global}) +} + + +#[derive(Deserialize)] +struct RegisterForm { + username: String, + password: String, + email: String, +} + + +impl RegisterForm { + fn validate(self) -> Result<Self, &'static str> { + if self.email.is_empty() { + Err("A email must be given") + } else if self.password.len() < 3 { + Err("Please use a better password") + } else { + Ok(self) + } + } +} + +// TODO move all authentication +fn do_register(form: RegisterForm) -> impl Reply{ + use db::schema::users::dsl::*; + let hash = bcrypt::hash(&form.password, bcrypt::DEFAULT_COST).unwrap(); + let new_user = NewUser {username: &form.username, password: &hash, email: &form.email}; + // todo data validation + let conn = establish_connection(); + insert_into(users).values(new_user).execute(&conn).unwrap(); + + // insert into database + do_login(LoginForm{username: form.username, password: form.password}) +} + +#[derive(Deserialize)] +struct LoginForm { + username: String, + password: String, +} + + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate<'a>{ + page: &'a str, + login_failed: bool, + global: Global<'a>, +} + +fn login_page() -> impl Reply { + // dont let you access this page if logged in + let global = Global::from_user(None); + render_template(&LoginTemplate{page: "login", login_failed: false, global:global}) +} + +fn do_login(form: LoginForm) -> impl Reply { + let conn = establish_connection(); + if let Some(cookie) = Session::authenticate(&conn, &form.username, &form.password) { + http::Response::builder() + .status(http::StatusCode::FOUND) + .header(http::header::LOCATION, "/") + .header( + http::header::SET_COOKIE, + format!("EXAUTH={}; SameSite=Strict; HttpOpnly", cookie), + ) + .body(Body::empty()).unwrap() + } else { + let global = Global::from_user(None); + render_template(&LoginTemplate{page: "login", login_failed: true, global:global}) + // TODO -- better error handling + } +} + +fn timeline(auth_cookie: Option<String>) -> impl Reply { + // no session -- anonymous + let conn = establish_connection(); + let session = Session::from_key(&conn, auth_cookie); + let global = Global::from_user(session.user); + //ownership? + use db::schema::notes::dsl::*; + let results = notes + .load::<Note>(&conn) + .expect("Error loading posts"); + render_template(&TimelineTemplate{ + page: "timeline", + global: global, + notes: results, + }) + +} +// fn do_logout(mut session: Session) -> Result<impl Reply, Rejection> { +// session.clear(); +// Response::builder() +// .status(StatusCode::FOUND) +// .header(header::LOCATION, "/") +// .header( +// header::SET_COOKIE, +// "EXAUTH=; Max-Age=0; SameSite=Strict; HttpOpnly", +// ) +// .body(b"".to_vec()) +// .map_err(custom) +// } + +fn logout() { +} +// ActivityPub inbox +fn inbox() { +} + +pub async fn run_server() { + env_logger::init(); + + let notifications = warp::path("notifications"); + + // How does this interact with tokio? who knows! + let test = warp::path("test").map(|| "Hello world"); + + let register_page = warp::path("register").map(|| register_page()); + let do_register = warp::path("register") + .and(warp::body::form()) + .map(|f: RegisterForm| do_register(f)); + + let login_page = warp::path("login").map(|| login_page()); + let do_login = warp::path("login") + .and(warp::body::form()) + .map(|f: LoginForm| do_login(f)); + + let logout = warp::path("logout").map(|| "Hello from logout"); + + // post + // user + // default page -- timeline + let home = warp::path::end() + .and(warp::filters::cookie::optional("EXAUTH")) + .map(|auth_cookie| timeline(auth_cookie)); + + let static_files = warp::path("static") + .and(warp::fs::dir("./static")); + + // https://github.com/seanmonstar/warp/issues/42 -- how to set up diesel + // TODO set content length limit + // TODO redirect via redirect in request + // TODO secure against xss + let create_note = warp::path("create_note") + .and(warp::body::form()) + .map(|note_req: NewNoteRequest| new_note(&note_req)); + + let delete_note = warp::path::param::<i32>() + .and(warp::path("delete")) + .map(|note_id| delete_note(note_id)); + + // catch all for any other paths + let not_found = warp::any().map(|| "404 not found"); + + let routes = warp::get().and( + home.or(test).or(static_files).or(login_page).or(register_page).or(not_found)) + .or(warp::post().and(create_note.or(delete_note).or(do_login).or(do_register))) + .with(warp::log("server")); + warp::serve(routes) + .run(([127, 0, 0, 1], 3030)) + .await; + } diff --git a/src/main.rs b/src/main.rs @@ -1,145 +1,6 @@ -#[macro_use] -extern crate diesel; -#[macro_use] extern crate log; +use gourami_social::run_server; -use gourami_social::*; -use warp::Filter; -use askama::Template; -use warp::http::{self, header, StatusCode}; -use warp::hyper::Body; -use warp::reply::{Response, Reply}; -use env_logger; -use db::status::{NoteInput, Note}; -use diesel::prelude::*; -use diesel::sqlite::SqliteConnection; -use diesel::insert_into; -use serde::{Deserialize, Serialize}; - -mod db; - -fn establish_connection() -> SqliteConnection { - let url = ::std::env::var("DATABASE_URL").unwrap(); - let conn = SqliteConnection::establish(&url).unwrap(); - conn -} -// TODO split into separate templates. not sure how -#[derive(Template)] -#[template(path = "timeline.html")] -struct TimelineTemplate<'a>{ - page: &'a str, - title: &'a str, - username: &'a str, - logged_in: bool, - notes: Vec<Note> -} - -// impl default - -#[derive(Template)] -#[template(path = "notifications.html")] -struct NotificationTemplate<'a>{ - name: &'a str, -} - -pub fn render_template<T: askama::Template>(t: &T) -> Response { - match t.render() { - Ok(body) => http::Response::builder() - .status(StatusCode::OK) - // TODO add headers etc - .body(body.into()), - Err(_) => http::Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()), - } - .unwrap() -} - -fn delete_note(note_id: i32) -> impl Reply { - use db::schema::note::dsl::*; - let conn = establish_connection(); - diesel::delete(note.filter(id.eq(note_id))).execute(&conn).unwrap(); - warp::redirect::redirect(warp::http::Uri::from_static("/")) -} - -#[derive(Deserialize)] -struct NewNoteRequest { - note_input: String, // has to be String -} - -fn new_note(req: &NewNoteRequest) -> impl Reply { - use db::schema::note::dsl::*; - // create activitypub activity object - // TODO -- micropub? - let conn = establish_connection(); - let new_note = NoteInput{ - creator_id: 1, - parent_id: None, - published: String::from("now"), - content: req.note_input.clone(), // how to avoid clone here? - }; - insert_into(note).values(new_note).execute(&conn).unwrap(); - - // generate activitypub object from post request - // send to outbox - // if request made from web form - warp::redirect::redirect(warp::http::Uri::from_static("/")) -} - -// ActivityPub outbox -fn send_to_outbox(activity: bool) { // activitystreams object - // fetch/store from db. - // db objects need to serialize/deserialize this object - // if get -> fetch from db - // if post -> put to db, send to inbox of followers - // send to inbox of followers -} - -// ActivityPub inbox -fn inbox() { -} #[tokio::main] async fn main() { - env_logger::init(); - - let notifications = warp::path("notifications"); - - // How does this interact with tokio? who knows! - let test = warp::path("test").map(|| "Hello world"); - - // post - // user - // default page -- timeline - let home = warp::path::end() - .map(|| render_template(&TimelineTemplate{ - page: "timeline", - logged_in: true, - notes: Note::get_for_user(&SqliteConnection::establish("sample.db").unwrap(), 1), - username: "alex", - title: "gourami"})); - - let static_files = warp::path("static") - .and(warp::fs::dir("./static")); - - // https://github.com/seanmonstar/warp/issues/42 -- how to set up diesel - // TODO set content length limit - // TODO redirect via redirect in request - // TODO secure against xss - let create_note = warp::path("create_note") - .and(warp::body::form()) - .map(|note_req: NewNoteRequest| new_note(&note_req)); - - let delete_note = warp::path::param::<i32>() - .and(warp::path("delete")) - .map(|note_id| delete_note(note_id)); - - // catch all for any other paths - let not_found = warp::any().map(|| "404 not found"); - - let routes = warp::get().and( - home.or(test).or(static_files).or(not_found)) - .or(warp::post().and(create_note.or(delete_note))) - .with(warp::log("server")); - warp::serve(routes) - .run(([127, 0, 0, 1], 3030)) - .await; - } + gourami_social::run_server().await; +} diff --git a/src/routes.rs b/src/routes.rs @@ -0,0 +1,47 @@ +use warp; + +def get_routes() { + let notifications = warp::path("notifications"); + + // How does this interact with tokio? who knows! + let test = warp::path("test").map(|| "Hello world"); + + let register = warp::path("register").map(|| "Hello from register"); + let login = warp::path("login").map(|| "Hello from login"); + let logout = warp::path("logout").map(|| "Hello from logout"); + + // post + // user + // default page -- timeline + let home = warp::path::end() + .map(|| render_template(&TimelineTemplate{ + page: "timeline", + logged_in: true, + notes: Note::get_for_user(&SqliteConnection::establish("sample.db").unwrap(), 1), + username: "alex", + title: "gourami"})); + + let static_files = warp::path("static") + .and(warp::fs::dir("./static")); + + // https://github.com/seanmonstar/warp/issues/42 -- how to set up diesel + // TODO set content length limit + // TODO redirect via redirect in request + // TODO secure against xss + let create_note = warp::path("create_note") + .and(warp::body::form()) + .map(|note_req: NewNoteRequest| new_note(&note_req)); + + let delete_note = warp::path::param::<i32>() + .and(warp::path("delete")) + .map(|note_id| delete_note(note_id)); + + // catch all for any other paths + let not_found = warp::any().map(|| "404 not found"); + + let routes = warp::get().and( + home.or(test).or(static_files).or(not_found)) + .or(warp::post().and(create_note.or(delete_note))); + routes +} + diff --git a/src/session.rs b/src/session.rs @@ -0,0 +1,67 @@ +use crate::*; +use db::user::User; +use log::{debug, error}; +use rand::thread_rng; +use rand::Rng; +use rand::distributions::Alphanumeric; +use diesel::sqlite::SqliteConnection; +use warp::filters::{cookie, BoxedFilter}; + + +pub struct Session { + // dbpool maybe + pub id: Option<i32>, + pub user: Option<User>, +} + +// TODO -- figure out if database pooling is strictly necessary for security + +impl Session { + /// Attempt to authenticate a user for this session. + /// + /// If the username and password is valid, create and return a session key. + /// If authentication fails, simply return None. + pub fn authenticate(conn: &SqliteConnection, username: &str, password: &str) -> Option<String> { + if let Some(user) = User::authenticate(conn, username, password) { + debug!("User authenticated"); + let secret = thread_rng().sample_iter(&Alphanumeric).take(48).collect(); + use crate::db::schema::sessions::dsl::*; + let result = diesel::insert_into(sessions) + .values((user_id.eq(user.id), cookie.eq(&secret))) + .execute(conn); + let session_id = sessions.select(id) + .filter(cookie.eq(&secret)) + .first::<i32>(conn); + if let Ok(s_id) = result { + // self.id = Some(s_id as i32); + // self.user = Some(user); + return Some(secret); + } else { + error!( + "Failed to create session for {}: {:?}", + user.username, result, + ); + } + } + None + } + pub fn from_key(conn: &SqliteConnection, sessionkey: Option<String>) -> Self { + debug!("{:?}", sessionkey); + use db::schema::sessions::dsl as s; + use db::schema::users::dsl as u; + let (id, user) = sessionkey + .and_then(|sessionkey| { + u::users + .inner_join(s::sessions) + .select((s::id, (u::id, u::username, u::email))) + .filter(s::cookie.eq(&sessionkey)) + .first::<(i32, User)>(conn) + .ok() + }) + .map(|(i, u)| (Some(i), Some(u))) + .unwrap_or((None, None)); + + debug!("Got: #{:?} {:?}", id, user); + Session { id, user } + } +} diff --git a/templates/base.html b/templates/base.html @@ -1,25 +1,26 @@ <!DOCTYPE html> <html lang="en"> <head> - <title>{{title}}</title> + <title>{{global.title}}</title> <link rel="stylesheet" type="text/css" href="../static/css/style.css"> <link rel="stylesheet" type="text/css" href="../static/css/bootstrap-grid.min.css"> + <meta charset="utf-8"/> </head> <body class="monospace"> <div class="container main"> <div id="header"> <div class="row navbar"> <div class="col-sm"> - <div class="title">{{title}}/{{page}}</div> + <div class="title">{{global.title}}/{{page}}</div> </div> <div class="col-sm text-center"> <a href="/">t</a> <a href="/test">n</a> </div> <div class="col-sm text-right"> - {% if logged_in %} - <a href="/user/{{username}}">{{username}}</a> + {% if global.logged_in %} + <a href="/user/{{global.username}}">{{global.username}}</a> {% else %} - logged out + <a href="/register">register</a> | <a href="login">login</a> {% endif %} </div> </div> diff --git a/templates/head.html b/templates/head.html @@ -1,5 +0,0 @@ -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>gourami</title> -</head> diff --git a/templates/login.html b/templates/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +<div class="container"> +<form action="/login" method="POST"> + <div class="container"> + <label for="username"><b>Username</b></label> + <input type="text" placeholder="Enter Username" name="username" required> + <br> + + <label for="password"><b>Password</b></label> + <input type="password" placeholder="Enter Password" name="password" required> + + <button type="submit">Login</button> + <br> + {% if login_failed %} + failed login. try again. + {% endif %} + </div> +</form> +</div> +{% endblock %} diff --git a/templates/register.html b/templates/register.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block content %} +<div class="container"> +<form action="/register" method="POST"> + <div class="container"> + <label for="email"><b>Email</b></label> + <input type="text" placeholder="Enter Email" name="email" required> + <br> + <label for="username"><b>Username</b></label> + <input type="text" placeholder="Enter Username" name="username" required> + <br> + + <label for="password"><b>Password</b></label> + <input type="password" placeholder="Enter Password" name="password" required> + <button type="submit">Register</button> + <br> + </div> +</form> +</div> +{% endblock %} diff --git a/templates/timeline.html b/templates/timeline.html @@ -2,10 +2,12 @@ {% block content %} <div class="container"> +{% if global.logged_in %} <form action="/create_note" method="POST"> <textarea name="note_input" rows=3 placeholder="note"></textarea> <br> <button id="post">create note</button> +{% endif %} </form> {% for note in notes %} <div class="row">