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/.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-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-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-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/.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/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..7e1fa50d 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 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. + available admin API requests can be seen [here](../doc/AdminAPI.md) + ## Running the backend yourself ### Deploy using docker-compose @@ -65,6 +76,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/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 f0023442..9d84d7ed 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; @@ -8,7 +10,9 @@ 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; @@ -47,6 +51,76 @@ 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. + async fn verify_image(&self, image_id: Uuid) -> Result<()>; + + /// Deletes an image. + async fn delete_image(&self, image_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 + } + + 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. @@ -65,9 +139,12 @@ 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}")] 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 6d8f8140..cef5d450 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? @@ -180,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)] @@ -219,7 +237,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()), ], } } @@ -462,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 68d97601..dab49365 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)] @@ -92,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/data/mail/mail_sender.rs b/backend/src/layer/data/mail/mail_sender.rs index 8e6aedae..cc5cfb97 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::{self, 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)) } @@ -121,12 +114,42 @@ 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") } + + 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)] @@ -195,7 +218,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!( @@ -259,16 +282,38 @@ 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, 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/notification.html b/backend/src/layer/data/mail/template/notification.html new file mode 100644 index 00000000..5c6b125d --- /dev/null +++ b/backend/src/layer/data/mail/template/notification.html @@ -0,0 +1,49 @@ + + + + + + + Mensa KA Image Notification + + + + {{ "" }} + + + + + +
+ +
+ + + + + + + + +

+ Mensa KA Image Notification +

+ +
+ +
Image {{ image_id }} 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 064903c9..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)}.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/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) }}
diff --git a/backend/src/layer/logic/api_command/command_handler.rs b/backend/src/layer/logic/api_command/command_handler.rs index 058711a5..150fb6c7 100644 --- a/backend/src/layer/logic/api_command/command_handler.rs +++ b/backend/src/layer/logic/api_command/command_handler.rs @@ -190,11 +190,30 @@ where .await?; Ok(()) } + + 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<()> { + self.command_data.verify_image(image_id).await?; + self.admin_notification + .notify_admin_image_verified(image_id) + .await?; + Ok(()) + } } #[cfg(test)] mod test { #![allow(clippy::unwrap_used)] + use std::sync::Arc; + use chrono::Local; use crate::interface::api_command::{Command, Result}; @@ -379,6 +398,46 @@ 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(); + handler.delete_image(id).await.unwrap(); + } + fn get_handler() -> Result< CommandHandler< CommandDatabaseMock, 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/admin.rs b/backend/src/layer/trigger/api/admin.rs new file mode 100644 index 00000000..2ecf300b --- /dev/null +++ b/backend/src/layer/trigger/api/admin.rs @@ -0,0 +1,233 @@ +//! Admin rest api functionality + +use std::sync::Arc; + +use axum::{ + debug_handler, + extract::{Path, State}, + headers::{authorization::Basic, Authorization}, + http::HeaderValue, + middleware::{self, Next}, + response::IntoResponse, + routing::method_routing::get, + Router, TypedHeader, +}; +use hyper::{header::WWW_AUTHENTICATE, HeaderMap, Request, StatusCode}; + +use tracing::warn; + +use crate::{ + interface::api_command::{Command, CommandError}, + util::Uuid, +}; + +#[derive(Clone)] +pub(super) struct AdminKey(String); + +pub(super) type ArcCommand = Arc; + +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 { + 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 { + command.delete_image(image_id).await?; + Ok(format!("Successfully deleted image {image_id}")) +} + +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) +} + +#[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, FAIL_ID}, + }, + 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::INTERNAL_SERVER_ERROR, + authed_client + .get(format!("http://{socket}/report/delete_image/{FAIL_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() + } +} 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/mock.rs b/backend/src/layer/trigger/api/mock.rs index ac0b0e1a..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] @@ -354,6 +355,20 @@ impl Command for CommandMock { ) -> 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<()> { + Ok(()) + } } pub struct AuthDataMock; 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..8ca2ccdf 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_router, 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,18 @@ impl ApiServer { Duration::from_secs(1), )); + let admin_router = admin_router( + self.server_info.admin_key.clone(), + self.command_copy.clone() as ArcCommand, + ); + let app = Router::new() .route( "/", get(graphql_playground).post(graphql_handler.layer(auth)), ) .layer(Extension(self.schema.clone())) + .nest("/admin", admin_router) .nest_service(IMAGE_BASE_PATH, ServeDir::new(&self.server_info.image_dir)) .layer(rate_limit) .layer(DefaultBodyLimit::max( @@ -280,6 +294,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 +305,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 +514,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( 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, 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. |