major features update
This commit is contained in:
parent
519fb49901
commit
4e2fab67c5
48 changed files with 3925 additions and 208 deletions
|
|
@ -1,4 +0,0 @@
|
|||
#[derive(Clone)]
|
||||
pub struct Contact {
|
||||
pub name: String,
|
||||
}
|
||||
35
src/db.rs
Normal file
35
src/db.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::models::user::User;
|
||||
|
||||
pub struct Database {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
pub type DbId = i64;
|
||||
|
||||
impl Database {
|
||||
pub async fn for_user(user: &User) -> Result<Self, anyhow::Error> {
|
||||
let file = if user.ephemeral {
|
||||
":memory:".to_string()
|
||||
} else {
|
||||
format!("./dbs/{}.db", user.username)
|
||||
};
|
||||
|
||||
let db_options = SqliteConnectOptions::from_str(&file)?
|
||||
.create_if_missing(true)
|
||||
.to_owned();
|
||||
|
||||
let pool = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||
|
||||
let migrator = if user.username == "demo" {
|
||||
sqlx::migrate!("./migrations/demo.db/")
|
||||
} else {
|
||||
sqlx::migrate!("./migrations/each_user/")
|
||||
};
|
||||
migrator.run(&pool).await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
}
|
||||
279
src/main.rs
279
src/main.rs
|
|
@ -1,46 +1,189 @@
|
|||
use axum::{Router, extract::State, response::IntoResponse, routing::get};
|
||||
use maud::html;
|
||||
use axum::Router;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum_login::AuthUser;
|
||||
use axum_login::{AuthManagerLayerBuilder, login_required};
|
||||
use clap::{Parser, Subcommand, arg, command};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use tokio::task::AbortHandle;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod contact;
|
||||
use contact::Contact;
|
||||
mod models;
|
||||
use models::contact::ContactTrie;
|
||||
use models::user::{Backend, User};
|
||||
|
||||
mod db;
|
||||
use db::{Database, DbId};
|
||||
|
||||
mod web;
|
||||
use web::{auth, contact, home, ics, journal, settings};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppStateEntry {
|
||||
database: Arc<Database>,
|
||||
contact_search: Arc<RwLock<ContactTrie>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
contacts: Vec<Contact>,
|
||||
map: Arc<RwLock<HashMap<DbId, AppStateEntry>>>,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn contacts(
|
||||
// access the state via the `State` extractor
|
||||
// extracting a state of the wrong type results in a compile error
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
html! {
|
||||
ul {
|
||||
@for contact in &state.contacts {
|
||||
li { (&contact.name) }
|
||||
}
|
||||
struct NameReference {
|
||||
name: String,
|
||||
contact_id: DbId,
|
||||
}
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
AppState {
|
||||
map: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
pub async fn init(&mut self, user: &User) -> Result<Option<AppStateEntry>, AppError> {
|
||||
let database = Database::for_user(&user).await?;
|
||||
let mut trie = radix_trie::Trie::new();
|
||||
let rows = sqlx::query_as!(
|
||||
NameReference,
|
||||
"select name, contact_id from (
|
||||
select contact_id, name, count(name) as ct from names group by name
|
||||
) where ct = 1;",
|
||||
)
|
||||
.fetch_all(&database.pool)
|
||||
.await?;
|
||||
|
||||
for row in rows {
|
||||
trie.insert(row.name, DbId::try_from(row.contact_id)?);
|
||||
}
|
||||
|
||||
let mut map = self.map.write().expect("rwlock poisoned");
|
||||
Ok(map.insert(
|
||||
user.id(),
|
||||
crate::AppStateEntry {
|
||||
database: Arc::new(database),
|
||||
contact_search: Arc::new(RwLock::new(trie)),
|
||||
},
|
||||
))
|
||||
}
|
||||
pub fn remove(&mut self, user: &impl AuthUser<Id = DbId>) {
|
||||
let mut map = self.map.write().expect("rwlock poisoned");
|
||||
map.remove(&user.id());
|
||||
}
|
||||
pub fn db(&self, user: &impl AuthUser<Id = DbId>) -> Arc<Database> {
|
||||
let map = self.map.read().expect("rwlock poisoned");
|
||||
|
||||
map.get(&user.id()).unwrap().database.clone()
|
||||
}
|
||||
pub fn contact_search(&self, user: &impl AuthUser<Id = DbId>) -> Arc<RwLock<ContactTrie>> {
|
||||
let map = self.map.read().expect("rwlock poisoned");
|
||||
map.get(&user.id()).unwrap().contact_search.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
let state = AppState {
|
||||
contacts: vec![
|
||||
Contact {
|
||||
name: "Foo Bar".to_string(),
|
||||
},
|
||||
Contact {
|
||||
name: "Baz Qux".to_string(),
|
||||
},
|
||||
],
|
||||
pub struct AppError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", self.0),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
impl<E> From<E> for AppError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// run mascarpone server (default)
|
||||
Serve {
|
||||
/// port to bind
|
||||
#[arg(short, long, default_value_t = 3000)]
|
||||
port: u32,
|
||||
},
|
||||
|
||||
SetPassword {
|
||||
/// username to create or set password
|
||||
username: String,
|
||||
},
|
||||
}
|
||||
|
||||
async fn serve(port: &u32) -> Result<(), anyhow::Error> {
|
||||
let users_db = {
|
||||
let db_options = SqliteConnectOptions::from_str("users.db")?
|
||||
.create_if_missing(true)
|
||||
.to_owned();
|
||||
|
||||
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||
sqlx::migrate!("./migrations/users.db").run(&db).await?;
|
||||
db
|
||||
};
|
||||
|
||||
let state = AppState::new();
|
||||
let session_store = SqliteStore::new(users_db.clone());
|
||||
session_store.migrate().await?;
|
||||
|
||||
let deletion_task = tokio::task::spawn(
|
||||
session_store
|
||||
.clone()
|
||||
.continuously_delete_expired(tokio::time::Duration::from_secs(600)),
|
||||
);
|
||||
|
||||
// Generate a cryptographic key to sign the session cookie.
|
||||
let key = Key::generate();
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_expiry(Expiry::OnInactivity(time::Duration::days(10)))
|
||||
.with_signed(key);
|
||||
|
||||
let backend = Backend::new(users_db.clone());
|
||||
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
format!(
|
||||
"{}=debug,tower_http=debug,axum=trace",
|
||||
env!("CARGO_CRATE_NAME")
|
||||
)
|
||||
.into()
|
||||
}),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer().without_time())
|
||||
.init();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "Hello, World!" }))
|
||||
.route("/contacts", get(contacts))
|
||||
.route("/", get(home::get::home))
|
||||
.merge(contact::router())
|
||||
.merge(journal::router())
|
||||
.merge(settings::router())
|
||||
.route_layer(login_required!(Backend, login_url = "/login"))
|
||||
.merge(auth::router())
|
||||
.merge(ics::router())
|
||||
.nest_service("/static", ServeDir::new("./static"))
|
||||
.layer(auth_layer)
|
||||
.with_state(state);
|
||||
|
||||
let mut listenfd = listenfd::ListenFd::from_env();
|
||||
|
|
@ -49,9 +192,83 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||
listener.set_nonblocking(true)?;
|
||||
TcpListener::from_std(listener)
|
||||
}
|
||||
None => TcpListener::bind("0.0.0.0:3000").await,
|
||||
None => TcpListener::bind(format!("0.0.0.0:{}", port)).await,
|
||||
}?;
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
tracing::debug!("Starting axum on 0.0.0.0:3000...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
deletion_task.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
Some(Commands::SetPassword { username }) => {
|
||||
let users_db = {
|
||||
let db_options = SqliteConnectOptions::from_str("users.db")?
|
||||
.create_if_missing(true)
|
||||
.to_owned();
|
||||
|
||||
let db = SqlitePoolOptions::new().connect_with(db_options).await?;
|
||||
sqlx::migrate!("./migrations/users.db").run(&db).await?;
|
||||
db
|
||||
};
|
||||
|
||||
let password =
|
||||
rpassword::prompt_password(format!("New password for {}: ", username)).unwrap();
|
||||
|
||||
let update = sqlx::query(
|
||||
"insert into users (username, password) values ($1, $2) on conflict do update set password=excluded.password",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_auth::generate_hash(password))
|
||||
.execute(&users_db)
|
||||
.await?;
|
||||
|
||||
if update.rows_affected() > 0 {
|
||||
println!("Updated password for {}.", username);
|
||||
} else {
|
||||
println!("No update was made; probably something went wrong.");
|
||||
}
|
||||
}
|
||||
Some(Commands::Serve { port }) => {
|
||||
serve(port).await?;
|
||||
}
|
||||
None => {
|
||||
serve(&3000).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => { deletion_task_abort_handle.abort() },
|
||||
_ = terminate => { deletion_task_abort_handle.abort() },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/models.rs
Normal file
13
src/models.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
pub mod contact;
|
||||
pub use contact::HydratedContact;
|
||||
|
||||
mod year_optional_date;
|
||||
pub use year_optional_date::YearOptionalDate;
|
||||
|
||||
mod birthday;
|
||||
pub use birthday::Birthday;
|
||||
|
||||
mod journal;
|
||||
pub use journal::JournalEntry;
|
||||
|
||||
pub mod user;
|
||||
93
src/models/birthday.rs
Normal file
93
src/models/birthday.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use chrono::Local;
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{FromRow, Row};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::models::YearOptionalDate;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Text {
|
||||
// language: Option<String>,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Birthday {
|
||||
Date(YearOptionalDate),
|
||||
Text(Text),
|
||||
}
|
||||
|
||||
impl Display for Birthday {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
Birthday::Date(date) => date.to_string(),
|
||||
Birthday::Text(t) => t.value.clone(),
|
||||
};
|
||||
write!(f, "{}", str)
|
||||
}
|
||||
}
|
||||
|
||||
impl Birthday {
|
||||
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
|
||||
match &self {
|
||||
Birthday::Text(_) => None,
|
||||
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
|
||||
self.next_occurrence()
|
||||
.map(|when| when.signed_duration_since(Local::now().date_naive()))
|
||||
}
|
||||
|
||||
/// None if this is a text birthday or doesn't have a year
|
||||
pub fn age(&self) -> Option<u32> {
|
||||
match &self {
|
||||
Birthday::Text(_) => None,
|
||||
Birthday::Date(date) => date
|
||||
.to_date_naive()
|
||||
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> String {
|
||||
match &self {
|
||||
Birthday::Text(text) => text.value.clone(),
|
||||
Birthday::Date(date) => date.serialize(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Birthday {
|
||||
type Err = ();
|
||||
fn from_str(str: &str) -> Result<Self, Self::Err> {
|
||||
if let Some(date) = YearOptionalDate::from_str(str).ok() {
|
||||
Ok(Birthday::Date(date))
|
||||
} else {
|
||||
Ok(Birthday::Text(super::birthday::Text {
|
||||
value: str.to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromRow<'_, SqliteRow> for Birthday {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let birthday_str = row.try_get("birthday")?;
|
||||
Ok(Birthday::from_str(birthday_str).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
use sqlx::{Database, Decode, Sqlite};
|
||||
impl<'r> Decode<'r, Sqlite> for Birthday
|
||||
where
|
||||
&'r str: Decode<'r, Sqlite>,
|
||||
{
|
||||
fn decode(
|
||||
value: <Sqlite as Database>::ValueRef<'r>,
|
||||
) -> Result<Birthday, Box<dyn std::error::Error + 'static + Send + Sync>> {
|
||||
let value = <&str as Decode<Sqlite>>::decode(value)?;
|
||||
|
||||
Ok(Birthday::from_str(value).unwrap())
|
||||
}
|
||||
}
|
||||
86
src/models/contact.rs
Normal file
86
src/models/contact.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{FromRow, Row};
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::Birthday;
|
||||
use crate::db::DbId;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contact {
|
||||
pub id: DbId,
|
||||
pub birthday: Option<Birthday>,
|
||||
pub manually_freshened_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HydratedContact {
|
||||
pub contact: Contact,
|
||||
pub last_mention_date: Option<NaiveDate>,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for HydratedContact {
|
||||
type Target = Contact;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.contact
|
||||
}
|
||||
}
|
||||
|
||||
impl HydratedContact {
|
||||
pub fn display_name(&self) -> String {
|
||||
if let Some(name) = self.names.first() {
|
||||
name.clone()
|
||||
} else {
|
||||
"(unnamed)".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub type ContactTrie = radix_trie::Trie<String, DbId>;
|
||||
|
||||
impl FromRow<'_, SqliteRow> for Contact {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let id: DbId = row.try_get("id")?;
|
||||
|
||||
let birthday = Birthday::from_row(row).ok();
|
||||
|
||||
let manually_freshened_at = row
|
||||
.try_get::<String, &str>("manually_freshened_at")
|
||||
.ok()
|
||||
.and_then(|str| {
|
||||
DateTime::parse_from_str(&str, "%+")
|
||||
.ok()
|
||||
.map(|d| d.to_utc())
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
birthday,
|
||||
manually_freshened_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRow<'_, SqliteRow> for HydratedContact {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let contact = Contact::from_row(row)?;
|
||||
|
||||
let names_str: String = row.try_get("names").unwrap_or("".to_string());
|
||||
let names = if names_str.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
names_str.split('\x1c').map(|s| s.to_string()).collect()
|
||||
};
|
||||
|
||||
let last_mention_date = row
|
||||
.try_get::<String, &str>("last_mention_date")
|
||||
.ok()
|
||||
.and_then(|str| NaiveDate::from_str(&str).ok());
|
||||
|
||||
Ok(Self {
|
||||
contact,
|
||||
names,
|
||||
last_mention_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
144
src/models/journal.rs
Normal file
144
src/models/journal.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use chrono::NaiveDate;
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::{SqlitePool, SqliteRow};
|
||||
use sqlx::{FromRow, Row};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::contact::ContactTrie;
|
||||
use crate::AppError;
|
||||
use crate::db::DbId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JournalEntry {
|
||||
pub id: DbId,
|
||||
pub value: String,
|
||||
pub date: NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, FromRow)]
|
||||
pub struct ContactMention {
|
||||
pub entry_id: DbId,
|
||||
pub contact_id: DbId,
|
||||
pub input_text: String,
|
||||
pub byte_range_start: u32,
|
||||
pub byte_range_end: u32,
|
||||
}
|
||||
|
||||
impl JournalEntry {
|
||||
pub fn extract_mentions(&self, trie: &ContactTrie) -> HashSet<ContactMention> {
|
||||
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
||||
name_re
|
||||
.captures_iter(&self.value)
|
||||
.map(|caps| {
|
||||
let range = caps.get_match().range();
|
||||
trie.get(&caps[1]).map(|cid| ContactMention {
|
||||
entry_id: self.id,
|
||||
contact_id: cid.to_owned(),
|
||||
input_text: caps[1].to_string(),
|
||||
byte_range_start: u32::try_from(range.start).unwrap(),
|
||||
byte_range_end: u32::try_from(range.end).unwrap(),
|
||||
})
|
||||
})
|
||||
.filter(|o| o.is_some())
|
||||
.map(|o| o.unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn insert_mentions(
|
||||
&self,
|
||||
trie: Arc<RwLock<ContactTrie>>,
|
||||
pool: &SqlitePool,
|
||||
) -> Result<HashSet<ContactMention>, AppError> {
|
||||
let mentions = {
|
||||
let trie = trie.read().unwrap();
|
||||
self.extract_mentions(&trie)
|
||||
};
|
||||
|
||||
for mention in &mentions {
|
||||
sqlx::query!(
|
||||
"insert into contact_mentions(
|
||||
entry_id, contact_id, input_text,
|
||||
byte_range_start, byte_range_end
|
||||
) values ($1, $2, $3, $4, $5)",
|
||||
mention.entry_id,
|
||||
mention.contact_id,
|
||||
mention.input_text,
|
||||
mention.byte_range_start,
|
||||
mention.byte_range_end
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(mentions)
|
||||
}
|
||||
|
||||
pub async fn to_html(&self, pool: &SqlitePool) -> Result<Markup, AppError> {
|
||||
// important to sort desc so that changing contents early in the string
|
||||
// doesn't break inserting mentions at byte offsets further in
|
||||
let mentions: Vec<ContactMention> = sqlx::query_as(
|
||||
"select * from contact_mentions
|
||||
where entry_id = $1 order by byte_range_start desc",
|
||||
)
|
||||
.bind(self.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut value = self.value.clone();
|
||||
for mention in mentions {
|
||||
value.replace_range(
|
||||
(mention.byte_range_start as usize)..(mention.byte_range_end as usize),
|
||||
&format!("[{}](/contact/{})", mention.input_text, mention.contact_id),
|
||||
);
|
||||
}
|
||||
|
||||
let entry_url = format!("/journal_entry/{}", self.id);
|
||||
let date = self.date.to_string();
|
||||
|
||||
Ok(html! {
|
||||
.entry {
|
||||
.view ":class"="{ hide: edit }" {
|
||||
.date { (date) }
|
||||
.content { (PreEscaped(markdown::to_html(&value))) }
|
||||
}
|
||||
form .edit ":class"="{ hide: !edit }" x-data=(json!({ "date": date, "initial_date": date, "value": self.value, "initial_value": self.value })) {
|
||||
input name="date" x-model="date";
|
||||
.controls {
|
||||
textarea name="value" x-model="value" {}
|
||||
button title="Delete"
|
||||
hx-delete=(entry_url)
|
||||
hx-target="closest .entry"
|
||||
hx-swap="delete" {
|
||||
svg .icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" {
|
||||
path d="M232.7 69.9C237.1 56.8 249.3 48 263.1 48L377 48C390.8 48 403 56.8 407.4 69.9L416 96L512 96C529.7 96 544 110.3 544 128C544 145.7 529.7 160 512 160L128 160C110.3 160 96 145.7 96 128C96 110.3 110.3 96 128 96L224 96L232.7 69.9zM128 208L512 208L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 208zM216 272C202.7 272 192 282.7 192 296L192 488C192 501.3 202.7 512 216 512C229.3 512 240 501.3 240 488L240 296C240 282.7 229.3 272 216 272zM320 272C306.7 272 296 282.7 296 296L296 488C296 501.3 306.7 512 320 512C333.3 512 344 501.3 344 488L344 296C344 282.7 333.3 272 320 272zM424 272C410.7 272 400 282.7 400 296L400 488C400 501.3 410.7 512 424 512C437.3 512 448 501.3 448 488L448 296C448 282.7 437.3 272 424 272z";
|
||||
}
|
||||
}
|
||||
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
|
||||
x-on:click="initial_date = date; initial_value = value"
|
||||
hx-patch=(entry_url)
|
||||
hx-target="previous .entry"
|
||||
hx-swap="outerHTML"
|
||||
title="Save" { "✓" }
|
||||
button x-bind:disabled="(date === initial_date) && (value === initial_value)"
|
||||
title="Discard changes"
|
||||
x-on:click="date = initial_date; value = initial_value"
|
||||
{ "✗" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRow<'_, SqliteRow> for JournalEntry {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let id: DbId = row.try_get("id")?;
|
||||
let value: String = row.try_get("value")?;
|
||||
let date_str: &str = row.try_get("date")?;
|
||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
|
||||
Ok(Self { id, value, date })
|
||||
}
|
||||
}
|
||||
126
src/models/user.rs
Normal file
126
src/models/user.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use password_auth::verify_password;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use tokio::task;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
id: i64,
|
||||
pub username: String,
|
||||
password: String,
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
// Here we've implemented `Debug` manually to avoid accidentally logging the
|
||||
// password hash.
|
||||
impl std::fmt::Debug for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("User")
|
||||
.field("id", &self.id)
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[redacted]")
|
||||
.field("ephemeral", &self.ephemeral)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i64;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
// We use the password hash as the auth hash--what this means
|
||||
// is when the user changes their password the auth session becomes invalid.
|
||||
self.password.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backend {
|
||||
db: SqlitePool,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn new(db: SqlitePool) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn set_password(&self, creds: Credentials) -> Result<(), anyhow::Error> {
|
||||
if creds.username != "demo" {
|
||||
sqlx::query("update users set password=$2 where username=$1")
|
||||
.bind(creds.username)
|
||||
.bind(password_auth::generate_hash(creds.password))
|
||||
.execute(&self.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_user(
|
||||
&self,
|
||||
username: impl AsRef<str>,
|
||||
) -> Result<Option<User>, anyhow::Error> {
|
||||
let user = sqlx::query_as("select * from users where username = ?")
|
||||
.bind(username.as_ref())
|
||||
.fetch_optional(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
TaskJoin(#[from] task::JoinError),
|
||||
}
|
||||
|
||||
impl AuthnBackend for Backend {
|
||||
type User = User;
|
||||
type Credentials = Credentials;
|
||||
type Error = Error;
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user: Option<Self::User> = sqlx::query_as("select * from users where username = $1")
|
||||
.bind(creds.username)
|
||||
.fetch_optional(&self.db)
|
||||
.await?;
|
||||
|
||||
// Verifying the password is blocking and potentially slow, so we'll do so via
|
||||
// `spawn_blocking`.
|
||||
task::spawn_blocking(|| {
|
||||
// We're using password-based authentication--this works by comparing our form
|
||||
// input with an argon2 password hash.
|
||||
Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok()))
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = sqlx::query_as("select * from users where id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.db)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthSession = axum_login::AuthSession<Backend>;
|
||||
114
src/models/year_optional_date.rs
Normal file
114
src/models/year_optional_date.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use regex::Regex;
|
||||
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YearOptionalDate {
|
||||
pub year: Option<i32>,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
}
|
||||
|
||||
impl YearOptionalDate {
|
||||
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||
let now = Local::now();
|
||||
let year = now.year();
|
||||
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||
if let Some(real_date) = date {
|
||||
if real_date >= now.date_naive() {
|
||||
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
|
||||
}
|
||||
}
|
||||
date
|
||||
}
|
||||
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||
let now = Local::now();
|
||||
let year = now.year();
|
||||
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||
if let Some(real_date) = date {
|
||||
if real_date < now.date_naive() {
|
||||
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
|
||||
}
|
||||
}
|
||||
date
|
||||
}
|
||||
|
||||
pub fn to_date_naive(&self) -> Option<NaiveDate> {
|
||||
if let Some(year) = self.year {
|
||||
NaiveDate::from_ymd_opt(year, self.month, self.day)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"{}{:0>2}{:0>2}",
|
||||
self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)),
|
||||
self.month,
|
||||
self.day
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for YearOptionalDate {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
if let Some(year) = self.year {
|
||||
write!(f, "{:0>4}-", year)?;
|
||||
}
|
||||
write!(f, "{:0>2}-{:0>2}", self.month, self.day)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for YearOptionalDate {
|
||||
type Err = anyhow::Error;
|
||||
fn from_str(str: &str) -> Result<Self, Self::Err> {
|
||||
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
|
||||
if let Some(caps) = date_re.captures(str) {
|
||||
let year_str = &caps[1];
|
||||
let month = u32::from_str(&caps[2]).unwrap();
|
||||
let day = u32::from_str(&caps[3]).unwrap();
|
||||
let year = if year_str == "--" {
|
||||
None
|
||||
} else {
|
||||
Some(i32::from_str(year_str).unwrap())
|
||||
};
|
||||
|
||||
return Ok(Self { year, month, day });
|
||||
}
|
||||
Err(anyhow::Error::msg(format!(
|
||||
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/",
|
||||
str
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// `'r` is the lifetime of the `Row` being decoded
|
||||
impl<'r, DB: Database> Decode<'r, DB> for YearOptionalDate
|
||||
where
|
||||
// we want to delegate some of the work to string decoding so let's make sure strings
|
||||
// are supported by the database
|
||||
&'r str: Decode<'r, DB>,
|
||||
{
|
||||
fn decode(
|
||||
value: <DB as Database>::ValueRef<'r>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + 'static + Send + Sync>> {
|
||||
let value = <&str as Decode<DB>>::decode(value)?;
|
||||
|
||||
Ok(value.parse()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Encode<'r, Sqlite> for YearOptionalDate
|
||||
where
|
||||
&'r str: Encode<'r, Sqlite>,
|
||||
{
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
|
||||
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
|
||||
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
|
||||
}
|
||||
}
|
||||
114
src/web/auth.rs
Normal file
114
src/web/auth.rs
Normal 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
443
src/web/contact.rs
Normal 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(" ")) }
|
||||
}
|
||||
.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
241
src/web/home.rs
Normal 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
88
src/web/ics.rs
Normal 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
146
src/web/journal.rs
Normal 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
105
src/web/mod.rs
Normal 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
167
src/web/settings.rs
Normal 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..."
|
||||
} })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue