major features update

This commit is contained in:
Robert Perce 2025-11-27 13:45:21 -06:00
parent 519fb49901
commit 4e2fab67c5
48 changed files with 3925 additions and 208 deletions

114
src/web/auth.rs Normal file
View file

@ -0,0 +1,114 @@
use axum::extract::State;
use axum::http::HeaderMap;
use axum::{
Form, Router,
extract::Query,
response::{IntoResponse, Redirect},
routing::{get, post},
};
use maud::{DOCTYPE, html};
use serde::Deserialize;
use crate::models::user::{AuthSession, Credentials};
use crate::{AppError, AppState};
#[derive(Deserialize, Debug)]
struct NextUrl {
next: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(self::post::login))
.route("/login", get(self::get::login))
.route("/logout", get(self::get::logout))
}
mod post {
use super::*;
#[axum::debug_handler]
pub async fn login(
mut auth_session: AuthSession,
State(mut state): State<AppState>,
Query(NextUrl { next }): Query<NextUrl>,
Form(creds): Form<Credentials>,
) -> Result<impl IntoResponse, AppError> {
let mut headers = HeaderMap::new();
let user = match auth_session.authenticate(creds.clone()).await {
Ok(Some(user)) => user,
Ok(None) => {
return Err(AppError(anyhow::Error::msg(
"Username and password do not match",
)));
}
Err(_) => return Err(AppError(anyhow::Error::msg("Internal server error"))),
};
if auth_session.login(&user).await.is_err() {
return Err(AppError(anyhow::Error::msg("Server error during login")));
}
state.init(&user).await?;
if let Some(url) = next {
headers.insert("HX-Redirect", url.parse()?);
} else {
headers.insert("HX-Redirect", "/".parse()?);
}
Ok((headers, "ok"))
}
}
mod get {
use super::*;
pub async fn login(Query(NextUrl { next }): Query<NextUrl>) -> impl IntoResponse {
let post_url = format!(
"/login{}",
next.map_or("".to_string(), |n| format!("?next={}", n))
);
html! {
(DOCTYPE)
html {
head {
meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
link rel="stylesheet" type="text/css" href="/static/index.css";
link rel="stylesheet" type="text/css" href="/static/login.css";
title { "Mascarpone" }
}
body hx-ext="response-targets" {
h1 { "Mascarpone" }
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" {
label for="username" { "Username" }
input name="username" #username autofocus x-model="user";
label for="password" { "Password" }
input name="password" #password type="password" x-model="pass";
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)";
#error {}
}
}
}
}
}
pub async fn logout(
mut auth_session: AuthSession,
State(mut state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.clone();
auth_session.logout().await?;
if let Some(user) = user {
state.remove(&user);
}
Ok(Redirect::to("/login").into_response())
}
}

443
src/web/contact.rs Normal file
View file

@ -0,0 +1,443 @@
use axum::{
Router,
extract::{State, path::Path},
http::HeaderMap,
response::IntoResponse,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
use chrono::DateTime;
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use serde_json::json;
use sqlx::{QueryBuilder, Sqlite};
use super::Layout;
use super::home::journal_section;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry};
use crate::{AppError, AppState};
#[derive(serde::Serialize)]
pub struct Address {
pub id: DbId,
pub contact_id: DbId,
pub label: Option<String>,
pub value: String,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/contact/new", post(self::post::contact))
.route("/contact/{contact_id}", get(self::get::contact))
.route("/contact/{contact_id}", put(self::put::contact))
.route("/contact/{contact_id}", delete(self::delete::contact))
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
}
fn human_delta(delta: &chrono::TimeDelta) -> String {
if delta.num_days() == 0 {
return "today".to_string();
}
let mut result = "in ".to_string();
let mut rem = delta.clone();
if rem.num_days().abs() >= 7 {
let weeks = rem.num_days() / 7;
rem -= chrono::TimeDelta::days(weeks * 7);
result.push_str(&format!("{}w ", weeks));
}
if rem.num_days().abs() > 0 {
result.push_str(&format!("{}d ", rem.num_days()));
}
result.trim().to_string()
}
mod get {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names
from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let entries: Vec<JournalEntry> = sqlx::query_as(
"select j.id, j.value, j.date from journal_entries j
join contact_mentions cm on j.id = cm.entry_id
where cm.contact_id = $1",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
Ok(layout.render(
Some(vec!["/static/contact.css", "/static/journal.css"]),
html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div id="fields" {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div {
@for name in &contact.names {
div { (name) }
}
}
@if let Some(bday) = &contact.birthday {
label { "birthday" }
div {
(bday.to_string())
@if let Some(delta) = &bday.until_next() {
" ("
(human_delta(delta))
@if let Some(age) = &bday.age() {
", turning " (age + 1)
}
")"
}
}
}
label { "freshened" }
div {
@if let Some(when) = &contact.manually_freshened_at {
(when.date_naive().to_string())
} @else {
"(never)"
}
}
@if addresses.len() == 1 {
label { "address" }
#addresses {
.value { (addresses[0].value) }
}
} @else if addresses.len() > 0 {
label { "addresses" }
#addresses {
@for address in addresses {
.label {
span { (address.label.unwrap_or(String::new())) }
// raw nbsp instead of col-gap since i want no
// gap when all labels are empty
span { (PreEscaped("&nbsp;")) }
}
.value { (address.value) }
}
}
}
}
(journal_section(pool, &entries).await?)
},
))
}
pub async fn contact_edit(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id
order by jes.date desc limit 1
) as last_mention_date from contacts c
where c.id = $1",
)
.bind(contact_id)
.fetch_one(pool)
.await?;
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
let cid_url = format!("/contact/{}", contact.id);
let mfresh_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| m.to_rfc3339());
Ok(layout.render(Some(vec!["/static/contact.css"]), html! {
form hx-ext="response-targets" {
div {
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
input type="button" value="Delete" hx-delete=(cid_url) hx-target-error="#error";
div #error;
}
div #fields {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" {
div {
input name="name" x-model="name";
input type="button" value="×" x-bind:disabled="idx == 0" x-on:click="names.splice(idx, 1)";
input type="button" value="" x-bind:disabled="idx == 0" x-on:click="[names[idx-1], names[idx]] = [names[idx], names[idx-1]]";
input type="button" value="" x-bind:disabled="idx == names.length - 1" x-on:click="[names[idx+1], names[idx]] = [names[idx], names[idx+1]]";
}
}
div {
input name="name" x-model="new_name" placeholder="New name";
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
}
}
label { "birthday" }
div {
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
}
label { "freshened" }
div x-data=(json!({ "date": mfresh_str })) {
input type="hidden" name="manually_freshened_at" x-model="date";
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
}
label { "addresses" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" {
div {
input name="address_label" x-show="addresses.length > 1" x-model="address.label" placeholder="label";
input name="address_value" x-model="address.value" placeholder="address";
}
}
div {
input x-show="addresses.length > 1" name="address_label" x-model="new_label" placeholder="label";
input name="address_value" x-model="new_address" placeholder="new address";
}
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
}
}
}
}))
}
}
mod post {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contact_id: (u32,) =
sqlx::query_as("insert into contacts (birthday) values (null) returning id")
.fetch_one(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert(
"HX-Redirect",
format!("/contact/{}/edit", contact_id.0).parse()?,
);
Ok((headers, "ok"))
}
}
mod put {
use super::*;
#[derive(Deserialize)]
pub struct PutContact {
name: Option<Vec<String>>,
birthday: String,
manually_freshened_at: String,
address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>,
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
Form(payload): Form<PutContact>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let birthday = if payload.birthday.is_empty() {
None
} else {
Some(payload.birthday)
};
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
None
} else {
Some(
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_utc()
.to_rfc3339(),
)
};
sqlx::query!(
"update contacts set (birthday, manually_freshened_at) = ($1, $2) where id = $3",
birthday,
manually_freshened_at,
contact_id
)
.execute(pool)
.await?;
{
// update addresses
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
.execute(pool)
.await?;
if let Some(values) = payload.address_value {
let labels = if values.len() == 1 {
Some(vec![String::new()])
} else {
payload.address_label
};
if let Some(labels) = labels {
let new_addresses = labels
.into_iter()
.zip(values)
.filter(|(_, val)| val.len() > 0);
for (label, value) in new_addresses {
sqlx::query!(
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
contact_id,
label,
value
)
.execute(pool)
.await?;
}
}
}
}
let old_names: Vec<(String,)> = sqlx::query_as(
"delete from contact_mentions;
delete from names where contact_id = $1 returning name;",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
"select name, contact_id from (
select name, contact_id, count(name) as ct from names where name in (",
);
let mut name_list = recalc_counts.separated(", ");
for (name,) in &old_names {
name_list.push_bind(name);
}
if let Some(names) = payload.name {
let names: Vec<String> = names.into_iter().filter(|n| n.len() > 0).collect();
if !names.is_empty() {
for name in &names {
name_list.push_bind(name.clone());
}
let mut name_insert: QueryBuilder<Sqlite> =
QueryBuilder::new("insert into names (contact_id, sort, name) ");
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
builder
.push_bind(contact_id)
.push_bind(DbId::try_from(sort).unwrap())
.push_bind(name);
});
name_insert.build().persistent(false).execute(pool).await?;
}
}
name_list.push_unseparated(") group by name) where ct = 1");
let recalc_names: Vec<(String, DbId)> = recalc_counts
.build_query_as()
.persistent(false)
.fetch_all(pool)
.await?;
{
let trie_mutex = state.contact_search(&user);
let mut trie = trie_mutex.write().unwrap();
for name in &old_names {
trie.remove(&name.0);
}
for name in recalc_names {
trie.insert(name.0, name.1);
}
}
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
.fetch_all(pool)
.await?;
for entry in journal_entries {
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
}
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
Ok((headers, "ok"))
}
}
mod delete {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query(
"delete from contact_mentions where contact_id = $1;
delete from names where contact_id = $1;
delete from contacts where id = $1;",
)
.bind(contact_id)
.execute(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);
Ok((headers, "ok"))
}
}

241
src/web/home.rs Normal file
View file

@ -0,0 +1,241 @@
use axum::extract::State;
use axum::response::IntoResponse;
use chrono::{Local, NaiveDate, TimeDelta};
use maud::{Markup, html};
use sqlx::sqlite::SqlitePool;
use super::Layout;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{Birthday, HydratedContact, JournalEntry};
use crate::{AppError, AppState};
#[derive(Debug, Clone)]
struct ContactFreshness {
contact_id: DbId,
display: String,
fresh_date: NaiveDate,
fresh_str: String,
elapsed_str: String,
}
fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppError> {
Ok(html! {
div id="freshness" {
h2 { "Stale Contacts" }
div class="grid" {
span .th { "name" }
span .th { "freshened" }
span .th { "elapsed" }
@for contact in &freshens[0..std::cmp::min(5, freshens.len())] {
span {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
}
span { (contact.fresh_str) }
span { (contact.elapsed_str) }
}
}
}
})
}
#[derive(Debug, Clone)]
struct KnownBirthdayContact {
contact_id: i64,
display: String,
prev_birthday: NaiveDate,
next_birthday: NaiveDate,
}
fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>,
upcoming_birthdays: &Vec<KnownBirthdayContact>,
) -> Result<Markup, AppError> {
Ok(html! {
div id="birthdays" {
h2 { "Birthdays" }
#birthday-sections {
.datelist {
h3 { "upcoming" }
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
span {
(contact.next_birthday.format("%m-%d"))
}
}
}
.datelist {
h3 { "recent" }
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
span {
(contact.prev_birthday.format("%m-%d"))
}
}
}
}
}
})
}
pub async fn journal_section(
pool: &SqlitePool,
entries: &Vec<JournalEntry>,
) -> Result<Markup, AppError> {
Ok(html! {
div id="journal" x-data="{ edit: false }" {
header {
h2 { "Journal" }
input id="journal-edit-mode" type="checkbox" x-model="edit" {
label for="journal-edit-mode" { "Edit" }
}
}
.disclaimer {
"Leave off year or year and month in the date field to default to what they
are now, or leave everything blank to default to 'today'. Entries will be
added to the top of the list regardless of date; refresh the page to re-sort."
}
form hx-post="/journal_entry" hx-target="next .entries" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
input name="date" placeholder=(Local::now().date_naive().to_string());
textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry";
}
.entries {
@for entry in entries {
(entry.to_html(pool).await?)
}
}
}
})
}
pub mod get {
use super::*;
pub async fn home(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contacts: Vec<HydratedContact> = sqlx::query_as(
"select id, birthday, manually_freshened_at, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names, (
select jes.date from journal_entries jes
join contact_mentions cms on cms.entry_id = jes.id
where cms.contact_id = c.id
order by jes.date desc limit 1
) as last_mention_date from contacts c",
)
.fetch_all(pool)
.await?;
let mut freshens: Vec<ContactFreshness> = contacts
.clone()
.into_iter()
.map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap();
let fresh_date = std::cmp::max(
contact
.manually_freshened_at
.map(|x| x.date_naive())
.unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero),
);
if fresh_date == zero {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: "never".to_string(),
elapsed_str: "".to_string(),
}
} else {
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
let mut elapsed: Vec<String> = Vec::new();
let y = duration.num_weeks() / 52;
let count = |n: i64, noun: &str| {
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
};
if y > 0 {
elapsed.push(count(y, "year"));
duration -= TimeDelta::weeks(y * 52);
}
let w = duration.num_weeks();
if w > 0 {
elapsed.push(count(w, "week"));
duration -= TimeDelta::weeks(w);
}
let d = duration.num_days();
if d > 0 {
elapsed.push(count(d, "day"));
}
let elapsed_str = if elapsed.is_empty() {
"today".to_string()
} else {
elapsed.join(", ")
};
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: fresh_date.to_string(),
elapsed_str,
}
}
})
.collect();
freshens.sort_by_key(|x| x.fresh_date);
let birthdays = contacts
.into_iter()
.map(|contact| {
if let Some(Birthday::Date(date)) = &contact.birthday {
Some(KnownBirthdayContact {
contact_id: contact.id,
display: contact.display_name(),
prev_birthday: date.prev_month_day_occurrence().unwrap(),
next_birthday: date.next_month_day_occurrence().unwrap(),
})
} else {
None
}
})
.filter(|x| x.is_some())
.map(|x| x.unwrap())
.collect::<Vec<KnownBirthdayContact>>();
let mut prev_birthdays = birthdays.clone();
prev_birthdays.sort_by_key(|x| x.prev_birthday);
prev_birthdays.reverse();
let mut upcoming_birthdays = birthdays;
upcoming_birthdays.sort_by_key(|x| x.next_birthday);
// I'm writing this as an n+1 query pattern deliberately
// since I *think* the overhead of string_agg+split might
// be worse than that of the n+1 since we're in sqlite.
let entries: Vec<JournalEntry> =
sqlx::query_as("select id,value,date from journal_entries order by date desc")
.fetch_all(pool)
.await?;
Ok(layout.render(
Some(vec!["/static/home.css", "/static/journal.css"]),
html! {
(freshness_section(&freshens)?)
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?)
(journal_section(pool, &entries).await?)
},
))
}
}

88
src/web/ics.rs Normal file
View file

@ -0,0 +1,88 @@
use axum::{Router, extract::Path, response::IntoResponse, routing::get};
use chrono::NaiveDate;
use icalendar::{Calendar, Component, Event, EventLike};
use regex::Regex;
use crate::models::user::{AuthSession, User};
use crate::models::{Birthday, HydratedContact};
use crate::{AppError, AppState, Database};
pub fn router() -> Router<AppState> {
Router::new().route("/cal/{path}", get(self::get::calendar))
}
mod get {
use super::*;
pub async fn calendar(
auth_session: AuthSession,
Path(ics_path): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let path_re = Regex::new(r"^(?<username>.+)-(?<hash>[0-9a-zA-Z]+).ics$").unwrap();
let username = if let Some(caps) = path_re.captures(&ics_path) {
caps.name("username").unwrap().as_str()
} else {
tracing::debug!(
"No username match in path {:?} for re /^.+-[0-9a-zA-Z]+.ics$/",
ics_path
);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
};
let user: Option<User> = auth_session.backend.find_user(username).await?;
if user.is_none() {
tracing::debug!("No matching user for username {:?}", username);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
}
let user = user.unwrap();
let pool = Database::for_user(&user).await?.pool;
let expected_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
.fetch_one(&pool)
.await?;
let debug_ics_path = ics_path.clone();
if expected_path.0 != Some(ics_path) {
tracing::debug!(
"Expected path {:?} did not match request path {:?}",
expected_path.0,
debug_ics_path
);
return Err(AppError(anyhow::Error::msg("TODO: 404")));
}
let calname = format!("Contact birthdays for {}", user.username);
let mut calendar = Calendar::new();
calendar.name(&calname);
calendar.append_property(("PRODID", "Mascarpone CRM"));
let contacts: Vec<HydratedContact> = sqlx::query_as(
"select id, birthday, (
select string_agg(name,'\x1c' order by sort)
from names where contact_id = c.id
) as names
from contacts c",
)
.fetch_all(&pool)
.await?;
for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt(
yo_date.year.unwrap_or(1900),
yo_date.month,
yo_date.day,
) {
calendar.push(
Event::new()
.starts(date) // start-with-no-end is "all day"
.summary(&format!("{}'s Birthday", &contact.display_name()))
.add_property("RRULE", "FREQ=YEARLY"),
);
}
}
}
tracing::debug!("{}", calendar);
Ok(calendar.to_string())
}
}

146
src/web/journal.rs Normal file
View file

@ -0,0 +1,146 @@
use axum::{
Form, Router,
extract::{State, path::Path},
response::IntoResponse,
routing::{delete, patch, post},
};
use chrono::{Datelike, Local, NaiveDate};
use maud::Markup;
use regex::Regex;
use serde::Deserialize;
use crate::models::JournalEntry;
use crate::models::user::AuthSession;
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/journal_entry", post(self::post::entry))
.route("/journal_entry/{entry_id}", patch(self::patch::entry))
.route("/journal_entry/{entry_id}", delete(self::delete::entry))
}
#[derive(Deserialize)]
pub struct PostJournalEntryBody {
date: String,
value: String,
}
mod post {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Form(payload): Form<PostJournalEntryBody>,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let now = Local::now().date_naive();
let date = if payload.date.is_empty() {
now
} else {
let date_re =
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
.unwrap();
let caps = date_re.captures(&payload.date).ok_or(anyhow::Error::msg(
"invalid date: must match (yyyy-)?(mm-)?dd",
))?;
// unwrapping these parses is safe since it's matching [0-9]{2,4}
let year = caps
.name("year")
.map(|m| m.as_str().parse::<i32>().unwrap())
.unwrap_or(now.year());
let month = caps
.name("month")
.map(|m| m.as_str().parse::<u32>().unwrap())
.unwrap_or(now.month());
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
"invalid date: failed NaiveDate construction",
))?
};
// not a macro query, we want to use JournalEntry's custom FromRow
let entry: JournalEntry = sqlx::query_as(
"insert into journal_entries (value, date) values ($1, $2) returning id, value, date",
)
.bind(payload.value)
.bind(date.to_string())
.fetch_one(pool)
.await?;
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
Ok(entry.to_html(pool).await?)
}
}
mod patch {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Path(entry_id): Path<u32>,
Form(payload): Form<PostJournalEntryBody>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
// not a macro query, we want to use JournalEntry's custom FromRow
let entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1")
.bind(entry_id)
.fetch_one(pool)
.await?;
sqlx::query!(
"update journal_entries set date = $1, value = $2 where id = $3",
payload.date,
payload.value,
entry_id
)
.execute(pool)
.await?;
if entry.value != payload.value {
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
.execute(pool)
.await?;
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
}
Ok(entry.to_html(pool).await?)
}
}
mod delete {
use super::*;
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
Path(entry_id): Path<u32>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query(
"delete from contact_mentions where entry_id = $1;
delete from journal_entries where id = $2 returning id,date,value",
)
.bind(entry_id)
.bind(entry_id)
.execute(pool)
.await?;
Ok(())
}
}

105
src/web/mod.rs Normal file
View file

@ -0,0 +1,105 @@
use axum::RequestPartsExt;
use axum::extract::FromRequestParts;
// use axum::response::{IntoResponse, Redirect};
use http::request::Parts;
use maud::{DOCTYPE, Markup, html};
use sqlx::FromRow;
use super::models::user::{AuthSession, User};
use super::{AppError, AppState};
pub mod auth;
pub mod contact;
pub mod home;
pub mod ics;
pub mod journal;
pub mod settings;
#[derive(Debug, FromRow)]
struct ContactLink {
name: String,
contact_id: u32,
}
pub struct Layout {
contact_links: Vec<ContactLink>,
user: User,
}
impl FromRequestParts<AppState> for Layout {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_session = parts
.extract::<AuthSession>()
.await
.map_err(|_| anyhow::Error::msg("could not get session"))?;
let user = auth_session.user.unwrap();
let contact_links: Vec<ContactLink> = sqlx::query_as(
"select c.id as contact_id,
coalesce(n.name, '(unnamed)') as name
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
Ok(Layout {
contact_links,
user,
})
}
}
impl Layout {
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
head {
link rel="stylesheet" type="text/css" href="/static/index.css";
meta name="viewport" content="width=device-width";
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(href);
}
}
}
body x-data="{ sidebar: false }" {
header {
input #sidebar-show-hide type="button" x-on:click="sidebar = !sidebar" value="";
h1 { a href="/" { "Mascarpone" } }
span { (self.user.username) }
a href="/settings" { "Settings" }
a href="/logout" { "Logout" }
}
section #content {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
ul {
li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links {
li {
a href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
}
}
}
main {
(content)
}
}
}
}
}
}
}

167
src/web/settings.rs Normal file
View file

@ -0,0 +1,167 @@
use axum::{
Router,
extract::State,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use short_uuid::ShortUuid;
use super::Layout;
use crate::models::user::{AuthSession, Credentials};
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/settings", get(self::get::settings))
.route("/settings/ics_path", post(self::post::ics_path))
.route("/settings/ics_path", delete(self::delete::ics_path))
.route("/password", put(self::put::password))
}
fn calendar_link(path: Option<String>) -> Markup {
if let Some(path) = path {
html! {
#cal-link x-data=(json!({ "path": path })) hx-target="this" hx-swap="outerHTML" {
a x-bind:href="window.location.origin + '/cal/' + path" {
span x-text="window.location.origin + '/cal/'" {}
span { (path) }
}
p {
"Warning: These actions unrecoverably change your calendar's URL."
}
button hx-post="/settings/ics_path" { "Regenerate path" }
button hx-delete="/settings/ics_path" { "Destroy calendar" }
}
}
} else {
html! {
#cal-link hx-target="this" hx-swap="outerHTML" {
div { "Birthdays calendar is disabled." }
button hx-post="/settings/ics_path" { "Enable calendar" }
}
}
}
}
mod get {
use super::*;
pub async fn settings(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let ics_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
.fetch_one(pool)
.await?;
let ics_path: Option<String> = ics_path.0;
Ok(layout.render(
Some(vec!["static/settings.css"]),
html! {
h2 { "Birthdays Calendar URL" }
(calendar_link(ics_path))
h2 { "Change Password" }
form x-data="{ old_p: '', new_p: '', confirm: '' }" hx-put="/password"
hx-on::after-request="if(event.detail.successful) { this.reset(); setTimeout(() => window.location.reload(), 5000); }"
hx-target="this" hx-target-error="this" hx-swap="beforeend" {
label for="old" { "Current password:" }
input id="old" name="current" x-model="old_p" type="password";
label for="new" { "New password:" }
input id="new" name="new_password" x-model="new_p" type="password";
label for="confirm" { "Confirm:" }
input id="confirm" x-model="confirm" type="password";
button type="submit" x-bind:disabled="!(new_p.length && new_p === confirm)" { "Submit" }
.error x-show="new_p.length && confirm.length && new_p !== confirm" {
"Passwords do not match"
}
}
},
))
}
}
mod post {
use super::*;
pub async fn ics_path(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let ics_path = format!("{}-{}.ics", &user.username, ShortUuid::generate());
sqlx::query!("update settings set ics_path=$1", ics_path)
.execute(pool)
.await?;
Ok(calendar_link(Some(ics_path)))
}
}
mod delete {
use super::*;
pub async fn ics_path(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
sqlx::query!("update settings set ics_path=null")
.execute(pool)
.await?;
Ok(calendar_link(None))
}
}
mod put {
use super::*;
#[derive(Deserialize)]
pub struct PassChange {
current: String,
new_password: String,
}
pub async fn password(
auth_session: AuthSession,
Form(payload): Form<PassChange>,
) -> Result<Markup, AppError> {
let username = auth_session.user.as_ref().unwrap().username.clone();
tracing::debug!("Resetting password for {}...", username);
let current_creds = Credentials {
username: username.clone(),
password: payload.current,
};
let new_creds = Credentials {
username: username,
password: payload.new_password,
};
match auth_session.authenticate(current_creds).await {
Err(_) => Ok(html! { .error { "Server error; could not verify authentication." } }),
Ok(None) => Ok(html! { .error { "Current password is incorrect." } }),
Ok(Some(_)) => {
auth_session.backend.set_password(new_creds).await?;
Ok(html! { .msg {
"Password changed successfully. Redirecting to login page after 5 seconds..."
} })
}
}
}
}