diff options
Diffstat (limited to 'web/converse/src')
-rw-r--r-- | web/converse/src/db.rs | 321 | ||||
-rw-r--r-- | web/converse/src/errors.rs | 142 | ||||
-rw-r--r-- | web/converse/src/handlers.rs | 345 | ||||
-rw-r--r-- | web/converse/src/main.rs | 224 | ||||
-rw-r--r-- | web/converse/src/models.rs | 127 | ||||
-rw-r--r-- | web/converse/src/oidc.rs | 159 | ||||
-rw-r--r-- | web/converse/src/render.rs | 240 | ||||
-rw-r--r-- | web/converse/src/schema.rs | 88 |
8 files changed, 1646 insertions, 0 deletions
diff --git a/web/converse/src/db.rs b/web/converse/src/db.rs new file mode 100644 index 000000000000..ae186bdf4e4d --- /dev/null +++ b/web/converse/src/db.rs @@ -0,0 +1,321 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +//! This module implements the database executor, which holds the +//! database connection and performs queries on it. + +use actix::prelude::*; +use diesel::{self, sql_query}; +use diesel::sql_types::Text; +use diesel::prelude::*; +use diesel::r2d2::{Pool, ConnectionManager}; +use crate::models::*; +use crate::errors::{ConverseError, Result}; + +/// Raw PostgreSQL query used to perform full-text search on posts +/// with a supplied phrase. For now, the query language is hardcoded +/// to English and only "plain" queries (i.e. no searches for exact +/// matches or more advanced query syntax) are supported. +const SEARCH_QUERY: &'static str = r#" +WITH search_query (query) AS (VALUES (plainto_tsquery('english', $1))) +SELECT post_id, + thread_id, + author, + title, + ts_headline('english', body, query) AS headline + FROM search_index, search_query + WHERE document @@ query + ORDER BY ts_rank(document, query) DESC + LIMIT 50 +"#; + +const REFRESH_QUERY: &'static str = "REFRESH MATERIALIZED VIEW search_index"; + +pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>); + +impl DbExecutor { + /// Request a list of threads. + // + // TODO(tazjin): This should support pagination. + pub fn list_threads(&self) -> Result<Vec<ThreadIndex>> { + use crate::schema::thread_index::dsl::*; + + let conn = self.0.get()?; + let results = thread_index + .load::<ThreadIndex>(&conn)?; + Ok(results) + } + + /// Look up a user based on their email-address. If the user does + /// not exist, it is created. + pub fn lookup_or_create_user(&self, user_email: &str, user_name: &str) -> Result<User> { + use crate::schema::users; + use crate::schema::users::dsl::*; + + let conn = self.0.get()?; + + let opt_user = users + .filter(email.eq(email)) + .first(&conn).optional()?; + + if let Some(user) = opt_user { + Ok(user) + } else { + let new_user = NewUser { + email: user_email.to_string(), + name: user_name.to_string(), + }; + + let user: User = diesel::insert_into(users::table) + .values(&new_user) + .get_result(&conn)?; + + info!("Created new user {} with ID {}", new_user.email, user.id); + + Ok(user) + } + } + + /// Fetch a specific thread and return it with its posts. + pub fn get_thread(&self, thread_id: i32) -> Result<(Thread, Vec<SimplePost>)> { + use crate::schema::threads::dsl::*; + use crate::schema::simple_posts::dsl::id; + + let conn = self.0.get()?; + let thread_result: Thread = threads + .find(thread_id).first(&conn)?; + + let post_list = SimplePost::belonging_to(&thread_result) + .order_by(id.asc()) + .load::<SimplePost>(&conn)?; + + Ok((thread_result, post_list)) + } + + /// Fetch a specific post. + pub fn get_post(&self, post_id: i32) -> Result<SimplePost> { + use crate::schema::simple_posts::dsl::*; + let conn = self.0.get()?; + Ok(simple_posts.find(post_id).first(&conn)?) + } + + /// Update the content of a post. + pub fn update_post(&self, post_id: i32, post_text: String) -> Result<Post> { + use crate::schema::posts::dsl::*; + let conn = self.0.get()?; + let updated = diesel::update(posts.find(post_id)) + .set(body.eq(post_text)) + .get_result(&conn)?; + + Ok(updated) + } + + /// Create a new thread. + pub fn create_thread(&self, new_thread: NewThread, post_text: String) -> Result<Thread> { + use crate::schema::threads; + use crate::schema::posts; + + let conn = self.0.get()?; + + conn.transaction::<Thread, ConverseError, _>(|| { + // First insert the thread structure itself + let thread: Thread = diesel::insert_into(threads::table) + .values(&new_thread) + .get_result(&conn)?; + + // ... then create the first post in the thread. + let new_post = NewPost { + thread_id: thread.id, + body: post_text, + user_id: new_thread.user_id, + }; + + diesel::insert_into(posts::table) + .values(&new_post) + .execute(&conn)?; + + Ok(thread) + }) + } + + /// Create a new post. + pub fn create_post(&self, new_post: NewPost) -> Result<Post> { + use crate::schema::posts; + + let conn = self.0.get()?; + + let closed: bool = { + use crate::schema::threads::dsl::*; + threads.select(closed) + .find(new_post.thread_id) + .first(&conn)? + }; + + if closed { + return Err(ConverseError::ThreadClosed { + id: new_post.thread_id + }) + } + + Ok(diesel::insert_into(posts::table) + .values(&new_post) + .get_result(&conn)?) + } + + /// Search for posts. + pub fn search_posts(&self, query: String) -> Result<Vec<SearchResult>> { + let conn = self.0.get()?; + + let search_results = sql_query(SEARCH_QUERY) + .bind::<Text, _>(query) + .get_results::<SearchResult>(&conn)?; + + Ok(search_results) + } + + /// Trigger a refresh of the view used for full-text searching. + pub fn refresh_search_view(&self) -> Result<()> { + let conn = self.0.get()?; + debug!("Refreshing search_index view in DB"); + sql_query(REFRESH_QUERY).execute(&conn)?; + Ok(()) + } +} + + +// Old actor implementation: + +impl Actor for DbExecutor { + type Context = SyncContext<Self>; +} + +/// Message used to look up a user based on their email-address. If +/// the user does not exist, it is created. +pub struct LookupOrCreateUser { + pub email: String, + pub name: String, +} + +message!(LookupOrCreateUser, Result<User>); + +impl Handler<LookupOrCreateUser> for DbExecutor { + type Result = <LookupOrCreateUser as Message>::Result; + + fn handle(&mut self, + _: LookupOrCreateUser, + _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to fetch a specific thread. Returns the thread and +/// its posts. +pub struct GetThread(pub i32); +message!(GetThread, Result<(Thread, Vec<SimplePost>)>); + +impl Handler<GetThread> for DbExecutor { + type Result = <GetThread as Message>::Result; + + fn handle(&mut self, _: GetThread, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to fetch a specific post. +#[derive(Deserialize, Debug)] +pub struct GetPost { pub id: i32 } + +message!(GetPost, Result<SimplePost>); + +impl Handler<GetPost> for DbExecutor { + type Result = <GetPost as Message>::Result; + + fn handle(&mut self, _: GetPost, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to update the content of a post. +#[derive(Deserialize)] +pub struct UpdatePost { + pub post_id: i32, + pub post: String, +} + +message!(UpdatePost, Result<Post>); + +impl Handler<UpdatePost> for DbExecutor { + type Result = Result<Post>; + + fn handle(&mut self, _: UpdatePost, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to create a new thread +pub struct CreateThread { + pub new_thread: NewThread, + pub post: String, +} +message!(CreateThread, Result<Thread>); + +impl Handler<CreateThread> for DbExecutor { + type Result = <CreateThread as Message>::Result; + + fn handle(&mut self, _: CreateThread, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to create a new reply +pub struct CreatePost(pub NewPost); +message!(CreatePost, Result<Post>); + +impl Handler<CreatePost> for DbExecutor { + type Result = <CreatePost as Message>::Result; + + fn handle(&mut self, _: CreatePost, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message used to search for posts +#[derive(Deserialize)] +pub struct SearchPosts { pub query: String } +message!(SearchPosts, Result<Vec<SearchResult>>); + +impl Handler<SearchPosts> for DbExecutor { + type Result = <SearchPosts as Message>::Result; + + fn handle(&mut self, _: SearchPosts, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} + +/// Message that triggers a refresh of the view used for full-text +/// searching. +pub struct RefreshSearchView; +message!(RefreshSearchView, Result<()>); + +impl Handler<RefreshSearchView> for DbExecutor { + type Result = Result<()>; + + fn handle(&mut self, _: RefreshSearchView, _: &mut Self::Context) -> Self::Result { + unimplemented!() + } +} diff --git a/web/converse/src/errors.rs b/web/converse/src/errors.rs new file mode 100644 index 000000000000..32507c51b0c2 --- /dev/null +++ b/web/converse/src/errors.rs @@ -0,0 +1,142 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +//! This module defines custom error types using the `failure`-crate. +//! Links to foreign error types (such as database connection errors) +//! are established in a similar way as was tradition in +//! `error_chain`, albeit manually. + +use std::result; +use actix_web::{ResponseError, HttpResponse}; +use actix_web::http::StatusCode; + +// Modules with foreign errors: +use actix; +use actix_web; +use askama; +use diesel; +use r2d2; +use tokio_timer; + +pub type Result<T> = result::Result<T, ConverseError>; +pub type ConverseResult<T> = result::Result<T, ConverseError>; + +#[derive(Debug, Fail)] +pub enum ConverseError { + #[fail(display = "an internal Converse error occured: {}", reason)] + InternalError { reason: String }, + + #[fail(display = "a database error occured: {}", error)] + Database { error: diesel::result::Error }, + + #[fail(display = "a database connection pool error occured: {}", error)] + ConnectionPool { error: r2d2::Error }, + + #[fail(display = "a template rendering error occured: {}", reason)] + Template { reason: String }, + + #[fail(display = "error occured during request handling: {}", error)] + ActixWeb { error: actix_web::Error }, + + #[fail(display = "error occured running timer: {}", error)] + Timer { error: tokio_timer::Error }, + + #[fail(display = "user {} does not have permission to edit post {}", user, id)] + PostEditForbidden { user: i32, id: i32 }, + + #[fail(display = "thread {} is closed and can not be responded to", id)] + ThreadClosed { id: i32 }, + + #[fail(display = "JSON serialisation failed: {}", error)] + Serialisation { error: serde_json::Error }, + + // This variant is used as a catch-all for wrapping + // actix-web-compatible response errors, such as the errors it + // throws itself. + #[fail(display = "Actix response error: {}", error)] + Actix { error: Box<dyn ResponseError> }, +} + +// Establish conversion links to foreign errors: + +impl From<diesel::result::Error> for ConverseError { + fn from(error: diesel::result::Error) -> ConverseError { + ConverseError::Database { error } + } +} + +impl From<r2d2::Error> for ConverseError { + fn from(error: r2d2::Error) -> ConverseError { + ConverseError::ConnectionPool { error } + } +} + +impl From<askama::Error> for ConverseError { + fn from(error: askama::Error) -> ConverseError { + ConverseError::Template { + reason: format!("{}", error), + } + } +} + +impl From<actix::MailboxError> for ConverseError { + fn from(error: actix::MailboxError) -> ConverseError { + ConverseError::Actix { error: Box::new(error) } + } +} + +impl From<actix_web::Error> for ConverseError { + fn from(error: actix_web::Error) -> ConverseError { + ConverseError::ActixWeb { error } + } +} + +impl From<serde_json::Error> for ConverseError { + fn from(error: serde_json::Error) -> ConverseError { + ConverseError::Serialisation { error } + } +} + +impl From<curl::Error> for ConverseError { + fn from(error: curl::Error) -> ConverseError { + ConverseError::InternalError { + reason: format!("error during HTTP request: {}", error), + } + } +} + +impl From<tokio_timer::Error> for ConverseError { + fn from(error: tokio_timer::Error) -> ConverseError { + ConverseError::Timer { error } + } +} + +// Support conversion of error type into HTTP error responses: + +impl ResponseError for ConverseError { + fn error_response(&self) -> HttpResponse { + // Everything is mapped to internal server errors for now. + match *self { + ConverseError::ThreadClosed { id } => HttpResponse::SeeOther() + .header("Location", format!("/thread/{}#post-reply", id)) + .finish(), + _ => HttpResponse::build(StatusCode::INTERNAL_SERVER_ERROR) + .body(format!("An error occured: {}", self)) + } + } +} diff --git a/web/converse/src/handlers.rs b/web/converse/src/handlers.rs new file mode 100644 index 000000000000..0759cec5c146 --- /dev/null +++ b/web/converse/src/handlers.rs @@ -0,0 +1,345 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +//! This module contains the implementation of converse's actix-web +//! HTTP handlers. +//! +//! Most handlers have an associated rendering function using one of +//! the tera templates stored in the `/templates` directory in the +//! project root. + +use actix::prelude::*; +use actix_web::*; +use actix_web::http::Method; +use actix_web::middleware::identity::RequestIdentity; +use actix_web::middleware::{Started, Middleware}; +use actix_web; +use crate::db::*; +use crate::errors::{ConverseResult, ConverseError}; +use futures::Future; +use crate::models::*; +use crate::oidc::*; +use crate::render::*; + +use rouille::{Request, Response}; + +type ConverseResponse = Box<dyn Future<Item=HttpResponse, Error=ConverseError>>; + +const HTML: &'static str = "text/html"; +const ANONYMOUS: i32 = 1; +const NEW_THREAD_LENGTH_ERR: &'static str = "Title and body can not be empty!"; + +/// Represents the state carried by the web server actors. +pub struct AppState { + /// Address of the database actor + pub db: Addr<DbExecutor>, + + /// Address of the OIDC actor + pub oidc: Addr<OidcExecutor>, + + /// Address of the rendering actor + pub renderer: Addr<Renderer>, +} + +/// Serve the forum's index page. +pub fn forum_index_rouille(db: &DbExecutor) -> ConverseResult<Response> { + let threads = db.list_threads()?; + Ok(Response::html(index_page(threads)?)) +} + +pub fn forum_index(_: State<AppState>) -> ConverseResponse { + unimplemented!() +} + +/// Returns the ID of the currently logged in user. If there is no ID +/// present, the ID of the anonymous user will be returned. +pub fn get_user_id(req: &HttpRequest<AppState>) -> i32 { + if let Some(id) = req.identity() { + // If this .expect() call is triggered, someone is likely + // attempting to mess with their cookies. These requests can + // be allowed to fail without further ado. + id.parse().expect("Session cookie contained invalid data!") + } else { + ANONYMOUS + } +} + +pub fn get_user_id_rouille(_req: &Request) -> i32 { + // TODO(tazjin): Implement session support in rouille somehow. + ANONYMOUS +} + +pub fn forum_thread_rouille(req: &Request, db: &DbExecutor, thread_id: i32) + -> ConverseResult<Response> { + let user = get_user_id_rouille(&req); + let thread = db.get_thread(thread_id)?; + Ok(Response::html(thread_page(user, thread.0, thread.1)?)) +} + +/// This handler retrieves and displays a single forum thread. +pub fn forum_thread(_: State<AppState>, + _: HttpRequest<AppState>, + _: Path<i32>) -> ConverseResponse { + unimplemented!() +} + +/// This handler presents the user with the "New Thread" form. +pub fn new_thread(state: State<AppState>) -> ConverseResponse { + state.renderer.send(NewThreadPage::default()).flatten() + .map(|res| HttpResponse::Ok().content_type(HTML).body(res)) + .responder() +} + +#[derive(Deserialize)] +pub struct NewThreadForm { + pub title: String, + pub post: String, +} + +/// This handler receives a "New thread"-form and redirects the user +/// to the new thread after creation. +pub fn submit_thread((state, input, req): (State<AppState>, + Form<NewThreadForm>, + HttpRequest<AppState>)) -> ConverseResponse { + // Trim whitespace out of inputs: + let input = NewThreadForm { + title: input.title.trim().into(), + post: input.post.trim().into(), + }; + + // Perform simple validation and abort here if it fails: + if input.title.is_empty() || input.post.is_empty() { + return state.renderer + .send(NewThreadPage { + alerts: vec![NEW_THREAD_LENGTH_ERR], + title: Some(input.title), + post: Some(input.post), + }) + .flatten() + .map(|res| HttpResponse::Ok().content_type(HTML).body(res)) + .responder(); + } + + let user_id = get_user_id(&req); + + let new_thread = NewThread { + user_id, + title: input.title, + }; + + let msg = CreateThread { + new_thread, + post: input.post, + }; + + state.db.send(msg) + .from_err() + .and_then(move |res| { + let thread = res?; + info!("Created new thread \"{}\" with ID {}", thread.title, thread.id); + Ok(HttpResponse::SeeOther() + .header("Location", format!("/thread/{}", thread.id)) + .finish()) + }) + .responder() +} + +#[derive(Deserialize)] +pub struct NewPostForm { + pub thread_id: i32, + pub post: String, +} + +/// This handler receives a "Reply"-form and redirects the user to the +/// new post after creation. +pub fn reply_thread(state: State<AppState>, + input: Form<NewPostForm>, + req: HttpRequest<AppState>) -> ConverseResponse { + let user_id = get_user_id(&req); + + let new_post = NewPost { + user_id, + thread_id: input.thread_id, + body: input.post.trim().into(), + }; + + state.db.send(CreatePost(new_post)) + .flatten() + .from_err() + .and_then(move |post| { + info!("Posted reply {} to thread {}", post.id, post.thread_id); + Ok(HttpResponse::SeeOther() + .header("Location", format!("/thread/{}#post-{}", post.thread_id, post.id)) + .finish()) + }) + .responder() +} + +/// This handler presents the user with the form to edit a post. If +/// the user attempts to edit a post that they do not have access to, +/// they are currently ungracefully redirected back to the post +/// itself. +pub fn edit_form(state: State<AppState>, + req: HttpRequest<AppState>, + query: Path<GetPost>) -> ConverseResponse { + let user_id = get_user_id(&req); + + state.db.send(query.into_inner()) + .flatten() + .from_err() + .and_then(move |post| { + if user_id != 1 && post.user_id == user_id { + return Ok(post); + } + + Err(ConverseError::PostEditForbidden { + user: user_id, + id: post.id, + }) + }) + .and_then(move |post| { + let edit_msg = EditPostPage { + id: post.id, + post: post.body, + }; + + state.renderer.send(edit_msg).from_err() + }) + .flatten() + .map(|page| HttpResponse::Ok().content_type(HTML).body(page)) + .responder() +} + +/// This handler "executes" an edit to a post if the current user owns +/// the edited post. +pub fn edit_post(state: State<AppState>, + req: HttpRequest<AppState>, + update: Form<UpdatePost>) -> ConverseResponse { + let user_id = get_user_id(&req); + + state.db.send(GetPost { id: update.post_id }) + .flatten() + .from_err() + .and_then(move |post| { + if user_id != 1 && post.user_id == user_id { + Ok(()) + } else { + Err(ConverseError::PostEditForbidden { + user: user_id, + id: post.id, + }) + } + }) + .and_then(move |_| state.db.send(update.0).from_err()) + .flatten() + .map(|updated| HttpResponse::SeeOther() + .header("Location", format!("/thread/{}#post-{}", + updated.thread_id, updated.id)) + .finish()) + .responder() +} + +/// This handler executes a full-text search on the forum database and +/// displays the results to the user. +pub fn search_forum(state: State<AppState>, + query: Query<SearchPosts>) -> ConverseResponse { + let query_string = query.query.clone(); + state.db.send(query.into_inner()) + .flatten() + .and_then(move |results| state.renderer.send(SearchResultPage { + results, + query: query_string, + }).from_err()) + .flatten() + .map(|res| HttpResponse::Ok().content_type(HTML).body(res)) + .responder() +} + +/// This handler initiates an OIDC login. +pub fn login(state: State<AppState>) -> ConverseResponse { + state.oidc.send(GetLoginUrl) + .from_err() + .and_then(|url| Ok(HttpResponse::TemporaryRedirect() + .header("Location", url) + .finish())) + .responder() +} + +/// This handler handles an OIDC callback (i.e. completed login). +/// +/// Upon receiving the callback, a token is retrieved from the OIDC +/// provider and a user lookup is performed. If a user with a matching +/// email-address is found in the database, it is logged in - +/// otherwise a new user is created. +pub fn callback(state: State<AppState>, + data: Form<CodeResponse>, + req: HttpRequest<AppState>) -> ConverseResponse { + state.oidc.send(RetrieveToken(data.0)).flatten() + .map(|author| LookupOrCreateUser { + email: author.email, + name: author.name, + }) + .and_then(move |msg| state.db.send(msg).from_err()).flatten() + .and_then(move |user| { + info!("Completed login for user {} ({})", user.email, user.id); + req.remember(user.id.to_string()); + Ok(HttpResponse::SeeOther() + .header("Location", "/") + .finish())}) + .responder() +} + +/// This is an extension trait to enable easy serving of embedded +/// static content. +/// +/// It is intended to be called with `include_bytes!()` when setting +/// up the actix-web application. +pub trait EmbeddedFile { + fn static_file(self, path: &'static str, content: &'static [u8]) -> Self; +} + +impl EmbeddedFile for App<AppState> { + fn static_file(self, path: &'static str, content: &'static [u8]) -> Self { + self.route(path, Method::GET, move |_: HttpRequest<_>| { + let mime = format!("{}", mime_guess::from_path(path).first_or_octet_stream()); + HttpResponse::Ok() + .content_type(mime.as_str()) + .body(content) + }) + } +} + +/// Middleware used to enforce logins unceremoniously. +pub struct RequireLogin; + +impl <S> Middleware<S> for RequireLogin { + fn start(&self, req: &HttpRequest<S>) -> actix_web::Result<Started> { + let logged_in = req.identity().is_some(); + let is_oidc_req = req.path().starts_with("/oidc"); + + if !is_oidc_req && !logged_in { + Ok(Started::Response( + HttpResponse::SeeOther() + .header("Location", "/oidc/login") + .finish() + )) + } else { + Ok(Started::Done) + } + } +} diff --git a/web/converse/src/main.rs b/web/converse/src/main.rs new file mode 100644 index 000000000000..6d6e9ac71020 --- /dev/null +++ b/web/converse/src/main.rs @@ -0,0 +1,224 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +extern crate askama; + +#[macro_use] +extern crate diesel; + +#[macro_use] +extern crate failure; + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate serde_derive; + +extern crate rouille; +extern crate actix; +extern crate actix_web; +extern crate chrono; +extern crate comrak; +extern crate crimp; +extern crate curl; +extern crate env_logger; +extern crate futures; +extern crate hyper; +extern crate md5; +extern crate mime_guess; +extern crate r2d2; +extern crate rand; +extern crate serde; +extern crate serde_json; +extern crate tokio; +extern crate tokio_timer; +extern crate url; +extern crate url_serde; + +/// Simple macro used to reduce boilerplate when defining actor +/// message types. +macro_rules! message { + ( $t:ty, $r:ty ) => { + impl Message for $t { + type Result = $r; + } + } +} + +pub mod db; +pub mod errors; +pub mod handlers; +pub mod models; +pub mod oidc; +pub mod render; +pub mod schema; + +use actix::prelude::*; +use actix_web::*; +use actix_web::http::Method; +use actix_web::middleware::Logger; +use actix_web::middleware::identity::{IdentityService, CookieIdentityPolicy}; +use crate::db::*; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool}; +use crate::handlers::*; +use crate::oidc::OidcExecutor; +use rand::{OsRng, Rng}; +use crate::render::Renderer; +use std::env; + +fn config(name: &str) -> String { + env::var(name).expect(&format!("{} must be set", name)) +} + +fn config_default(name: &str, default: &str) -> String { + env::var(name).unwrap_or(default.into()) +} + +fn start_db_executor() -> Addr<DbExecutor> { + info!("Initialising database connection pool ..."); + let db_url = config("DATABASE_URL"); + + let manager = ConnectionManager::<PgConnection>::new(db_url); + let pool = Pool::builder().build(manager).expect("Failed to initialise DB pool"); + + SyncArbiter::start(2, move || DbExecutor(pool.clone())) +} + +fn schedule_search_refresh(db: Addr<DbExecutor>) { + use tokio::prelude::*; + use tokio::timer::Interval; + use std::time::{Duration, Instant}; + use std::thread; + + let task = Interval::new(Instant::now(), Duration::from_secs(60)) + .from_err() + .for_each(move |_| db.send(db::RefreshSearchView).flatten()) + .map_err(|err| error!("Error while updating search view: {}", err)); + + thread::spawn(|| tokio::run(task)); +} + +fn start_oidc_executor(base_url: &str) -> Addr<OidcExecutor> { + info!("Initialising OIDC integration ..."); + let oidc_url = config("OIDC_DISCOVERY_URL"); + let oidc_config = oidc::load_oidc(&oidc_url) + .expect("Failed to retrieve OIDC discovery document"); + + let oidc = oidc::OidcExecutor { + oidc_config, + client_id: config("OIDC_CLIENT_ID"), + client_secret: config("OIDC_CLIENT_SECRET"), + redirect_uri: format!("{}/oidc/callback", base_url), + }; + + oidc.start() +} + +fn start_renderer() -> Addr<Renderer> { + let comrak = comrak::ComrakOptions{ + github_pre_lang: true, + ext_strikethrough: true, + ext_table: true, + ext_autolink: true, + ext_tasklist: true, + ext_footnotes: true, + ext_tagfilter: true, + ..Default::default() + }; + + Renderer{ comrak }.start() +} + +fn gen_session_key() -> [u8; 64] { + let mut key_bytes = [0; 64]; + let mut rng = OsRng::new() + .expect("Failed to retrieve RNG for key generation"); + rng.fill_bytes(&mut key_bytes); + + key_bytes +} + +fn start_http_server(base_url: String, + db_addr: Addr<DbExecutor>, + oidc_addr: Addr<OidcExecutor>, + renderer_addr: Addr<Renderer>) { + info!("Initialising HTTP server ..."); + let bind_host = config_default("CONVERSE_BIND_HOST", "127.0.0.1:4567"); + let key = gen_session_key(); + let require_login = config_default("REQUIRE_LOGIN", "true".into()) == "true"; + + server::new(move || { + let state = AppState { + db: db_addr.clone(), + oidc: oidc_addr.clone(), + renderer: renderer_addr.clone(), + }; + + let identity = IdentityService::new( + CookieIdentityPolicy::new(&key) + .name("converse_auth") + .path("/") + .secure(base_url.starts_with("https")) + ); + + let app = App::with_state(state) + .middleware(Logger::default()) + .middleware(identity) + .resource("/", |r| r.method(Method::GET).with(forum_index)) + .resource("/thread/new", |r| r.method(Method::GET).with(new_thread)) + .resource("/thread/submit", |r| r.method(Method::POST).with(submit_thread)) + .resource("/thread/reply", |r| r.method(Method::POST).with(reply_thread)) + .resource("/thread/{id}", |r| r.method(Method::GET).with(forum_thread)) + .resource("/post/{id}/edit", |r| r.method(Method::GET).with(edit_form)) + .resource("/post/edit", |r| r.method(Method::POST).with(edit_post)) + .resource("/search", |r| r.method(Method::GET).with(search_forum)) + .resource("/oidc/login", |r| r.method(Method::GET).with(login)) + .resource("/oidc/callback", |r| r.method(Method::POST).with(callback)) + .static_file("/static/highlight.css", include_bytes!("../static/highlight.css")) + .static_file("/static/highlight.js", include_bytes!("../static/highlight.js")) + .static_file("/static/styles.css", include_bytes!("../static/styles.css")); + + if require_login { + app.middleware(RequireLogin) + } else { + app + }}) + .bind(&bind_host).expect(&format!("Could not bind on '{}'", bind_host)) + .start(); +} + +fn main() { + env_logger::init(); + + info!("Welcome to Converse! Hold on tight while we're getting ready."); + let sys = actix::System::new("converse"); + + let base_url = config("BASE_URL"); + + let db_addr = start_db_executor(); + let oidc_addr = start_oidc_executor(&base_url); + let renderer_addr = start_renderer(); + + schedule_search_refresh(db_addr.clone()); + + start_http_server(base_url, db_addr, oidc_addr, renderer_addr); + + sys.run(); +} diff --git a/web/converse/src/models.rs b/web/converse/src/models.rs new file mode 100644 index 000000000000..da628f78b5bc --- /dev/null +++ b/web/converse/src/models.rs @@ -0,0 +1,127 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +use chrono::prelude::{DateTime, Utc}; +use crate::schema::{users, threads, posts, simple_posts}; +use diesel::sql_types::{Text, Integer}; + +/// Represents a single user in the Converse database. Converse does +/// not handle logins itself, but rather looks them up based on the +/// email address received from an OIDC provider. +#[derive(Identifiable, Queryable, Serialize)] +pub struct User { + pub id: i32, + pub name: String, + pub email: String, + pub admin: bool, +} + +#[derive(Identifiable, Queryable, Serialize, Associations)] +#[belongs_to(User)] +pub struct Thread { + pub id: i32, + pub title: String, + pub posted: DateTime<Utc>, + pub sticky: bool, + pub user_id: i32, + pub closed: bool, +} + +#[derive(Identifiable, Queryable, Serialize, Associations)] +#[belongs_to(Thread)] +#[belongs_to(User)] +pub struct Post { + pub id: i32, + pub thread_id: i32, + pub body: String, + pub posted: DateTime<Utc>, + pub user_id: i32, +} + +/// This struct is used as the query result type for the simplified +/// post view, which already joins user information in the database. +#[derive(Identifiable, Queryable, Serialize, Associations)] +#[belongs_to(Thread)] +pub struct SimplePost { + pub id: i32, + pub thread_id: i32, + pub body: String, + pub posted: DateTime<Utc>, + pub user_id: i32, + pub closed: bool, + pub author_name: String, + pub author_email: String, +} + +/// This struct is used as the query result type for the thread index +/// view, which lists the index of threads ordered by the last post in +/// each thread. +#[derive(Queryable, Serialize)] +pub struct ThreadIndex { + pub thread_id: i32, + pub title: String, + pub thread_author: String, + pub created: DateTime<Utc>, + pub sticky: bool, + pub closed: bool, + pub post_id: i32, + pub post_author: String, + pub posted: DateTime<Utc>, +} + +#[derive(Deserialize, Insertable)] +#[table_name="threads"] +pub struct NewThread { + pub title: String, + pub user_id: i32, +} + +#[derive(Deserialize, Insertable)] +#[table_name="users"] +pub struct NewUser { + pub email: String, + pub name: String, +} + +#[derive(Deserialize, Insertable)] +#[table_name="posts"] +pub struct NewPost { + pub thread_id: i32, + pub body: String, + pub user_id: i32, +} + +/// This struct models the response of a full-text search query. It +/// does not use a table/schema definition struct like the other +/// tables, as no table of this type actually exists. +#[derive(QueryableByName, Debug, Serialize)] +pub struct SearchResult { + #[sql_type = "Integer"] + pub post_id: i32, + #[sql_type = "Integer"] + pub thread_id: i32, + #[sql_type = "Text"] + pub author: String, + #[sql_type = "Text"] + pub title: String, + + /// Headline represents the result of Postgres' ts_headline() + /// function, which highlights search terms in the search results. + #[sql_type = "Text"] + pub headline: String, +} diff --git a/web/converse/src/oidc.rs b/web/converse/src/oidc.rs new file mode 100644 index 000000000000..9f566c04a71a --- /dev/null +++ b/web/converse/src/oidc.rs @@ -0,0 +1,159 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +//! This module provides authentication via OIDC compliant +//! authentication sources. +//! +//! Currently Converse only supports a single OIDC provider. Note that +//! this has so far only been tested with Office365. + +use actix::prelude::*; +use crate::errors::*; +use crimp::Request; +use url::Url; +use url_serde; +use curl::easy::Form; + +/// This structure represents the contents of an OIDC discovery +/// document. +#[derive(Deserialize, Debug, Clone)] +pub struct OidcConfig { + #[serde(with = "url_serde")] + authorization_endpoint: Url, + token_endpoint: String, + userinfo_endpoint: String, + + scopes_supported: Vec<String>, + issuer: String, +} + +#[derive(Clone, Debug)] +pub struct OidcExecutor { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub oidc_config: OidcConfig, +} + +/// This struct represents the form response returned by an OIDC +/// provider with the `code`. +#[derive(Debug, Deserialize)] +pub struct CodeResponse { + pub code: String, +} + +/// This struct represents the data extracted from the ID token and +/// stored in the user's session. +#[derive(Debug, Serialize, Deserialize)] +pub struct Author { + pub name: String, + pub email: String, +} + +impl Actor for OidcExecutor { + type Context = Context<Self>; +} + +/// Message used to request the login URL: +pub struct GetLoginUrl; // TODO: Add a nonce parameter stored in session. +message!(GetLoginUrl, String); + +impl Handler<GetLoginUrl> for OidcExecutor { + type Result = String; + + fn handle(&mut self, _: GetLoginUrl, _: &mut Self::Context) -> Self::Result { + let mut url: Url = self.oidc_config.authorization_endpoint.clone(); + { + let mut params = url.query_pairs_mut(); + params.append_pair("client_id", &self.client_id); + params.append_pair("response_type", "code"); + params.append_pair("scope", "openid"); + params.append_pair("redirect_uri", &self.redirect_uri); + params.append_pair("response_mode", "form_post"); + } + return url.into_string(); + } +} + +/// Message used to request the token from the returned code and +/// retrieve userinfo from the appropriate endpoint. +pub struct RetrieveToken(pub CodeResponse); +message!(RetrieveToken, Result<Author>); + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, +} + +// TODO: This is currently hardcoded to Office365 fields. +#[derive(Debug, Deserialize)] +struct Userinfo { + name: String, + unique_name: String, // email in office365 +} + +impl Handler<RetrieveToken> for OidcExecutor { + type Result = Result<Author>; + + fn handle(&mut self, msg: RetrieveToken, _: &mut Self::Context) -> Self::Result { + debug!("Received OAuth2 code, requesting access_token"); + + let mut form = Form::new(); + form.part("client_id").contents(&self.client_id.as_bytes()) + .add().expect("critical error: invalid form data"); + + form.part("client_secret").contents(&self.client_secret.as_bytes()) + .add().expect("critical error: invalid form data"); + + form.part("grant_type").contents("authorization_code".as_bytes()) + .add().expect("critical error: invalid form data"); + + form.part("code").contents(&msg.0.code.as_bytes()) + .add().expect("critical error: invalid form data"); + + form.part("redirect_uri").contents(&self.redirect_uri.as_bytes()) + .add().expect("critical error: invalid form data"); + + let response = Request::post(&self.oidc_config.token_endpoint) + .user_agent(concat!("converse-", env!("CARGO_PKG_VERSION")))? + .form(form) + .send()?; + + debug!("Received token response: {:?}", response); + let token: TokenResponse = response.as_json()?.body; + + let bearer = format!("Bearer {}", token.access_token); + let user: Userinfo = Request::get(&self.oidc_config.userinfo_endpoint) + .user_agent(concat!("converse-", env!("CARGO_PKG_VERSION")))? + .header("Authorization", &bearer)? + .send()? + .as_json()?.body; + + Ok(Author { + name: user.name, + email: user.unique_name, + }) + } +} + +/// Convenience function to attempt loading an OIDC discovery document +/// from a specified URL: +pub fn load_oidc(url: &str) -> Result<OidcConfig> { + let config: OidcConfig = Request::get(url).send()?.as_json()?.body; + Ok(config) +} diff --git a/web/converse/src/render.rs b/web/converse/src/render.rs new file mode 100644 index 000000000000..749e77ef50eb --- /dev/null +++ b/web/converse/src/render.rs @@ -0,0 +1,240 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +//! This module defines a rendering actor used for processing Converse +//! data into whatever format is needed by the templates and rendering +//! them. + +use actix::prelude::*; +use askama::Template; +use crate::errors::*; +use std::fmt; +use md5; +use crate::models::*; +use chrono::prelude::{DateTime, Utc}; +use comrak::{ComrakOptions, markdown_to_html}; + +pub struct Renderer { + pub comrak: ComrakOptions, +} + +impl Actor for Renderer { + type Context = actix::Context<Self>; +} + +/// Represents a data formatted for human consumption +#[derive(Debug)] +struct FormattedDate(DateTime<Utc>); + +impl fmt::Display for FormattedDate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0.format("%a %d %B %Y, %R")) + } +} + +#[derive(Debug)] +struct IndexThread { + id: i32, + title: String, + sticky: bool, + closed: bool, + posted: FormattedDate, + author_name: String, + post_author: String, +} + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexPageTemplate { + threads: Vec<IndexThread>, +} + +// "Renderable" structures with data transformations applied. +#[derive(Debug)] +struct RenderablePost { + id: i32, + body: String, + posted: FormattedDate, + author_name: String, + author_gravatar: String, + editable: bool, +} + +/// This structure represents the transformed thread data with +/// Markdown rendering and other changes applied. +#[derive(Template)] +#[template(path = "thread.html")] +struct RenderableThreadPage { + id: i32, + title: String, + closed: bool, + posts: Vec<RenderablePost>, +} + +/// Helper function for computing Gravatar links. +fn md5_hex(input: &[u8]) -> String { + format!("{:x}", md5::compute(input)) +} + +/// The different types of editing modes supported by the editing +/// template: +#[derive(Debug, PartialEq)] +pub enum EditingMode { + NewThread, + PostReply, + EditPost, +} + +impl Default for EditingMode { + fn default() -> EditingMode { EditingMode::NewThread } +} + +/// This is the template used for rendering the new thread, edit post +/// and reply to thread forms. +#[derive(Template, Default)] +#[template(path = "post.html")] +pub struct FormTemplate { + /// Which editing mode is to be used by the template? + pub mode: EditingMode, + + /// Potential alerts to display to the user (e.g. input validation + /// results) + pub alerts: Vec<&'static str>, + + /// Either the title to be used in the subject field or the title + /// of the thread the user is responding to. + pub title: Option<String>, + + /// Body of the post being edited, if present. + pub post: Option<String>, + + /// ID of the thread being replied to or the post being edited. + pub id: Option<i32>, +} + +/// Message used to render new thread page. +/// +/// It can optionally contain a vector of warnings to display to the +/// user in alert boxes, such as input validation errors. +#[derive(Default)] +pub struct NewThreadPage { + pub alerts: Vec<&'static str>, + pub title: Option<String>, + pub post: Option<String>, +} +message!(NewThreadPage, Result<String>); + +impl Handler<NewThreadPage> for Renderer { + type Result = Result<String>; + + fn handle(&mut self, msg: NewThreadPage, _: &mut Self::Context) -> Self::Result { + let ctx = FormTemplate { + alerts: msg.alerts, + title: msg.title, + post: msg.post, + ..Default::default() + }; + ctx.render().map_err(|e| e.into()) + } +} + +/// Message used to render post editing page. +pub struct EditPostPage { + pub id: i32, + pub post: String, +} +message!(EditPostPage, Result<String>); + +impl Handler<EditPostPage> for Renderer { + type Result = Result<String>; + + fn handle(&mut self, msg: EditPostPage, _: &mut Self::Context) -> Self::Result { + let ctx = FormTemplate { + mode: EditingMode::EditPost, + id: Some(msg.id), + post: Some(msg.post), + ..Default::default() + }; + + ctx.render().map_err(|e| e.into()) + } +} + +/// Message used to render search results +#[derive(Template)] +#[template(path = "search.html")] +pub struct SearchResultPage { + pub query: String, + pub results: Vec<SearchResult>, +} +message!(SearchResultPage, Result<String>); + +impl Handler<SearchResultPage> for Renderer { + type Result = Result<String>; + + fn handle(&mut self, msg: SearchResultPage, _: &mut Self::Context) -> Self::Result { + msg.render().map_err(|e| e.into()) + } +} + +// TODO: actor-free implementation below + +/// Render the index page for the given thread list. +pub fn index_page(threads: Vec<ThreadIndex>) -> Result<String> { + let threads: Vec<IndexThread> = threads + .into_iter() + .map(|thread| IndexThread { + id: thread.thread_id, + title: thread.title, // escape_html(&thread.title), + sticky: thread.sticky, + closed: thread.closed, + posted: FormattedDate(thread.posted), + author_name: thread.thread_author, + post_author: thread.post_author, + }) + .collect(); + + let tpl = IndexPageTemplate { threads }; + tpl.render().map_err(|e| e.into()) +} + +// Render the page of a given thread. +pub fn thread_page(user: i32, thread: Thread, posts: Vec<SimplePost>) -> Result<String> { + let posts = posts.into_iter().map(|post| { + let editable = user != 1 && post.user_id == user; + + let comrak = ComrakOptions::default(); // TODO(tazjin): cheddar + RenderablePost { + id: post.id, + body: markdown_to_html(&post.body, &comrak), + posted: FormattedDate(post.posted), + author_name: post.author_name.clone(), + author_gravatar: md5_hex(post.author_email.as_bytes()), + editable, + } + }).collect(); + + let renderable = RenderableThreadPage { + posts, + closed: thread.closed, + id: thread.id, + title: thread.title, + }; + + Ok(renderable.render()?) +} diff --git a/web/converse/src/schema.rs b/web/converse/src/schema.rs new file mode 100644 index 000000000000..7de6d13668c2 --- /dev/null +++ b/web/converse/src/schema.rs @@ -0,0 +1,88 @@ +// Copyright (C) 2018-2021 Vincent Ambo <tazjin@tvl.su> +// +// This file is part of Converse. +// +// This program is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see +// <https://www.gnu.org/licenses/>. + +table! { + posts (id) { + id -> Int4, + thread_id -> Int4, + body -> Text, + posted -> Timestamptz, + user_id -> Int4, + } +} + +table! { + threads (id) { + id -> Int4, + title -> Varchar, + posted -> Timestamptz, + sticky -> Bool, + user_id -> Int4, + closed -> Bool, + } +} + +table! { + users (id) { + id -> Int4, + email -> Varchar, + name -> Varchar, + admin -> Bool, + } +} + +// Note: Manually inserted as print-schema does not add views. +table! { + simple_posts (id) { + id -> Int4, + thread_id -> Int4, + body -> Text, + posted -> Timestamptz, + user_id -> Int4, + closed -> Bool, + author_name -> Text, + author_email -> Text, + } +} + +// Note: Manually inserted as print-schema does not add views. +table! { + thread_index (thread_id) { + thread_id -> Int4, + title -> Text, + thread_author -> Text, + created -> Timestamptz, + sticky -> Bool, + closed -> Bool, + post_id -> Int4, + post_author -> Text, + posted -> Timestamptz, + } +} + +joinable!(posts -> threads (thread_id)); +joinable!(posts -> users (user_id)); +joinable!(threads -> users (user_id)); +joinable!(simple_posts -> threads (thread_id)); + +allow_tables_to_appear_in_same_query!( + posts, + threads, + users, + simple_posts, +); |