I know the person who owns the domain FaktCheck.org, which before recently was just a Google Site. They gave me permission to make a cooler website to put on it, so this is how I did that.
The word Fakt means something proven to be true, and is often misspelled ‘fact’.
The goals
For context, FaktCheck.org is a site full of fakts for you to check out. All of them are completely true. Before, when it was a Google Site, the way to add fakts was to go into Google Sites, go to the list, and add it to the text box; and there was no search functionality or anything. Not very cool, right?
What I wanted from this new version was to have a database you could manipulate from the website, only if you have the password of course. Then, with that, I wanted there to be a page for each fakt, so that people could link to one or something. Since each fakt had its own page, I realized I could add a bit of extra info to its page, so I decided to add a source for each fakt. I also wanted you to be able to search the website. Finally, I wanted to stay away from the things that make the modern web heavy, like UI frameworks, javascript, and cookies. Those shouldn’t be needed for a website so simple anyways!
Tools I decided to use
I decided to use Sqlite for the database as it’s easy to work with and works well enough until you’re getting tons of requests, which I don’t think this website will. With all the bots that have been scraping it, maybe I’m wrong on that though.
I wanted to write the server in Rust, because the one who owns the website likes rust, and I do too. For the framework, I chose Axum; it is quite popular and commonly used for the purpose. I chose to use SQLx to interact with the database. It is also a very popular choice for what it does. Finally, for HTML templating, I decided to use Askama, which, once more, is the popular choice for the job. You might be noticing a theme here.
For configuration, I chose to use environment variables, and automatically load .env files with dotenvy. This is easy, works well for the small amount of things to configure, and works well with docker.
Implementation
There’s nothing that crazy going on with the main function, I just have dotenvy look for a .env file, initialize a database pool, and start serving the pages.
async fn main() {
dotenv().ok();
let pool = db::get_pool();
let port = match std::env::var("FAKTCHECK_PORT") {
Ok(port) => port,
Err(_) => {
println!("FAKTCHECK_PORT not set; using 8080");
"8080".to_string()
}
};
let bind = format!("0.0.0.0:{}", port);
let app = Router::new()
.route("/style.css", get(pages::style))
.route("/", get(pages::root))
.route("/fakt/{id}", get(pages::fakt))
.route("/fakts", get(pages::faktlist))
.route("/search", get(pages::search))
.route("/add", get(pages::add))
.route("/add", post(pages::add_post))
.route("/del/{id}", get(pages::del))
.route("/del/{id}", post(pages::del_post))
.route("/edit/{id}", get(pages::edit))
.route("/edit/{id}", post(pages::edit_post))
.fallback(pages::not_found)
.with_state(pool.await);
let listener = tokio::net::TcpListener::bind(bind).await.unwrap();
let _ = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
}
And here’s that get_pool function.
pub async fn get_pool() -> SqlitePool {
let db_name = match std::env::var("FAKTCHECK_DB_NAME") {
Ok(name) => name,
Err(_) => {
println!("FAKTCHECK_DB_NAME not set; using fakts.db");
"fakts.db".to_string()
}
};
let db_url = format!("sqlite:{}", db_name);
let pool = SqlitePool::connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query(
"CREATE TABLE IF NOT EXISTS fakts (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
keyword TEXT NOT NULL,
source TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("Failed to create table");
pool
}
Returning pages with Axum
A lot of the functions for different pages look very similar, but I can show you a few so you can get an idea for how it works.
For a really basic one, we have the home page. The only thing it displays that needs to be inserted into the template is a random fakt. We also need that fakt’s ID so that we can link to it. The format_content function is to make every instance of the keyword in the fakt bold and italicized.
#[derive(Template)]
#[template(path = "home.html")]
struct HomePage {
id: i32,
fakt: String,
}
pub async fn root(State(pool): State<SqlitePool>) -> Html<String> {
let fakt = db::random(pool).await;
let page = HomePage {
id: fakt.id,
fakt: format_fakt(&fakt),
};
Html(page.render().expect("Failed to render page"))
}
fn format_fakt(fakt: &Fakt) -> String {
format_content(&fakt.content, &fakt.keyword)
}
fn format_content(content: &str, keyword: &str) -> String {
let length = keyword.len();
let mut last_index = 0;
let mut formatted = String::new();
let lower_content = content.to_lowercase();
let lower_keyword = keyword.to_lowercase();
for (start_index, _) in lower_content.match_indices(&lower_keyword) {
formatted.push_str(&content[last_index..start_index]);
formatted.push_str("<b><i>");
formatted.push_str(&content[start_index..start_index + length]);
formatted.push_str("</i></b>");
last_index = start_index + length;
}
formatted.push_str(&content[last_index..]);
formatted
}
Here’s the function for showing a page for a fakt. It uses the ID we got from the path in the main function.
#[derive(Template)]
#[template(path = "fakt.html")]
struct FaktPage {
keyword: String,
fakt: String,
source: String,
}
pub async fn fakt(Path(id): Path<i32>, State(pool): State<SqlitePool>) -> Html<String> {
let Some(fakt) = db::id(pool, id).await else {
return not_found().await;
};
let page = FaktPage {
fakt: format_fakt(&fakt),
keyword: fakt.keyword,
source: fakt.source,
};
Html(page.render().expect("Failed to render page"))
}
Those aren’t too crazy. One that actually has a bit going on is the page for search results. It gets the url parameter ‘q’ and searches the database for that (more on that later). The format_faktlist function is also used in the fakt list page, and it deals with all the html to make the page show the list of fakts, formatted with links and headers, with an arbitrary number of fakts.
#[derive(Template)]
#[template(path = "search.html")]
struct SearchPage {
search: String,
faktlist: String,
}
pub async fn search(
State(pool): State<SqlitePool>,
Query(mut params): Query<HashMap<String, String>>,
) -> Html<String> {
let term = params.remove("q").unwrap_or(String::new());
let fakts = db::search(pool, &term);
let faktlist = format_faktlist(fakts.await);
let page = SearchPage {
search: term,
faktlist,
};
Html(page.render().expect("Failed to render page"))
}
fn format_faktlist(fakts: Vec<Fakt>) -> String {
let mut faktlist = String::new();
let mut last_letter = 'A';
for fakt in fakts {
let letter = fakt.keyword.chars().next().unwrap();
let lower = letter.to_lowercase().next().unwrap();
if lower != last_letter {
last_letter = lower;
faktlist.push_str("<h1>");
faktlist.push(letter.to_uppercase().next().unwrap());
faktlist.push_str("</h1>");
}
faktlist.push_str("<p><a href=\"/fakt/");
faktlist.push_str(&fakt.id.to_string());
faktlist.push_str("\">");
faktlist.push_str(&format_fakt(&fakt));
faktlist.push_str("</a></p>");
}
faktlist
}
All the admin pages have forms and manipulate the database, so I should show you one of those. The simplest one is to delete a fakt, because the only field in the form is the admin password. They all have a message section that can show an error or success message.
#[derive(Template)]
#[template(path = "del.html")]
struct DelPage {
id: i32,
fakt: String,
source: String,
message: String,
}
pub async fn del_post(
Path(id): Path<i32>,
State(pool): State<SqlitePool>,
Form(payload): Form<DelForm>,
) -> Html<String> {
let Some(fakt) = db::id(pool.clone(), id).await else {
return not_found().await;
};
if !is_correct_password(&payload.password) {
let page = DelPage {
id,
fakt: format_fakt(&fakt),
source: fakt.source,
message: "Error: incorrect password.".to_string(),
};
return Html(page.render().expect("Failed to render page"));
}
if let Err(error) = db::del(pool, id).await {
let page = DelPage {
id,
fakt: format_fakt(&fakt),
source: fakt.source,
message: error,
};
return Html(page.render().expect("Failed to render page"));
};
let page = DelPage {
id,
fakt: format_fakt(&fakt),
source: fakt.source,
message: "Successfully deleted fakt. <a href=\"/fakts\">Fakt List</a>".to_string(),
};
Html(page.render().expect("Failed to render page"))
}
fn is_correct_password(pass: &str) -> bool {
let Ok(correct_pass) = env::var("FAKTCHECK_ADMIN_PASSWORD") else {
return false;
};
pass == correct_pass
}
Querying the database with SQLx
In all those functions, we called functions from db. Lets take a look at those. These functions all look very similar so I’ll just show two.
First, I’ll show the most basic one, just getting the fakt from an ID.
#[derive(Serialize, sqlx::FromRow)]
pub struct Fakt {
pub id: i32,
pub content: String,
pub keyword: String,
pub source: String,
}
pub async fn id(pool: SqlitePool, id: i32) -> Option<Fakt> {
sqlx::query_as::<_, Fakt>("SELECT id, content, keyword, source FROM fakts WHERE id = ?")
.bind(id)
.fetch_optional(&pool)
.await
.expect("Failed to query database")
}
And here’s the search function, which returns all the fakts that have the query as a substring of the content.
pub async fn search(pool: SqlitePool, term: &str) -> Vec<Fakt> {
let pattern = format!("%{}%", term);
sqlx::query_as::<_, Fakt>(
"SELECT id, content, keyword, source FROM fakts WHERE content LIKE ? ORDER BY keyword COLLATE NOCASE",
)
.bind(pattern)
.fetch_all(&pool)
.await
.unwrap_or_default()
}
Front end
Oh the pain, the agony, the unrelenting horror, of having to do front end. I know I could probably find some theme or something to do this for me, or even ask AI, it would probably do a better job at the front end than me, as I don’t really know what I’m doing. But, I think that goes against the goals of this project, and, especially with the AI one, is just not something I want to do. So, I did it myself. It may be ugly, but its my ugly.
To put a variable into the html, I put {{var_name}}, so that’s what all those are. If I do {{var_name|safe}}, it allows html in it, which is why all the fakts have that.
Once again, we have a lot of similar looking stuff, so I’ll just show one file, that being fakt.html, which is used to display a fakt, such as if you go to faktcheck.org/fakt/67.
<!DOCTYPE html>
<html>
<head>
<title>{{keyword}} - FaktCheck.org</title>
<link rel="stylesheet" href="/style.css">
</head>
<header class="navbar">
<h1 class="logo"><a href="/">FaktCheck.org</a></h1>
<div class="flex">
<form method="GET" action="/search">
<input type="text" name="q" required>
<input type="submit" value="Search">
</form>
<a href="/fakts">Fakt List</a>
</div>
</header>
<body>
<p>{{fakt|safe}}</p>
<p>Source: {{source}}</p>
</body>
</html>
There’s also, of course, a CSS file. I don’t think anything crazy is going on in it, but here it is.
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.flex {
display: flex;
gap: 10px;
}
input {
border: 1px solid #e0e0e0;
border-radius: 0px;
font-size: 20px;
}
input:focus {
border-color: #3092f4;
}
input[type="submit"] {
background-color: white;
}
input[type="submit"]:hover {
border: 2px solid #3092f4;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0px 10px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
font-size: 22px;
}
.logo {
font-weight: bold;
font-size: 26px;
}
a {
color: black;
text-decoration: none;
}
a:hover {
color: #3092f4;
}
body {
padding: 0rem 1rem;
}
p {
font-size: 18px;
}
h1 {
font-weight: bold;
font-size: 26px;
}
small {
font-size: 16px;
}
Read the whole thing if you want
All source code is viewable on GitHub.