From 164914644a7453b2db979674db2124e0112dbe08 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Thu, 20 Jun 2024 22:23:01 +0200 Subject: [PATCH 01/14] fist steps of admin api --- backend/.env.example | 6 ++- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/README.md | 1 + backend/src/interface/api_command.rs | 58 +++++++++++++++++++++ backend/src/layer/trigger/api/admin.rs | 67 +++++++++++++++++++++++++ backend/src/layer/trigger/api/bin.rs | 1 + backend/src/layer/trigger/api/mod.rs | 1 + backend/src/layer/trigger/api/server.rs | 27 +++++++++- backend/src/startup/config.rs | 1 + 10 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 backend/src/layer/trigger/api/admin.rs diff --git a/backend/.env.example b/backend/.env.example index ec59e383..bdb284ba 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -42,4 +42,8 @@ IMAGE_DIR= #MAX_UPLOAD_SIZE= # --- logging --- -#LOG_CONFIG=warn,mensa_app_backend=trace \ No newline at end of file +#LOG_CONFIG=warn,mensa_app_backend=trace + + +# --- Admin Api --- +ADMIN_KEY= \ No newline at end of file diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8d236a6d..6bcd0410 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1603,7 +1603,7 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mensa-app-backend" -version = "1.2.0" +version = "1.3.0" dependencies = [ "async-graphql", "async-graphql-axum", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f730d465..33735673 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mensa-app-backend" -version = "1.2.0" +version = "1.3.0" edition = "2021" authors = [ "Alexander Albers ", diff --git a/backend/README.md b/backend/README.md index d297a8ac..4ece0349 100644 --- a/backend/README.md +++ b/backend/README.md @@ -65,6 +65,7 @@ The following options are available: | `MAX_IMAGE_WIDTH` and `MAX_IMAGE_HEIGHT` | Maximum width and height stored for stored images. Uploaded images will be scaled accordingly. | `1920` and `1080` | | `RATE_LIMIT` | Limit the number of API requests per second. `0` means disabled. | `0` (disabled) | | `MAX_UPLOAD_SIZE` | Maximal size (in bytes) an http body can have to get accepted. This implies a maximal size an image upload can have. | `10485760` (10 MiB) | +| `ADMIN_KEY` | Key to access admin api commands. Must be entered for http basic auth, username "admin". | required | ### Notes - The **timezone** of log messages and the chron schedule is only queried once at backend startup from the host os because of technical limitations. For changes in timezone (e.g. summer time) the server has to be restarted. diff --git a/backend/src/interface/api_command.rs b/backend/src/interface/api_command.rs index f0023442..c5e1bcc8 100644 --- a/backend/src/interface/api_command.rs +++ b/backend/src/interface/api_command.rs @@ -1,5 +1,7 @@ //! This interface allows to execute API commands. +use std::sync::Arc; + use async_trait::async_trait; use thiserror::Error; @@ -49,6 +51,62 @@ pub trait Command: Send + Sync { async fn set_meal_rating(&self, meal_id: Uuid, rating: u32, client_id: Uuid) -> Result<()>; } +#[async_trait] +impl Command for Arc { + async fn report_image( + &self, + image_id: Uuid, + reason: ReportReason, + client_id: Uuid, + ) -> Result<()> { + Self::as_ref(self) + .report_image(image_id, reason, client_id) + .await + } + + async fn add_image_upvote(&self, image_id: Uuid, client_id: Uuid) -> Result<()> { + Self::as_ref(self) + .add_image_upvote(image_id, client_id) + .await + } + + async fn add_image_downvote(&self, image_id: Uuid, client_id: Uuid) -> Result<()> { + Self::as_ref(self) + .add_image_downvote(image_id, client_id) + .await + } + + async fn remove_image_upvote(&self, image_id: Uuid, client_id: Uuid) -> Result<()> { + Self::as_ref(self) + .remove_image_upvote(image_id, client_id) + .await + } + + async fn remove_image_downvote(&self, image_id: Uuid, client_id: Uuid) -> Result<()> { + Self::as_ref(self) + .remove_image_downvote(image_id, client_id) + .await + } + + async fn add_image( + &self, + meal_id: Uuid, + image_type: Option, + image_file: Vec, + client_id: Uuid, + ) -> Result<()> { + Self::as_ref(self) + .add_image(meal_id, image_type, image_file, client_id) + .await + } + + async fn set_meal_rating(&self, meal_id: Uuid, rating: u32, client_id: Uuid) -> Result<()> { + Self::as_ref(self) + .set_meal_rating(meal_id, rating, client_id) + .await + } +} + /// Enum describing the possible ways, a command can fail. #[derive(Debug, Error)] pub enum CommandError { diff --git a/backend/src/layer/trigger/api/admin.rs b/backend/src/layer/trigger/api/admin.rs new file mode 100644 index 00000000..809c3fe0 --- /dev/null +++ b/backend/src/layer/trigger/api/admin.rs @@ -0,0 +1,67 @@ +//! Admin rest api functionality + +use std::sync::Arc; + +use axum::{ + debug_handler, + extract::State, + headers::{authorization::Basic, Authorization}, + http::HeaderValue, + middleware::Next, + response::IntoResponse, + routing::method_routing::get, + Router, TypedHeader, +}; +use hyper::{header::WWW_AUTHENTICATE, HeaderMap, Request, StatusCode}; + +use crate::interface::api_command::Command; + +#[derive(Clone)] +pub(super) struct AdminKey(pub String); + +pub(super) type ArcCommand = Arc; + +pub(super) fn admin_router() -> Router { + Router::new().route("/test", get(test_command)) +} + +#[debug_handler] +async fn test_command( + State(command): State, + body: Request, +) -> &'static str { + "working" // todo +} + +const ADMIN_USER: &str = "admin"; +const XXX_AUTHENTICATE_CONTENT: &str = "Basic realm=MensaKaAdmin"; + +fn unauthenticated() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert( + WWW_AUTHENTICATE, + HeaderValue::from_str(XXX_AUTHENTICATE_CONTENT).expect("contains no invalid characters"), + ); + ( + StatusCode::UNAUTHORIZED, + headers, + "Please authenticate to access the admin api!", + ) +} + +pub(super) async fn admin_auth_middleware( + creds: Option>>, + State(auth_key): State, + req: Request, + next: Next, +) -> impl IntoResponse { + let Some(creds) = creds else { + return Err(unauthenticated()); + }; + + if creds.0 .0.username() != ADMIN_USER || creds.0 .0.password() != auth_key.0 { + return Err(unauthenticated()); + } + + Ok(next.run(req).await) +} diff --git a/backend/src/layer/trigger/api/bin.rs b/backend/src/layer/trigger/api/bin.rs index 7bc9a4b1..d94c30c5 100644 --- a/backend/src/layer/trigger/api/bin.rs +++ b/backend/src/layer/trigger/api/bin.rs @@ -35,6 +35,7 @@ async fn main() { image_dir: temp_dir(), rate_limit: None, max_body_size: 10 << 20, + admin_key: "admin".into(), }; let image_pre_info = ImagePreprocessingInfo { diff --git a/backend/src/layer/trigger/api/mod.rs b/backend/src/layer/trigger/api/mod.rs index 89d36617..aba01f57 100644 --- a/backend/src/layer/trigger/api/mod.rs +++ b/backend/src/layer/trigger/api/mod.rs @@ -1,5 +1,6 @@ //! This component contains the web server that enables API requests and represents the entry point for these. +mod admin; pub mod auth; pub mod mock; pub mod mutation; diff --git a/backend/src/layer/trigger/api/server.rs b/backend/src/layer/trigger/api/server.rs index 71692c77..7c508052 100644 --- a/backend/src/layer/trigger/api/server.rs +++ b/backend/src/layer/trigger/api/server.rs @@ -39,7 +39,10 @@ use crate::{ api_command::Command, persistent_data::{model::ApiKey, AuthDataAccess, RequestDataAccess}, }, - layer::trigger::api::auth::auth_middleware, + layer::trigger::api::{ + admin::{admin_auth_middleware, admin_router, AdminKey, ArcCommand}, + auth::auth_middleware, + }, util::{local_to_global_url, IMAGE_BASE_PATH}, }; @@ -62,6 +65,8 @@ pub struct ApiServerInfo { pub rate_limit: Option, /// Maximum accepted http body size pub max_body_size: u64, + /// Api key for accessing the admin api + pub admin_key: String, } enum State { @@ -87,6 +92,7 @@ pub struct ApiServer { schema: GraphQLSchema, state: State, api_keys: Vec, + command_copy: Arc, } impl ApiServer { @@ -99,7 +105,8 @@ impl ApiServer { command: impl Command + 'static, auth: impl AuthDataAccess, ) -> Self { - let schema: GraphQLSchema = construct_schema(data_access, command); + let command_arc = Arc::new(command); + let schema: GraphQLSchema = construct_schema(data_access, command_arc.clone()); Self { server_info, schema, @@ -108,6 +115,7 @@ impl ApiServer { .get_api_keys() .await .expect("could not get api keys from database"), + command_copy: command_arc, } } @@ -139,12 +147,24 @@ impl ApiServer { Duration::from_secs(1), )); + let admin_auth = middleware::from_fn_with_state( + AdminKey(self.server_info.admin_key.clone()), + admin_auth_middleware, + ); + let admin_router = admin_router(); + let app = Router::new() .route( "/", get(graphql_playground).post(graphql_handler.layer(auth)), ) .layer(Extension(self.schema.clone())) + .nest( + "/admin", + admin_router + .layer(admin_auth) + .with_state(self.command_copy.clone() as ArcCommand), + ) .nest_service(IMAGE_BASE_PATH, ServeDir::new(&self.server_info.image_dir)) .layer(rate_limit) .layer(DefaultBodyLimit::max( @@ -280,6 +300,7 @@ mod tests { image_dir: temp_dir(), rate_limit: None, max_body_size: BODY_SIZE, + admin_key: "admin".into(), }; ApiServer::new(info, RequestDatabaseMock, CommandMock, AuthDataMock).await } @@ -290,6 +311,7 @@ mod tests { image_dir, rate_limit: None, max_body_size: BODY_SIZE, + admin_key: "admin".into(), }; ApiServer::new(info, RequestDatabaseMock, CommandMock, AuthDataMock).await } @@ -498,6 +520,7 @@ mod tests { image_dir: temp_dir(), rate_limit: None, max_body_size: 1 << 10, + admin_key: "admin".into(), }; let mut server = ApiServer::new(info, RequestDatabaseMock, CommandMock, AuthDataMock).await; diff --git a/backend/src/startup/config.rs b/backend/src/startup/config.rs index e7eb44e9..62282ab9 100644 --- a/backend/src/startup/config.rs +++ b/backend/src/startup/config.rs @@ -172,6 +172,7 @@ impl ConfigReader { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(DEFAULT_UPLOAD_SIZE), + admin_key: read_var("ADMIN_KEY")?, }; info.rate_limit.map_or_else( From e29fcc8b646ba8c3cc0296004e74b5309390a6b7 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Fri, 21 Jun 2024 20:33:38 +0200 Subject: [PATCH 02/14] bugfix: same image gets show as other images in report --- backend/src/layer/data/database/command.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/layer/data/database/command.rs b/backend/src/layer/data/database/command.rs index 6d8f8140..9f54c8ce 100644 --- a/backend/src/layer/data/database/command.rs +++ b/backend/src/layer/data/database/command.rs @@ -37,10 +37,11 @@ impl CommandDataAccess for PersistentCommandData { let other_image_urls = sqlx::query_scalar!( " SELECT image_id FROM image_detail - WHERE currently_visible AND food_id = $1 + WHERE currently_visible AND food_id = $1 AND image_id <> $2 ORDER BY rank DESC ", - record.food_id + record.food_id, + image_id ) .fetch_all(&self.pool) .await? @@ -219,7 +220,6 @@ mod test { other_image_urls: vec![ image_id_to_url(Uuid::parse_str("ea8cce48-a3c7-4f8e-a222-5f3891c13804").unwrap()), image_id_to_url(Uuid::parse_str("1aa73d5d-1701-4975-aa3c-1422a8bc10e8").unwrap()), - image_id_to_url(Uuid::parse_str("76b904fe-d0f1-4122-8832-d0e21acab86d").unwrap()), ], } } From 95c2670eebdcbe0a4c644ec1fec3cce7a35a2246 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Fri, 21 Jun 2024 21:37:36 +0200 Subject: [PATCH 03/14] added command function scafholdings --- backend/src/interface/api_command.rs | 14 +++++ .../logic/api_command/command_handler.rs | 8 +++ backend/src/layer/trigger/api/admin.rs | 58 +++++++++++++++---- backend/src/layer/trigger/api/mock.rs | 8 +++ backend/src/layer/trigger/api/server.rs | 14 ++--- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/backend/src/interface/api_command.rs b/backend/src/interface/api_command.rs index c5e1bcc8..caa2e6c7 100644 --- a/backend/src/interface/api_command.rs +++ b/backend/src/interface/api_command.rs @@ -49,6 +49,12 @@ pub trait Command: Send + Sync { /// command to add a rating to a meal. async fn set_meal_rating(&self, meal_id: Uuid, rating: u32, client_id: Uuid) -> Result<()>; + + /// Marks an image as verified and deletes all its reports. // todo do we want this? + async fn verify_image(&self, image_id: Uuid) -> Result<()>; + + /// Deletes an image. // todo only hide? + async fn delete_image(&self, image_id: Uuid) -> Result<()>; } #[async_trait] @@ -105,6 +111,14 @@ impl Command for Arc { .set_meal_rating(meal_id, rating, client_id) .await } + + async fn verify_image(&self, image_id: Uuid) -> Result<()> { + Self::as_ref(self).verify_image(image_id).await + } + + async fn delete_image(&self, image_id: Uuid) -> Result<()> { + Self::as_ref(self).delete_image(image_id).await + } } /// Enum describing the possible ways, a command can fail. diff --git a/backend/src/layer/logic/api_command/command_handler.rs b/backend/src/layer/logic/api_command/command_handler.rs index 058711a5..43386cb5 100644 --- a/backend/src/layer/logic/api_command/command_handler.rs +++ b/backend/src/layer/logic/api_command/command_handler.rs @@ -190,6 +190,14 @@ where .await?; Ok(()) } + + async fn delete_image(&self, _image_id: Uuid) -> Result<()> { + todo!() + } + + async fn verify_image(&self, _image_id: Uuid) -> Result<()> { + todo!() + } } #[cfg(test)] diff --git a/backend/src/layer/trigger/api/admin.rs b/backend/src/layer/trigger/api/admin.rs index 809c3fe0..bd1a3557 100644 --- a/backend/src/layer/trigger/api/admin.rs +++ b/backend/src/layer/trigger/api/admin.rs @@ -4,33 +4,71 @@ use std::sync::Arc; use axum::{ debug_handler, - extract::State, + extract::{Path, State}, headers::{authorization::Basic, Authorization}, http::HeaderValue, - middleware::Next, + middleware::{self, Next}, response::IntoResponse, routing::method_routing::get, Router, TypedHeader, }; use hyper::{header::WWW_AUTHENTICATE, HeaderMap, Request, StatusCode}; -use crate::interface::api_command::Command; +use tracing::warn; + +use crate::{ + interface::api_command::{Command, CommandError}, + util::Uuid, +}; #[derive(Clone)] -pub(super) struct AdminKey(pub String); +pub(super) struct AdminKey(String); pub(super) type ArcCommand = Arc; -pub(super) fn admin_router() -> Router { - Router::new().route("/test", get(test_command)) +pub(super) fn admin_router(admin_key: String, command: ArcCommand) -> Router<()> { + let admin_auth = middleware::from_fn_with_state(AdminKey(admin_key), admin_auth_middleware); + // let router = Router::new() + // .route("/version", get(version)) + // .route("/report/delete_image/:image_id", get(delete_image)) + // .route("/report/verify_image/:image_id", get(verify_image)) + // .layer(HandleErrorLayer::new(handle_error)); + + Router::new() + .route("/version", get(version)) + .route("/report/delete_image/:image_id", get(delete_image)) + .route("/report/verify_image/:image_id", get(verify_image)) + .layer(admin_auth) + .with_state(command) +} + +impl IntoResponse for CommandError { + fn into_response(self) -> axum::response::Response { + let error = self.to_string(); + warn!("On Admin API request: {error}"); + (StatusCode::INTERNAL_SERVER_ERROR, error).into_response() + } +} + +#[debug_handler] +async fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[debug_handler] +async fn verify_image( + State(command): State, + Path(image_id): Path, +) -> Result<(), CommandError> { + command.verify_image(image_id).await } #[debug_handler] -async fn test_command( +async fn delete_image( State(command): State, - body: Request, -) -> &'static str { - "working" // todo + Path(image_id): Path, +) -> Result<(), CommandError> { + command.delete_image(image_id).await } const ADMIN_USER: &str = "admin"; diff --git a/backend/src/layer/trigger/api/mock.rs b/backend/src/layer/trigger/api/mock.rs index ac0b0e1a..d164b6aa 100644 --- a/backend/src/layer/trigger/api/mock.rs +++ b/backend/src/layer/trigger/api/mock.rs @@ -354,6 +354,14 @@ impl Command for CommandMock { ) -> CommandResult<()> { Ok(()) } + + async fn delete_image(&self, _image_id: Uuid) -> CommandResult<()> { + Ok(()) + } + + async fn verify_image(&self, _image_id: Uuid) -> CommandResult<()> { + Ok(()) + } } pub struct AuthDataMock; diff --git a/backend/src/layer/trigger/api/server.rs b/backend/src/layer/trigger/api/server.rs index 7c508052..553d452e 100644 --- a/backend/src/layer/trigger/api/server.rs +++ b/backend/src/layer/trigger/api/server.rs @@ -147,11 +147,10 @@ impl ApiServer { Duration::from_secs(1), )); - let admin_auth = middleware::from_fn_with_state( - AdminKey(self.server_info.admin_key.clone()), - admin_auth_middleware, + let admin_router = admin_router( + self.server_info.admin_key.clone(), + self.command_copy.clone() as ArcCommand, ); - let admin_router = admin_router(); let app = Router::new() .route( @@ -159,12 +158,7 @@ impl ApiServer { get(graphql_playground).post(graphql_handler.layer(auth)), ) .layer(Extension(self.schema.clone())) - .nest( - "/admin", - admin_router - .layer(admin_auth) - .with_state(self.command_copy.clone() as ArcCommand), - ) + .nest("/admin", admin_router) .nest_service(IMAGE_BASE_PATH, ServeDir::new(&self.server_info.image_dir)) .layer(rate_limit) .layer(DefaultBodyLimit::max( From 577231c5eb092a37db3ba654c5862e64b6635085 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Wed, 26 Jun 2024 13:23:46 +0200 Subject: [PATCH 04/14] mail fixes --- backend/src/layer/data/mail/mail_sender.rs | 6 +++--- backend/src/layer/data/mail/template/output.css | 2 +- backend/src/layer/data/mail/template/template.html | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/layer/data/mail/mail_sender.rs b/backend/src/layer/data/mail/mail_sender.rs index 8e6aedae..069b07d3 100644 --- a/backend/src/layer/data/mail/mail_sender.rs +++ b/backend/src/layer/data/mail/mail_sender.rs @@ -195,7 +195,7 @@ mod test { "the template must contain all of the information from the report info" ); assert!( - report.contains(info.image_rank.to_string().as_str()), + report.contains(&info.image_rank.to_string()[0..4]), "the template must contain all of the information from the report info" ); assert!( @@ -264,11 +264,11 @@ mod test { reason: crate::util::ReportReason::Advert, image_got_hidden: true, image_id: Uuid::from_u128(9_789_789), - image_url: String::from("https://picsum.photos/500/330"), + image_url: String::from("https://picsum.photos/500/200"), report_count: 1, positive_rating_count: 10, negative_rating_count: 20, - image_rank: 1.0, + image_rank: 0.123_456, report_barrier: 1, client_id: Uuid::from_u128(123), image_age: 1, diff --git a/backend/src/layer/data/mail/template/output.css b/backend/src/layer/data/mail/template/output.css index 064903c9..740b9ba1 100644 --- a/backend/src/layer/data/mail/template/output.css +++ b/backend/src/layer/data/mail/template/output.css @@ -1 +1 @@ -/*! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.left-0{left:0}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.m-0{margin:0}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mr-4{margin-right:1rem}.inline{display:inline}.flex{display:flex}.table{display:table}.table-cell{display:table-cell}.table-row-group{display:table-row-group}.table-row{display:table-row}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.size-10{width:2.5rem;height:2.5rem}.size-5{width:1.25rem;height:1.25rem}.size-full{width:100%;height:100%}.h-40{height:10rem}.h-auto{height:auto}.h-full{height:100%}.w-1\/2{width:50%}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:42rem}.flex-none{flex:none}.table-fixed{table-layout:fixed}.border-separate{border-collapse:initial}.border-spacing-y-1{--tw-border-spacing-y:0.25rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-text{cursor:text}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-always{scroll-snap-stop:always}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.gap-8{gap:2rem}.overflow-x-auto{overflow-x:auto}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.bg-dark-grey{--tw-bg-opacity:1;background-color:rgb(30 30 30/var(--tw-bg-opacity))}.bg-green{--tw-bg-opacity:1;background-color:rgb(122 172 43/var(--tw-bg-opacity))}.bg-light-grey{--tw-bg-opacity:1;background-color:rgb(51 51 51/var(--tw-bg-opacity))}.bg-red{--tw-bg-opacity:1;background-color:rgb(211 47 47/var(--tw-bg-opacity))}.fill-green{fill:#7aac2b}.fill-red{fill:#d32f2f}.fill-white{fill:#fff}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-8{padding-left:2rem;padding-right:2rem}.pb-2{padding-bottom:.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.6em\]{font-size:.6em}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.leading-none{line-height:1}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f)}.drop-shadow,.drop-shadow-md{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-md{--tw-drop-shadow:drop-shadow(0 4px 3px #00000012) drop-shadow(0 2px 2px #0000000f)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}*{scrollbar-color:gray #0000;scrollbar-width:thick}.active\:invisible:active{visibility:hidden} \ No newline at end of file +/*! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.left-0{left:0}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.m-0{margin:0}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mr-4{margin-right:1rem}.inline{display:inline}.flex{display:flex}.table{display:table}.table-cell{display:table-cell}.table-row-group{display:table-row-group}.table-row{display:table-row}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.size-10{width:2.5rem;height:2.5rem}.size-5{width:1.25rem;height:1.25rem}.size-full{width:100%;height:100%}.h-40{height:10rem}.h-auto{height:auto}.h-full{height:100%}.w-1\/2{width:50%}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:42rem}.flex-none{flex:none}.table-fixed{table-layout:fixed}.border-separate{border-collapse:initial}.border-spacing-y-1{--tw-border-spacing-y:0.25rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-text{cursor:text}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-always{scroll-snap-stop:always}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.gap-8{gap:2rem}.overflow-x-auto{overflow-x:auto}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.bg-dark-grey{--tw-bg-opacity:1;background-color:rgb(30 30 30/var(--tw-bg-opacity))}.bg-green{--tw-bg-opacity:1;background-color:rgb(122 172 43/var(--tw-bg-opacity))}.bg-light-grey{--tw-bg-opacity:1;background-color:rgb(51 51 51/var(--tw-bg-opacity))}.bg-red{--tw-bg-opacity:1;background-color:rgb(211 47 47/var(--tw-bg-opacity))}.fill-green{fill:#7aac2b}.fill-red{fill:#d32f2f}.fill-white{fill:#fff}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-8{padding-left:2rem;padding-right:2rem}.pb-2{padding-bottom:.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.6em\]{font-size:.6em}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.leading-none{line-height:1}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f)}.drop-shadow,.drop-shadow-md{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-md{--tw-drop-shadow:drop-shadow(0 4px 3px #00000012) drop-shadow(0 2px 2px #0000000f)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}*{scrollbar-color:gray #0000;scrollbar-width:thick}.active\:invisible:active{visibility:hidden} \ No newline at end of file diff --git a/backend/src/layer/data/mail/template/template.html b/backend/src/layer/data/mail/template/template.html index 4069c429..c6cd89d4 100644 --- a/backend/src/layer/data/mail/template/template.html +++ b/backend/src/layer/data/mail/template/template.html @@ -40,7 +40,7 @@

- +
More Information

Rank
-
{{ image_rank }}
+
{{ image_rank|round(3) }}
From b0d76af6fa5b87e3130098c13e2def1508dcdf00 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Wed, 3 Jul 2024 22:11:16 +0200 Subject: [PATCH 05/14] added traits and implementations for image verification and deletion --- backend/src/interface/admin_notification.rs | 23 ++++ backend/src/interface/api_command.rs | 9 +- backend/src/interface/image_storage.rs | 6 + backend/src/interface/persistent_data.rs | 6 + backend/src/layer/data/database/command.rs | 17 +++ backend/src/layer/data/file_handler/mod.rs | 11 ++ backend/src/layer/data/mail/mail_sender.rs | 105 +++++++++++------- .../data/mail/template/notification.html | 22 ++++ .../src/layer/data/mail/template/output.css | 2 +- .../data/mail/template/tailwind.config.js | 2 +- .../logic/api_command/command_handler.rs | 17 ++- backend/src/layer/logic/api_command/mocks.rs | 20 +++- backend/src/layer/trigger/api/server.rs | 2 +- backend/src/startup/server.rs | 7 +- 14 files changed, 193 insertions(+), 56 deletions(-) create mode 100644 backend/src/layer/data/mail/template/notification.html diff --git a/backend/src/interface/admin_notification.rs b/backend/src/interface/admin_notification.rs index d843a814..4182937e 100644 --- a/backend/src/interface/admin_notification.rs +++ b/backend/src/interface/admin_notification.rs @@ -1,15 +1,38 @@ //! This interface allows administrators to be notified of reporting requests. use async_trait::async_trait; +use lettre::address::AddressError; use serde::Serialize; +use thiserror::Error; use crate::util::{Date, ReportReason, Uuid}; +/// Result returned when sending emails, potentially containing a [`MailError`]. +pub type Result = std::result::Result; + /// Interface for notification of administrators. #[async_trait] pub trait AdminNotification: Sync + Send { /// Notifies an administrator about a newly reported image and the response automatically taken. async fn notify_admin_image_report(&self, info: ImageReportInfo); + /// Notifies an administrator about an image gotten verified. + async fn notify_admin_image_verified(&self, image_id: Uuid) -> Result<()>; + /// Notifies an administrator about an image gotten deleted. + async fn notify_admin_image_deleted(&self, image_id: Uuid) -> Result<()>; +} + +/// Enum describing the possible ways, the mail notification can fail. +#[derive(Debug, Error)] +pub enum MailError { + /// Error occurring when an email address could not be parsed. + #[error("an error occurred while parsing the addresses: {0}")] + AddressError(#[from] AddressError), + /// Error occurring when an email could not be constructed. + #[error("an error occurred while parsing the mail: {0}")] + MailParseError(#[from] lettre::error::Error), + /// Error occurring when mail sender instance could bot be build. + #[error("an error occurred while sending the mail: {0}")] + MailSendError(#[from] lettre::transport::smtp::Error), } #[derive(Debug, Serialize)] diff --git a/backend/src/interface/api_command.rs b/backend/src/interface/api_command.rs index caa2e6c7..f30ed83a 100644 --- a/backend/src/interface/api_command.rs +++ b/backend/src/interface/api_command.rs @@ -10,7 +10,7 @@ use crate::{ util::{ReportReason, Uuid}, }; -use super::{image_storage, image_validation, persistent_data::DataError}; +use super::{admin_notification::MailError, image_storage, image_validation, persistent_data::DataError}; /// Result returned from commands, potentially containing a [`CommandError`]. pub type Result = std::result::Result; @@ -50,10 +50,10 @@ pub trait Command: Send + Sync { /// command to add a rating to a meal. async fn set_meal_rating(&self, meal_id: Uuid, rating: u32, client_id: Uuid) -> Result<()>; - /// Marks an image as verified and deletes all its reports. // todo do we want this? + /// Marks an image as verified. async fn verify_image(&self, image_id: Uuid) -> Result<()>; - /// Deletes an image. // todo only hide? + /// Deletes an image. async fn delete_image(&self, image_id: Uuid) -> Result<()>; } @@ -142,4 +142,7 @@ pub enum CommandError { /// Error while image verification. #[error("Image could not be verified: {0}")] ImageValidationError(#[from] image_validation::ImageValidationError), + /// Error while trying to send aan admin notification. + #[error("Administrator could not be notified: {0}")] + AdminNotificationError(#[from] MailError) } diff --git a/backend/src/interface/image_storage.rs b/backend/src/interface/image_storage.rs index 995c94df..f5c57745 100644 --- a/backend/src/interface/image_storage.rs +++ b/backend/src/interface/image_storage.rs @@ -13,6 +13,8 @@ pub type Result = std::result::Result; pub trait ImageStorage: Send + Sync { /// Permanently saves an image with the given id. async fn save_image(&self, id: Uuid, image: ImageResource) -> Result<()>; + /// Deletes an image resource. + async fn delete_image(&self, id: Uuid) -> Result<()>; } /// Enum describing possible ways an file operation can go wrong. @@ -21,4 +23,8 @@ pub enum ImageError { /// An error in the image processing library occurred. #[error("Error while image operation: {0}")] ImageError(#[from] image::ImageError), + + /// An error while io operation to an image. + #[error("Error while image io operation: {0}")] + IoError(#[from] tokio::io::Error), } diff --git a/backend/src/interface/persistent_data.rs b/backend/src/interface/persistent_data.rs index 00ae288f..0399a4d0 100644 --- a/backend/src/interface/persistent_data.rs +++ b/backend/src/interface/persistent_data.rs @@ -198,6 +198,12 @@ pub trait CommandDataAccess: Sync + Send { /// Adds or updates a rating to the database. The rating will be related to the given meal and the given user. async fn add_rating(&self, meal_id: Uuid, user_id: Uuid, rating: u32) -> Result<()>; + + /// Marks an image as verified. This leads to future reports being ignored. + async fn verify_image(&self, image_id: Uuid) -> Result<()>; + + /// Deletes all entries related to an image. + async fn delete_image(&self, image_id: Uuid) -> Result<()>; } /// An interface for database access necessary for the authentication process. diff --git a/backend/src/layer/data/database/command.rs b/backend/src/layer/data/database/command.rs index 9f54c8ce..66149dcc 100644 --- a/backend/src/layer/data/database/command.rs +++ b/backend/src/layer/data/database/command.rs @@ -181,6 +181,23 @@ impl CommandDataAccess for PersistentCommandData { .await?; Ok(()) } + + async fn delete_image(&self, image_id: Uuid) -> Result<()> { + sqlx::query!("DELETE FROM image WHERE image_id = $1", image_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn verify_image(&self, image_id: Uuid) -> Result<()> { + sqlx::query!( + "UPDATE image SET approved = true WHERE image_id = $1", + image_id + ) + .execute(&self.pool) + .await?; + Ok(()) + } } #[cfg(test)] diff --git a/backend/src/layer/data/file_handler/mod.rs b/backend/src/layer/data/file_handler/mod.rs index 68d97601..0c524b16 100644 --- a/backend/src/layer/data/file_handler/mod.rs +++ b/backend/src/layer/data/file_handler/mod.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use crate::util::IMAGE_EXTENSION; use async_trait::async_trait; +use tokio::fs; use tracing::trace; use crate::{ @@ -50,6 +51,16 @@ impl ImageStorage for FileHandler { Ok(()) } + + async fn delete_image(&self, id: Uuid) -> Result<()> { + let mut path = self.image_path.clone(); + path.push(id.to_string()); + path.set_extension(IMAGE_EXTENSION); + + fs::remove_file(path).await?; + + Ok(()) + } } #[cfg(test)] diff --git a/backend/src/layer/data/mail/mail_sender.rs b/backend/src/layer/data/mail/mail_sender.rs index 069b07d3..80efe332 100644 --- a/backend/src/layer/data/mail/mail_sender.rs +++ b/backend/src/layer/data/mail/mail_sender.rs @@ -1,50 +1,31 @@ //! Module responsible for sending email notifications to administrators. -use std::fmt::Debug; use async_trait::async_trait; use minijinja::{context, Environment, Value}; -use thiserror::Error; +use crate::{ + interface::admin_notification::{AdminNotification, ImageReportInfo, Result}, + layer::data::mail::mail_info::MailInfo, + util::Uuid, +}; use lettre::{ - address::AddressError, message::{Mailbox, MaybeString, SinglePart}, transport::smtp::authentication::Credentials, Address, Message, SmtpTransport, Transport, }; -use crate::{ - interface::admin_notification::{AdminNotification, ImageReportInfo}, - layer::data::mail::mail_info::MailInfo, -}; - use tracing::{error, info}; -/// Result returned when sending emails, potentially containing a [`MailError`]. -pub type MailResult = std::result::Result; - const REPORT_TEMPLATE: &str = include_str!("./template/template.html"); +const NOTIFY_TEMPLATE: &str = include_str!("./template/notification.html"); const REPORT_CSS: &str = include_str!("./template/output.css"); const SENDER_NAME: &str = "MensaKa"; const RECEIVER_NAME: &str = "Administrator"; -/// Enum describing the possible ways, the mail notification can fail. -#[derive(Debug, Error)] -pub enum MailError { - /// Error occurring when an email address could not be parsed. - #[error("an error occurred while parsing the addresses: {0}")] - AddressError(#[from] AddressError), - /// Error occurring when an email could not be constructed. - #[error("an error occurred while parsing the mail: {0}")] - MailParseError(#[from] lettre::error::Error), - /// Error occurring when mail sender instance could bot be build. - #[error("an error occurred while sending the mail: {0}")] - MailSendError(#[from] lettre::transport::smtp::Error), -} - /// Class for sending emails. pub struct MailSender { config: MailInfo, - mailer: SmtpTransport, + mailer: SmtpTransport, // todo async transport? } #[async_trait] @@ -54,6 +35,21 @@ impl AdminNotification for MailSender { error!(%info.image_id, %info.reason, self.config.admin_email_address, "Error notifying administrator: {error}"); } } + async fn notify_admin_image_deleted(&self, image_id: Uuid) -> Result<()> { + let subject = format!("🗑️ Image {}… deleted", &image_id.to_string()[..6],); + + let body = Self::get_notification_body("deleted", image_id); + + self.send_message(subject, image_id, body) + } + + async fn notify_admin_image_verified(&self, image_id: Uuid) -> Result<()> { + let subject = format!("✅ Image {}… verified", &image_id.to_string()[..6],); + + let body = Self::get_notification_body("verified", image_id); + + self.send_message(subject, image_id, body) + } } impl MailSender { @@ -61,7 +57,7 @@ impl MailSender { /// /// # Errors /// Returns an error, if the connection could not be established to the smtp server - pub fn new(config: MailInfo) -> MailResult { + pub fn new(config: MailInfo) -> Result { let creds = Credentials::new(config.username.clone(), config.password.clone()); let transport_builder = SmtpTransport::relay(&config.smtp_server)?; let mailer = transport_builder @@ -71,13 +67,11 @@ impl MailSender { Ok(Self { config, mailer }) } - fn try_notify_admin_image_report(&self, info: &ImageReportInfo) -> MailResult<()> { - let sender = self.get_sender()?; - let reciever = self.get_receiver()?; + fn try_notify_admin_image_report(&self, info: &ImageReportInfo) -> Result<()> { let report = Self::get_report(info); let subject = format!( - "Image {}… {}, {}x: {}", + "{icon} Image {}… {}, {}x: {}", &info.image_id.to_string()[..6], if info.image_got_hidden { "hidden" @@ -85,16 +79,15 @@ impl MailSender { "reported" }, info.report_count, - info.reason + info.reason, + icon = if info.image_got_hidden { + "👻" + } else { + "📜" + } ); - let email = Message::builder() - .from(sender) - .to(reciever) - .subject(subject) - .references(format!("<{}@image-reports.mensa-ka.de>", info.image_id)) - .singlepart(SinglePart::html(MaybeString::String(report)))?; - self.mailer.send(&email)?; + self.send_message(subject, info.image_id, report)?; info!( ?info, "Notified administrators about image report for image with id {}", info.image_id, @@ -102,12 +95,12 @@ impl MailSender { Ok(()) } - fn get_sender(&self) -> MailResult { + fn get_sender(&self) -> Result { let address = self.config.username.parse::
()?; Ok(Mailbox::new(Some(SENDER_NAME.to_string()), address)) } - fn get_receiver(&self) -> MailResult { + fn get_receiver(&self) -> Result { let address = self.config.admin_email_address.parse::
()?; Ok(Mailbox::new(Some(RECEIVER_NAME.to_string()), address)) } @@ -127,6 +120,36 @@ impl MailSender { )) .expect("all arguments provided at compile time") } + + fn get_notification_body(action: &str, image_id: Uuid) -> String { + let env = Environment::new(); + let template = env + .template_from_str(NOTIFY_TEMPLATE) + .expect("template always preset"); + + template + .render(context!( + css => REPORT_CSS, + action => action, + image_id => image_id, + )) + .expect("all arguments provided at compile time") + } + + fn get_references_tag(image_id: Uuid) -> String { + format!("<{image_id}@image-reports.mensa-ka.de>") + } + + fn send_message(&self, subject: impl Into, image_id: Uuid, body: String) -> Result<()> { + let message = Message::builder() + .from(self.get_sender()?) + .to(self.get_receiver()?) + .subject(subject) + .references(Self::get_references_tag(image_id)) + .singlepart(SinglePart::html(MaybeString::String(body)))?; + self.mailer.send(&message)?; + Ok(()) + } } #[cfg(test)] diff --git a/backend/src/layer/data/mail/template/notification.html b/backend/src/layer/data/mail/template/notification.html new file mode 100644 index 00000000..4bf3cc83 --- /dev/null +++ b/backend/src/layer/data/mail/template/notification.html @@ -0,0 +1,22 @@ + + + + + + + Mensa KA Image Notification + + + + {{ "" }} + + + + +
Image got {{ action }}!
+ + + + \ No newline at end of file diff --git a/backend/src/layer/data/mail/template/output.css b/backend/src/layer/data/mail/template/output.css index 740b9ba1..fd2c3558 100644 --- a/backend/src/layer/data/mail/template/output.css +++ b/backend/src/layer/data/mail/template/output.css @@ -1 +1 @@ -/*! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.left-0{left:0}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.m-0{margin:0}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mr-4{margin-right:1rem}.inline{display:inline}.flex{display:flex}.table{display:table}.table-cell{display:table-cell}.table-row-group{display:table-row-group}.table-row{display:table-row}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.size-10{width:2.5rem;height:2.5rem}.size-5{width:1.25rem;height:1.25rem}.size-full{width:100%;height:100%}.h-40{height:10rem}.h-auto{height:auto}.h-full{height:100%}.w-1\/2{width:50%}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:42rem}.flex-none{flex:none}.table-fixed{table-layout:fixed}.border-separate{border-collapse:initial}.border-spacing-y-1{--tw-border-spacing-y:0.25rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-text{cursor:text}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-always{scroll-snap-stop:always}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.gap-8{gap:2rem}.overflow-x-auto{overflow-x:auto}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.bg-dark-grey{--tw-bg-opacity:1;background-color:rgb(30 30 30/var(--tw-bg-opacity))}.bg-green{--tw-bg-opacity:1;background-color:rgb(122 172 43/var(--tw-bg-opacity))}.bg-light-grey{--tw-bg-opacity:1;background-color:rgb(51 51 51/var(--tw-bg-opacity))}.bg-red{--tw-bg-opacity:1;background-color:rgb(211 47 47/var(--tw-bg-opacity))}.fill-green{fill:#7aac2b}.fill-red{fill:#d32f2f}.fill-white{fill:#fff}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-8{padding-left:2rem;padding-right:2rem}.pb-2{padding-bottom:.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.6em\]{font-size:.6em}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.leading-none{line-height:1}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f)}.drop-shadow,.drop-shadow-md{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-md{--tw-drop-shadow:drop-shadow(0 4px 3px #00000012) drop-shadow(0 2px 2px #0000000f)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}*{scrollbar-color:gray #0000;scrollbar-width:thick}.active\:invisible:active{visibility:hidden} \ No newline at end of file +/*! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.inset-y-0{top:0;bottom:0}.bottom-0{bottom:0}.left-0{left:0}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.m-0{margin:0}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mr-4{margin-right:1rem}.inline{display:inline}.flex{display:flex}.table{display:table}.table-cell{display:table-cell}.table-row-group{display:table-row-group}.table-row{display:table-row}.grid{display:grid}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.size-10{width:2.5rem;height:2.5rem}.size-5{width:1.25rem;height:1.25rem}.size-full{width:100%;height:100%}.h-40{height:10rem}.h-auto{height:auto}.h-full{height:100%}.w-1\/2{width:50%}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:42rem}.flex-none{flex:none}.table-fixed{table-layout:fixed}.border-separate{border-collapse:initial}.border-spacing-y-1{--tw-border-spacing-y:0.25rem;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-text{cursor:text}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-center{scroll-snap-align:center}.snap-always{scroll-snap-stop:always}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-4{gap:1rem}.gap-8{gap:2rem}.overflow-x-auto{overflow-x:auto}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-xl{border-radius:.75rem}.bg-dark-grey{--tw-bg-opacity:1;background-color:rgb(30 30 30/var(--tw-bg-opacity))}.bg-green{--tw-bg-opacity:1;background-color:rgb(122 172 43/var(--tw-bg-opacity))}.bg-light-grey{--tw-bg-opacity:1;background-color:rgb(51 51 51/var(--tw-bg-opacity))}.bg-red{--tw-bg-opacity:1;background-color:rgb(211 47 47/var(--tw-bg-opacity))}.fill-green{fill:#7aac2b}.fill-red{fill:#d32f2f}.fill-white{fill:#fff}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-8{padding-left:2rem;padding-right:2rem}.pb-2{padding-bottom:.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.6em\]{font-size:.6em}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.leading-none{line-height:1}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f)}.drop-shadow,.drop-shadow-md{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-md{--tw-drop-shadow:drop-shadow(0 4px 3px #00000012) drop-shadow(0 2px 2px #0000000f)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}*{scrollbar-color:gray #0000;scrollbar-width:thick}.active\:invisible:active{visibility:hidden} \ No newline at end of file diff --git a/backend/src/layer/data/mail/template/tailwind.config.js b/backend/src/layer/data/mail/template/tailwind.config.js index 46f22166..397ea4e3 100644 --- a/backend/src/layer/data/mail/template/tailwind.config.js +++ b/backend/src/layer/data/mail/template/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["template.html"], + content: ["template.html", "notification.html"], theme: { colors: { 'dark-grey': '#1E1E1E', diff --git a/backend/src/layer/logic/api_command/command_handler.rs b/backend/src/layer/logic/api_command/command_handler.rs index 43386cb5..3c787025 100644 --- a/backend/src/layer/logic/api_command/command_handler.rs +++ b/backend/src/layer/logic/api_command/command_handler.rs @@ -191,12 +191,21 @@ where Ok(()) } - async fn delete_image(&self, _image_id: Uuid) -> Result<()> { - todo!() + async fn delete_image(&self, image_id: Uuid) -> Result<()> { + self.command_data.delete_image(image_id).await?; + self.image_storage.delete_image(image_id).await?; + self.admin_notification + .notify_admin_image_deleted(image_id) + .await?; + Ok(()) } - async fn verify_image(&self, _image_id: Uuid) -> Result<()> { - todo!() + async fn verify_image(&self, image_id: Uuid) -> Result<()> { + self.command_data.verify_image(image_id).await?; + self.admin_notification + .notify_admin_image_verified(image_id) + .await?; + Ok(()) } } diff --git a/backend/src/layer/logic/api_command/mocks.rs b/backend/src/layer/logic/api_command/mocks.rs index c79bd972..d6d05ced 100644 --- a/backend/src/layer/logic/api_command/mocks.rs +++ b/backend/src/layer/logic/api_command/mocks.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use crate::{ interface::{ - admin_notification::{AdminNotification, ImageReportInfo}, + admin_notification::{self, AdminNotification, ImageReportInfo}, image_storage::ImageStorage, image_validation::ImageValidation, persistent_data::{ @@ -124,6 +124,14 @@ impl CommandDataAccess for CommandDatabaseMock { Ok(()) } } + + async fn delete_image(&self, _image_id: Uuid) -> DataResult<()> { + Ok(()) + } + + async fn verify_image(&self, _image_id: Uuid) -> DataResult<()> { + Ok(()) + } } #[derive(Default, Debug)] @@ -133,6 +141,12 @@ pub struct CommandAdminNotificationMock; impl AdminNotification for CommandAdminNotificationMock { /// Notifies an administrator about a newly reported image and the response automatically taken. async fn notify_admin_image_report(&self, _info: ImageReportInfo) {} + async fn notify_admin_image_deleted(&self, _image_id: Uuid) -> admin_notification::Result<()> { + Ok(()) + } + async fn notify_admin_image_verified(&self, _image_id: Uuid) -> admin_notification::Result<()> { + Ok(()) + } } #[derive(Default, Debug)] @@ -160,4 +174,8 @@ impl ImageStorage for CommandImageStorageMock { ) -> crate::interface::image_storage::Result<()> { Ok(()) } + + async fn delete_image(&self, _image_id: Uuid) -> crate::interface::image_storage::Result<()> { + Ok(()) + } } diff --git a/backend/src/layer/trigger/api/server.rs b/backend/src/layer/trigger/api/server.rs index 553d452e..8ca2ccdf 100644 --- a/backend/src/layer/trigger/api/server.rs +++ b/backend/src/layer/trigger/api/server.rs @@ -40,7 +40,7 @@ use crate::{ persistent_data::{model::ApiKey, AuthDataAccess, RequestDataAccess}, }, layer::trigger::api::{ - admin::{admin_auth_middleware, admin_router, AdminKey, ArcCommand}, + admin::{admin_router, ArcCommand}, auth::auth_middleware, }, util::{local_to_global_url, IMAGE_BASE_PATH}, diff --git a/backend/src/startup/server.rs b/backend/src/startup/server.rs index 97757046..c329eb53 100644 --- a/backend/src/startup/server.rs +++ b/backend/src/startup/server.rs @@ -5,16 +5,15 @@ use thiserror::Error; use tokio::signal::ctrl_c; use tracing::info; +use crate::interface::admin_notification::MailError; use crate::interface::image_validation::ImageValidationError; use crate::layer::data::image_validation::google_api_handler::GoogleApiHandler; use crate::{ interface::{api_command::CommandError, mensa_parser::ParseError, persistent_data::DataError}, layer::{ data::{ - database::factory::DataAccessFactory, - file_handler::FileHandler, - mail::mail_sender::{MailError, MailSender}, - swka_parser::swka_parse_manager::SwKaParseManager, + database::factory::DataAccessFactory, file_handler::FileHandler, + mail::mail_sender::MailSender, swka_parser::swka_parse_manager::SwKaParseManager, }, logic::{ api_command::command_handler::CommandHandler, From 951d81d4116b29794b00e8a98a46fb1bac798751 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Mon, 8 Jul 2024 21:45:22 +0200 Subject: [PATCH 06/14] documentation --- backend/README.md | 11 +++++++++++ doc/AdminAPI.md | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 doc/AdminAPI.md diff --git a/backend/README.md b/backend/README.md index 4ece0349..3303d0df 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,6 +5,17 @@ Backend application for providing and synchronizing meal plan data of the cantee If you just want to use the (Android, iOS) App, the following is not necessary. + +## APIs + +There are two kinds of APIs available: +- The main GraphQL API for accessing data like meal plans etc. \ + This API is accesssable under `/` and ad documentation can be accessed on the there hosted GraphQL playground. + For authentication herefore see [here](../doc/ApiAuth.md) +- An admin API for deleting reported images etc. \ + This API can be accessed under `/admin/...` and requires HTTP-Basic authentication for user `admin` with the password set in the `ADMIN_KEY` env var. + available admin API requests can be seen [here](../doc/AdminAPI.md) + ## Running the backend yourself ### Deploy using docker-compose diff --git a/doc/AdminAPI.md b/doc/AdminAPI.md new file mode 100644 index 00000000..4692c5da --- /dev/null +++ b/doc/AdminAPI.md @@ -0,0 +1,19 @@ +# Admin API +Separate from the main GraphQL API to access data like meal plans, there exists a (minimal) admin REST API for managing image reports (and maybe more in the future). + + +The API can be accesses by sending requests to `/admin/...`. + +## Authetication +The admin API requires HTTP basic auth with the following parameters: + +**Username** `admin` \ +**Password** set in `ADMIN_KEY` environment variable of backend + +## Available Requests + +| Type | Path | Request Content | Response | Description | +| ---- | -------------------------------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------- | +| GET | `/admin/version` | no data | 200 with version string | Returns the backend version. Can act as a health check. | +| GET | `/admin/report/delete_image/:image_id` | no data | 200 on success | Deletes the image with id `:image_id` | +| GET | `/admin/report/verify_image/:image_id` | no data | 200 on success | Verifies the image with id `:image_id`. Future image reports will no longer be handled. | From a8adc423d6105f60ff7f3aa599fdaa0cae23475e Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Mon, 8 Jul 2024 21:45:42 +0200 Subject: [PATCH 07/14] last api features? --- backend/src/interface/api_command.rs | 2 +- backend/src/layer/data/mail/mail_sender.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/interface/api_command.rs b/backend/src/interface/api_command.rs index f30ed83a..ef9be2bd 100644 --- a/backend/src/interface/api_command.rs +++ b/backend/src/interface/api_command.rs @@ -137,7 +137,7 @@ pub enum CommandError { #[error("Error during image preprocessing occured: {0}")] ImagePreprocessingError(#[from] ImagePreprocessingError), /// Error ocurred while saving image. - #[error("Error while saving image: {0}")] + #[error("Error while accessing image storage: {0}")] ImageStorageError(#[from] image_storage::ImageError), /// Error while image verification. #[error("Image could not be verified: {0}")] diff --git a/backend/src/layer/data/mail/mail_sender.rs b/backend/src/layer/data/mail/mail_sender.rs index 80efe332..4e7b6c02 100644 --- a/backend/src/layer/data/mail/mail_sender.rs +++ b/backend/src/layer/data/mail/mail_sender.rs @@ -6,7 +6,7 @@ use minijinja::{context, Environment, Value}; use crate::{ interface::admin_notification::{AdminNotification, ImageReportInfo, Result}, layer::data::mail::mail_info::MailInfo, - util::Uuid, + util::{self, Uuid}, }; use lettre::{ message::{Mailbox, MaybeString, SinglePart}, @@ -114,8 +114,8 @@ impl MailSender { template .render(context!( css => REPORT_CSS, - delete_url => "#", // todo - verify_url => "#", // todo + delete_url => util::local_to_global_url(&format!("/admin/report/delete_image/{}", info.image_id)), + verify_url => util::local_to_global_url(&format!("/admin/report/verify_image/{}", info.image_id)), ..Value::from_serialize(info), )) .expect("all arguments provided at compile time") From 51a4d57aad8d910c592465d0fbd27965b713be2e Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Mon, 8 Jul 2024 21:57:25 +0200 Subject: [PATCH 08/14] tested and fixed notify mails (verify/delete image) --- backend/src/layer/data/mail/mail_sender.rs | 24 ++++++++++++++- .../data/mail/template/notification.html | 29 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/backend/src/layer/data/mail/mail_sender.rs b/backend/src/layer/data/mail/mail_sender.rs index 4e7b6c02..cc5cfb97 100644 --- a/backend/src/layer/data/mail/mail_sender.rs +++ b/backend/src/layer/data/mail/mail_sender.rs @@ -36,7 +36,7 @@ impl AdminNotification for MailSender { } } async fn notify_admin_image_deleted(&self, image_id: Uuid) -> Result<()> { - let subject = format!("🗑️ Image {}… deleted", &image_id.to_string()[..6],); + let subject = format!("❌ Image {}… deleted", &image_id.to_string()[..6],); let body = Self::get_notification_body("deleted", image_id); @@ -282,6 +282,28 @@ mod test { }); } + #[tokio::test] + async fn test_notify_admin_image_deleted() { + let mail_info = get_mail_info().unwrap(); + let sender = MailSender::new(mail_info).unwrap(); + assert!(sender.mailer.test_connection().unwrap()); + + let id = Uuid::default(); + + assert!(sender.notify_admin_image_deleted(id).await.is_ok()); + } + + #[tokio::test] + async fn test_notify_admin_image_verified() { + let mail_info = get_mail_info().unwrap(); + let sender = MailSender::new(mail_info).unwrap(); + assert!(sender.mailer.test_connection().unwrap()); + + let id = Uuid::default(); + + assert!(sender.notify_admin_image_verified(id).await.is_ok()); + } + fn get_report_info() -> ImageReportInfo { ImageReportInfo { reason: crate::util::ReportReason::Advert, diff --git a/backend/src/layer/data/mail/template/notification.html b/backend/src/layer/data/mail/template/notification.html index 4bf3cc83..5c6b125d 100644 --- a/backend/src/layer/data/mail/template/notification.html +++ b/backend/src/layer/data/mail/template/notification.html @@ -15,7 +15,34 @@ -
Image got {{ action }}!
+ +
+ +
+ + + + + + + + +

+ Mensa KA Image Notification +

+ +
+ +
Image {{ image_id }} got {{ action }}.
+ +
From 9ee66e2bc45ca1d55e2a91a899603a827d471cbe Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Mon, 8 Jul 2024 22:08:03 +0200 Subject: [PATCH 09/14] cleanup --- ...e79addefda462b9531d9aef307e191adedf4a450f.json} | 5 +++-- ...10ba7f4ba4e272d08553500d34150a90db03622705.json | 14 ++++++++++++++ backend/src/interface/api_command.rs | 8 +++++--- 3 files changed, 22 insertions(+), 5 deletions(-) rename backend/.sqlx/{query-e5dfd2fa2a3bd373c5bfc5a8c8e960593a414ee58c6cb089ccd27e522260f9e4.json => query-2e25d6fb4f1fbab9b9afcb8e79addefda462b9531d9aef307e191adedf4a450f.json} (61%) create mode 100644 backend/.sqlx/query-c72a8ef7437d3e48d1b44b10ba7f4ba4e272d08553500d34150a90db03622705.json diff --git a/backend/.sqlx/query-e5dfd2fa2a3bd373c5bfc5a8c8e960593a414ee58c6cb089ccd27e522260f9e4.json b/backend/.sqlx/query-2e25d6fb4f1fbab9b9afcb8e79addefda462b9531d9aef307e191adedf4a450f.json similarity index 61% rename from backend/.sqlx/query-e5dfd2fa2a3bd373c5bfc5a8c8e960593a414ee58c6cb089ccd27e522260f9e4.json rename to backend/.sqlx/query-2e25d6fb4f1fbab9b9afcb8e79addefda462b9531d9aef307e191adedf4a450f.json index f8b71c4b..fd713916 100644 --- a/backend/.sqlx/query-e5dfd2fa2a3bd373c5bfc5a8c8e960593a414ee58c6cb089ccd27e522260f9e4.json +++ b/backend/.sqlx/query-2e25d6fb4f1fbab9b9afcb8e79addefda462b9531d9aef307e191adedf4a450f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT image_id FROM image_detail \n WHERE currently_visible AND food_id = $1\n ORDER BY rank DESC\n ", + "query": "\n SELECT image_id FROM image_detail \n WHERE currently_visible AND food_id = $1 AND image_id <> $2\n ORDER BY rank DESC\n ", "describe": { "columns": [ { @@ -11,6 +11,7 @@ ], "parameters": { "Left": [ + "Uuid", "Uuid" ] }, @@ -18,5 +19,5 @@ true ] }, - "hash": "e5dfd2fa2a3bd373c5bfc5a8c8e960593a414ee58c6cb089ccd27e522260f9e4" + "hash": "2e25d6fb4f1fbab9b9afcb8e79addefda462b9531d9aef307e191adedf4a450f" } diff --git a/backend/.sqlx/query-c72a8ef7437d3e48d1b44b10ba7f4ba4e272d08553500d34150a90db03622705.json b/backend/.sqlx/query-c72a8ef7437d3e48d1b44b10ba7f4ba4e272d08553500d34150a90db03622705.json new file mode 100644 index 00000000..e941fbca --- /dev/null +++ b/backend/.sqlx/query-c72a8ef7437d3e48d1b44b10ba7f4ba4e272d08553500d34150a90db03622705.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE image SET approved = true WHERE image_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c72a8ef7437d3e48d1b44b10ba7f4ba4e272d08553500d34150a90db03622705" +} diff --git a/backend/src/interface/api_command.rs b/backend/src/interface/api_command.rs index ef9be2bd..9d84d7ed 100644 --- a/backend/src/interface/api_command.rs +++ b/backend/src/interface/api_command.rs @@ -10,7 +10,9 @@ use crate::{ util::{ReportReason, Uuid}, }; -use super::{admin_notification::MailError, image_storage, image_validation, persistent_data::DataError}; +use super::{ + admin_notification::MailError, image_storage, image_validation, persistent_data::DataError, +}; /// Result returned from commands, potentially containing a [`CommandError`]. pub type Result = std::result::Result; @@ -50,7 +52,7 @@ pub trait Command: Send + Sync { /// command to add a rating to a meal. async fn set_meal_rating(&self, meal_id: Uuid, rating: u32, client_id: Uuid) -> Result<()>; - /// Marks an image as verified. + /// Marks an image as verified. async fn verify_image(&self, image_id: Uuid) -> Result<()>; /// Deletes an image. @@ -144,5 +146,5 @@ pub enum CommandError { ImageValidationError(#[from] image_validation::ImageValidationError), /// Error while trying to send aan admin notification. #[error("Administrator could not be notified: {0}")] - AdminNotificationError(#[from] MailError) + AdminNotificationError(#[from] MailError), } From b0313c5fed40d1ef3f94d92707b44b01058e2032 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler <47496388+worldofjoni@users.noreply.github.com> Date: Sun, 14 Jul 2024 11:30:31 +0200 Subject: [PATCH 10/14] Update backend/README.md Co-authored-by: Alexander <32518454+Whatsuup@users.noreply.github.com> --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 3303d0df..7e1fa50d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,7 +10,7 @@ If you just want to use the (Android, iOS) App, the following is not necessary. There are two kinds of APIs available: - The main GraphQL API for accessing data like meal plans etc. \ - This API is accesssable under `/` and ad documentation can be accessed on the there hosted GraphQL playground. + This API is accessible under `/`. The documentation can be found there, at the GraphQL playground. For authentication herefore see [here](../doc/ApiAuth.md) - An admin API for deleting reported images etc. \ This API can be accessed under `/admin/...` and requires HTTP-Basic authentication for user `admin` with the password set in the `ADMIN_KEY` env var. From 7b7ec54bf126f9b2d03e03fbe84fa52efa8adb73 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Sun, 14 Jul 2024 11:31:38 +0200 Subject: [PATCH 11/14] human readable admin api response --- backend/src/layer/trigger/api/admin.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/layer/trigger/api/admin.rs b/backend/src/layer/trigger/api/admin.rs index bd1a3557..a1867d67 100644 --- a/backend/src/layer/trigger/api/admin.rs +++ b/backend/src/layer/trigger/api/admin.rs @@ -59,16 +59,19 @@ async fn version() -> &'static str { async fn verify_image( State(command): State, Path(image_id): Path, -) -> Result<(), CommandError> { - command.verify_image(image_id).await +) -> Result { + command.verify_image(image_id).await?; + + Ok(format!("Successfully verified image {image_id}")) } #[debug_handler] async fn delete_image( State(command): State, Path(image_id): Path, -) -> Result<(), CommandError> { - command.delete_image(image_id).await +) -> Result { + command.delete_image(image_id).await?; + Ok(format!("Successfully deleted image {image_id}")) } const ADMIN_USER: &str = "admin"; From c36e9203f15ea2328782ac56527158c66310a1a8 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Sun, 14 Jul 2024 11:56:31 +0200 Subject: [PATCH 12/14] tests --- ...ea9d69d034cedf83fe074eafc76f64c89f433.json | 45 ++++++++++++ ...a67ded1847337506a1b20b0881ce9b69123e8.json | 22 ++++++ ...25a42c18f51ab4bd825f88d986bb64a38ab2f.json | 22 ++++++ ...dfbd2ebeba2be0932e49bdc8ab2115071ef0c.json | 60 ++++++++++++++++ ...1bdc30805287a4edeb0c9529c3efbd94e0361.json | 32 +++++++++ ...8c640451459b6cf8da2a36003b24e54ce45f4.json | 20 ++++++ ...40c52fe1209ca8ca02ad0320f6b2a311bc35f.json | 22 ++++++ ...d6a7db252776dfd1b7e4c5bd825938932a9e4.json | 58 ++++++++++++++++ ...d198bc91bec8ea3d9500461f63faf9e756307.json | 68 +++++++++++++++++++ ...8a2c49b764ed36bd0210d24dd718d2a338e91.json | 45 ++++++++++++ ...8bf7d273e6e7b6a46ad3e9ce14f5012ab2a45.json | 28 ++++++++ ...2769d9055614b675957384cdd83c771db2d60.json | 22 ++++++ ...747f5720a78867aeac6522ed2d2302fbbe4d5.json | 59 ++++++++++++++++ ...cf004fc1f499166e32931042deba8cad9d3ab.json | 45 ++++++++++++ ...bda56c16cddf277f2f91255550f045da2bbbc.json | 23 +++++++ ...7fada61b86ca23e7d874a9c60324705b56da2.json | 45 ++++++++++++ ...d7b2485da124f98182ea357a9d536d7d826b6.json | 59 ++++++++++++++++ ...2a6e60df9334ca60f77ede868543116b6c99a.json | 23 +++++++ ...923b58e86db2ab791b266d1c71be1f9323de5.json | 22 ++++++ ...76dc6a6d52699d1f5464ee4d4afb6e625800f.json | 68 +++++++++++++++++++ ...99bead369112047a0ea4703daa0616c9898a9.json | 28 ++++++++ ...18b801d07b778e3b3a469eb9c39b6d8c6ea17.json | 22 ++++++ backend/src/layer/data/database/command.rs | 30 ++++++++ backend/src/layer/data/file_handler/mod.rs | 28 ++++++++ .../logic/api_command/command_handler.rs | 41 +++++++++++ 25 files changed, 937 insertions(+) create mode 100644 backend/.sqlx/query-0645b66930de687a25df6807d03ea9d69d034cedf83fe074eafc76f64c89f433.json create mode 100644 backend/.sqlx/query-0d87a255827660421a7d7a6c5a0a67ded1847337506a1b20b0881ce9b69123e8.json create mode 100644 backend/.sqlx/query-0ec6bbc0453d070c0e5af8f158525a42c18f51ab4bd825f88d986bb64a38ab2f.json create mode 100644 backend/.sqlx/query-2565b5a0dc047093196615919d1dfbd2ebeba2be0932e49bdc8ab2115071ef0c.json create mode 100644 backend/.sqlx/query-3be900a267c11fd8c89b4932c521bdc30805287a4edeb0c9529c3efbd94e0361.json create mode 100644 backend/.sqlx/query-42ca26818ae01d523199bbad63c8c640451459b6cf8da2a36003b24e54ce45f4.json create mode 100644 backend/.sqlx/query-54ccdfd8707b6f72edef0590a2340c52fe1209ca8ca02ad0320f6b2a311bc35f.json create mode 100644 backend/.sqlx/query-625e48c29861f5d29aa0aa2e60fd6a7db252776dfd1b7e4c5bd825938932a9e4.json create mode 100644 backend/.sqlx/query-779e4fb0b3fff95cc682d09d868d198bc91bec8ea3d9500461f63faf9e756307.json create mode 100644 backend/.sqlx/query-86e0744907d496487633e8821bb8a2c49b764ed36bd0210d24dd718d2a338e91.json create mode 100644 backend/.sqlx/query-93fa2b68a2affe372d111919fc68bf7d273e6e7b6a46ad3e9ce14f5012ab2a45.json create mode 100644 backend/.sqlx/query-98f04e31f7a3a3ba304b1b0ef142769d9055614b675957384cdd83c771db2d60.json create mode 100644 backend/.sqlx/query-a49a18ac75d7b1223f83938cdb8747f5720a78867aeac6522ed2d2302fbbe4d5.json create mode 100644 backend/.sqlx/query-a4cbc6443846fe45ce097bace8fcf004fc1f499166e32931042deba8cad9d3ab.json create mode 100644 backend/.sqlx/query-a78fefe6f0ca27e4b61119aaa73bda56c16cddf277f2f91255550f045da2bbbc.json create mode 100644 backend/.sqlx/query-b0a73eece63b2155329e52ac42e7fada61b86ca23e7d874a9c60324705b56da2.json create mode 100644 backend/.sqlx/query-be69adb4671ebf9acdbb4820a3cd7b2485da124f98182ea357a9d536d7d826b6.json create mode 100644 backend/.sqlx/query-d474e86a4b31aeba9b8477c51ca2a6e60df9334ca60f77ede868543116b6c99a.json create mode 100644 backend/.sqlx/query-e89b78465d3d73bcd4a4d1b6d1f923b58e86db2ab791b266d1c71be1f9323de5.json create mode 100644 backend/.sqlx/query-eaca64cd8e2efa750c336001e0076dc6a6d52699d1f5464ee4d4afb6e625800f.json create mode 100644 backend/.sqlx/query-f3db204e4895b77a798c18def6599bead369112047a0ea4703daa0616c9898a9.json create mode 100644 backend/.sqlx/query-f6f3060473e3e1b28e2913f51db18b801d07b778e3b3a469eb9c39b6d8c6ea17.json diff --git a/backend/.sqlx/query-0645b66930de687a25df6807d03ea9d69d034cedf83fe074eafc76f64c89f433.json b/backend/.sqlx/query-0645b66930de687a25df6807d03ea9d69d034cedf83fe074eafc76f64c89f433.json new file mode 100644 index 00000000..11235848 --- /dev/null +++ b/backend/.sqlx/query-0645b66930de687a25df6807d03ea9d69d034cedf83fe074eafc76f64c89f433.json @@ -0,0 +1,45 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT additive as \"additive: Additive\" FROM food_additive WHERE food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "additive: Additive", + "type_info": { + "Custom": { + "name": "additive", + "kind": { + "Enum": [ + "COLORANT", + "PRESERVING_AGENTS", + "ANTIOXIDANT_AGENTS", + "FLAVOUR_ENHANCER", + "PHOSPHATE", + "SURFACE_WAXED", + "SULPHUR", + "ARTIFICIALLY_BLACKENED_OLIVES", + "SWEETENER", + "LAXATIVE_IF_OVERUSED", + "PHENYLALANINE", + "ALCOHOL", + "PRESSED_MEAT", + "GLAZING_WITH_CACAO", + "PRESSED_FISH" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0645b66930de687a25df6807d03ea9d69d034cedf83fe074eafc76f64c89f433" +} diff --git a/backend/.sqlx/query-0d87a255827660421a7d7a6c5a0a67ded1847337506a1b20b0881ce9b69123e8.json b/backend/.sqlx/query-0d87a255827660421a7d7a6c5a0a67ded1847337506a1b20b0881ce9b69123e8.json new file mode 100644 index 00000000..ad754da1 --- /dev/null +++ b/backend/.sqlx/query-0d87a255827660421a7d7a6c5a0a67ded1847337506a1b20b0881ce9b69123e8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * from meal WHERE food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "food_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0d87a255827660421a7d7a6c5a0a67ded1847337506a1b20b0881ce9b69123e8" +} diff --git a/backend/.sqlx/query-0ec6bbc0453d070c0e5af8f158525a42c18f51ab4bd825f88d986bb64a38ab2f.json b/backend/.sqlx/query-0ec6bbc0453d070c0e5af8f158525a42c18f51ab4bd825f88d986bb64a38ab2f.json new file mode 100644 index 00000000..45153710 --- /dev/null +++ b/backend/.sqlx/query-0ec6bbc0453d070c0e5af8f158525a42c18f51ab4bd825f88d986bb64a38ab2f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name FROM food where food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0ec6bbc0453d070c0e5af8f158525a42c18f51ab4bd825f88d986bb64a38ab2f" +} diff --git a/backend/.sqlx/query-2565b5a0dc047093196615919d1dfbd2ebeba2be0932e49bdc8ab2115071ef0c.json b/backend/.sqlx/query-2565b5a0dc047093196615919d1dfbd2ebeba2be0932e49bdc8ab2115071ef0c.json new file mode 100644 index 00000000..1d81c095 --- /dev/null +++ b/backend/.sqlx/query-2565b5a0dc047093196615919d1dfbd2ebeba2be0932e49bdc8ab2115071ef0c.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM food_plan WHERE line_id = $1 AND food_id = $2 AND serve_date = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "line_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "serve_date", + "type_info": "Date" + }, + { + "ordinal": 3, + "name": "price_student", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "price_employee", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "price_guest", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "price_pupil", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Date" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2565b5a0dc047093196615919d1dfbd2ebeba2be0932e49bdc8ab2115071ef0c" +} diff --git a/backend/.sqlx/query-3be900a267c11fd8c89b4932c521bdc30805287a4edeb0c9529c3efbd94e0361.json b/backend/.sqlx/query-3be900a267c11fd8c89b4932c521bdc30805287a4edeb0c9529c3efbd94e0361.json new file mode 100644 index 00000000..8947d0a9 --- /dev/null +++ b/backend/.sqlx/query-3be900a267c11fd8c89b4932c521bdc30805287a4edeb0c9529c3efbd94e0361.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM meal_rating", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "rating", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3be900a267c11fd8c89b4932c521bdc30805287a4edeb0c9529c3efbd94e0361" +} diff --git a/backend/.sqlx/query-42ca26818ae01d523199bbad63c8c640451459b6cf8da2a36003b24e54ce45f4.json b/backend/.sqlx/query-42ca26818ae01d523199bbad63c8c640451459b6cf8da2a36003b24e54ce45f4.json new file mode 100644 index 00000000..80f5fed8 --- /dev/null +++ b/backend/.sqlx/query-42ca26818ae01d523199bbad63c8c640451459b6cf8da2a36003b24e54ce45f4.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT image_id FROM image_report", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "image_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "42ca26818ae01d523199bbad63c8c640451459b6cf8da2a36003b24e54ce45f4" +} diff --git a/backend/.sqlx/query-54ccdfd8707b6f72edef0590a2340c52fe1209ca8ca02ad0320f6b2a311bc35f.json b/backend/.sqlx/query-54ccdfd8707b6f72edef0590a2340c52fe1209ca8ca02ad0320f6b2a311bc35f.json new file mode 100644 index 00000000..5df3ee06 --- /dev/null +++ b/backend/.sqlx/query-54ccdfd8707b6f72edef0590a2340c52fe1209ca8ca02ad0320f6b2a311bc35f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT image_id FROM image_rating WHERE rating = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "image_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Int2" + ] + }, + "nullable": [ + false + ] + }, + "hash": "54ccdfd8707b6f72edef0590a2340c52fe1209ca8ca02ad0320f6b2a311bc35f" +} diff --git a/backend/.sqlx/query-625e48c29861f5d29aa0aa2e60fd6a7db252776dfd1b7e4c5bd825938932a9e4.json b/backend/.sqlx/query-625e48c29861f5d29aa0aa2e60fd6a7db252776dfd1b7e4c5bd825938932a9e4.json new file mode 100644 index 00000000..19053439 --- /dev/null +++ b/backend/.sqlx/query-625e48c29861f5d29aa0aa2e60fd6a7db252776dfd1b7e4c5bd825938932a9e4.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT allergen as \"allergen: Allergen\" FROM food_allergen WHERE food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "allergen: Allergen", + "type_info": { + "Custom": { + "name": "allergen", + "kind": { + "Enum": [ + "CA", + "DI", + "EI", + "ER", + "FI", + "GE", + "HF", + "HA", + "KA", + "KR", + "LU", + "MA", + "ML", + "PA", + "PE", + "PI", + "QU", + "RO", + "SA", + "SE", + "SF", + "SN", + "SO", + "WA", + "WE", + "WT", + "LA", + "GL" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "625e48c29861f5d29aa0aa2e60fd6a7db252776dfd1b7e4c5bd825938932a9e4" +} diff --git a/backend/.sqlx/query-779e4fb0b3fff95cc682d09d868d198bc91bec8ea3d9500461f63faf9e756307.json b/backend/.sqlx/query-779e4fb0b3fff95cc682d09d868d198bc91bec8ea3d9500461f63faf9e756307.json new file mode 100644 index 00000000..0013100f --- /dev/null +++ b/backend/.sqlx/query-779e4fb0b3fff95cc682d09d868d198bc91bec8ea3d9500461f63faf9e756307.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM image", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "image_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "link_date", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "last_verified_date", + "type_info": "Date" + }, + { + "ordinal": 7, + "name": "approved", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "currently_visible", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "779e4fb0b3fff95cc682d09d868d198bc91bec8ea3d9500461f63faf9e756307" +} diff --git a/backend/.sqlx/query-86e0744907d496487633e8821bb8a2c49b764ed36bd0210d24dd718d2a338e91.json b/backend/.sqlx/query-86e0744907d496487633e8821bb8a2c49b764ed36bd0210d24dd718d2a338e91.json new file mode 100644 index 00000000..13ebcf20 --- /dev/null +++ b/backend/.sqlx/query-86e0744907d496487633e8821bb8a2c49b764ed36bd0210d24dd718d2a338e91.json @@ -0,0 +1,45 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, food_type as \"food_type: FoodType\" FROM food where food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "food_type: FoodType", + "type_info": { + "Custom": { + "name": "meal_type", + "kind": { + "Enum": [ + "VEGAN", + "VEGETARIAN", + "BEEF", + "BEEF_AW", + "PORK", + "PORK_AW", + "FISH", + "UNKNOWN", + "POULTRY" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "86e0744907d496487633e8821bb8a2c49b764ed36bd0210d24dd718d2a338e91" +} diff --git a/backend/.sqlx/query-93fa2b68a2affe372d111919fc68bf7d273e6e7b6a46ad3e9ce14f5012ab2a45.json b/backend/.sqlx/query-93fa2b68a2affe372d111919fc68bf7d273e6e7b6a46ad3e9ce14f5012ab2a45.json new file mode 100644 index 00000000..e05ab93f --- /dev/null +++ b/backend/.sqlx/query-93fa2b68a2affe372d111919fc68bf7d273e6e7b6a46ad3e9ce14f5012ab2a45.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, position FROM canteen WHERE canteen_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "position", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "93fa2b68a2affe372d111919fc68bf7d273e6e7b6a46ad3e9ce14f5012ab2a45" +} diff --git a/backend/.sqlx/query-98f04e31f7a3a3ba304b1b0ef142769d9055614b675957384cdd83c771db2d60.json b/backend/.sqlx/query-98f04e31f7a3a3ba304b1b0ef142769d9055614b675957384cdd83c771db2d60.json new file mode 100644 index 00000000..66bf022b --- /dev/null +++ b/backend/.sqlx/query-98f04e31f7a3a3ba304b1b0ef142769d9055614b675957384cdd83c771db2d60.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM image WHERE image_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "98f04e31f7a3a3ba304b1b0ef142769d9055614b675957384cdd83c771db2d60" +} diff --git a/backend/.sqlx/query-a49a18ac75d7b1223f83938cdb8747f5720a78867aeac6522ed2d2302fbbe4d5.json b/backend/.sqlx/query-a49a18ac75d7b1223f83938cdb8747f5720a78867aeac6522ed2d2302fbbe4d5.json new file mode 100644 index 00000000..964408dc --- /dev/null +++ b/backend/.sqlx/query-a49a18ac75d7b1223f83938cdb8747f5720a78867aeac6522ed2d2302fbbe4d5.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM food_plan WHERE food_id = $1 AND line_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "line_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "serve_date", + "type_info": "Date" + }, + { + "ordinal": 3, + "name": "price_student", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "price_employee", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "price_guest", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "price_pupil", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a49a18ac75d7b1223f83938cdb8747f5720a78867aeac6522ed2d2302fbbe4d5" +} diff --git a/backend/.sqlx/query-a4cbc6443846fe45ce097bace8fcf004fc1f499166e32931042deba8cad9d3ab.json b/backend/.sqlx/query-a4cbc6443846fe45ce097bace8fcf004fc1f499166e32931042deba8cad9d3ab.json new file mode 100644 index 00000000..e2d88bea --- /dev/null +++ b/backend/.sqlx/query-a4cbc6443846fe45ce097bace8fcf004fc1f499166e32931042deba8cad9d3ab.json @@ -0,0 +1,45 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, food_type as \"food_type: FoodType\" FROM food WHERE food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "food_type: FoodType", + "type_info": { + "Custom": { + "name": "meal_type", + "kind": { + "Enum": [ + "VEGAN", + "VEGETARIAN", + "BEEF", + "BEEF_AW", + "PORK", + "PORK_AW", + "FISH", + "UNKNOWN", + "POULTRY" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a4cbc6443846fe45ce097bace8fcf004fc1f499166e32931042deba8cad9d3ab" +} diff --git a/backend/.sqlx/query-a78fefe6f0ca27e4b61119aaa73bda56c16cddf277f2f91255550f045da2bbbc.json b/backend/.sqlx/query-a78fefe6f0ca27e4b61119aaa73bda56c16cddf277f2f91255550f045da2bbbc.json new file mode 100644 index 00000000..3d1bb779 --- /dev/null +++ b/backend/.sqlx/query-a78fefe6f0ca27e4b61119aaa73bda56c16cddf277f2f91255550f045da2bbbc.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT rating FROM meal_rating WHERE user_id = $1 AND food_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "rating", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a78fefe6f0ca27e4b61119aaa73bda56c16cddf277f2f91255550f045da2bbbc" +} diff --git a/backend/.sqlx/query-b0a73eece63b2155329e52ac42e7fada61b86ca23e7d874a9c60324705b56da2.json b/backend/.sqlx/query-b0a73eece63b2155329e52ac42e7fada61b86ca23e7d874a9c60324705b56da2.json new file mode 100644 index 00000000..c0abafcf --- /dev/null +++ b/backend/.sqlx/query-b0a73eece63b2155329e52ac42e7fada61b86ca23e7d874a9c60324705b56da2.json @@ -0,0 +1,45 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, food_type as \"food_type: FoodType\" FROM food JOIN meal USING (food_id) where food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "food_type: FoodType", + "type_info": { + "Custom": { + "name": "meal_type", + "kind": { + "Enum": [ + "VEGAN", + "VEGETARIAN", + "BEEF", + "BEEF_AW", + "PORK", + "PORK_AW", + "FISH", + "UNKNOWN", + "POULTRY" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b0a73eece63b2155329e52ac42e7fada61b86ca23e7d874a9c60324705b56da2" +} diff --git a/backend/.sqlx/query-be69adb4671ebf9acdbb4820a3cd7b2485da124f98182ea357a9d536d7d826b6.json b/backend/.sqlx/query-be69adb4671ebf9acdbb4820a3cd7b2485da124f98182ea357a9d536d7d826b6.json new file mode 100644 index 00000000..a16ccb0f --- /dev/null +++ b/backend/.sqlx/query-be69adb4671ebf9acdbb4820a3cd7b2485da124f98182ea357a9d536d7d826b6.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM food_plan WHERE line_id = $1 AND serve_date = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "line_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "serve_date", + "type_info": "Date" + }, + { + "ordinal": 3, + "name": "price_student", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "price_employee", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "price_guest", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "price_pupil", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Date" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "be69adb4671ebf9acdbb4820a3cd7b2485da124f98182ea357a9d536d7d826b6" +} diff --git a/backend/.sqlx/query-d474e86a4b31aeba9b8477c51ca2a6e60df9334ca60f77ede868543116b6c99a.json b/backend/.sqlx/query-d474e86a4b31aeba9b8477c51ca2a6e60df9334ca60f77ede868543116b6c99a.json new file mode 100644 index 00000000..52a30a78 --- /dev/null +++ b/backend/.sqlx/query-d474e86a4b31aeba9b8477c51ca2a6e60df9334ca60f77ede868543116b6c99a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT rating FROM image_rating WHERE image_id = $1 AND user_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "rating", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d474e86a4b31aeba9b8477c51ca2a6e60df9334ca60f77ede868543116b6c99a" +} diff --git a/backend/.sqlx/query-e89b78465d3d73bcd4a4d1b6d1f923b58e86db2ab791b266d1c71be1f9323de5.json b/backend/.sqlx/query-e89b78465d3d73bcd4a4d1b6d1f923b58e86db2ab791b266d1c71be1f9323de5.json new file mode 100644 index 00000000..65f217ad --- /dev/null +++ b/backend/.sqlx/query-e89b78465d3d73bcd4a4d1b6d1f923b58e86db2ab791b266d1c71be1f9323de5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name FROM food WHERE food_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e89b78465d3d73bcd4a4d1b6d1f923b58e86db2ab791b266d1c71be1f9323de5" +} diff --git a/backend/.sqlx/query-eaca64cd8e2efa750c336001e0076dc6a6d52699d1f5464ee4d4afb6e625800f.json b/backend/.sqlx/query-eaca64cd8e2efa750c336001e0076dc6a6d52699d1f5464ee4d4afb6e625800f.json new file mode 100644 index 00000000..fa8f18d7 --- /dev/null +++ b/backend/.sqlx/query-eaca64cd8e2efa750c336001e0076dc6a6d52699d1f5464ee4d4afb6e625800f.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM image WHERE currently_visible = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "image_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "food_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "link_date", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "last_verified_date", + "type_info": "Date" + }, + { + "ordinal": 7, + "name": "approved", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "currently_visible", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "eaca64cd8e2efa750c336001e0076dc6a6d52699d1f5464ee4d4afb6e625800f" +} diff --git a/backend/.sqlx/query-f3db204e4895b77a798c18def6599bead369112047a0ea4703daa0616c9898a9.json b/backend/.sqlx/query-f3db204e4895b77a798c18def6599bead369112047a0ea4703daa0616c9898a9.json new file mode 100644 index 00000000..f2f440f9 --- /dev/null +++ b/backend/.sqlx/query-f3db204e4895b77a798c18def6599bead369112047a0ea4703daa0616c9898a9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name, position FROM line WHERE line_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "position", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "f3db204e4895b77a798c18def6599bead369112047a0ea4703daa0616c9898a9" +} diff --git a/backend/.sqlx/query-f6f3060473e3e1b28e2913f51db18b801d07b778e3b3a469eb9c39b6d8c6ea17.json b/backend/.sqlx/query-f6f3060473e3e1b28e2913f51db18b801d07b778e3b3a469eb9c39b6d8c6ea17.json new file mode 100644 index 00000000..6b9f7c2c --- /dev/null +++ b/backend/.sqlx/query-f6f3060473e3e1b28e2913f51db18b801d07b778e3b3a469eb9c39b6d8c6ea17.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT approved FROM image WHERE image_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "approved", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f6f3060473e3e1b28e2913f51db18b801d07b778e3b3a469eb9c39b6d8c6ea17" +} diff --git a/backend/src/layer/data/database/command.rs b/backend/src/layer/data/database/command.rs index 66149dcc..cef5d450 100644 --- a/backend/src/layer/data/database/command.rs +++ b/backend/src/layer/data/database/command.rs @@ -479,4 +479,34 @@ mod test { .unwrap() .len() } + + #[sqlx::test(fixtures("meal", "image"))] + async fn test_delete_image(pool: PgPool) { + let command = PersistentCommandData { pool: pool.clone() }; + let id = "ea8cce48-a3c7-4f8e-a222-5f3891c13804".try_into().unwrap(); + command.delete_image(id).await.unwrap(); + + assert_eq!( + 0, + sqlx::query_scalar!("SELECT COUNT(*) FROM image WHERE image_id = $1", id) + .fetch_one(&pool) + .await + .unwrap() + .unwrap() + ); + } + + #[sqlx::test(fixtures("meal", "image"))] + async fn test_verify_image(pool: PgPool) { + let command = PersistentCommandData { pool: pool.clone() }; + let id = "ea8cce48-a3c7-4f8e-a222-5f3891c13804".try_into().unwrap(); + command.verify_image(id).await.unwrap(); + + assert!( + sqlx::query_scalar!("SELECT approved FROM image WHERE image_id = $1", id) + .fetch_one(&pool) + .await + .unwrap() + ); + } } diff --git a/backend/src/layer/data/file_handler/mod.rs b/backend/src/layer/data/file_handler/mod.rs index 0c524b16..dab49365 100644 --- a/backend/src/layer/data/file_handler/mod.rs +++ b/backend/src/layer/data/file_handler/mod.rs @@ -103,4 +103,32 @@ mod tests { assert_eq!(image, read_image); // this only works for very basic (like monotone) images due to JPEG compression. } + + #[tokio::test] + async fn test_delete_image() { + let image = + ImageResource::ImageRgb8(ImageBuffer::from_fn(10, 10, |_, _| image::Rgb([10; 3]))); + + let uuid = Uuid::new_v4(); + + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path(); + println!("saving to: {}", path.display()); + + let info = FileHandlerInfo { + image_dir: path.to_path_buf(), + }; + + let file_handler = FileHandler::new(info); + + let mut image_path = path.to_path_buf(); + image_path.push(uuid.to_string()); + image_path.set_extension(IMAGE_EXTENSION); + image.save(&image_path).unwrap(); + + assert!(fs::try_exists(&image_path).await.unwrap()); + + file_handler.delete_image(uuid).await.unwrap(); + assert!(!fs::try_exists(&image_path).await.unwrap()); + } } diff --git a/backend/src/layer/logic/api_command/command_handler.rs b/backend/src/layer/logic/api_command/command_handler.rs index 3c787025..0fe11157 100644 --- a/backend/src/layer/logic/api_command/command_handler.rs +++ b/backend/src/layer/logic/api_command/command_handler.rs @@ -212,6 +212,8 @@ where #[cfg(test)] mod test { #![allow(clippy::unwrap_used)] + use std::sync::Arc; + use chrono::Local; use crate::interface::api_command::{Command, Result}; @@ -396,6 +398,45 @@ mod test { .is_err()); } + #[tokio::test] + async fn test_delete_image() { + let handler = get_handler().unwrap(); + + let image = Uuid::try_from("94cf40a7-ade4-4c1f-b718-89b2d418c2d0").unwrap(); + + handler.delete_image(image).await.unwrap(); + } + + #[tokio::test] + async fn test_verify_image() { + let handler = get_handler().unwrap(); + + let image = Uuid::try_from("94cf40a7-ade4-4c1f-b718-89b2d418c2d0").unwrap(); + + handler.verify_image(image).await.unwrap(); + } + + #[tokio::test] + async fn test_arc() { + let handler = get_handler().unwrap(); + let handler = Arc::new(handler); + + let id = Uuid::default(); + let image_file = include_bytes!("tests/test.jpg").to_vec(); + + handler.add_image(id, None, image_file, id).await.unwrap(); + handler.add_image_downvote(id, id).await.unwrap(); + handler.add_image_upvote(id, id).await.unwrap(); + handler.remove_image_downvote(id, id).await.unwrap(); + handler.remove_image_upvote(id, id).await.unwrap(); + handler + .report_image(id, ReportReason::Advert, id) + .await + .unwrap(); + handler.set_meal_rating(id, 1, id).await.unwrap(); + handler.verify_image(id).await.unwrap(); + } + fn get_handler() -> Result< CommandHandler< CommandDatabaseMock, From 1dca4708f25d4eacf0b1db5cfaf09bc29dcf6e33 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Sun, 14 Jul 2024 12:29:31 +0200 Subject: [PATCH 13/14] admin api test --- backend/src/layer/trigger/api/admin.rs | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/backend/src/layer/trigger/api/admin.rs b/backend/src/layer/trigger/api/admin.rs index a1867d67..818a6db9 100644 --- a/backend/src/layer/trigger/api/admin.rs +++ b/backend/src/layer/trigger/api/admin.rs @@ -106,3 +106,115 @@ pub(super) async fn admin_auth_middleware( Ok(next.run(req).await) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use std::{ + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + sync::Arc, + }; + + use axum::{http::HeaderValue, Server}; + use base64::Engine; + use hyper::{header::AUTHORIZATION, HeaderMap, StatusCode}; + use reqwest::Client; + + use super::ADMIN_USER; + use crate::{ + layer::trigger::api::{admin::admin_router, mock::CommandMock}, + util::Uuid, + }; + + #[tokio::test] + async fn test_api() { + let key: String = "asdasdasdasd".into(); + let command = Arc::new(CommandMock); + + let router = admin_router(key.clone(), command); + let socket = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8081)); + println!("socket: {socket}"); + let server = Server::bind(&socket).serve(router.into_make_service()); + + tokio::spawn(server); + + assert_eq!( + StatusCode::UNAUTHORIZED, + reqwest::get(format!("http://{socket}/version")) + .await + .unwrap() + .status() + ); + + let auth_header = build_auth_string(ADMIN_USER, &key); + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, auth_header); + let authed_client = Client::builder().default_headers(headers).build().unwrap(); + + let version = authed_client + .get(format!("http://{socket}/version")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + assert_eq!(env!("CARGO_PKG_VERSION"), version); + + let id = Uuid::default(); + + assert_eq!( + StatusCode::OK, + authed_client + .get(format!("http://{socket}/report/delete_image/{id}")) + .send() + .await + .unwrap() + .status() + ); + + assert_eq!( + StatusCode::OK, + authed_client + .get(format!("http://{socket}/report/verify_image/{id}")) + .send() + .await + .unwrap() + .status() + ); + + assert_eq!( + StatusCode::UNAUTHORIZED, + authed_client + .get(format!("http://{socket}/version")) + .header( + AUTHORIZATION.to_string(), + build_auth_string(ADMIN_USER, "invalid") + ) + .send() + .await + .unwrap() + .status() + ); + assert_eq!( + StatusCode::UNAUTHORIZED, + authed_client + .get(format!("http://{socket}/version")) + .header( + AUTHORIZATION.to_string(), + build_auth_string("wrong_user", &key) + ) + .send() + .await + .unwrap() + .status() + ); + } + + fn build_auth_string(username: &str, password: &str) -> HeaderValue { + let auth_string = format!("{username}:{password}"); + let auth_string = base64::engine::general_purpose::STANDARD.encode(auth_string); + HeaderValue::from_str(&format!("Basic {auth_string}")).unwrap() + } +} From 07237906b5a871ea08297a86b10e034814852220 Mon Sep 17 00:00:00 2001 From: Jonatan Ziegler Date: Sun, 14 Jul 2024 12:44:04 +0200 Subject: [PATCH 14/14] missed test cases --- .../layer/logic/api_command/command_handler.rs | 1 + backend/src/layer/trigger/api/admin.rs | 15 ++++++++++++++- backend/src/layer/trigger/api/mock.rs | 11 +++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/src/layer/logic/api_command/command_handler.rs b/backend/src/layer/logic/api_command/command_handler.rs index 0fe11157..150fb6c7 100644 --- a/backend/src/layer/logic/api_command/command_handler.rs +++ b/backend/src/layer/logic/api_command/command_handler.rs @@ -435,6 +435,7 @@ mod test { .unwrap(); handler.set_meal_rating(id, 1, id).await.unwrap(); handler.verify_image(id).await.unwrap(); + handler.delete_image(id).await.unwrap(); } fn get_handler() -> Result< diff --git a/backend/src/layer/trigger/api/admin.rs b/backend/src/layer/trigger/api/admin.rs index 818a6db9..2ecf300b 100644 --- a/backend/src/layer/trigger/api/admin.rs +++ b/backend/src/layer/trigger/api/admin.rs @@ -122,7 +122,10 @@ mod test { use super::ADMIN_USER; use crate::{ - layer::trigger::api::{admin::admin_router, mock::CommandMock}, + layer::trigger::api::{ + admin::admin_router, + mock::{CommandMock, FAIL_ID}, + }, util::Uuid, }; @@ -174,6 +177,16 @@ mod test { .status() ); + assert_eq!( + StatusCode::INTERNAL_SERVER_ERROR, + authed_client + .get(format!("http://{socket}/report/delete_image/{FAIL_ID}")) + .send() + .await + .unwrap() + .status() + ); + assert_eq!( StatusCode::OK, authed_client diff --git a/backend/src/layer/trigger/api/mock.rs b/backend/src/layer/trigger/api/mock.rs index d164b6aa..69a7897d 100644 --- a/backend/src/layer/trigger/api/mock.rs +++ b/backend/src/layer/trigger/api/mock.rs @@ -299,6 +299,7 @@ impl RequestDataAccess for RequestDatabaseMock { } } +pub const FAIL_ID: Uuid = Uuid::from_u128(12345); pub struct CommandMock; #[async_trait] @@ -355,8 +356,14 @@ impl Command for CommandMock { Ok(()) } - async fn delete_image(&self, _image_id: Uuid) -> CommandResult<()> { - Ok(()) + async fn delete_image(&self, image_id: Uuid) -> CommandResult<()> { + if image_id == FAIL_ID { + Err(crate::interface::api_command::CommandError::DataError( + crate::interface::persistent_data::DataError::NoSuchItem, + )) + } else { + Ok(()) + } } async fn verify_image(&self, _image_id: Uuid) -> CommandResult<()> {