diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/controller/login.rs | 3 | ||||
-rw-r--r-- | src/controller/utils.rs | 4 | ||||
-rw-r--r-- | src/db/incomes.rs | 67 | ||||
-rw-r--r-- | src/db/jobs.rs | 1 | ||||
-rw-r--r-- | src/db/migrations/01-init.sql | 65 | ||||
-rw-r--r-- | src/db/migrations/02-payment-category.sql | 44 | ||||
-rw-r--r-- | src/db/migrations/03-sign-in-token.sql | 5 | ||||
-rw-r--r-- | src/db/migrations/04-plural-naming.sql | 91 | ||||
-rw-r--r-- | src/db/migrations/05-strict-tables.sql | 107 | ||||
-rw-r--r-- | src/db/migrations/06-remove-weekly-report-job.sql | 1 | ||||
-rw-r--r-- | src/db/mod.rs | 49 | ||||
-rw-r--r-- | src/db/payments.rs | 73 | ||||
-rw-r--r-- | src/db/utils.rs | 16 | ||||
-rw-r--r-- | src/jobs/mod.rs | 16 | ||||
-rw-r--r-- | src/jobs/weekly_report.rs | 65 | ||||
-rw-r--r-- | src/mail.rs | 104 | ||||
-rw-r--r-- | src/main.rs | 17 | ||||
-rw-r--r-- | src/model/config.rs | 2 | ||||
-rw-r--r-- | src/model/job.rs | 1 | ||||
-rw-r--r-- | src/model/mod.rs | 1 | ||||
-rw-r--r-- | src/model/report.rs | 40 | ||||
-rw-r--r-- | src/routes.rs | 2 | ||||
-rw-r--r-- | src/templates.rs | 28 | ||||
-rw-r--r-- | src/utils/cookie.rs | 12 |
24 files changed, 408 insertions, 406 deletions
diff --git a/src/controller/login.rs b/src/controller/login.rs index d01f799..f7e0695 100644 --- a/src/controller/login.rs +++ b/src/controller/login.rs @@ -44,7 +44,8 @@ pub async fn login( { Some(hash) => match bcrypt::verify(login.password, &hash) { Ok(true) => { - let login_token = cookie::generate_token(); + // TODO: error handling + let login_token = cookie::generate_token().unwrap(); if db::users::set_login_token( &db_conn, diff --git a/src/controller/utils.rs b/src/controller/utils.rs index 340a5c7..ccef33c 100644 --- a/src/controller/utils.rs +++ b/src/controller/utils.rs @@ -71,8 +71,8 @@ fn server_error( ) } -pub fn text(str: String) -> Response<Full<Bytes>> { - let mut response = Response::new(str.into()); +pub fn text(str: impl Into<String>) -> Response<Full<Bytes>> { + let mut response = Response::new(str.into().into()); *response.status_mut() = StatusCode::OK; response } diff --git a/src/db/incomes.rs b/src/db/incomes.rs index 90282c0..d33cbcb 100644 --- a/src/db/incomes.rs +++ b/src/db/incomes.rs @@ -5,7 +5,6 @@ use tokio_rusqlite::{named_params, Connection, Row}; use crate::db::utils; use crate::model::income::{Create, Form, Stat, Table, Update}; -use crate::model::report::Report; fn row_to_table(row: &Row) -> Result<Table, rusqlite::Error> { Ok(Table { @@ -373,8 +372,8 @@ fn cumulative_query(from: NaiveDate) -> String { ON users.id = incomes.user_id "#, - bounded_query(">".to_string(), from.format("%Y-%m-%d").to_string()), - bounded_query("<".to_string(), "date()".to_string()) + bounded_query(">", &from.format("%Y-%m-%d").to_string()), + bounded_query("<", "date()") ) } @@ -382,7 +381,7 @@ fn cumulative_query(from: NaiveDate) -> String { /// /// It filters incomes according to the operator and date, /// and adds the income at this date. -fn bounded_query(op: String, date: String) -> String { +fn bounded_query(op: &str, date: &str) -> String { format!( r#" SELECT @@ -487,63 +486,3 @@ pub async fn total_each_month(conn: &Connection) -> Vec<Stat> { } } } - -pub async fn last_week(conn: &Connection) -> Vec<Report> { - let query = r#" - SELECT - strftime('%m/%Y', incomes.date) AS date, - users.name AS name, - incomes.amount AS amount, - (CASE - WHEN - incomes.deleted_at IS NOT NULL - THEN - 'Deleted' - WHEN - incomes.updated_at IS NOT NULL - AND incomes.created_at < date('now', 'weekday 0', '-13 days') - THEN - 'Updated' - ELSE - 'Created' - END) AS action - FROM - incomes - INNER JOIN - users - ON - incomes.user_id = users.id - WHERE - ( - incomes.created_at >= date('now', 'weekday 0', '-13 days') - AND incomes.created_at < date('now', 'weekday 0', '-6 days') - ) OR ( - incomes.updated_at >= date('now', 'weekday 0', '-13 days') - AND incomes.updated_at < date('now', 'weekday 0', '-6 days') - ) OR ( - incomes.deleted_at >= date('now', 'weekday 0', '-13 days') - AND incomes.deleted_at < date('now', 'weekday 0', '-6 days') - ) - ORDER BY - incomes.date - "#; - - let res = conn - .call(move |conn| { - let mut stmt = conn.prepare(query)?; - let xs = stmt - .query_map([], utils::row_to_report)? - .collect::<Result<Vec<Report>, _>>()?; - - Ok(xs) - }) - .await; - - match res { - Ok(xs) => xs, - Err(err) => { - log::error!("Error listing payments for report: {:?}", err); - vec![] - } - } -} diff --git a/src/db/jobs.rs b/src/db/jobs.rs index 1d00408..0080339 100644 --- a/src/db/jobs.rs +++ b/src/db/jobs.rs @@ -4,7 +4,6 @@ use crate::model::job::Job; pub async fn should_run(conn: &Connection, job: Job) -> bool { let run_from = match job { - Job::WeeklyReport => "date('now', 'weekday 0', '-6 days')", Job::MonthlyPayment => "date('now', 'start of month')", }; diff --git a/src/db/migrations/01-init.sql b/src/db/migrations/01-init.sql new file mode 100644 index 0000000..d7c300e --- /dev/null +++ b/src/db/migrations/01-init.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "user" ( + "id" INTEGER PRIMARY KEY, + "creation" TIMESTAMP NOT NULL, + "email" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL, + CONSTRAINT "uniq_user_email" UNIQUE ("email"), + CONSTRAINT "uniq_user_name" UNIQUE ("name") +); + +CREATE TABLE IF NOT EXISTS "job" ( + "id" INTEGER PRIMARY KEY, + "kind" VARCHAR NOT NULL, + "last_execution" TIMESTAMP NULL, + "last_check" TIMESTAMP NULL, + CONSTRAINT "uniq_job_kind" UNIQUE ("kind") +); + +CREATE TABLE IF NOT EXISTS "sign_in"( + "id" INTEGER PRIMARY KEY, + "token" VARCHAR NOT NULL, + "creation" TIMESTAMP NOT NULL, + "email" VARCHAR NOT NULL, + "is_used" BOOLEAN NOT NULL, + CONSTRAINT "uniq_sign_in_token" UNIQUE ("token") +); + +CREATE TABLE IF NOT EXISTS "payment"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "user", + "name" VARCHAR NOT NULL, + "cost" INTEGER NOT NULL, + "date" DATE NOT NULL, + "frequency" VARCHAR NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "income"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "user", + "date" DATE NOT NULL, + "amount" INTEGERNOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "category"( + "id" INTEGER PRIMARY KEY, + "name" VARCHAR NOT NULL, + "color" VARCHAR NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "payment_category"( + "id" INTEGER PRIMARY KEY, + "name" VARCHAR NOT NULL, + "category" INTEGER NOT NULL REFERENCES "category", + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + CONSTRAINT "uniq_payment_category_name" UNIQUE ("name") +); diff --git a/src/db/migrations/02-payment-category.sql b/src/db/migrations/02-payment-category.sql new file mode 100644 index 0000000..c1d502f --- /dev/null +++ b/src/db/migrations/02-payment-category.sql @@ -0,0 +1,44 @@ +-- Add payment categories with accents from payment with accents + +INSERT INTO + payment_category (name, category, created_at) +SELECT + DISTINCT lower(payment.name), payment_category.category, datetime('now') +FROM + payment +INNER JOIN + payment_category +ON + replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower(payment.name), 'é', 'e'), 'è', 'e'), 'à', 'a'), 'û', 'u'), 'â', 'a'), 'ê', 'e'), 'â', 'a'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ë', 'e') = payment_category.name +WHERE + payment.name +IN + (SELECT DISTINCT payment.name FROM payment WHERE lower(payment.name) NOT IN (SELECT payment_category.name FROM payment_category) AND payment.deleted_at IS NULL); + +-- Remove unused payment categories + +DELETE FROM + payment_category +WHERE + name NOT IN (SELECT DISTINCT lower(name) FROM payment); + +-- Add category id to payment table + +PRAGMA foreign_keys = 0; + +ALTER TABLE payment ADD COLUMN "category" INTEGER NOT NULL REFERENCES "category" DEFAULT -1; + +PRAGMA foreign_keys = 1; + +UPDATE + payment +SET + category = (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)) +WHERE + EXISTS (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)); + +DELETE FROM payment WHERE category = -1; + +-- Remove + +DROP TABLE payment_category; diff --git a/src/db/migrations/03-sign-in-token.sql b/src/db/migrations/03-sign-in-token.sql new file mode 100644 index 0000000..a3d8a13 --- /dev/null +++ b/src/db/migrations/03-sign-in-token.sql @@ -0,0 +1,5 @@ +DROP TABLE sign_in; + +ALTER TABLE user ADD COLUMN "password" TEXT NOT NULL DEFAULT "password"; + +ALTER TABLE user ADD COLUMN "sign_in_token" TEXT NULL; diff --git a/src/db/migrations/04-plural-naming.sql b/src/db/migrations/04-plural-naming.sql new file mode 100644 index 0000000..ec386cb --- /dev/null +++ b/src/db/migrations/04-plural-naming.sql @@ -0,0 +1,91 @@ +-- Payments + +CREATE TABLE IF NOT EXISTS "payments"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "name" TEXT NOT NULL, + "cost" INTEGER NOT NULL, + "date" DATE NOT NULL, + "frequency" TEXT NOT NULL, + "category_id" INTEGER NOT NULL REFERENCES "categories", + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO payments (id, user_id, name, cost, date, frequency, category_id, created_at, updated_at, deleted_at) + SELECT id, user_id, name, cost, date, frequency, category, created_at, edited_at, deleted_at + FROM payment; + +DROP TABLE payment; + +CREATE INDEX payment_date ON payments(date); + +-- Categories + +CREATE TABLE IF NOT EXISTS "categories"( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO categories (id, name, color, created_at, updated_at, deleted_at) + SELECT id, name, color, created_at, edited_at, deleted_at + FROM category; + +DROP TABLE category; + +-- Users + +CREATE TABLE IF NOT EXISTS "users"( + "id" INTEGER PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL, + "login_token" TEXT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL, + CONSTRAINT "uniq_user_email" UNIQUE ("email"), + CONSTRAINT "uniq_user_name" UNIQUE ("name") +); + +INSERT INTO users (id, created_at, email, name, password, login_token) + SELECT id, creation, email, name, password, sign_in_token + FROM user; + +DROP TABLE user; + +-- Jobs + +CREATE TABLE IF NOT EXISTS "jobs"( + "name" TEXT PRIMARY KEY, + "last_execution" DATE NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO jobs (name, last_execution) + SELECT kind, last_execution + FROM job; + +DROP TABLE job; + +-- Incomes + +CREATE TABLE IF NOT EXISTS "incomes"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "date" DATE NOT NULL, + "amount" INTEGER NOT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO incomes (id, user_id, date, amount, created_at, updated_at, deleted_at) + SELECT id, user_id, date, amount, created_at, edited_at, deleted_at + FROM income; + +DROP TABLE income; diff --git a/src/db/migrations/05-strict-tables.sql b/src/db/migrations/05-strict-tables.sql new file mode 100644 index 0000000..cf7ef4b --- /dev/null +++ b/src/db/migrations/05-strict-tables.sql @@ -0,0 +1,107 @@ +-- Activate strict mode + +-- Start with users and categories, as it’s referenced in other tables. +-- Otherwise, the reference is set to the renamed non strict table. + +-- Users + +ALTER TABLE "users" RENAME TO "users_non_strict"; + +CREATE TABLE IF NOT EXISTS "users"( + "id" INTEGER PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL, + "login_token" TEXT NULL, + "created_at" TEXT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NULL, + "deleted_at" TEXT NULL, + CONSTRAINT "uniq_user_email" UNIQUE ("email"), + CONSTRAINT "uniq_user_name" UNIQUE ("name") +) STRICT; + +INSERT INTO users (id, created_at, email, name, password, login_token) + SELECT id, created_at, email, name, password, login_token + FROM users_non_strict; + +DROP TABLE users_non_strict; + +-- Categories + +ALTER TABLE "categories" RENAME TO "categories_non_strict"; + +CREATE TABLE IF NOT EXISTS "categories"( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + "created_at" TEXT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NULL, + "deleted_at" TEXT NULL +) STRICT; + +INSERT INTO categories (id, name, color, created_at, updated_at, deleted_at) + SELECT id, name, color, created_at, updated_at, deleted_at + FROM categories_non_strict; + +DROP TABLE categories_non_strict; + +-- Payments + +ALTER TABLE "payments" RENAME TO "payments_non_strict"; + +CREATE TABLE IF NOT EXISTS "payments"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "name" TEXT NOT NULL, + "cost" INTEGER NOT NULL, + "date" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "category_id" INTEGER NOT NULL REFERENCES "categories", + "created_at" TEXT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NULL, + "deleted_at" TEXT NULL +) STRICT; + +DROP INDEX IF EXISTS payment_date; +CREATE INDEX payment_date ON payments(date); + +INSERT INTO payments (id, user_id, name, cost, date, frequency, category_id, created_at, updated_at, deleted_at) + SELECT id, user_id, name, cost, date, frequency, category_id, created_at, updated_at, deleted_at + FROM payments_non_strict; + +DROP TABLE payments_non_strict; + +-- Jobs + +ALTER TABLE "jobs" RENAME TO "jobs_non_strict"; + +CREATE TABLE IF NOT EXISTS "jobs"( + "name" TEXT PRIMARY KEY, + "last_execution" TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +INSERT INTO jobs (name, last_execution) + SELECT name, last_execution + FROM jobs_non_strict; + +DROP TABLE jobs_non_strict; + +-- Incomes + +ALTER TABLE "incomes" RENAME TO "incomes_non_strict"; + +CREATE TABLE IF NOT EXISTS "incomes"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "date" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "created_at" TEXT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NULL, + "deleted_at" TEXT NULL +) STRICT; + +INSERT INTO incomes (id, user_id, date, amount, created_at, updated_at, deleted_at) + SELECT id, user_id, date, amount, created_at, updated_at, deleted_at + FROM incomes_non_strict; + +DROP TABLE incomes_non_strict; diff --git a/src/db/migrations/06-remove-weekly-report-job.sql b/src/db/migrations/06-remove-weekly-report-job.sql new file mode 100644 index 0000000..415ea76 --- /dev/null +++ b/src/db/migrations/06-remove-weekly-report-job.sql @@ -0,0 +1 @@ +DELETE FROM jobs WHERE name = 'WeeklyReport'; diff --git a/src/db/mod.rs b/src/db/mod.rs index a0aa3dc..4894e95 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,55 @@ +use anyhow::{Error, Result}; +use rusqlite_migration::{Migrations, M}; +use tokio_rusqlite::Connection; + pub mod categories; pub mod incomes; pub mod jobs; pub mod payments; pub mod users; mod utils; + +pub async fn init(path: &str) -> Result<Connection> { + let connection = Connection::open(path).await.map_err(|err| { + Error::msg(format!("Error opening connection: {err}")) + })?; + + apply_migrations(&connection).await?; + set_pragma(&connection, "foreign_keys", "ON").await?; + set_pragma(&connection, "journal_mode", "wal").await?; + Ok(connection) +} + +async fn apply_migrations(conn: &Connection) -> Result<()> { + let migrations = Migrations::new(vec![ + M::up(include_str!("migrations/01-init.sql")), + M::up(include_str!("migrations/02-payment-category.sql")), + M::up(include_str!("migrations/03-sign-in-token.sql")), + M::up(include_str!("migrations/04-plural-naming.sql")), + M::up(include_str!("migrations/05-strict-tables.sql")), + M::up(include_str!("migrations/06-remove-weekly-report-job.sql")), + ]); + + Ok(conn + .call(move |conn| { + migrations.to_latest(conn).map_err(|migration_err| { + tokio_rusqlite::Error::Other(Box::new(migration_err)) + }) + }) + .await?) +} + +async fn set_pragma( + conn: &Connection, + key: impl Into<String>, + value: impl Into<String>, +) -> Result<()> { + let key = key.into(); + let value = value.into(); + Ok(conn + .call(move |conn| { + conn.pragma_update(None, &key, &value) + .map_err(tokio_rusqlite::Error::Rusqlite) + }) + .await?) +} diff --git a/src/db/payments.rs b/src/db/payments.rs index 4a6774c..23b4d2f 100644 --- a/src/db/payments.rs +++ b/src/db/payments.rs @@ -7,7 +7,6 @@ use tokio_rusqlite::{ use crate::db::utils; use crate::model::frequency::Frequency; use crate::model::payment; -use crate::model::report::Report; use crate::queries; use crate::utils::text; @@ -61,9 +60,7 @@ pub async fn count( payment_query: &queries::Payments, ) -> Count { let mut query = r#" - SELECT - COUNT(*) AS count, - SUM(payments.cost) AS total_cost + SELECT COUNT(*), SUM(payments.cost) FROM payments INNER JOIN users ON users.id = payments.user_id INNER JOIN categories ON categories.id = payments.category_id @@ -85,13 +82,10 @@ pub async fn count( match res { Ok(count) => count, - Err(err) => { - log::error!("Error counting payments: {:?}", err); - Count { - count: 0, - total_cost: 0, - } - } + Err(_) => Count { + count: 0, + total_cost: 0, + }, } } @@ -192,7 +186,7 @@ fn complete_name( ) .as_str(), ); - params.push(Box::new(name)); + params.push(Box::new(text::format_search(&name))); } } } @@ -572,58 +566,3 @@ pub async fn create_monthly_payments(conn: &Connection) { Err(err) => log::error!("Error creating monthly payments: {:?}", err), } } - -pub async fn last_week(conn: &Connection) -> Vec<Report> { - let query = r#" - SELECT - strftime('%d/%m/%Y', payments.date) AS date, - (payments.name || ' (' || users.name || ')') AS name, - payments.cost AS amount, - (CASE - WHEN payments.deleted_at IS NOT NULL - THEN 'Deleted' - WHEN - payments.updated_at IS NOT NULL - AND payments.created_at < date('now', 'weekday 0', '-13 days') - THEN 'Updated' - ELSE 'Created' - END) AS action - FROM payments - INNER JOIN users - ON payments.user_id = users.id - WHERE - payments.frequency = 'Punctual' - AND ( - ( - payments.created_at >= date('now', 'weekday 0', '-13 days') - AND payments.created_at < date('now', 'weekday 0', '-6 days') - ) OR ( - payments.updated_at >= date('now', 'weekday 0', '-13 days') - AND payments.updated_at < date('now', 'weekday 0', '-6 days') - ) OR ( - payments.deleted_at >= date('now', 'weekday 0', '-13 days') - AND payments.deleted_at < date('now', 'weekday 0', '-6 days') - ) - ) - ORDER BY payments.date - "#; - - let res = conn - .call(move |conn| { - let mut stmt = conn.prepare(query)?; - let xs = stmt - .query_map([], utils::row_to_report)? - .collect::<Result<Vec<Report>, _>>()?; - - Ok(xs) - }) - .await; - - match res { - Ok(payments) => payments, - Err(err) => { - log::error!("Error listing payments for report: {:?}", err); - vec![] - } - } -} diff --git a/src/db/utils.rs b/src/db/utils.rs index f61d20a..8f8a31d 100644 --- a/src/db/utils.rs +++ b/src/db/utils.rs @@ -1,8 +1,7 @@ -use crate::model::report::Report; -use tokio_rusqlite::Row; - pub fn format_key_for_search(value: &str) -> String { - format!("replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower({}), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u')", value) + // Lower doesn’t work on accentuated letters, hence the need to remove manually accents for + // uppercase letters as well. + format!("replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower({}), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u'), 'À', 'A'), 'Â', 'A'), 'Ç', 'C'), 'È', 'E'), 'É', 'E'), 'Ê', 'E'), 'Ë', 'E'), 'Î', 'I'), 'Ï', 'I'), 'Ô', 'O'), 'Ù', 'U'), 'Û', 'U'), 'Ü', 'U')", value) } pub fn one<A, I: Iterator<Item = Result<A, rusqlite::Error>>>( @@ -16,12 +15,3 @@ pub fn one<A, I: Iterator<Item = Result<A, rusqlite::Error>>>( )), } } - -pub fn row_to_report(row: &Row) -> Result<Report, rusqlite::Error> { - Ok(Report { - date: row.get(0)?, - name: row.get(1)?, - amount: row.get(2)?, - action: row.get(3)?, - }) -} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 3bfca71..0a903c4 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -1,25 +1,11 @@ -mod weekly_report; - use tokio::time::{sleep, Duration}; use tokio_rusqlite::Connection; use crate::db; -use crate::model::config::Config; use crate::model::job::Job; -pub async fn start( - config: Config, - db_conn: Connection, - templates: minijinja::Environment<'_>, -) { +pub async fn start(db_conn: Connection) { loop { - if db::jobs::should_run(&db_conn, Job::WeeklyReport).await { - log::info!("Starting weekly report job"); - if weekly_report::send(&config, &db_conn, &templates).await { - db::jobs::actualize_last_execution(&db_conn, Job::WeeklyReport) - .await; - } - } if db::jobs::should_run(&db_conn, Job::MonthlyPayment).await { log::info!("Starting monthly payment job"); db::payments::create_monthly_payments(&db_conn).await; diff --git a/src/jobs/weekly_report.rs b/src/jobs/weekly_report.rs deleted file mode 100644 index a91a3fb..0000000 --- a/src/jobs/weekly_report.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::collections::HashMap; -use tokio_rusqlite::Connection; - -use crate::db; -use crate::mail; -use crate::model::config::Config; -use crate::payer; - -pub async fn send( - config: &Config, - db_conn: &Connection, - env: &minijinja::Environment<'_>, -) -> bool { - match get_weekly_report(db_conn, env).await { - Ok(report) => { - let users = db::users::list(db_conn).await; - mail::send( - config, - users - .into_iter() - .map(|u| mail::Recipient { - name: u.name, - address: u.email, - }) - .collect(), - "Rapport hebdomadaire".to_string(), - report, - ) - .await - } - Err(err) => { - log::error!( - "Error preparing weekly report from template: {:?}", - err - ); - false - } - } -} - -async fn get_weekly_report( - db_conn: &Connection, - env: &minijinja::Environment<'_>, -) -> Result<String, minijinja::Error> { - let users = db::users::list(db_conn).await; - let incomes_from = db::incomes::defined_for_all(db_conn).await; - let user_incomes = match incomes_from { - Some(from) => db::incomes::cumulative(db_conn, from).await, - None => HashMap::new(), - }; - let user_payments = db::payments::repartition(db_conn).await; - let exceeding_payers = - payer::exceeding(&users, &user_incomes, &user_payments); - - let last_week_payments = db::payments::last_week(db_conn).await; - let last_week_incomes = db::incomes::last_week(db_conn).await; - - let template = env.get_template("report/report.j2")?; - template.render(minijinja::context!( - name => "John", - exceeding_payers => exceeding_payers, - payments => last_week_payments, - incomes => last_week_incomes - )) -} diff --git a/src/mail.rs b/src/mail.rs deleted file mode 100644 index b6db0cd..0000000 --- a/src/mail.rs +++ /dev/null @@ -1,104 +0,0 @@ -use chrono::Utc; -use std::io::{Error, ErrorKind}; -use std::process::{Output, Stdio}; -use tokio::io::AsyncWriteExt; -use tokio::process::Command; - -use crate::model::config::Config; - -static FROM_NAME: &str = "Budget"; -static FROM_ADDRESS: &str = "budget@guyonvarch.me"; - -#[derive(Clone)] -pub struct Recipient { - pub name: String, - pub address: String, -} - -pub async fn send( - config: &Config, - recipients: Vec<Recipient>, - subject: String, - message: String, -) -> bool { - let headers = format_headers(recipients.clone(), subject); - - log::info!( - "Sending mail{}\n{}", - if config.mock_mails { " (MOCK)" } else { "" }, - headers.clone() - ); - - if config.mock_mails { - true - } else { - let recipient_addresses = recipients - .clone() - .into_iter() - .map(|r| r.address) - .collect::<Vec<String>>(); - - // https://github.com/NixOS/nixpkgs/issues/90248 - let mut command = Command::new("/run/wrappers/bin/sendmail"); - command.kill_on_drop(true); - command.arg("-f").arg(FROM_ADDRESS); - command.arg("--").args(recipient_addresses); - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let message = format!("{}\n\n{}", headers, message); - match spawn(command, &message.into_bytes()).await { - Ok(output) => { - if output.status.success() { - log::info!("Mail sent"); - true - } else { - match String::from_utf8(output.stderr) { - Ok(error) => { - log::error!("Error sending email: {}", error) - } - _ => log::error!("Error sending email"), - }; - false - } - } - Err(err) => { - log::error!("Error spawning command: {:?}", err); - false - } - } - } -} - -fn format_headers(recipients: Vec<Recipient>, subject: String) -> String { - let recipients = recipients - .into_iter() - .map(|r| format_address(r.name, r.address)) - .collect::<Vec<String>>() - .join(", "); - - format!( - "Date: {}\nFrom: {}\nTo: {}\nSubject: {}", - Utc::now().to_rfc2822(), - format_address(FROM_NAME.to_string(), FROM_ADDRESS.to_string()), - recipients, - subject, - ) -} - -fn format_address(name: String, address: String) -> String { - format!("{} <{}>", name, address) -} - -async fn spawn(mut command: Command, stdin: &[u8]) -> Result<Output, Error> { - let mut process = command.spawn()?; - process - .stdin - .as_mut() - .ok_or(Error::new(ErrorKind::Other, "Getting mutable stdin"))? - .write_all(stdin) - .await?; - process.wait_with_output().await -} diff --git a/src/main.rs b/src/main.rs index 0786f46..70bac81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,14 @@ +use anyhow::Result; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; -use tokio_rusqlite::Connection; mod assets; mod controller; mod crypto; mod db; mod jobs; -mod mail; mod model; mod payer; mod queries; @@ -21,27 +20,19 @@ mod validation; use model::config; #[tokio::main] -async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { +async fn main() -> Result<()> { env_logger::init(); let config = config::from_env() .unwrap_or_else(|err| panic!("Error reading config: {err}")); - let db_conn = Connection::open(config.db_path.clone()) - .await - .unwrap_or_else(|_| { - panic!("Error while openning DB: {}", config.db_path) - }); + let db_conn = db::init(&config.db_path).await?; let assets = assets::get(); let templates = templates::get()?; - tokio::spawn(jobs::start( - config.clone(), - db_conn.clone(), - templates.clone(), - )); + tokio::spawn(jobs::start(db_conn.clone())); let listener = TcpListener::bind(config.socket_address).await?; log::info!("Starting server at {}", config.socket_address); diff --git a/src/model/config.rs b/src/model/config.rs index 1fa5bb4..f40b0fb 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -6,7 +6,6 @@ use std::str::FromStr; pub struct Config { pub auth_secret: String, pub db_path: String, - pub mock_mails: bool, pub secure_cookies: bool, pub socket_address: SocketAddr, } @@ -15,7 +14,6 @@ pub fn from_env() -> Result<Config, String> { Ok(Config { auth_secret: read_string("AUTH_SECRET")?, db_path: read_string("DB_PATH")?, - mock_mails: read_bool("MOCK_MAILS")?, secure_cookies: read_bool("SECURE_COOKIES")?, socket_address: read_socket_address("SOCKET_ADDRESS")?, }) diff --git a/src/model/job.rs b/src/model/job.rs index f31cfa0..b10b2df 100644 --- a/src/model/job.rs +++ b/src/model/job.rs @@ -3,7 +3,6 @@ use std::fmt; #[derive(Debug)] pub enum Job { MonthlyPayment, - WeeklyReport, } impl fmt::Display for Job { diff --git a/src/model/mod.rs b/src/model/mod.rs index fb07721..55adadd 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -5,5 +5,4 @@ pub mod income; pub mod job; pub mod login; pub mod payment; -pub mod report; pub mod user; diff --git a/src/model/report.rs b/src/model/report.rs deleted file mode 100644 index e944745..0000000 --- a/src/model/report.rs +++ /dev/null @@ -1,40 +0,0 @@ -use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef}; -use std::fmt; - -#[derive(Debug, serde::Serialize)] -pub struct Report { - pub date: String, - pub name: String, - pub amount: i64, - pub action: Action, -} - -#[derive(Debug, PartialEq, serde::Serialize)] -pub enum Action { - Created, - Updated, - Deleted, -} - -impl fmt::Display for Action { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } -} - -impl FromSql for Action { - fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { - match value { - ValueRef::Text(text) => match std::str::from_utf8(text) { - Ok("Created") => Ok(Action::Created), - Ok("Updated") => Ok(Action::Updated), - Ok("Deleted") => Ok(Action::Deleted), - Ok(str) => Err(FromSqlError::Other( - format!("Unknown action: {str}").into(), - )), - Err(err) => Err(FromSqlError::Other(err.into())), - }, - _ => Err(FromSqlError::InvalidType), - } - } -} diff --git a/src/routes.rs b/src/routes.rs index aca4284..7107a60 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -28,7 +28,7 @@ pub async fn routes( let response = match (method, path) { (&Method::HEAD, ["status"]) => controller::utils::ok(), - (&Method::GET, ["status"]) => controller::utils::text("ok".to_string()), + (&Method::GET, ["status"]) => controller::utils::text("ok"), (&Method::GET, ["login"]) => { controller::login::page(&assets, &templates, None).await } diff --git a/src/templates.rs b/src/templates.rs index 8f160dc..f6f4e62 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,3 +1,4 @@ +use anyhow::{Error, Result}; use std::fs; use crate::queries; @@ -11,21 +12,26 @@ pub enum Header { Statistics, } -pub fn get() -> Result<minijinja::Environment<'static>, String> { +pub fn get() -> Result<minijinja::Environment<'static>> { let mut env = minijinja::Environment::new(); for path in read_files_recursive("templates") { let path = path .to_str() - .ok_or("Error getting string of path: {path:?}")? + .ok_or(Error::msg("Error getting string of path: {path:?}"))? .to_string(); - let content = fs::read_to_string(&path) - .map_err(|_| "Error reading template {path}")?; + let content = fs::read_to_string(&path).map_err(|err| { + Error::msg(format!("Error reading template {path}: {err}")) + })?; let path_without_prefix = path .strip_prefix("templates/") - .ok_or("Error removing prefix from template path")? + .ok_or(Error::msg("Error removing prefix from template path"))? .to_string(); env.add_template_owned(path_without_prefix, content) - .map_err(|_| "Error adding template {path} to environment")?; + .map_err(|err| { + Error::msg(format!( + "Error adding template {path} to environment: {err}" + )) + })?; } env.add_function("payments_params", payments_params); @@ -92,11 +98,11 @@ fn numeric(n: i64) -> String { format!("{}{}", sign, str) } -fn pluralize(n: i32, s: String) -> String { +fn pluralize(n: i32, s: &str) -> String { if n > 0 { format!("{s}s") } else { - s + s.to_string() } } @@ -104,7 +110,7 @@ fn round(n: f32) -> i32 { n.round() as i32 } -fn with_param(url: String, key: String, value: String) -> String { +fn with_param(url: &str, key: &str, value: String) -> String { if url.contains("?") { format!("{url}&{key}={value}") } else { @@ -130,8 +136,8 @@ fn filter( res } -fn rgrouped(str: String, n: usize) -> Vec<String> { - let mut str = str; +fn rgrouped(str: impl Into<String>, n: usize) -> Vec<String> { + let mut str = str.into(); let mut l = str.len(); let mut res = vec![]; while l > n { diff --git a/src/utils/cookie.rs b/src/utils/cookie.rs index 826efa9..e21e7d4 100644 --- a/src/utils/cookie.rs +++ b/src/utils/cookie.rs @@ -1,5 +1,5 @@ use hex; -use rand_core::{OsRng, RngCore}; +use rand_core::{OsRng, TryRngCore}; use crate::crypto::signed; use crate::model::config::Config; @@ -8,7 +8,7 @@ const TOKEN_BYTES: usize = 20; pub fn login(config: &Config, token: &str) -> Result<String, String> { let signed_token = signed::sign(&config.auth_secret, token)?; - Ok(cookie(config, &signed_token, 24 * 60 * 60)) + Ok(cookie(config, &signed_token, 365 * 24 * 60 * 60)) } pub fn logout(config: &Config) -> String { @@ -22,10 +22,12 @@ pub fn extract_token(config: &Config, cookie: &str) -> Result<String, String> { signed::verify(&config.auth_secret, signed_cookie) } -pub fn generate_token() -> String { +pub fn generate_token() -> Result<String, String> { let mut token = [0u8; TOKEN_BYTES]; - OsRng.fill_bytes(&mut token); - hex::encode(token) + OsRng + .try_fill_bytes(&mut token) + .map_err(|_| "Error generating random token")?; + Ok(hex::encode(token)) } fn cookie(config: &Config, token: &str, max_age_seconds: i32) -> String { |