about summary refs log tree commit diff
path: root/web/converse/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/converse/src')
-rw-r--r--web/converse/src/db.rs317
-rw-r--r--web/converse/src/errors.rs139
-rw-r--r--web/converse/src/handlers.rs391
-rw-r--r--web/converse/src/main.rs239
-rw-r--r--web/converse/src/models.rs127
-rw-r--r--web/converse/src/oidc.rs170
-rw-r--r--web/converse/src/render.rs245
-rw-r--r--web/converse/src/schema.rs83
8 files changed, 1711 insertions, 0 deletions
diff --git a/web/converse/src/db.rs b/web/converse/src/db.rs
new file mode 100644
index 0000000000..a0d8915504
--- /dev/null
+++ b/web/converse/src/db.rs
@@ -0,0 +1,317 @@
+// 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 crate::errors::{ConverseError, Result};
+use crate::models::*;
+use actix::prelude::*;
+use diesel::prelude::*;
+use diesel::r2d2::{ConnectionManager, Pool};
+use diesel::sql_types::Text;
+use diesel::{self, sql_query};
+
+/// 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::simple_posts::dsl::id;
+        use crate::schema::threads::dsl::*;
+
+        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::{posts, threads};
+
+        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 0000000000..a4bd69023b
--- /dev/null
+++ b/web/converse/src/errors.rs
@@ -0,0 +1,139 @@
+// 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 actix_web::http::StatusCode;
+use actix_web::{HttpResponse, ResponseError};
+use std::result;
+
+// Modules with foreign errors:
+use {actix, actix_web, askama, diesel, r2d2, 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 0000000000..49f9dcf974
--- /dev/null
+++ b/web/converse/src/handlers.rs
@@ -0,0 +1,391 @@
+// 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 crate::db::*;
+use crate::errors::{ConverseError, ConverseResult};
+use crate::models::*;
+use crate::oidc::*;
+use crate::render::*;
+use actix::prelude::*;
+use actix_web;
+use actix_web::http::Method;
+use actix_web::middleware::identity::RequestIdentity;
+use actix_web::middleware::{Middleware, Started};
+use actix_web::*;
+use futures::Future;
+
+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 0000000000..78d0241600
--- /dev/null
+++ b/web/converse/src/main.rs
@@ -0,0 +1,239 @@
+// 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 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 rouille;
+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 crate::db::*;
+use crate::handlers::*;
+use crate::oidc::OidcExecutor;
+use crate::render::Renderer;
+use actix::prelude::*;
+use actix_web::http::Method;
+use actix_web::middleware::identity::{CookieIdentityPolicy, IdentityService};
+use actix_web::middleware::Logger;
+use actix_web::*;
+use diesel::pg::PgConnection;
+use diesel::r2d2::{ConnectionManager, Pool};
+use rand::{OsRng, Rng};
+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 std::thread;
+    use std::time::{Duration, Instant};
+    use tokio::prelude::*;
+    use tokio::timer::Interval;
+
+    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 0000000000..63b15fbed0
--- /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 crate::schema::{posts, simple_posts, threads, users};
+use chrono::prelude::{DateTime, Utc};
+use diesel::sql_types::{Integer, Text};
+
+/// 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 0000000000..75e3eabc88
--- /dev/null
+++ b/web/converse/src/oidc.rs
@@ -0,0 +1,170 @@
+// 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 crate::errors::*;
+use actix::prelude::*;
+use crimp::Request;
+use curl::easy::Form;
+use url::Url;
+use url_serde;
+
+/// 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 0000000000..d06af12bd9
--- /dev/null
+++ b/web/converse/src/render.rs
@@ -0,0 +1,245 @@
+// 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 crate::errors::*;
+use crate::models::*;
+use actix::prelude::*;
+use askama::Template;
+use chrono::prelude::{DateTime, Utc};
+use comrak::{markdown_to_html, ComrakOptions};
+use md5;
+use std::fmt;
+
+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 0000000000..520af43422
--- /dev/null
+++ b/web/converse/src/schema.rs
@@ -0,0 +1,83 @@
+// 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,);