From 161af74fd88d96a30616338858c9bb825fc85e79 Mon Sep 17 00:00:00 2001 From: Tobias de Bruijn Date: Wed, 23 Oct 2024 16:43:10 +0200 Subject: [PATCH] Fix frontend to new routes, add random code to email, add digidecs uploaded page --- .github/workflows/release.yml | 83 +++++++++++ .github/workflows/test.yml | 53 +++++++ frontend/index.html | 2 +- frontend/src/plugins/locales/en.ts | 40 +++++- frontend/src/plugins/locales/locale.ts | 5 +- frontend/src/plugins/locales/nl.ts | 7 +- frontend/src/router/index.ts | 4 + frontend/src/scripts/digidecs.ts | 76 +++++++--- frontend/src/views/HomeView.vue | 46 +++++-- frontend/src/views/SubmittedView.vue | 31 +++++ server/Cargo.lock | 56 ++++---- server/Cargo.toml | 6 +- server/src/email/ipv4.rs | 10 +- server/src/email/mod.rs | 28 ++-- server/src/email/template.rs | 6 +- server/src/file/config.rs | 6 +- server/src/server/mod.rs | 32 ++--- server/src/server/routes/digidecs.rs | 108 --------------- .../src/server/routes/digidecs/attachment.rs | 34 +++++ server/src/server/routes/digidecs/complete.rs | 83 +++++++++++ server/src/server/routes/digidecs/mod.rs | 20 +++ server/src/server/routes/digidecs/start.rs | 130 ++++++++++++++++++ server/src/server/routes/mod.rs | 6 +- server/src/server/types/data.rs | 31 +++++ server/src/server/types/error.rs | 11 +- server/src/server/types/mod.rs | 2 +- 26 files changed, 697 insertions(+), 219 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 frontend/src/views/SubmittedView.vue delete mode 100644 server/src/server/routes/digidecs.rs create mode 100644 server/src/server/routes/digidecs/attachment.rs create mode 100644 server/src/server/routes/digidecs/complete.rs create mode 100644 server/src/server/routes/digidecs/mod.rs create mode 100644 server/src/server/routes/digidecs/start.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1d653bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: release +on: + push: + tags: + - '**' + +jobs: + build-server-musl: + name: build-server-musl + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup target add x86_64-unknown-linux-musl --toolchain stable + - run: sudo apt install gcc musl-tools cmake clang + - uses: Swatinem/rust-cache@v2.7.0 + with: + workspaces: "server" + save-if: ${{ github.ref == 'refs/heads/master' }} + - run: cargo build --release --target x86_64-unknown-linux-musl + working-directory: server + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: server-x86_64-unknown-linux-musl + path: ./server/target/x86_64-unknown-linux-musl/release/digidecs + + build-frontend: + name: build-frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set Node.js 21 + uses: actions/setup-node@v3 + with: + node-version: 21.x + - name: Run install + uses: borales/actions-yarn@v4 + with: + dir: frontend + cmd: install + - name: Build + uses: borales/actions-yarn@v4 + with: + dir: frontend + cmd: build + - run: tar -czf frontend.tar.gz dist/* + working-directory: frontend + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: frontend.tar.gz + path: ./frontend/frontend.tar.gz + + + create-release: + name: create-release + needs: + - build-server-musl + - build-frontend + runs-on: ubuntu-latest + steps: + - name: Download all workflow run artifacts + uses: actions/download-artifact@v3 + + - name: Create Release + id: create_release + uses: actions/create-release@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + + - run: mv server-x86_64-unknown-linux-musl/digidecs server-x86_64-unknown-linux-musl/server-x86_64-unknown-linux-musl + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: | + server-x86_64-unknown-linux-musl/server-x86_64-unknown-linux-musl + frontend.tar.gz/frontend.tar.gz \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..508ee91 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: test +on: + push: {} + +jobs: + server-fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup component add rustfmt + - uses: Swatinem/rust-cache@v2.7.0 + with: + workspaces: "server" + save-if: ${{ github.ref == 'refs/heads/master' }} + - run: cargo fmt --all --check + working-directory: server + + server-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - uses: Swatinem/rust-cache@v2.7.0 + with: + workspaces: "server" + save-if: ${{ github.ref == 'refs/heads/master' }} + - run: cargo test + working-directory: server + + server-clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup component add clippy + - uses: Swatinem/rust-cache@v2.7.0 + with: + workspaces: "server" + save-if: ${{ github.ref == 'refs/heads/master' }} + - run: cargo clippy + working-directory: server + + frontend-eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install modules + working-directory: frontend + run: yarn + - name: Run eslint + working-directory: frontend + run: yarn eslint \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 1745091..a530905 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + Sticky DigiDecs diff --git a/frontend/src/plugins/locales/en.ts b/frontend/src/plugins/locales/en.ts index a2c6b25..6166ac2 100644 --- a/frontend/src/plugins/locales/en.ts +++ b/frontend/src/plugins/locales/en.ts @@ -1,7 +1,45 @@ import {Locale} from "@/plugins/locales/locale"; const EN: Locale = { - + site_title: "DigiDecs", + error: "Something went wrong, please try again later", + home: { + title: "DigiDecs", + subtitle: "Declare digitally at Sticky", + invalidFieldsError: "One or multiple fields are incorreect", + form: { + name: "Naam", + iban: "IBAN", + email: "Email", + value: "Amount", + what: "What", + commission: "For what / which commission", + notes: "Notes", + files: "Receipts / Invoices", + filesExplanation: "Only .pdf, .jpg or .png files. You can submit multiple documents. Ensure the date, the (VAT) amount and the different products or services are clearly readable.", + checked: "I have checked everything and filled this form thruthfully", + rules: { + required: "Required", + ibanInvalid: "Invalid IBAN", + emailInvalid: "Invalid email address", + valueInvalid: "Invalid amount", + filesTooLarge: "The maximum file size is 15MB" + }, + hints: { + name: "Treasurer", + email: "{'eindbaas@svsticky.nl'}", + iban: "NL13TEST0123456789", + value: "19,19", + what: "Digging Machine", + commission: "The board, obviously!" + } + }, + submit: "Submit" + }, + submitted: { + title: 'Success!', + description: "Your digidecs has been sent! If you haven't received your money back after 7 days, contact the treasurer" + } } export default EN; \ No newline at end of file diff --git a/frontend/src/plugins/locales/locale.ts b/frontend/src/plugins/locales/locale.ts index 8e2f59f..af73b46 100644 --- a/frontend/src/plugins/locales/locale.ts +++ b/frontend/src/plugins/locales/locale.ts @@ -1,11 +1,14 @@ export interface Locale { site_title: string, error: string, + submitted: { + title: string, + description: string + }, home: { title: string, subtitle: string, invalidFieldsError: string, - success: string, form: { name: string, iban: string, diff --git a/frontend/src/plugins/locales/nl.ts b/frontend/src/plugins/locales/nl.ts index 6dbae80..553b83a 100644 --- a/frontend/src/plugins/locales/nl.ts +++ b/frontend/src/plugins/locales/nl.ts @@ -7,7 +7,6 @@ const NL: Locale = { title: "DigiDecs", subtitle: "Digitaal declareren bij Sticky", invalidFieldsError: "Een of meerdere velden zijn onjuist ingevuld", - success: "Success! Je declaratie is ingestuurd. Mocht je na 5 werkdagen je geldf nog niet hebben ontvangen, neem dan contact op met de Penningmeester: penningmeester@svsticky.nl.", form: { name: "Naam", iban: "IBAN", @@ -16,7 +15,7 @@ const NL: Locale = { what: "Wat", commission: "Waarvoor / Welke commissie", notes: "Opmerkingen", - files: "Bonnetjes", + files: "Bonnetjes / Facturen", filesExplanation: "Alleen .pdf, .jpg, en .png bestanden, je kan meerdere bestanden selecteren. Zorg dat de datum, het (btw) bedrag en de verschillende producten of diensten goed leesbaar zijn.", checked: "Ik heb alles gecheckt en naar waarheid ingevuld", rules: { @@ -36,6 +35,10 @@ const NL: Locale = { } }, submit: "Verstuur" + }, + submitted: { + title: "Succes!", + description: "Jouw digidecs is verstuurd! Mocht je na 7 dagen je geld nog niet terug hebben gekregen, neem dan contact op met de penningmeester" } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bf044b4..60275ec 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,10 @@ const routes: Array = [ { path: '', component: () => import('@/views/HomeView.vue') + }, + { + path: 'complete', + component: () => import('@/views/SubmittedView.vue') } ] } diff --git a/frontend/src/scripts/digidecs.ts b/frontend/src/scripts/digidecs.ts index f8b6494..4a9c0df 100644 --- a/frontend/src/scripts/digidecs.ts +++ b/frontend/src/scripts/digidecs.ts @@ -2,10 +2,18 @@ import {Result} from "@/scripts/core/result"; import {ApiError} from "@/scripts/core/error"; import {fetch1} from "@/scripts/core/fetch1"; import {server} from "@/main"; -import {encode} from "base64-arraybuffer"; + export class Digidecs { - static async digidecs( + trackingId: string; + attachments: string[] + + constructor(trackingId: string, attachments: string[]) { + this.trackingId = trackingId; + this.attachments = attachments; + } + + static async start( name: string, iban: string, email: string, @@ -14,19 +22,8 @@ export class Digidecs { commission: string, notes: string, attachments: File[], - ): Promise> { - const attachmentsBase64 = await Promise.all(attachments.map(async attachment => { - const buffer = await attachment.arrayBuffer(); - // const content = btoa(String.fromCharCode(...new Uint8Array(buffer))); - const content = encode(buffer); - return { - content: content, - name: attachment.name, - mime: attachment.type, - } - })); - - const r = await fetch1(`${server}/api/digidecs`, { + ): Promise> { + const r = await fetch1(`${server}/api/digidecs/start`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -39,14 +36,59 @@ export class Digidecs { what: what, commission: commission, notes: notes, - attachments: attachmentsBase64, + attachments: attachments.map((att) => { + return { + name: att.name, + mime: att.type, + }; + }), }) - }); + }) + if(r.isOk()) { + interface StartedDigidecsResponse { + tracking_id: string; + attachments: { + name: string, + mime: string, + tracking_id: string, + }[] + } + + + return r.map1(async (response) => { + const r = await response.json(); + return new Digidecs(r.tracking_id, r.attachments.map((att) => att.tracking_id)); + }); + } else { + return Result.err(r.unwrapErr()); + } + } + + async upload_attachment(file: File, index: number): Promise> { + const r = await fetch1(`${server}/api/digidecs/attachment?tracking_id=${this.trackingId}&attachment_tracking_id=${this.attachments[index]}`, { + method: 'POST', + body: file, + }); + + if(r.isOk()) { + return Result.ok([]); + } else { + return Result.err(r.unwrapErr()); + } + } + + async complete(): Promise> { + const r = await fetch1(`${server}/api/digidecs/complete?tracking_id=${this.trackingId}`, { + method: 'POST', + }); + if(r.isOk()) { return Result.ok([]); } else { return Result.err(r.unwrapErr()); } } + + } \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 5cedb26..a40dcee 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -78,9 +78,9 @@ size /> - +

{{ $t('home.form.filesExplanation') }} - +

+ + + {{ $t('submitted.title') }} + +
+ {{ $t('submitted.description') }} +
+
+
+
+ + + \ No newline at end of file diff --git a/server/Cargo.lock b/server/Cargo.lock index 1bdd65d..bcf8ddd 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -606,6 +606,35 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "digidecs" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-route-config", + "actix-web", + "base64", + "clap", + "color-eyre", + "futures-util", + "handlebars", + "iban_validate", + "lettre", + "nix", + "noiseless-tracing-actix-web", + "rand", + "regex", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-actix-web", + "tracing-error", + "tracing-subscriber", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1663,33 +1692,6 @@ dependencies = [ "serde", ] -[[package]] -name = "server" -version = "0.1.0" -dependencies = [ - "actix-cors", - "actix-route-config", - "actix-web", - "base64", - "clap", - "color-eyre", - "futures-util", - "handlebars", - "iban_validate", - "lettre", - "nix", - "noiseless-tracing-actix-web", - "regex", - "serde", - "serde_json", - "thiserror", - "tokio", - "tracing", - "tracing-actix-web", - "tracing-error", - "tracing-subscriber", -] - [[package]] name = "sha1" version = "0.10.6" diff --git a/server/Cargo.toml b/server/Cargo.toml index dcf76e2..2206e11 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "server" +name = "digidecs" version = "0.1.0" edition = "2021" @@ -24,4 +24,6 @@ regex = "1.10.6" iban_validate = "4.0.1" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-error = "0.2.0" -base64 = "0.22.1" \ No newline at end of file +base64 = "0.22.1" +rand = "0.8.5" +time = "0.3.36" \ No newline at end of file diff --git a/server/src/email/ipv4.rs b/server/src/email/ipv4.rs index 80ac555..63ce7d3 100644 --- a/server/src/email/ipv4.rs +++ b/server/src/email/ipv4.rs @@ -22,16 +22,16 @@ pub async fn get_local_v4() -> color_eyre::Result { 80, ))), ) - .await + .await { Ok(stream_r) => stream_r.map(|_| addr), Err(e) => Err(std::io::Error::new(std::io::ErrorKind::TimedOut, e)), } })) - .await - .into_iter() - .flatten() - .collect::>(); + .await + .into_iter() + .flatten() + .collect::>(); if connectable_addrs.is_empty() { Err(color_eyre::eyre::Error::msg( diff --git a/server/src/email/mod.rs b/server/src/email/mod.rs index 675f0e2..b6285d9 100644 --- a/server/src/email/mod.rs +++ b/server/src/email/mod.rs @@ -1,18 +1,19 @@ use crate::file::SmtpConfig; +use lettre::message::header::ContentType; use lettre::message::{Mailbox, MultiPart, SinglePart}; use lettre::transport::smtp::client::{AsyncSmtpConnection, TlsParameters}; use lettre::transport::smtp::extension::ClientId; use lettre::Address; use lettre::Message; +use rand::Rng; use std::net::{IpAddr, Ipv4Addr}; use std::str::FromStr; use std::time::Duration; -use lettre::message::header::ContentType; use thiserror::Error; use tracing::{debug, error, trace}; -pub mod template; pub mod ipv4; +pub mod template; #[derive(Debug, Error)] pub enum SendError { @@ -53,23 +54,28 @@ pub async fn send_email( Address::from_str(&smtp_config.from_email)?, ); - let mb_reply_to = Mailbox::new( - Some(reply_to_name), - Address::from_str(&reply_to_email)?, - ); + let mb_reply_to = Mailbox::new(Some(reply_to_name), Address::from_str(&reply_to_email)?); let msg = Message::builder() .reply_to(mb_reply_to) .from(mb_from) .to(mb_to) - .subject(format!("[DigiDecs] Nieuwe declaratie: {commission}")); + .subject(format!( + "[DigiDecs] Nieuwe declaratie: {commission} ({})", + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(6) + .map(char::from) + .collect::() + )); let mut mp = MultiPart::mixed().build(); mp = mp.singlepart(SinglePart::html(body)); for att in attachments { - mp = mp.singlepart(lettre::message::Attachment::new(att.name) - .body(att.content, ContentType::parse(&att.mime)?) + mp = mp.singlepart( + lettre::message::Attachment::new(att.name) + .body(att.content, ContentType::parse(&att.mime)?), ); } @@ -88,14 +94,14 @@ pub async fn send_email( None, Some(IpAddr::V4(local_addr4)), ) - .await?; + .await?; if conn.can_starttls() { conn.starttls( TlsParameters::new_rustls(smtp_config.smtp_relay.as_str().into())?, &client_id, ) - .await?; + .await?; } trace!("Checking SMTP connection"); diff --git a/server/src/email/template.rs b/server/src/email/template.rs index 5a646ca..d0aeab1 100644 --- a/server/src/email/template.rs +++ b/server/src/email/template.rs @@ -14,9 +14,7 @@ pub struct TemplateData { pub notes: Option, } -pub fn render_template( - data: &TemplateData, -) -> Result { +pub fn render_template(data: &TemplateData) -> Result { let mut engine = Handlebars::new(); engine.set_strict_mode(true); @@ -24,4 +22,4 @@ pub fn render_template( engine.set_dev_mode(true); engine.render_template(TEMPLATE, data) -} \ No newline at end of file +} diff --git a/server/src/file/config.rs b/server/src/file/config.rs index 8082c6e..3757760 100644 --- a/server/src/file/config.rs +++ b/server/src/file/config.rs @@ -1,11 +1,11 @@ -use serde::{Deserialize, Serialize}; use crate::file::DataFile; +use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct AppConfig { pub server: ServerConfig, pub smtp: SmtpConfig, - pub treasurer_email: String + pub treasurer_email: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -35,4 +35,4 @@ impl Default for ServerConfig { } } -impl DataFile for AppConfig {} \ No newline at end of file +impl DataFile for AppConfig {} diff --git a/server/src/server/mod.rs b/server/src/server/mod.rs index 0fb6f91..e4a31db 100644 --- a/server/src/server/mod.rs +++ b/server/src/server/mod.rs @@ -1,24 +1,23 @@ -use actix_cors::Cors; -use actix_route_config::Routable; -use actix_web::{web, App, HttpServer}; -use noiseless_tracing_actix_web::NoiselessRootSpanBuilder; -use tracing::info; use crate::args::AppArgs; use crate::email::ipv4::get_local_v4; use crate::file::AppConfig; use crate::server::types::{RuntimeData, WArgs, WConfig, WRuntime}; +use actix_cors::Cors; +use actix_route_config::Routable; +use actix_web::{App, HttpServer}; +use noiseless_tracing_actix_web::NoiselessRootSpanBuilder; +use std::sync::{Arc, Mutex}; +use tracing::info; -mod types; mod routes; +mod types; -pub async fn run_server( - config: AppConfig, - args: AppArgs, -) -> color_eyre::Result<()> { +pub async fn run_server(config: AppConfig, args: AppArgs) -> color_eyre::Result<()> { let port = config.server.port; let runtime_data = RuntimeData { local_v4_addr: get_local_v4().await?, + pending_digidecs: Arc::new(Mutex::new(vec![])), }; info!("Using {} for SMTP connections", runtime_data.local_v4_addr); @@ -31,15 +30,12 @@ pub async fn run_server( .app_data(WConfig::new(config.clone())) .app_data(WArgs::new(args.clone())) .app_data(WRuntime::new(runtime_data.clone())) - .app_data(web::JsonConfig::default() - .limit(20*10^6) - ) .configure(routes::Router::configure) }) - .bind(format!("0.0.0.0:{port}"))? - .server_hostname(&host) - .run() - .await?; + .bind(format!("0.0.0.0:{port}"))? + .server_hostname(&host) + .run() + .await?; Ok(()) -} \ No newline at end of file +} diff --git a/server/src/server/routes/digidecs.rs b/server/src/server/routes/digidecs.rs deleted file mode 100644 index 0af22b4..0000000 --- a/server/src/server/routes/digidecs.rs +++ /dev/null @@ -1,108 +0,0 @@ -use crate::email::send_email; -use crate::email::template::{render_template, TemplateData}; -use crate::server::types::{Empty, Error, WArgs, WConfig, WResult, WRuntime}; -use actix_web::web; -use iban::Iban; -use regex::Regex; -use serde::Deserialize; -use std::str::FromStr; -use std::sync::OnceLock; -use base64::{DecodeError, Engine}; -use tracing::{info, instrument}; - -#[derive(Deserialize)] -pub struct DigidecsRequest { - pub name: String, - pub iban: String, - pub email: String, - pub value: f64, - pub what: String, - pub commission: String, - pub notes: Option, - pub attachments: Vec, -} - -#[derive(Deserialize)] -pub struct Attachment { - pub content: String, - pub name: String, - pub mime: String, -} - -#[instrument(skip_all)] -pub async fn digidecs( - config: WConfig, - runtime_config: WRuntime, - payload: web::Json, - args: WArgs, -) -> WResult { - let payload = payload.into_inner(); - - if !validate_email(&payload.email) { - return Err(Error::InvalidEmail); - } - - if !validate_iban(&payload.iban) { - return Err(Error::InvalidIban); - } - - if payload.attachments.is_empty() { - return Err(Error::MissingAttachment); - } - - if payload.value <= 0.0 { - return Err(Error::ValueNegativeOrZero); - - } - - let attachments = payload.attachments.into_iter() - .map(|att| { - Ok(crate::email::Attachment { - content: base64::engine::general_purpose::STANDARD.decode(&att.content.as_bytes())?, - name: att.name, - mime: att.mime - }) - }) - .collect::, DecodeError>>()?; - - let email_body = render_template(&TemplateData { - name: payload.name.clone(), - iban: payload.iban, - email: payload.email.clone(), - value: format!("{:.2}", payload.value), - what: payload.what.clone(), - commission: payload.commission, - notes: payload.notes, - })?; - - if args.dry_run { - info!("Dry run is enabled. Not sending email."); - info!("Email body: \n{email_body}"); - } else { - send_email( - &config.smtp, - runtime_config.local_v4_addr, - &config.treasurer_email, - email_body, - payload.name, - &payload.email, - &payload.what, - attachments, - ).await?; - } - - - - Ok(Empty) -} - -static EMAIL_REGEX: OnceLock = OnceLock::new(); - -fn validate_email(email: &str) -> bool { - let regex = EMAIL_REGEX.get_or_init(|| Regex::new(r#"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+"#).unwrap()); - regex.is_match(email) -} - -fn validate_iban(iban: &str) -> bool { - Iban::from_str(iban).is_ok() -} \ No newline at end of file diff --git a/server/src/server/routes/digidecs/attachment.rs b/server/src/server/routes/digidecs/attachment.rs new file mode 100644 index 0000000..eccfb2b --- /dev/null +++ b/server/src/server/routes/digidecs/attachment.rs @@ -0,0 +1,34 @@ +use actix_web::web; +use serde::Deserialize; +use tracing::instrument; + +use crate::server::types::{Empty, Error, WResult, WRuntime}; + +#[derive(Deserialize)] +pub struct Query { + tracking_id: String, + attachment_tracking_id: String, +} + +#[instrument(skip_all)] +pub async fn attachment( + query: web::Query, + payload: web::Bytes, + runtime: WRuntime, +) -> WResult { + let mut lock = runtime.pending_digidecs.lock().unwrap(); + let digidecs = lock + .iter_mut() + .find(|digidecs| digidecs.tracking_id.eq(&query.tracking_id)) + .ok_or(Error::UnknownTrackingId)?; + + let attachment = digidecs + .attachments + .iter_mut() + .find(|att| att.tracking_id.eq(&query.attachment_tracking_id)) + .ok_or(Error::UnknownAttachmentTrackingId)?; + + attachment.content = Some(payload.to_vec()); + + Ok(Empty) +} diff --git a/server/src/server/routes/digidecs/complete.rs b/server/src/server/routes/digidecs/complete.rs new file mode 100644 index 0000000..93c5ebd --- /dev/null +++ b/server/src/server/routes/digidecs/complete.rs @@ -0,0 +1,83 @@ +use crate::email::send_email; +use crate::email::template::{render_template, TemplateData}; +use crate::server::types::{Empty, Error, WArgs, WConfig, WResult, WRuntime}; +use actix_web::web; +use serde::Deserialize; +use time::OffsetDateTime; +use tracing::{info, instrument, trace}; + +#[derive(Deserialize)] +pub struct Query { + tracking_id: String, +} + +#[instrument(skip_all)] +pub async fn complete( + query: web::Query, + config: WConfig, + runtime: WRuntime, + args: WArgs, +) -> WResult { + let mut lock = runtime.pending_digidecs.lock().unwrap(); + let (idx, _) = lock + .iter() + .enumerate() + .find(|(_, digidecs)| digidecs.tracking_id.eq(&query.tracking_id)) + .ok_or(Error::UnknownTrackingId)?; + + let digidecs = lock.remove(idx); + + if digidecs.expires_at <= OffsetDateTime::now_utc() { + return Err(Error::DigidecsExpired); + } + + let attachments_cnt = digidecs + .attachments + .iter() + .filter(|att| att.content.is_some()) + .count(); + + if digidecs.attachment_count != attachments_cnt { + return Err(Error::MissingAttachment); + } + + let email_body = render_template(&TemplateData { + name: digidecs.data.name.clone(), + iban: digidecs.data.iban.clone(), + email: digidecs.data.email.clone(), + value: format!("{:.2}", digidecs.data.value), + what: digidecs.data.what.clone(), + commission: digidecs.data.commission.clone(), + notes: digidecs.data.notes.clone(), + })?; + + let attachments = digidecs + .attachments + .into_iter() + .map(|att| crate::email::Attachment { + name: att.name, + mime: att.mime, + content: att.content.unwrap(), // Some state is checked when checking if every attachment has content set earier + }) + .collect::>(); + + if args.dry_run { + info!("Dry run is enabled. Not sending email."); + info!("Email body: \n{email_body}"); + } else { + trace!("Sending Digidecs email"); + send_email( + &config.smtp, + runtime.local_v4_addr, + &config.treasurer_email, + email_body, + digidecs.data.name.clone(), + &digidecs.data.email.clone(), + &digidecs.data.what.clone(), + attachments, + ) + .await?; + } + + Ok(Empty) +} diff --git a/server/src/server/routes/digidecs/mod.rs b/server/src/server/routes/digidecs/mod.rs new file mode 100644 index 0000000..3eb9e03 --- /dev/null +++ b/server/src/server/routes/digidecs/mod.rs @@ -0,0 +1,20 @@ +use actix_route_config::Routable; +use actix_web::web; +use actix_web::web::ServiceConfig; + +mod attachment; +mod complete; +mod start; + +pub struct Router; + +impl Routable for Router { + fn configure(config: &mut ServiceConfig) { + config.service( + web::scope("/digidecs") + .route("/start", web::post().to(start::start)) + .route("/attachment", web::post().to(attachment::attachment)) + .route("/complete", web::post().to(complete::complete)), + ); + } +} diff --git a/server/src/server/routes/digidecs/start.rs b/server/src/server/routes/digidecs/start.rs new file mode 100644 index 0000000..65fa194 --- /dev/null +++ b/server/src/server/routes/digidecs/start.rs @@ -0,0 +1,130 @@ +use std::str::FromStr; +use std::sync::OnceLock; + +use actix_web::web; +use iban::Iban; +use rand::Rng; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; +use tracing::instrument; + +use crate::server::types::{ + Error, PendingDigidecs, PendingDigidecsAttachment, PendingDigidecsData, WResult, WRuntime, +}; + +#[derive(Deserialize)] +pub struct StartDigidecsRequest { + name: String, + iban: String, + email: String, + value: f64, + what: String, + commission: String, + notes: Option, + attachments: Vec, +} + +#[derive(Deserialize)] +pub struct Attachment { + name: String, + mime: String, +} + +#[derive(Serialize)] +pub struct StartDigidecsResponse { + tracking_id: String, + attachments: Vec, +} + +#[derive(Serialize)] +pub struct AttachmentResponse { + name: String, + mime: String, + tracking_id: String, +} + +#[instrument(skip_all)] +pub async fn start( + payload: web::Json, + runtime: WRuntime, +) -> WResult> { + let payload = payload.into_inner(); + + if !validate_email(&payload.email) { + return Err(Error::InvalidEmail); + } + + if !validate_iban(&payload.iban) { + return Err(Error::InvalidIban); + } + + if payload.attachments.is_empty() { + return Err(Error::MissingAttachment); + } + + if payload.value <= 0.0 { + return Err(Error::ValueNegativeOrZero); + } + + let tracking_id = gen_tracking_id(); + let attachment_tracking_ids = payload + .attachments + .iter() + .map(|att| AttachmentResponse { + name: att.name.clone(), + tracking_id: gen_tracking_id(), + mime: att.mime.clone(), + }) + .collect::>(); + + let mut lock = runtime.pending_digidecs.lock().unwrap(); + lock.push(PendingDigidecs { + expires_at: OffsetDateTime::now_utc() + Duration::hours(1), + tracking_id: tracking_id.clone(), + attachment_count: attachment_tracking_ids.len(), + attachments: attachment_tracking_ids + .iter() + .map(|att| PendingDigidecsAttachment { + name: att.name.clone(), + tracking_id: att.tracking_id.clone(), + content: None, + mime: att.mime.clone(), + }) + .collect(), + data: PendingDigidecsData { + name: payload.name, + email: payload.email, + value: payload.value, + iban: payload.iban, + notes: payload.notes, + what: payload.what, + commission: payload.commission, + }, + }); + + Ok(web::Json(StartDigidecsResponse { + tracking_id, + attachments: attachment_tracking_ids, + })) +} + +static EMAIL_REGEX: OnceLock = OnceLock::new(); + +fn gen_tracking_id() -> String { + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(16) + .map(char::from) + .collect() +} + +fn validate_email(email: &str) -> bool { + let regex = EMAIL_REGEX + .get_or_init(|| Regex::new(r#"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+"#).unwrap()); + regex.is_match(email) +} + +fn validate_iban(iban: &str) -> bool { + Iban::from_str(iban).is_ok() +} diff --git a/server/src/server/routes/mod.rs b/server/src/server/routes/mod.rs index 8fbd233..6a02a72 100644 --- a/server/src/server/routes/mod.rs +++ b/server/src/server/routes/mod.rs @@ -8,8 +8,6 @@ pub struct Router; impl Routable for Router { fn configure(config: &mut ServiceConfig) { - config.service(web::scope("/api") - .route("/digidecs", web::post().to(digidecs::digidecs)) - ); + config.service(web::scope("/api").configure(digidecs::Router::configure)); } -} \ No newline at end of file +} diff --git a/server/src/server/types/data.rs b/server/src/server/types/data.rs index 488900c..80ae270 100644 --- a/server/src/server/types/data.rs +++ b/server/src/server/types/data.rs @@ -2,6 +2,8 @@ use crate::args::AppArgs; use crate::file::AppConfig; use actix_web::web; use std::net::Ipv4Addr; +use std::sync::{Arc, Mutex}; +use time::OffsetDateTime; pub type WConfig = web::Data; @@ -12,4 +14,33 @@ pub type WRuntime = web::Data; #[derive(Clone)] pub struct RuntimeData { pub local_v4_addr: Ipv4Addr, + pub pending_digidecs: Arc>>, +} + +#[derive(Clone)] +pub struct PendingDigidecs { + pub expires_at: OffsetDateTime, + pub data: PendingDigidecsData, + pub tracking_id: String, + pub attachment_count: usize, + pub attachments: Vec, +} + +#[derive(Clone)] +pub struct PendingDigidecsData { + pub name: String, + pub iban: String, + pub email: String, + pub value: f64, + pub what: String, + pub commission: String, + pub notes: Option, +} + +#[derive(Clone)] +pub struct PendingDigidecsAttachment { + pub name: String, + pub tracking_id: String, + pub mime: String, + pub content: Option>, } diff --git a/server/src/server/types/error.rs b/server/src/server/types/error.rs index fa5e85f..1a85ff6 100644 --- a/server/src/server/types/error.rs +++ b/server/src/server/types/error.rs @@ -14,12 +14,18 @@ pub enum Error { InvalidIban, #[error("Invalid Email address")] InvalidEmail, - #[error("At least one attachment is required")] + #[error("Missing attachments")] MissingAttachment, #[error("Value may not be negative or zero")] ValueNegativeOrZero, #[error("Attachment contains invalid base64")] InvalidAttachmentBase64(#[from] base64::DecodeError), + #[error("No digidecs with that tracking ID exists")] + UnknownTrackingId, + #[error("No digidecs attachment with that tracking ID exists")] + UnknownAttachmentTrackingId, + #[error("Digidecs has expired. Start over again")] + DigidecsExpired, } impl ResponseError for Error { @@ -32,6 +38,9 @@ impl ResponseError for Error { Self::MissingAttachment => StatusCode::BAD_REQUEST, Self::ValueNegativeOrZero => StatusCode::BAD_REQUEST, Self::InvalidAttachmentBase64(_) => StatusCode::BAD_REQUEST, + Self::UnknownTrackingId => StatusCode::NOT_FOUND, + Self::UnknownAttachmentTrackingId => StatusCode::NOT_FOUND, + Self::DigidecsExpired => StatusCode::BAD_REQUEST, } } } diff --git a/server/src/server/types/mod.rs b/server/src/server/types/mod.rs index 035b23e..fba3789 100644 --- a/server/src/server/types/mod.rs +++ b/server/src/server/types/mod.rs @@ -4,4 +4,4 @@ pub mod error; pub use data::*; pub use empty::*; -pub use error::*; \ No newline at end of file +pub use error::*;