use crate::auth;
use crate::db::DbConfig;
use crate::templates;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Utc};
use maud::html;
use uuid::Uuid;
fn render_content(raw: &str) -> String {
if raw.contains("<p>") || raw.contains("<br") {
raw.to_string()
} else {
let escaped = raw
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
format!(
"<p>{}</p>",
escaped.replace("\n\n", "</p><p>").replace('\n', "<br>")
)
}
}
fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
if ch == '<' {
in_tag = true;
} else if ch == '>' {
in_tag = false;
} else if !in_tag {
result.push(ch);
}
}
result
.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
struct LatestArticle {
slug: String,
title: String,
audience_slug: String,
content: String,
published_at: DateTime<Utc>,
}
struct AudienceRow {
slug: String,
name: String,
description: Option<String>,
}
struct ArticleRow {
slug: String,
title: String,
}
struct DashboardArticle {
id: Uuid,
title: String,
audience_name: String,
is_published: bool,
}
struct CommitRow {
id: Uuid,
message: Option<String>,
created_at: DateTime<Utc>,
}
#[get("/")]
pub async fn index(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let latest = client
.query_opt(
"SELECT a.slug, a.title, aud.slug AS audience_slug, c.content, p.published_at \
FROM published p \
JOIN articles a ON p.article_id = a.id \
JOIN audiences aud ON a.audience_id = aud.id \
JOIN commits c ON p.commit_id = c.id \
ORDER BY p.published_at DESC \
LIMIT 1",
&[],
)
.await
.ok()
.flatten()
.map(|row| LatestArticle {
slug: row.get(0),
title: row.get(1),
audience_slug: row.get(2),
content: row.get(3),
published_at: row.get(4),
});
let author = auth::get_current_author(&req, &db).await;
let body = html! {
header .tl.mt5.mb4.mw7 {
h1 .f-headline.serif.ma0 { "L" span .f2 { "ettre" } }
p .f4.mid-gray.mt2 { "Just articles" }
}
section .tl.mb5.mw7 {
@if let Some(ref art) = latest {
@let excerpt: String = strip_html_tags(&art.content).chars().take(200).collect();
@let date_str = art.published_at.with_timezone(&chrono::Local).format("%B %-d, %Y").to_string();
a .link.dark-gray.dim href={"/"(&art.audience_slug)"/"(&art.slug)} {
h2 .f2.serif.mb1 { (&art.title) }
}
p .f5.mid-gray.mb2 { (date_str) }
p .f5.mid-gray { (excerpt) "…" }
a .link.dim.db.pa3.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.mt3.pointer href="/audiences" { "all audiences →" }
} @else {
p .mid-gray { "Nothing published yet." }
a .link.dim.db.pa3.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer href="/audiences" { "browse audiences →" }
}
}
section .mw7 {
@if let Some((_id, name)) = author {
p .tl { "hello, " (name) " — " a .link.dark-gray.dim href="/dashboard" { "dashboard" } }
} @else {
p .tl { a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/login" { "author login" } }
}
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("lettre", body).into_string())
}
#[get("/audiences")]
pub async fn audiences(_req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let audience_list: Vec<AudienceRow> = client
.query(
"SELECT slug, name, description FROM audiences ORDER BY name",
&[],
)
.await
.unwrap_or_default()
.into_iter()
.map(|row| AudienceRow {
slug: row.get(0),
name: row.get(1),
description: row.get(2),
})
.collect();
let body = html! {
header .mt5.mb4.mw7 {
h1 .f2.serif.ma0 { "audiences" }
p .f5.mid-gray { "writing for every kind of reader" }
}
section .mw7 {
@for aud in &audience_list {
article .mb4.pv3.bt.b--near-white {
a .link.dark-gray.dim href={"/"(&aud.slug)} {
h2 .f3.serif.mb1 { (&aud.name) }
}
@if let Some(ref desc) = aud.description {
p .f5.mid-gray.measure { (desc) }
}
}
}
}
nav .tl.mt4.mid-gray {
a .link.dark-gray.dim href="/" { "← back home" }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("audiences — lettre", body).into_string())
}
#[get("/{audience_slug}")]
pub async fn audience_page(
_req: HttpRequest,
db: web::Data<DbConfig>,
path: web::Path<String>,
) -> HttpResponse {
let slug = path.into_inner();
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let audience = client
.query_opt(
"SELECT id, name, description FROM audiences WHERE slug = $1",
&[&slug],
)
.await
.ok()
.flatten();
let Some(audience) = audience else {
return HttpResponse::NotFound().body("Audience not found");
};
let audience_name: String = audience.get(1);
let article_list: Vec<ArticleRow> = client
.query(
"SELECT a.slug, a.title \
FROM articles a \
JOIN published pub ON pub.article_id = a.id \
WHERE a.audience_id = (SELECT id FROM audiences WHERE slug = $1) \
ORDER BY pub.published_at DESC",
&[&slug],
)
.await
.unwrap_or_default()
.into_iter()
.map(|row| ArticleRow {
slug: row.get(0),
title: row.get(1),
})
.collect();
let page_title = format!("{audience_name} — lettre");
let body = html! {
header .mt5.mb4.mw7 {
h1 .f2.serif.ma0 { (&audience_name) }
p .f5.mid-gray { "an audience" }
}
section .mw7 {
@for art in &article_list {
article .mb3.pv3.bt.b--near-white {
a .link.dark-gray.dim href={"/"(&slug)"/"(&art.slug)} {
h3 .f3.serif.mb1 { (&art.title) }
}
}
}
}
nav .tl.mt4.mid-gray {
a .link.dark-gray.dim href="/audiences" { "← all audiences" }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout(&page_title, body).into_string())
}
#[get("/{audience_slug}/{article_slug}")]
pub async fn article_page(
_req: HttpRequest,
db: web::Data<DbConfig>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (audience_slug, article_slug) = path.into_inner();
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let row = client
.query_opt(
"SELECT a.title, c.content, pub.published_at, c.message \
FROM articles a \
JOIN published pub ON pub.article_id = a.id \
JOIN commits c ON pub.commit_id = c.id \
JOIN audiences aud ON a.audience_id = aud.id \
WHERE aud.slug = $1 AND a.slug = $2",
&[&audience_slug, &article_slug],
)
.await
.ok()
.flatten();
let Some(row) = row else {
return HttpResponse::NotFound().body("Article not found");
};
let title: String = row.get(0);
let content_text: String = row.get(1);
let published_at: DateTime<Utc> = row.get(2);
let commit_msg: Option<&str> = row.get(3);
let date_str = published_at
.with_timezone(&chrono::Local)
.format("%B %-d, %Y")
.to_string();
let content_html = render_content(&content_text);
let page_title = format!("{title} — lettre");
let body = html! {
article .mt5.lh-copy style="max-width:83rem" {
header .mb4 {
h1 .f2.serif.ma0.mb2 { (title) }
p .f6.mid-gray {
(date_str)
@if let Some(msg) = commit_msg {
" — " (msg)
}
}
}
section .measure-wide.lh-copy.serif {
(maud::PreEscaped(&content_html))
}
}
nav .tl.mt4.mb5.mid-gray {
a .link.dark-gray.dim href={"/"(audience_slug)} { "← back to " (audience_slug) }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout(&page_title, body).into_string())
}
#[get("/login")]
pub async fn login_page(_req: HttpRequest) -> HttpResponse {
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "sign in" }
form .measure action="/login" method="post" {
div .mb3 {
label .db.mb1.f6.mid-gray for="email" { "email" }
input #email .pa2.w-100.ba.b--near-white type="email" name="email" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="password" { "password" }
input #password .pa2.w-100.ba.b--near-white type="password" name="password" required="";
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "sign in" }
}
}
p .tl.mid-gray.mt4.f6 { "need an account? " a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/invite" { "use an invite" } }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("sign in — lettre", body).into_string())
}
#[post("/login")]
pub async fn login_post(
_req: HttpRequest,
db: web::Data<DbConfig>,
form: web::Form<LoginFormData>,
) -> HttpResponse {
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let author = client
.query_opt(
"SELECT id, password_hash FROM authors WHERE email = $1",
&[&form.email],
)
.await
.ok()
.flatten();
match author {
Some(row) => {
let id: Uuid = row.get(0);
let hash: String = row.get(1);
if auth::verify_password(&form.password, &hash) {
match auth::create_session(&db, id).await {
Ok(token) => HttpResponse::SeeOther()
.cookie(
actix_web::cookie::Cookie::build("session_token", token.to_string())
.path("/")
.http_only(true)
.finish(),
)
.insert_header(("Location", "/dashboard"))
.finish(),
Err(e) => {
log::error!("Session creation failed: {e}");
HttpResponse::InternalServerError().body("Could not create session")
}
}
} else {
HttpResponse::SeeOther()
.insert_header(("Location", "/login?error=invalid"))
.finish()
}
}
None => HttpResponse::SeeOther()
.insert_header(("Location", "/login?error=invalid"))
.finish(),
}
}
#[post("/logout")]
pub async fn logout_post(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
if let Some(cookie) = req.cookie("session_token")
&& let Ok(token) = Uuid::parse_str(cookie.value())
{
let _ = auth::delete_session(&db, token).await;
}
HttpResponse::SeeOther()
.cookie(
actix_web::cookie::Cookie::build("session_token", "")
.path("/")
.http_only(true)
.max_age(actix_web::cookie::time::Duration::ZERO)
.finish(),
)
.insert_header(("Location", "/"))
.finish()
}
#[get("/invite")]
pub async fn invite_page(_req: HttpRequest) -> HttpResponse {
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "generate invite" }
p .f5.mid-gray.tl.mb4 { "enter the main key to create an invite ticket" }
form .measure action="/invite" method="post" {
div .mb3 {
label .db.mb1.f6.mid-gray for="key" { "main key" }
input .pa2.w-100.ba.b--near-white type="password" name="key" required="";
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "generate" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("invite — lettre", body).into_string())
}
#[post("/invite")]
pub async fn invite_post(
_req: HttpRequest,
db: web::Data<DbConfig>,
form: web::Form<InviteFormData>,
) -> HttpResponse {
let main_key = std::env::var("MAIN_KEY").unwrap_or_default();
if form.key != main_key {
return HttpResponse::SeeOther()
.insert_header(("Location", "/invite?error=invalid"))
.finish();
}
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let code = Uuid::new_v4().to_string();
match client
.execute("INSERT INTO invite_tickets (code) VALUES ($1)", &[&code])
.await
{
Ok(_) => {
let invite_link = format!("/register/{code}");
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "invite created" }
p .f5.tl.mb4 { "share this link with the person you want to invite:" }
div .pa3.ba.b--near-white.bg-light-gray.tl.mono.f6.break-all {
(invite_link)
}
p .f6.mid-gray.tl.mt3 { "this link can only be used once" }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("invite created — lettre", body).into_string())
}
Err(e) => {
log::error!("Invite creation failed: {e}");
HttpResponse::InternalServerError().body("Could not create invite")
}
}
}
#[get("/register")]
pub async fn register_redirect(_req: HttpRequest) -> HttpResponse {
HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish()
}
#[get("/register/{code}")]
pub async fn register_page(
_req: HttpRequest,
path: web::Path<String>,
db: web::Data<DbConfig>,
) -> HttpResponse {
let code = path.into_inner();
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let valid = client
.query_opt(
"SELECT id FROM invite_tickets WHERE code = $1 AND used = FALSE",
&[&code],
)
.await
.ok()
.flatten();
if valid.is_none() {
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "invalid invite" }
p .f5.mid-gray.tl { "this invite code is not valid or has already been used" }
}
};
return HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("invalid invite — lettre", body).into_string());
}
let form_action = format!("/register/{code}");
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "create account" }
form .measure action=(form_action) method="post" {
div .mb3 {
label .db.mb1.f6.mid-gray for="display_name" { "display name" }
input .pa2.w-100.ba.b--near-white type="text" name="display_name" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="reg_email" { "email" }
input .pa2.w-100.ba.b--near-white type="email" name="email" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="reg_password" { "password" }
input .pa2.w-100.ba.b--near-white type="password" name="password" required="";
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create account" }
}
}
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("create account — lettre", body).into_string())
}
#[post("/register/{code}")]
pub async fn register_post(
_req: HttpRequest,
path: web::Path<String>,
db: web::Data<DbConfig>,
form: web::Form<RegisterFormData>,
) -> HttpResponse {
let code = path.into_inner();
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let valid = client
.query_opt(
"SELECT id FROM invite_tickets WHERE code = $1 AND used = FALSE",
&[&code],
)
.await
.ok()
.flatten();
if valid.is_none() {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
}
let password_hash = match auth::hash_password(&form.password) {
Ok(h) => h,
Err(e) => {
log::error!("Password hashing failed: {e}");
return HttpResponse::InternalServerError().body("Could not create account");
}
};
let author_id = Uuid::new_v4();
let result = client
.execute(
"INSERT INTO authors (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)",
&[&author_id, &form.email, &password_hash, &form.display_name],
)
.await;
match result {
Ok(_) => {
let _ = client
.execute(
"UPDATE invite_tickets SET used = TRUE WHERE code = $1",
&[&code],
)
.await;
match auth::create_session(&db, author_id).await {
Ok(token) => HttpResponse::SeeOther()
.cookie(
actix_web::cookie::Cookie::build("session_token", token.to_string())
.path("/")
.http_only(true)
.finish(),
)
.insert_header(("Location", "/dashboard"))
.finish(),
Err(e) => {
log::error!("Session creation failed: {e}");
HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish()
}
}
}
Err(e) => {
log::error!("Author creation failed: {e}");
HttpResponse::SeeOther()
.insert_header(("Location", "/register"))
.finish()
}
}
}
#[get("/dashboard")]
pub async fn dashboard(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
let Some((author_id, author_name)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let dash_articles: Vec<DashboardArticle> = client
.query(
"SELECT a.id, a.title, aud.name AS audience_name, \
pub.commit_id IS NOT NULL AS is_published \
FROM articles a \
JOIN audiences aud ON a.audience_id = aud.id \
LEFT JOIN published pub ON pub.article_id = a.id \
WHERE a.author_id = $1 \
ORDER BY a.created_at DESC",
&[&author_id],
)
.await
.unwrap_or_default()
.into_iter()
.map(|row| DashboardArticle {
id: row.get(0),
title: row.get(1),
audience_name: row.get(2),
is_published: row.get(3),
})
.collect();
let body = html! {
header .mt5.mb4.mw7 {
h1 .f2.serif.ma0 { "dashboard" }
p .f5.mid-gray { "hello, " (author_name) }
}
section .mb4.mw7 {
a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.mr2.pointer href="/dashboard/articles/new" { "new article" }
a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.mr2.pointer href="/dashboard/audiences/new" { "new audience" }
a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/invite" { "invite author" }
}
section .mb5.mw7 {
@for art in &dash_articles {
@let edit_url = format!("/dashboard/articles/{}/edit", art.id);
article .mb3.pv3.bt.b--near-white {
div .flex.items-center {
h3 .f4.serif.ma0 { (&art.title) }
@if art.is_published {
span .ml2.f7.dark-green { "published" }
} @else {
span .ml2.f7.mid-gray { "draft" }
}
}
p .f6.mid-gray { (&art.audience_name) " — " a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href=(edit_url) { "edit" } }
}
}
}
nav .tl.mid-gray {
form action="/logout" method="post" style="display:inline" {
button .pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "sign out" }
}
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("dashboard — lettre", body).into_string())
}
#[get("/dashboard/articles/new")]
pub async fn new_article_page(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let audience_list: Vec<(Uuid, String)> = client
.query("SELECT id, name FROM audiences ORDER BY name", &[])
.await
.unwrap_or_default()
.into_iter()
.map(|row| (row.get::<_, Uuid>(0), row.get::<_, String>(1)))
.collect();
if audience_list.is_empty() {
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "no audiences yet" }
p .f5.mid-gray.tl { "you need at least one audience before creating articles" }
a .link.dark-gray.dim.db.tl.mt3 href="/dashboard" { "← back to dashboard" }
}
};
return HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("no audiences — lettre", body).into_string());
}
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "new article" }
form .measure action="/dashboard/articles" method="post" on-submit="q('#content').value = window.quillEditor.getSemanticHTML()" {
div .mb3 {
label .db.mb1.f6.mid-gray for="title" { "title" }
input #title .pa2.w-100.ba.b--near-white type="text" name="title" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="slug" { "slug (url-safe)" }
input #slug .pa2.w-100.ba.b--near-white type="text" name="slug" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="audience_id" { "audience" }
select #audience_id .pa2.w-100.ba.b--near-white name="audience_id" {
@for (id, name) in &audience_list {
option value=(id.to_string()) { (name) }
}
}
}
div .mb3 {
label .db.mb1.f6.mid-gray for="quill-editor" { "content" }
div #quill-editor style="min-height: 20rem;" on-init="window.quillEditor = new Quill('#quill-editor', {theme: 'snow', placeholder: 'write your article…'})" {}
textarea #content style="display:none" name="content" {}
}
div .mb3 {
label .db.mb1.f6.mid-gray for="message" { "commit message" }
input #message .pa2.w-100.ba.b--near-white type="text" name="message" placeholder="initial draft";
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create" }
}
}
a .link.dark-gray.dim.db.tl.mt4.f6 href="/dashboard" { "← back to dashboard" }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("new article — lettre", body).into_string())
}
#[post("/dashboard/articles")]
pub async fn create_article(
req: HttpRequest,
db: web::Data<DbConfig>,
form: web::Form<CreateArticleFormData>,
) -> HttpResponse {
let Some((author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let article_id = Uuid::new_v4();
let audience_id = match Uuid::parse_str(&form.audience_id) {
Ok(id) => id,
Err(_) => return HttpResponse::BadRequest().body("Invalid audience"),
};
let result = client
.execute(
"INSERT INTO articles (id, title, slug, audience_id, author_id) VALUES ($1, $2, $3, $4, $5)",
&[&article_id, &form.title, &form.slug, &audience_id, &author_id],
)
.await;
if let Err(e) = result {
log::error!("Article creation failed: {e}");
return HttpResponse::SeeOther()
.insert_header(("Location", "/dashboard/articles/new"))
.finish();
}
let commit_id = Uuid::new_v4();
let message = if form.message.is_empty() {
None
} else {
Some(form.message.as_str())
};
let _ = client
.execute(
"INSERT INTO commits (id, article_id, content, message) VALUES ($1, $2, $3, $4)",
&[&commit_id, &article_id, &form.content, &message],
)
.await;
let redirect_url = format!("/dashboard/articles/{article_id}/edit");
HttpResponse::SeeOther()
.insert_header(("Location", redirect_url))
.finish()
}
#[get("/dashboard/articles/{id}/edit")]
pub async fn edit_article_page(
req: HttpRequest,
db: web::Data<DbConfig>,
path: web::Path<String>,
) -> HttpResponse {
let article_id_str = path.into_inner();
let article_id = match Uuid::parse_str(&article_id_str) {
Ok(id) => id,
Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
};
let Some((author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let article = client
.query_opt(
"SELECT a.title, a.slug, aud.name, a.audience_id \
FROM articles a JOIN audiences aud ON a.audience_id = aud.id \
WHERE a.id = $1 AND a.author_id = $2",
&[&article_id, &author_id],
)
.await
.ok()
.flatten();
let Some(article) = article else {
return HttpResponse::NotFound().body("Article not found");
};
let title: String = article.get(0);
let audience_name: String = article.get(2);
let current_content: String = client
.query_opt(
"SELECT content FROM commits WHERE article_id = $1 ORDER BY created_at DESC LIMIT 1",
&[&article_id],
)
.await
.ok()
.flatten()
.map(|r| r.get::<_, String>(0))
.unwrap_or_default();
let published_commit_id: Option<Uuid> = client
.query_opt(
"SELECT commit_id FROM published WHERE article_id = $1",
&[&article_id],
)
.await
.ok()
.flatten()
.map(|r| r.get(0));
let commit_list: Vec<CommitRow> = client
.query(
"SELECT id, message, created_at FROM commits WHERE article_id = $1 ORDER BY created_at DESC",
&[&article_id],
)
.await
.unwrap_or_default()
.into_iter()
.map(|row| CommitRow {
id: row.get(0),
message: row.get(1),
created_at: row.get(2),
})
.collect();
let page_title = format!("edit: {title} — lettre");
let commit_action = format!("/dashboard/articles/{article_id}/commit");
let body = html! {
section .mt5 {
div .tl.mid-gray.f6.mb4 {
a .link.dark-gray.dim href="/dashboard" { "← back to dashboard" }
}
div .flex {
aside .w-50.pr4 {
p .f7.mid-gray.ttu.tracked.mb3 { "preview" }
article .lh-copy {
header .mb4 {
h1 .f2.serif.ma0.mb2 { (title) }
p .f6.mid-gray { (audience_name) }
}
section #preview-content .measure.lh-copy.serif {
(maud::PreEscaped(¤t_content))
}
}
}
div .w-50.pl4.bl.b--near-white {
form .mb4 action=(commit_action) method="post" on-submit="q('#ed_content').value = window.quillEditor.getSemanticHTML()" {
div .mb3 {
label .db.mb1.f6.mid-gray for="quill-editor" { "content" }
div #quill-editor style="min-height: 20rem;" on-init="window.quillEditor = new Quill('#quill-editor', {theme: 'snow'}); window.quillEditor.on('text-change', () => { q('#preview-content').innerHTML = window.quillEditor.getSemanticHTML() })" {
(maud::PreEscaped(¤t_content))
}
textarea #ed_content style="display:none" name="content" {}
}
div .mb3 {
label .db.mb1.f6.mid-gray for="commit_msg" { "commit message" }
input #commit_msg .pa2.w-100.ba.b--near-white type="text" name="message" placeholder="what changed";
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "save commit" }
}
}
@if !commit_list.is_empty() {
section .mb4 {
h2 .f4.serif.mb2 { "commits" }
@for commit in commit_list.iter().take(2) {
@let date_str = commit.created_at.with_timezone(&chrono::Local).format("%b %-d %H:%M").to_string();
@let publish_action = format!("/dashboard/articles/{article_id}/publish");
div .mb2.pv2.bt.b--near-white.flex.items-center {
span .f6.mr2 { (date_str) }
@if let Some(ref msg) = commit.message {
span .f6.mid-gray { (msg) }
}
@if Some(commit.id) == published_commit_id {
span .ml2.f7.dark-green { "published" }
} @else {
form .ml2 action=(publish_action) method="post" style="display:inline" {
input type="hidden" name="commit_id" value=(commit.id.to_string());
button .f7.ba.b--moon-gray.bg-near-white.serif.pa1.ph2.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "publish this" }
}
}
}
}
@if commit_list.len() > 2 {
details .mt2 {
summary .f6.mid-gray.pointer.mb2 { "older commits" }
@for commit in commit_list.iter().skip(2) {
@let date_str = commit.created_at.with_timezone(&chrono::Local).format("%b %-d %H:%M").to_string();
@let publish_action = format!("/dashboard/articles/{article_id}/publish");
div .mb2.pv2.bt.b--near-white.flex.items-center {
span .f6.mr2 { (date_str) }
@if let Some(ref msg) = commit.message {
span .f6.mid-gray { (msg) }
}
@if Some(commit.id) == published_commit_id {
span .ml2.f7.dark-green { "published" }
} @else {
form .ml2 action=(publish_action) method="post" style="display:inline" {
input type="hidden" name="commit_id" value=(commit.id.to_string());
button .f7.ba.b--moon-gray.bg-near-white.serif.pa1.ph2.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "publish this" }
}
}
}
}
}
}
}
}
}
}
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout(&page_title, body).into_string())
}
#[post("/dashboard/articles/{id}/commit")]
pub async fn commit_article(
req: HttpRequest,
db: web::Data<DbConfig>,
path: web::Path<String>,
form: web::Form<CommitFormData>,
) -> HttpResponse {
let article_id_str = path.into_inner();
let article_id = match Uuid::parse_str(&article_id_str) {
Ok(id) => id,
Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
};
let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let parent = client
.query_opt(
"SELECT id FROM commits WHERE article_id = $1 ORDER BY created_at DESC LIMIT 1",
&[&article_id],
)
.await
.ok()
.flatten();
let parent_id: Option<Uuid> = parent.as_ref().map(|r| r.get(0));
let commit_id = Uuid::new_v4();
let message = if form.message.is_empty() {
None
} else {
Some(form.message.as_str())
};
let _ = client
.execute(
"INSERT INTO commits (id, article_id, content, message, parent_id) VALUES ($1, $2, $3, $4, $5)",
&[&commit_id, &article_id, &form.content, &message, &parent_id],
)
.await;
let redirect_url = format!("/dashboard/articles/{article_id}/edit");
HttpResponse::SeeOther()
.insert_header(("Location", redirect_url))
.finish()
}
#[post("/dashboard/articles/{id}/publish")]
pub async fn publish_article(
req: HttpRequest,
db: web::Data<DbConfig>,
path: web::Path<String>,
form: web::Form<PublishFormData>,
) -> HttpResponse {
let article_id_str = path.into_inner();
let article_id = match Uuid::parse_str(&article_id_str) {
Ok(id) => id,
Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
};
let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let commit_id = match Uuid::parse_str(&form.commit_id) {
Ok(id) => id,
Err(_) => return HttpResponse::BadRequest().body("Invalid commit ID"),
};
let _ = client
.execute(
"INSERT INTO published (article_id, commit_id) VALUES ($1, $2) \
ON CONFLICT (article_id) DO UPDATE SET commit_id = $2, published_at = NOW()",
&[&article_id, &commit_id],
)
.await;
let redirect_url = format!("/dashboard/articles/{article_id}/edit");
HttpResponse::SeeOther()
.insert_header(("Location", redirect_url))
.finish()
}
#[derive(Debug, serde::Deserialize)]
pub struct LoginFormData {
pub email: String,
pub password: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct RegisterFormData {
pub display_name: String,
pub email: String,
pub password: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct InviteFormData {
pub key: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct CreateArticleFormData {
pub title: String,
pub slug: String,
pub audience_id: String,
pub content: String,
pub message: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct CommitFormData {
pub content: String,
pub message: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct PublishFormData {
pub commit_id: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct CreateAudienceFormData {
pub name: String,
pub slug: String,
pub description: String,
}
#[get("/dashboard/audiences/new")]
pub async fn new_audience_page(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let body = html! {
section .mt5.mw7.center {
h1 .f2.serif.tl.mb3 { "new audience" }
form .measure action="/dashboard/audiences" method="post" {
div .mb3 {
label .db.mb1.f6.mid-gray for="name" { "name" }
input #name .pa2.w-100.ba.b--near-white type="text" name="name" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="slug" { "slug (url-safe)" }
input #slug .pa2.w-100.ba.b--near-white type="text" name="slug" required="";
}
div .mb3 {
label .db.mb1.f6.mid-gray for="description" { "description" }
textarea #description .pa2.w-100.ba.b--near-white style="min-height: 6rem;" name="description" {}
}
div .tl {
button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create" }
}
}
a .link.dark-gray.dim.db.tl.mt4.f6 href="/dashboard" { "← back to dashboard" }
}
};
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(templates::layout("new audience — lettre", body).into_string())
}
#[post("/dashboard/audiences")]
pub async fn create_audience(
req: HttpRequest,
db: web::Data<DbConfig>,
form: web::Form<CreateAudienceFormData>,
) -> HttpResponse {
let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
return HttpResponse::SeeOther()
.insert_header(("Location", "/login"))
.finish();
};
let client = match db.connect().await {
Ok(c) => c,
Err(e) => {
log::error!("DB connection failed: {e}");
return HttpResponse::InternalServerError().body("Database error");
}
};
let description = if form.description.is_empty() {
None
} else {
Some(form.description.as_str())
};
let result = client
.execute(
"INSERT INTO audiences (slug, name, description) VALUES ($1, $2, $3)",
&[&form.slug, &form.name, &description],
)
.await;
match result {
Ok(_) => HttpResponse::SeeOther()
.insert_header(("Location", "/dashboard"))
.finish(),
Err(e) => {
log::error!("Audience creation failed: {e}");
HttpResponse::SeeOther()
.insert_header(("Location", "/dashboard/audiences/new"))
.finish()
}
}
}