Comment on page

Brutalist Twitter

See, Elon? Twitter isn't so hard.
Hey there, how's it going? Good? Probably not, given you had enough interest to start reading an article called "Brutalist Twitter." That's fine though, you might also be wondering "What is this all about?" Well, if you'd stop asking questions I'd tell you;
Brutalist Twitter is about providing the base functionality of Twitter, without all the crap that makes Twitter bad. No, we're not banning parody accounts or adding a subscription model -- we're just cutting out the extra javascript, tracking, css, ads, pretty much everything I can think of so that I can make this as quickly and effectively as possible.
I'll layout how I built it, so hopefully you can build your own, and you can see that it's not so hard. Maybe you'll learn something along the way.
Without further ado, let's make some twitter.

The Setup

We'll be using shuttle.rs as our hosting provider for this, and thruster as our web framework. That makes it easy for us to set up a new project using:
mkdir brutalist-twitter
cargo shuttle init --thruster
We're going to need a ton of dependencies too, so just add these to Cargo.toml now.
argon2 = "0.4.1" # For password hashing
askama = "0.11.1" # For templating
chrono = "0.4.22" # For time stuff
form_urlencoded = "1.1.0" # For decoding x-www-form-encoded
log = "0.4.17" # For teh logz
serde = { version = "1.0", features = ["derive"] } # For serializing/deserializing
serde_urlencoded = "0.7.1" # For url encoding
sha2 = "0.10.6" # Because sometimes you gotta hash things
shuttle-aws-rds = { version = "0.7.2", features = ["postgres"] } # For storing data
shuttle-service = { version = "0.7.2", features = ["web-thruster"] } # For deploying easily
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres", "chrono", "uuid"] } # For dealing with postgres
thruster = { version = "1.3.0", features = ["hyper_server"] } # For http framework stuff
tokio = { version = "1.20.1", features = ["macros"] } # For an async runtime
uuid = { version = "1.2.1", features = ["v4", "serde"] } # For ids

The Schema

Schemas are simple, the ideas behind twitter are simple, we'll probably never need to make a change to this, so here's the schema; throw it in schema.sql.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Creating the users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
password VARCHAR(1024) NOT NULL,
username VARCHAR(24) NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Creating the sessions table (for when someone logs in)
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
token VARCHAR(64) NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- For storing tweets
CREATE TABLE IF NOT EXISTS tweets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
responding_to UUID,
content VARCHAR(280),
like_count BIGINT DEFAULT 0,
retweet_count BIGINT DEFAULT 0,
reply_count BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- For relating users to what content they like
CREATE TABLE IF NOT EXISTS likes (
tweet_id UUID NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY(tweet_id, user_id)
);
-- For relating users to other users, but in like a friend way
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL,
following_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY(follower_id, following_id)
);
-- For "retweeting" content rather than duplicating tweets
CREATE TABLE IF NOT EXISTS retweets (
tweet_id UUID NOT NULL,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY(tweet_id, user_id)
)
Next, let's make some quick models for everything. These models will also have the methods to get and update the content, so we'll throw each of them in their own files, and all those files into a src/models folder. Don't forget to add the files as modules in src/models/mod.rs, that'll get you every time.
I'm going to just list the models in here with the file name at the top because this is mostly straightforward boilerplate. If there are questions about individual parts, feel free to ask a question and I'll get more specific, but come on, this is Brutal Twitter, we're just plowing ahead.
// src/models/users.rs
use sqlx::{
types::chrono::{DateTime, Utc},
FromRow, Pool, Postgres,
};
use uuid::Uuid;
#[derive(Clone, Debug, FromRow)]
pub struct User {
pub id: Uuid,
pub password: String,
pub username: String,
pub created_at: DateTime<Utc>,
}
impl User {
pub async fn create_user(
pool: &Pool<Postgres>,
username: &str,
password_hash: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as(
"
INSERT INTO users (LOWER(username), password)
VALUES ($1, $2)
RETURNING id, username, password, created_at",
)
.bind(username)
.bind(&password_hash)
.fetch_one(pool)
.await
}
pub async fn get_user_for_id(pool: &Pool<Postgres>, id: &Uuid) -> Result<User, sqlx::Error> {
sqlx::query_as(
"
SELECT id, username, password, created_at FROM users WHERE id = $1",
)
.bind(id)
.fetch_one(pool)
.await
}
pub async fn get_user_for_username(
pool: &Pool<Postgres>,
username: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as(
"
SELECT id, username, password, created_at FROM users WHERE username = LOWER($1)",
)
.bind(username)
.fetch_one(pool)
.await
}
}
// src/models/sessions.rs
use sha2::{Digest, Sha256};
use sqlx::{
types::chrono::{DateTime, Utc},
FromRow, Pool, Postgres,
};
use uuid::Uuid;
#[derive(Debug, FromRow)]
pub struct Session {
pub id: Uuid,
pub token: String,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
}
impl Session {
pub async fn create_session(
pool: &Pool<Postgres>,
user_id: &Uuid,
) -> Result<Session, sqlx::Error> {
sqlx::query_as(
"
INSERT INTO sessions (token, user_id)
VALUES ($1, $2)
RETURNING id, token, user_id, created_at",
)
.bind(format!(
"{:x}",
Sha256::new()
.chain_update(Uuid::new_v4().to_bytes_le())
.finalize()
))
.bind(user_id)
.fetch_one(pool)
.await
}
pub async fn get_session_from_token(
pool: &Pool<Postgres>,
token: &str,
) -> Result<Session, sqlx::Error> {
sqlx::query_as(
"
SELECT id, token, user_id, created_at FROM sessions WHERE token = $1",
)
.bind(token)
.fetch_one(pool)
.await
}
}
// src/models/tweets.rs
use chrono::Duration;
use sqlx::{
types::chrono::{DateTime, Utc},
FromRow, Pool, Postgres,
};
use uuid::Uuid;
#[derive(Debug, FromRow)]
pub struct Tweet {
pub id: Uuid,
pub user_id: Uuid,
pub responding_to: Option<Uuid>,
pub content: String,
pub like_count: i64,
pub retweet_count: i64,
pub reply_count: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
pub struct TweetWithUserInfo {
pub id: Uuid,
pub user_id: Uuid,
pub responding_to: Option<Uuid>,
pub content: String,
pub username: String,
pub user_has_retweeted: bool,
pub user_has_liked: bool,
pub like_count: i64,
pub retweet_count: i64,
pub reply_count: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Tweet {
pub async fn create_tweet(
pool: &Pool<Postgres>,
user_id: &Uuid,
responding_to: Option<Uuid>,
content: String,
) -> Result<Tweet, sqlx::Error> {
let mut transaction = pool.begin().await?;
let tweet = sqlx::query_as(
"
INSERT INTO tweets (user_id, responding_to, content)
VALUES ($1, $2, $3)
RETURNING id, user_id, responding_to, content, created_at, updated_at, like_count, retweet_count, reply_count",
)
.bind(user_id)
.bind(responding_to)
.bind(content)
.fetch_one(&mut transaction)
.await?;
if let Some(tweet_id) = responding_to {
sqlx::query(
"
UPDATE tweets
SET reply_count = reply_count + 1
WHERE id = $1",
)
.bind(tweet_id)
.execute(&mut transaction)
.await?;
}
transaction.commit().await?;
Ok(tweet)
}
pub async fn get_tweet_for_id(pool: &Pool<Postgres>, id: &Uuid) -> Result<Tweet, sqlx::Error> {
sqlx::query_as(
"
SELECT id, user_id, responding_to, content, created_at, updated_at, like_count, retweet_count, reply_count FROM tweets WHERE id = $1",
)
.bind(id)
.fetch_one(pool)
.await
}
pub async fn get_tweet_with_user_info(
pool: &Pool<Postgres>,
id: &Uuid,
user_id: Option<&Uuid>,
) -> Result<TweetWithUserInfo, sqlx::Error> {
sqlx::query_as(
"
SELECT
t.id, t.user_id, t.responding_to, t.content,
t.created_at, t.updated_at, u.username, t.like_count,
t.retweet_count, t.reply_count,
l.user_id IS NOT NULL as user_has_liked,
r.user_id IS NOT NULL as user_has_retweeted
FROM
tweets as t
JOIN
users as u ON t.user_id = u.id
LEFT JOIN
likes as l ON l.tweet_id = t.id AND l.user_id = $2
LEFT JOIN
retweets as r ON r.tweet_id = t.id AND r.user_id = $2
WHERE
t.id = $1",
)
.bind(id)
.bind(user_id)
.fetch_one(pool)
.await
}
pub async fn get_recent_tweets_with_user_info(
pool: &Pool<Postgres>,
user_id: Option<&Uuid>,
offset: Option<DateTime<Utc>>,
) -> Result<Vec<TweetWithUserInfo>, sqlx::Error> {
sqlx::query_as(
"
SELECT
t.id, t.user_id, t.responding_to, t.content,
t.created_at, t.updated_at, u.username, t.like_count,
t.retweet_count, t.reply_count,
l.user_id IS NOT NULL as user_has_liked,
r.user_id IS NOT NULL as user_has_retweeted
FROM
tweets as t
JOIN
users as u ON t.user_id = u.id
LEFT JOIN
likes as l ON l.tweet_id = t.id AND l.user_id = $2
LEFT JOIN
retweets as r ON r.tweet_id = t.id AND r.user_id = $2
WHERE
t.created_at < $1
AND
t.responding_to IS NULL
ORDER BY
t.created_at DESC
LIMIT 20",
)
.bind(offset.unwrap_or_else(|| Utc::now() + Duration::days(1)))
.bind(user_id)
.fetch_all(pool)
.await
}
pub async fn get_tweet_replies_with_user_info(
pool: &Pool<Postgres>,
tweet_id: &Uuid,
user_id: Option<&Uuid>,
offset: Option<DateTime<Utc>>,
) -> Result<Vec<TweetWithUserInfo>, sqlx::Error> {
sqlx::query_as(
"
SELECT
t.id, t.user_id, t.responding_to, t.content,
t.created_at, t.updated_at, u.username, t.like_count,
t.retweet_count, t.reply_count,
l.user_id IS NOT NULL as user_has_liked,
r.user_id IS NOT NULL as user_has_retweeted
FROM
tweets as t
JOIN
users as u ON t.user_id = u.id
LEFT JOIN
likes as l ON l.tweet_id = t.id AND l.user_id = $3
LEFT JOIN
retweets as r ON r.tweet_id = t.id AND r.user_id = $3
WHERE
t.created_at < $2
AND
t.responding_to = $1
ORDER BY
t.created_at DESC
LIMIT 20",
)
.bind(tweet_id)
.bind(offset.unwrap_or_else(|| Utc::now() + Duration::days(1)))
.bind(user_id)
.fetch_all(pool)
.await
}
}
// src/models/likes.rs
use sqlx::{
types::chrono::{DateTime, Utc},
FromRow, Pool, Postgres,
};
use uuid::Uuid;
#[derive(Debug, FromRow)]
pub struct Like {
pub tweet_id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
}
impl Like {
pub async fn create_like(
pool: &Pool<Postgres>,
tweet_id: &Uuid,
user_id: &Uuid,
) -> Result<Like, sqlx::Error> {
let mut transaction = pool.begin().await?;
let like = sqlx::query_as(
"
INSERT INTO likes (tweet_id, user_id)
VALUES ($1, $2)
RETURNING tweet_id, user_id, created_at",
)
.bind(tweet_id)
.bind(user_id)
.fetch_one(&mut transaction)
.await?;
sqlx::query(
"
UPDATE tweets
SET like_count = like_count + 1
WHERE id = $1",
)
.bind(tweet_id)
.execute(&mut transaction)
.await?;
transaction.commit().await?;
Ok(like)
}
pub async fn delete_like(
pool: &Pool<Postgres>,
tweet_id: &Uuid,
user_id: &Uuid,
) -> Result<(), sqlx::Error> {
let mut transaction = pool.begin().await?;
sqlx::query(
"
DELETE FROM likes WHERE tweet_id = $1 AND user_id = $2",
)
.bind(tweet_id)
.bind(user_id)
.execute(&mut transaction)
.await?;
sqlx::query(
"
UPDATE tweets
SET like_count = like_count - 1
WHERE id = $1",
)
.bind(tweet_id)
.execute(&mut transaction)
.await?;
Ok(())
}
}
// src/models/retweets.rs
use sqlx::{
types::chrono::{DateTime, Utc},
FromRow, Pool, Postgres,
};
use uuid::Uuid;
#[derive(Debug, FromRow)]
pub struct Retweet {
pub tweet_id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
}
impl Retweet {
pub async fn create_retweet(
pool: &Pool<Postgres>,
tweet_id: &Uuid,
user_id: &Uuid,
) -> Result<Retweet, sqlx::Error> {
let mut transaction = pool.begin().await?;
let like = sqlx::query_as(
"
INSERT INTO retweets (tweet_id, user_id)
VALUES ($1, $2)
RETURNING tweet_id, user_id, created_at",
)
.bind(tweet_id)
.bind(user_id)
.fetch_one(&mut transaction)
.await?;
sqlx::query(
"
UPDATE tweets
SET retweet_count = retweet_count + 1
WHERE id = $1",
)
.bind(tweet_id)
.execute(&mut transaction)
.await?;
transaction.commit().await?;
Ok(like)
}
}
Great. That's a lot of random sql queries and boilerplate for our models. None of it is really terribly important -- if we were a bit fancier maybe we'd have something like prisma that could provide all this functionality for us without the need for this boilerplate, but this is Brutal Twitter -- we're going ugly.

The Controllers

Now, we're not trying to build some "mobile first" API bullshit here, we're building a website. You want to use it on your phone? Open up your web browser.
In light of that, our controllers are going to serve up some good old fashioned HTML justice. To do that, we're using askama to staple together a bunch of templates so we can reuse code. For all you React based web devs, it's like using "components" but it's "not shit."
The relevant pages we'll need here are:
  • Sign up
  • Sign in
  • Home
  • View a tweet
Starting with the users, we make a src/controllers/users.rs and throw the imports we need to the top of the file:
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use log::error;
use serde::Deserialize;
use thruster::{
errors::{ErrorSet, ThrusterError},
middleware::cookies::CookieOptions,
middleware_fn, MiddlewareNext, MiddlewareResult,
};
use crate::{
app::Ctx,
models::{sessions::Session, users::User},
};
It'll be much clearer why we need these things as we fill in the middleware functions, so just trust me for now.
For starters, let's make the sign up function. Basically this will get called as a POST whenever someone submits the signup form.
Note: assume all of these functions are still in the users controller until I say otherwise.
#[derive(Deserialize)]
pub struct CreatUserReq {
pub username: String,
pub password: String,
}
#[middleware_fn]
pub async fn create_user(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
// First we parse the request content as an x-www-form-urlencoded body. That's
// what the CreateUserReq is used for above.
let CreatUserReq { username, password } =
serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
error!("_e: {:#?}", _e);
// If there's an error, map it to a thruster error
ThrusterError::parsing_error(
Ctx::new_without_request(context.extra.clone()),
"Bad request",
)
})?)
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::parsing_error(
Ctx::new_without_request(context.extra.clone()),
"Bad request",
)
})?;
// Now we need to take the password, generate a salt, hash the password,
// and store the hash, because we're #secure
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(password.as_bytes(), salt.as_ref())
.map(|h| h.to_string())
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::parsing_error(
Ctx::new_without_request(context.extra.clone()),
"Bad request",
)
})?
.to_string();
// Assuming we can hash the password, we create a new user with that hashed password
let user = User::create_user(&context.extra.pool, &username, &password_hash)
.await
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
})?;
// Now that we have a user, let's be nice and make them a session too
let session = Session::create_session(&context.extra.pool, &user.id)
.await
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
})?;
// We're going to redirect the user back to the home screen
context.redirect("/");
// And set a session cookie for them so their requests can be authenticated
context.cookie(
"Session",
&session.token,
&CookieOptions {
http_only: true,
..CookieOptions::default()
},
);
// In thruster, you always have to return a context so that any middleware
// that was run before it can pick up where it left off.
Ok(context)
}
Now, creating a user is great, but we need to let users sign in as well.
#[derive(Deserialize)]
pub struct SignInReq {
pub username: String,
pub password: String,
}
#[middleware_fn]
pub async fn sign_in_user(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
// Similar to before, we gotta parse that ish -- would be nice if we just made this
// into a trait, huh?
let SignInReq { username, password } =
serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::parsing_error(
Ctx::new_without_request(context.extra.clone()),
"Bad request",
)
})?)
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::parsing_error(
Ctx::new_without_request(context.extra.clone()),
"Bad request",
)
})?;
// We fetch the user that is trying to sign in
let user = User::get_user_for_username(&context.extra.pool, &username)
.await
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
})?;
// Now we have to test their password
if Argon2::default()
.verify_password(
password.as_bytes(),
&PasswordHash::new(&user.password).unwrap(),
)
.is_ok()
{
// Assuming it's okay, we make a session
let session = Session::create_session(&context.extra.pool, &user.id)
.await
.map_err(|_e| {
error!("_e: {:#?}", _e);
ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
})?;
// Just like the create user, we redirect and make an auth cookie
context.redirect("/");
context.cookie(
"Session",
&session.token,
&CookieOptions {
http_only: true,
..CookieOptions::default()
},
);
} else {
// Otherwise someone forgot their password or is trying to hack us,
// so throw them back an error.
return Err(ThrusterError::unauthorized_error(context));
}
Ok(context)
}
These next two bits are specific to how thruster is structured, so I'll describe a bit before the code this time. Thruster works by chaining together functions, but not as much in a linear fashion as much as a nested fashion. So if there are three pieces of middleware, a, b, and c, where c is the "last" in the chain, i.e. it doesn't call next, then the execution would look something like
a(context, |context| {
b(context, |context| {
c(context, |context| context)
}
}
This lets us do some neat things around execution and timing. It also means that the same context object gets continuously passed to the different middleware functions.
So, with that, here's some code to take the session cookie and get the user from it, and then another one to stop execution and return an error if there's no valid user on the context.
// Same as all the other middleware. That's the neat part, thruster is homogenous
// in that all the middleware, endpoints, basically anything that touches or handles
// a request looks exactly the same. It's all middleware.
#[middleware_fn]
pub async fn fetch_user_from_cookie(
mut context: Ctx,
next: MiddlewareNext<Ctx>,
) -> MiddlewareResult<Ctx> {
// We get the Sessino token from the cookies on the request
let session_token = context.cookies.get("Session");
if let Some(session_token) = session_token {
// Then we get the session from the database
let session =
Session::get_session_from_token(&context.extra.pool, &session_token.value).await;
if let Ok(session) = session {
// Then we get the user from the session and we attach it to the context.
//
// context.extra is a generic field on the provided `TypedHyperContext`.
// This is a long way of saying, there's a `user` on the `extra` field
// that we have yet to define (but will soon.)
context.extra.user = User::get_user_for_id(&context.extra.pool, &session.user_id)
.await
.ok();
} else {
// If there isn't a valid session, we want to make sure we clear out the
// bad cookies.
context.cookie(
"Session",
"",
&CookieOptions {
http_only: true,
expires: 1,
..CookieOptions::default()
},
);
}
}
// We're returning the result of `next` here, meaning we just call the next
// middleware in the chain and return the result of that
next(context).await
}
#[middleware_fn]
pub async fn authenticate(context: Ctx, next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
// Remember that generic type earlier? Well we're using it. Not all
// routes need a user, but some do, and some change depending on if
// they do have a user.
if let None = context.extra.user.as_ref() {
Err(ThrusterError::unauthorized_error(context))
} else {
next(context).await
}
}
Before we jump into the endpoints for creating tweets and retweets, let's make the static pages first. Now we make another controller, src/controllers/pages.rs, and throw in these imports:
use std::str::FromStr;
use askama::Template;
use log::{error, info};
use thruster::{
context::context_ext::ContextExt,
errors::{ErrorSet, ThrusterError},
middleware_fn, Context, MiddlewareNext, MiddlewareResult,
};
use uuid::Uuid;
use crate::{
app::Ctx,
models::{
tweets::{Tweet, TweetWithUserInfo},
users::User,
},
};
We'll go through the same process as the form endpoints: sign up, sign in.
// This is a template in Askama, basically when there are no variables in the template
// then we can use a type struct. The template will live in `templates/signup.html`.
#[derive(Template)]
#[template(path = "signup.html")]
pub struct SignUp;
// LOOK HOW SIMPLE THIS SHIT IS! Everything should seem familiar from before, but since
// we know the render will succeed (since there are no variables,) we can just unwrap
// the result. That's right, we know better than the compiler.
#[middleware_fn]
pub async fn signup(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
context.set("Content-Type", "text/html");
context.body(&SignUp.render().unwrap());
Ok(context)
}
// Sign in, surprise, looks basically the same
#[derive(Template)]
#[template(path = "signin.html")]
pub struct SignIn;
#[middleware_fn]
pub async fn signin(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
context.set("Content-Type", "text/html");
context.body(&SignIn.render().unwrap());
Ok(context)
}
Now it's time to add our first templates. I made: