about summary refs log tree commit diff
path: root/web/converse/src
diff options
context:
space:
mode:
authorVincent Ambo <mail@tazj.in>2021-04-05T20·09+0200
committertazjin <mail@tazj.in>2021-04-20T10·44+0000
commit929b38e8ae4d61340cbd70352663af8f6f2418cf (patch)
treeb2562c19bf3727bab14e11dabdffb3c8bd3569d0 /web/converse/src
parent54f59a5cc5835932c62c0f2d58712e30c248da4d (diff)
refactor(web/converse): Refactor first handlers to rouille r/2530
This commit starts the refactoring process towards dropping actix (and
tokio, ...). It builds, but at this commit, Converse does *not* work.

I decided to commit to avoid more ridiculous diffs.

Included changes:

* Added dependency on rouille.

* Refactored DbExecutor (formerly actix actor) to simply be a type
  with a few methods. Most actor messages still exist as they are
  being referred to by handlers.

* Started refactoring two of the handlers (and their related renderer
  functions) into Rouille's call scheme.

Important note: Rouille does not have safe session management out of
the box, and it will need to be implemented as this progresses.

Change-Id: I3e3f203e0705e561e1a3392e8f75dbe273d5fa81
Reviewed-on: https://cl.tvl.fyi/c/depot/+/2861
Tested-by: BuildkiteCI
Reviewed-by: tazjin <mail@tazj.in>
Diffstat (limited to 'web/converse/src')
-rw-r--r--web/converse/src/db.rs303
-rw-r--r--web/converse/src/errors.rs1
-rw-r--r--web/converse/src/handlers.rs53
-rw-r--r--web/converse/src/main.rs1
-rw-r--r--web/converse/src/render.rs119
5 files changed, 247 insertions, 230 deletions
diff --git a/web/converse/src/db.rs b/web/converse/src/db.rs
index 1d61595898fa..ae186bdf4e4d 100644
--- a/web/converse/src/db.rs
+++ b/web/converse/src/db.rs
@@ -16,7 +16,8 @@
 // along with this program. If not, see
 // <https://www.gnu.org/licenses/>.
 
-//! This module implements the database connection actor.
+//! This module implements the database executor, which holds the
+//! database connection and performs queries on it.
 
 use actix::prelude::*;
 use diesel::{self, sql_query};
@@ -26,23 +27,32 @@ use diesel::r2d2::{Pool, ConnectionManager};
 use crate::models::*;
 use crate::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>;
-}
+/// 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
+"#;
 
-/// Message used to request a list of threads.
-/// TODO: This should support page numbers.
-pub struct ListThreads;
-message!(ListThreads, Result<Vec<ThreadIndex>>);
+const REFRESH_QUERY: &'static str = "REFRESH MATERIALIZED VIEW search_index";
 
-impl Handler<ListThreads> for DbExecutor {
-    type Result = <ListThreads as Message>::Result;
+pub struct DbExecutor(pub Pool<ConnectionManager<PgConnection>>);
 
-    fn handle(&mut self, _: ListThreads, _: &mut Self::Context) -> Self::Result {
+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()?;
@@ -50,38 +60,25 @@ impl Handler<ListThreads> for DbExecutor {
             .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 {
+    /// 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(&msg.email))
+            .filter(email.eq(email))
             .first(&conn).optional()?;
 
         if let Some(user) = opt_user {
             Ok(user)
         } else {
             let new_user = NewUser {
-                email: msg.email,
-                name: msg.name,
+                email: user_email.to_string(),
+                name: user_name.to_string(),
             };
 
             let user: User = diesel::insert_into(users::table)
@@ -93,23 +90,15 @@ impl Handler<LookupOrCreateUser> for DbExecutor {
             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 {
+    /// Fetch a specific thread and return it with its posts.
+    pub fn get_thread(&self, thread_id: i32) -> Result<(Thread, Vec<SimplePost>)> {
         use crate::schema::threads::dsl::*;
         use crate::schema::simple_posts::dsl::id;
 
         let conn = self.0.get()?;
         let thread_result: Thread = threads
-            .find(msg.0).first(&conn)?;
+            .find(thread_id).first(&conn)?;
 
         let post_list = SimplePost::belonging_to(&thread_result)
             .order_by(id.asc())
@@ -117,59 +106,28 @@ impl Handler<GetThread> for DbExecutor {
 
         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 {
+    /// 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(msg.id).first(&conn)?)
+        Ok(simple_posts.find(post_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 {
+    /// 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(msg.post_id))
-            .set(body.eq(msg.post))
+        let updated = diesel::update(posts.find(post_id))
+            .set(body.eq(post_text))
             .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 crate::schema::threads;
+    /// Create a new thread.
+    pub fn create_thread(&self, new_thread: NewThread, post_text: String) -> Result<Thread> {
+                use crate::schema::threads;
         use crate::schema::posts;
 
         let conn = self.0.get()?;
@@ -177,14 +135,14 @@ impl Handler<CreateThread> for DbExecutor {
         conn.transaction::<Thread, ConverseError, _>(|| {
             // First insert the thread structure itself
             let thread: Thread = diesel::insert_into(threads::table)
-                .values(&msg.new_thread)
+                .values(&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,
+                body: post_text,
+                user_id: new_thread.user_id,
             };
 
             diesel::insert_into(posts::table)
@@ -194,16 +152,9 @@ impl Handler<CreateThread> for DbExecutor {
             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 {
+    /// Create a new post.
+    pub fn create_post(&self, new_post: NewPost) -> Result<Post> {
         use crate::schema::posts;
 
         let conn = self.0.get()?;
@@ -211,20 +162,136 @@ impl Handler<CreatePost> for DbExecutor {
         let closed: bool = {
             use crate::schema::threads::dsl::*;
             threads.select(closed)
-                .find(msg.0.thread_id)
+                .find(new_post.thread_id)
                 .first(&conn)?
         };
 
         if closed {
             return Err(ConverseError::ThreadClosed {
-                id: msg.0.thread_id
+                id: new_post.thread_id
             })
         }
 
         Ok(diesel::insert_into(posts::table)
-           .values(&msg.0)
+           .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
@@ -232,34 +299,11 @@ impl Handler<CreatePost> for DbExecutor {
 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)
+    fn handle(&mut self, _: SearchPosts, _: &mut Self::Context) -> Self::Result {
+        unimplemented!()
     }
 }
 
@@ -268,15 +312,10 @@ impl Handler<SearchPosts> for DbExecutor {
 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(())
+        unimplemented!()
     }
 }
diff --git a/web/converse/src/errors.rs b/web/converse/src/errors.rs
index b079f41c4fff..32507c51b0c2 100644
--- a/web/converse/src/errors.rs
+++ b/web/converse/src/errors.rs
@@ -34,6 +34,7 @@ use r2d2;
 use tokio_timer;
 
 pub type Result<T> = result::Result<T, ConverseError>;
+pub type ConverseResult<T> = result::Result<T, ConverseError>;
 
 #[derive(Debug, Fail)]
 pub enum ConverseError {
diff --git a/web/converse/src/handlers.rs b/web/converse/src/handlers.rs
index c4448c748365..0759cec5c146 100644
--- a/web/converse/src/handlers.rs
+++ b/web/converse/src/handlers.rs
@@ -30,12 +30,14 @@ use actix_web::middleware::identity::RequestIdentity;
 use actix_web::middleware::{Started, Middleware};
 use actix_web;
 use crate::db::*;
-use crate::errors::ConverseError;
+use crate::errors::{ConverseResult, ConverseError};
 use futures::Future;
 use crate::models::*;
 use crate::oidc::*;
 use crate::render::*;
 
+use rouille::{Request, Response};
+
 type ConverseResponse = Box<dyn Future<Item=HttpResponse, Error=ConverseError>>;
 
 const HTML: &'static str = "text/html";
@@ -54,15 +56,14 @@ pub struct AppState {
     pub renderer: Addr<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()
+/// 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
@@ -78,23 +79,23 @@ pub fn get_user_id(req: &HttpRequest<AppState>) -> i32 {
     }
 }
 
-/// 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);
+pub fn get_user_id_rouille(_req: &Request) -> i32 {
+    // TODO(tazjin): Implement session support in rouille somehow.
+    ANONYMOUS
+}
 
-    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()
+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.
diff --git a/web/converse/src/main.rs b/web/converse/src/main.rs
index d17626c17f07..6d6e9ac71020 100644
--- a/web/converse/src/main.rs
+++ b/web/converse/src/main.rs
@@ -30,6 +30,7 @@ extern crate log;
 #[macro_use]
 extern crate serde_derive;
 
+extern crate rouille;
 extern crate actix;
 extern crate actix_web;
 extern crate chrono;
diff --git a/web/converse/src/render.rs b/web/converse/src/render.rs
index cfc08377a6fc..749e77ef50eb 100644
--- a/web/converse/src/render.rs
+++ b/web/converse/src/render.rs
@@ -47,12 +47,6 @@ impl fmt::Display for FormattedDate {
     }
 }
 
-/// Message used to render the index page.
-pub struct IndexPage {
-    pub threads: Vec<ThreadIndex>,
-}
-message!(IndexPage, Result<String>);
-
 #[derive(Debug)]
 struct IndexThread {
     id: i32,
@@ -70,39 +64,6 @@ 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 {
@@ -130,39 +91,6 @@ 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)]
@@ -263,3 +191,50 @@ impl Handler<SearchResultPage> for Renderer {
         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()?)
+}