feat(web/converse): Import repository
Imports the converse forum software I wrote a few years ago. I want to clean this up a bit and try using Hotwire with it. Note: The original repository was AGPL-3.0 licensed. I'm the copyright holder and have relicensed it to GPL-3.0 in the commit that is being merged. Imported from: https://github.com/tazjin/converse git-subtree-dir: web/converse git-subtree-mainline:386afdc794git-subtree-split:09168021e7Change-Id: Ia8b587db5174ef5b3c52910d3d027199150c58e0
This commit is contained in:
commit
8142149e28
54 changed files with 11309 additions and 0 deletions
282
web/converse/src/db.rs
Normal file
282
web/converse/src/db.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// 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 connection actor.
|
||||
|
||||
use actix::prelude::*;
|
||||
use diesel::{self, sql_query};
|
||||
use diesel::sql_types::Text;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{Pool, ConnectionManager};
|
||||
use models::*;
|
||||
use errors::{ConverseError, Result};
|
||||
|
||||
/// The DB actor itself. Several of these will be run in parallel by
|
||||
/// `SyncArbiter`.
|
||||
pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>);
|
||||
|
||||
impl Actor for DbExecutor {
|
||||
type Context = SyncContext<Self>;
|
||||
}
|
||||
|
||||
/// Message used to request a list of threads.
|
||||
/// TODO: This should support page numbers.
|
||||
pub struct ListThreads;
|
||||
message!(ListThreads, Result<Vec<ThreadIndex>>);
|
||||
|
||||
impl Handler<ListThreads> for DbExecutor {
|
||||
type Result = <ListThreads as Message>::Result;
|
||||
|
||||
fn handle(&mut self, _: ListThreads, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::thread_index::dsl::*;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
let results = thread_index
|
||||
.load::<ThreadIndex>(&conn)?;
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
msg: LookupOrCreateUser,
|
||||
_: &mut Self::Context) -> Self::Result {
|
||||
use schema::users;
|
||||
use schema::users::dsl::*;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let opt_user = users
|
||||
.filter(email.eq(&msg.email))
|
||||
.first(&conn).optional()?;
|
||||
|
||||
if let Some(user) = opt_user {
|
||||
Ok(user)
|
||||
} else {
|
||||
let new_user = NewUser {
|
||||
email: msg.email,
|
||||
name: msg.name,
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, msg: GetThread, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::threads::dsl::*;
|
||||
use schema::simple_posts::dsl::id;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
let thread_result: Thread = threads
|
||||
.find(msg.0).first(&conn)?;
|
||||
|
||||
let post_list = SimplePost::belonging_to(&thread_result)
|
||||
.order_by(id.asc())
|
||||
.load::<SimplePost>(&conn)?;
|
||||
|
||||
Ok((thread_result, post_list))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, msg: GetPost, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::simple_posts::dsl::*;
|
||||
let conn = self.0.get()?;
|
||||
Ok(simple_posts.find(msg.id).first(&conn)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, msg: UpdatePost, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::posts::dsl::*;
|
||||
let conn = self.0.get()?;
|
||||
let updated = diesel::update(posts.find(msg.post_id))
|
||||
.set(body.eq(msg.post))
|
||||
.get_result(&conn)?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, msg: CreateThread, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::threads;
|
||||
use 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(&msg.new_thread)
|
||||
.get_result(&conn)?;
|
||||
|
||||
// ... then create the first post in the thread.
|
||||
let new_post = NewPost {
|
||||
thread_id: thread.id,
|
||||
body: msg.post,
|
||||
user_id: msg.new_thread.user_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(posts::table)
|
||||
.values(&new_post)
|
||||
.execute(&conn)?;
|
||||
|
||||
Ok(thread)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, msg: CreatePost, _: &mut Self::Context) -> Self::Result {
|
||||
use schema::posts;
|
||||
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let closed: bool = {
|
||||
use schema::threads::dsl::*;
|
||||
threads.select(closed)
|
||||
.find(msg.0.thread_id)
|
||||
.first(&conn)?
|
||||
};
|
||||
|
||||
if closed {
|
||||
return Err(ConverseError::ThreadClosed {
|
||||
id: msg.0.thread_id
|
||||
})
|
||||
}
|
||||
|
||||
Ok(diesel::insert_into(posts::table)
|
||||
.values(&msg.0)
|
||||
.get_result(&conn)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to search for posts
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchPosts { pub query: String }
|
||||
message!(SearchPosts, Result<Vec<SearchResult>>);
|
||||
|
||||
/// 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
|
||||
"#;
|
||||
|
||||
impl Handler<SearchPosts> for DbExecutor {
|
||||
type Result = <SearchPosts as Message>::Result;
|
||||
|
||||
fn handle(&mut self, msg: SearchPosts, _: &mut Self::Context) -> Self::Result {
|
||||
let conn = self.0.get()?;
|
||||
|
||||
let search_results = sql_query(SEARCH_QUERY)
|
||||
.bind::<Text, _>(msg.query)
|
||||
.get_results::<SearchResult>(&conn)?;
|
||||
|
||||
Ok(search_results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message that triggers a refresh of the view used for full-text
|
||||
/// searching.
|
||||
pub struct RefreshSearchView;
|
||||
message!(RefreshSearchView, Result<()>);
|
||||
|
||||
const REFRESH_QUERY: &'static str = "REFRESH MATERIALIZED VIEW search_index";
|
||||
|
||||
impl Handler<RefreshSearchView> for DbExecutor {
|
||||
type Result = Result<()>;
|
||||
|
||||
fn handle(&mut self, _: RefreshSearchView, _: &mut Self::Context) -> Self::Result {
|
||||
let conn = self.0.get()?;
|
||||
debug!("Refreshing search_index view in DB");
|
||||
sql_query(REFRESH_QUERY).execute(&conn)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
133
web/converse/src/errors.rs
Normal file
133
web/converse/src/errors.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// 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 reqwest;
|
||||
use tokio_timer;
|
||||
|
||||
pub type Result<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 },
|
||||
|
||||
// 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<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<reqwest::Error> for ConverseError {
|
||||
fn from(error: reqwest::Error) -> ConverseError {
|
||||
ConverseError::InternalError {
|
||||
reason: format!("Failed to make 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
345
web/converse/src/handlers.rs
Normal file
345
web/converse/src/handlers.rs
Normal file
|
|
@ -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 db::*;
|
||||
use errors::ConverseError;
|
||||
use futures::Future;
|
||||
use mime_guess::guess_mime_type;
|
||||
use models::*;
|
||||
use oidc::*;
|
||||
use render::*;
|
||||
|
||||
type ConverseResponse = Box<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<Syn, DbExecutor>,
|
||||
|
||||
/// Address of the OIDC actor
|
||||
pub oidc: Addr<Syn, OidcExecutor>,
|
||||
|
||||
/// Address of the rendering actor
|
||||
pub renderer: Addr<Syn, Renderer>,
|
||||
}
|
||||
|
||||
pub fn forum_index(state: State<AppState>) -> ConverseResponse {
|
||||
state.db.send(ListThreads)
|
||||
.flatten()
|
||||
.and_then(move |res| state.renderer.send(IndexPage {
|
||||
threads: res
|
||||
}).from_err())
|
||||
.flatten()
|
||||
.map(|res| HttpResponse::Ok().content_type(HTML).body(res))
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// This handler retrieves and displays a single forum thread.
|
||||
pub fn forum_thread(state: State<AppState>,
|
||||
req: HttpRequest<AppState>,
|
||||
thread_id: Path<i32>) -> ConverseResponse {
|
||||
let id = thread_id.into_inner();
|
||||
let user = get_user_id(&req);
|
||||
|
||||
state.db.send(GetThread(id))
|
||||
.flatten()
|
||||
.and_then(move |res| state.renderer.send(ThreadPage {
|
||||
current_user: user,
|
||||
thread: res.0,
|
||||
posts: res.1,
|
||||
}).from_err())
|
||||
.flatten()
|
||||
.map(|res| HttpResponse::Ok().content_type(HTML).body(res))
|
||||
.responder()
|
||||
}
|
||||
|
||||
/// 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: State<AppState>,
|
||||
input: Form<NewThreadForm>,
|
||||
req: 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>,
|
||||
mut 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!("{}", guess_mime_type(path));
|
||||
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: &mut 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
web/converse/src/main.rs
Normal file
223
web/converse/src/main.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// 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/>.
|
||||
|
||||
#[macro_use]
|
||||
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 actix;
|
||||
extern crate actix_web;
|
||||
extern crate chrono;
|
||||
extern crate comrak;
|
||||
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 reqwest;
|
||||
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 db::*;
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use handlers::*;
|
||||
use oidc::OidcExecutor;
|
||||
use rand::{OsRng, Rng};
|
||||
use 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<Syn, 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<Syn, 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<Syn, 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<Syn, 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<Syn, DbExecutor>,
|
||||
oidc_addr: Addr<Syn, OidcExecutor>,
|
||||
renderer_addr: Addr<Syn, 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).with3(submit_thread))
|
||||
.resource("/thread/reply", |r| r.method(Method::POST).with3(reply_thread))
|
||||
.resource("/thread/{id}", |r| r.method(Method::GET).with3(forum_thread))
|
||||
.resource("/post/{id}/edit", |r| r.method(Method::GET).with3(edit_form))
|
||||
.resource("/post/edit", |r| r.method(Method::POST).with3(edit_post))
|
||||
.resource("/search", |r| r.method(Method::GET).with2(search_forum))
|
||||
.resource("/oidc/login", |r| r.method(Method::GET).with(login))
|
||||
.resource("/oidc/callback", |r| r.method(Method::POST).with3(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();
|
||||
}
|
||||
127
web/converse/src/models.rs
Normal file
127
web/converse/src/models.rs
Normal file
|
|
@ -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 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,
|
||||
}
|
||||
149
web/converse/src/oidc.rs
Normal file
149
web/converse/src/oidc.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// 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 reqwest;
|
||||
use url::Url;
|
||||
use url_serde;
|
||||
use errors::*;
|
||||
use reqwest::header::Authorization;
|
||||
use hyper::header::Bearer;
|
||||
|
||||
/// 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 client = reqwest::Client::new();
|
||||
let params: [(&str, &str); 5] = [
|
||||
("client_id", &self.client_id),
|
||||
("client_secret", &self.client_secret),
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &msg.0.code),
|
||||
("redirect_uri", &self.redirect_uri),
|
||||
];
|
||||
|
||||
let mut response = client.post(&self.oidc_config.token_endpoint)
|
||||
.form(¶ms)
|
||||
.send()?;
|
||||
|
||||
debug!("Received token response: {:?}", response);
|
||||
let token: TokenResponse = response.json()?;
|
||||
|
||||
let user: Userinfo = client.get(&self.oidc_config.userinfo_endpoint)
|
||||
.header(Authorization(Bearer { token: token.access_token }))
|
||||
.send()?
|
||||
.json()?;
|
||||
|
||||
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 = reqwest::get(url)?.json()?;
|
||||
Ok(config)
|
||||
}
|
||||
265
web/converse/src/render.rs
Normal file
265
web/converse/src/render.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// 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 errors::*;
|
||||
use std::fmt;
|
||||
use md5;
|
||||
use 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"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to render the index page.
|
||||
pub struct IndexPage {
|
||||
pub threads: Vec<ThreadIndex>,
|
||||
}
|
||||
message!(IndexPage, Result<String>);
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
impl Handler<IndexPage> for Renderer {
|
||||
type Result = Result<String>;
|
||||
|
||||
fn handle(&mut self, msg: IndexPage, _: &mut Self::Context) -> Self::Result {
|
||||
let threads: Vec<IndexThread> = msg.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())
|
||||
}
|
||||
}
|
||||
|
||||
/// Message used to render a thread.
|
||||
pub struct ThreadPage {
|
||||
pub current_user: i32,
|
||||
pub thread: Thread,
|
||||
pub posts: Vec<SimplePost>,
|
||||
}
|
||||
message!(ThreadPage, Result<String>);
|
||||
|
||||
// "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))
|
||||
}
|
||||
|
||||
fn prepare_thread(comrak: &ComrakOptions, page: ThreadPage) -> RenderableThreadPage {
|
||||
let user = page.current_user;
|
||||
|
||||
let posts = page.posts.into_iter().map(|post| {
|
||||
let editable = user != 1 && post.user_id == user;
|
||||
|
||||
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();
|
||||
|
||||
RenderableThreadPage {
|
||||
posts,
|
||||
closed: page.thread.closed,
|
||||
id: page.thread.id,
|
||||
title: page.thread.title,
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ThreadPage> for Renderer {
|
||||
type Result = Result<String>;
|
||||
|
||||
fn handle(&mut self, msg: ThreadPage, _: &mut Self::Context) -> Self::Result {
|
||||
let renderable = prepare_thread(&self.comrak, msg);
|
||||
renderable.render().map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
88
web/converse/src/schema.rs
Normal file
88
web/converse/src/schema.rs
Normal file
|
|
@ -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,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue