From 07b08955cbfaadcd31d3379a5a6cc98a0627e4ed Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Wed, 12 Apr 2023 23:34:57 +0200 Subject: [PATCH] [feedback-pipeline-1] refactored the feedback system to be flatter structured (#502) * refactored the feedback system to be flatter structured * [feedback-pipeline-2] API Change (#503) * changed the feedback api (renaming `delete_issue_requested` to `deletion_requested`) * [feedback-pipeline-3] removed `structopt` for parsing commandline arguments (#504) * removed structopt for parsing commandline arguments * documented the used environment variables in the README * refactored token management to be fully based in the tokens module * formatting fix --- openapi.yaml | 4 +- server/feedback/Cargo.lock | 131 +---------------------- server/feedback/Cargo.toml | 1 - server/feedback/README.md | 7 ++ server/feedback/src/core/mod.rs | 124 --------------------- server/feedback/src/core/tokens.rs | 90 ---------------- server/feedback/src/{core => }/github.rs | 21 ++-- server/feedback/src/main.rs | 36 ++----- server/feedback/src/post_feedback.rs | 66 ++++++++++++ server/feedback/src/tokens.rs | 124 +++++++++++++++++++++ webclient/src/feedback.js | 2 +- 11 files changed, 220 insertions(+), 386 deletions(-) delete mode 100644 server/feedback/src/core/mod.rs delete mode 100644 server/feedback/src/core/tokens.rs rename server/feedback/src/{core => }/github.rs (89%) create mode 100644 server/feedback/src/post_feedback.rs create mode 100644 server/feedback/src/tokens.rs diff --git a/openapi.yaml b/openapi.yaml index fcd136ce7..d7f2476b1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1862,7 +1862,7 @@ components: For inspiration on how to do this, see our website. type: boolean example: true - delete_issue_requested: + deletion_requested: description: | Whether the user has requested to delete the issue. If the user has requested to delete the issue, we will delete it from GitHub after processing it @@ -1875,7 +1875,7 @@ components: - subject - body - privacy_checked - - delete_issue_requested + - deletion_requested tags: - name: core description: the API to access/search for room information diff --git a/server/feedback/Cargo.lock b/server/feedback/Cargo.lock index 687a4aa25..d0bc4f748 100644 --- a/server/feedback/Cargo.lock +++ b/server/feedback/Cargo.lock @@ -297,15 +297,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - [[package]] name = "arc-swap" version = "1.6.0" @@ -323,17 +314,6 @@ dependencies = [ "syn 2.0.12", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -455,21 +435,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "clap" -version = "2.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" -dependencies = [ - "ansi_term", - "atty", - "bitflags", - "strsim", - "textwrap", - "unicode-width", - "vec_map", -] - [[package]] name = "codespan-reporting" version = "0.11.1" @@ -914,30 +879,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "heck" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.2.6" @@ -1297,7 +1244,6 @@ dependencies = [ "regex", "serde", "serde_json", - "structopt", "tokio", ] @@ -1532,30 +1478,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.54" @@ -1956,7 +1878,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "475b3bbe5245c26f2d8a6f62d67c1f30eb9fffeccee721c45d162c3ebbdf81b2" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "syn 1.0.109", @@ -1978,36 +1900,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" -[[package]] -name = "strsim" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" - -[[package]] -name = "structopt" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" -dependencies = [ - "clap", - "lazy_static", - "structopt-derive", -] - -[[package]] -name = "structopt-derive" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" -dependencies = [ - "heck 0.3.3", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "syn" version = "1.0.109" @@ -2052,15 +1944,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.40" @@ -2259,12 +2142,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" - [[package]] name = "unicode-width" version = "0.1.10" @@ -2295,12 +2172,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vec_map" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" - [[package]] name = "version_check" version = "0.9.4" diff --git a/server/feedback/Cargo.toml b/server/feedback/Cargo.toml index aa7eceb95..75d6cf420 100644 --- a/server/feedback/Cargo.toml +++ b/server/feedback/Cargo.toml @@ -27,7 +27,6 @@ serde_json = "1.0.95" actix-cors = "0.6.4" tokio = { version = "1.27.0", features = ["full"] } rand = "0.8.5" -structopt = "0.3.26" env_logger = "0.10.0" jsonwebtoken = "8.3.0" chrono = "0.4.23" diff --git a/server/feedback/README.md b/server/feedback/README.md index 8faacac5a..e5fd2668c 100644 --- a/server/feedback/README.md +++ b/server/feedback/README.md @@ -11,6 +11,13 @@ Please follow the [system dependencys docs](/resources/documentation/Dependencys ### Starting the server +The following environment variables are required for all features to work: + +| variable | usage/description | +|----------------|----------------------------------------------------------------------------------------------------| +| `GITHUB_TOKEN` | A GitHub token with `write` access to `repo`. This is used to create issues/PRs on the repository. | +| `JWT_KEY` | A key used to sign JWTs. This is used to authenticate that feedback tokens were given out by us. | + Run `cargo run` to start the server. The server should now be available on `localhost:8070`. diff --git a/server/feedback/src/core/mod.rs b/server/feedback/src/core/mod.rs deleted file mode 100644 index 76f874502..000000000 --- a/server/feedback/src/core/mod.rs +++ /dev/null @@ -1,124 +0,0 @@ -mod github; -mod tokens; -use crate::core::tokens::Claims; -use actix_web::web::{Data, Json}; -use actix_web::{post, HttpResponse}; -use jsonwebtoken::{encode, EncodingKey, Header}; -use log::error; -use serde::Deserialize; - -use tokio::sync::Mutex; - -pub struct AppStateFeedback { - feedback_keys: crate::FeedbackKeys, - token_record: Mutex>, -} -impl AppStateFeedback { - pub fn from(feedback_keys: crate::FeedbackKeys) -> AppStateFeedback { - AppStateFeedback { - feedback_keys, - token_record: Mutex::new(Vec::new()), - } - } - pub fn able_to_process_feedback(&self) -> bool { - self.feedback_keys.github_token.is_some() && self.feedback_keys.jwt_key.is_some() - } -} - -pub struct TokenRecord { - kid: u64, - next_reset: usize, -} - -#[derive(Deserialize)] -pub struct FeedbackPostData { - token: String, - category: String, - subject: String, - body: String, - privacy_checked: bool, - delete_issue_requested: bool, -} - -pub async fn get_token(state: Data) -> HttpResponse { - if !state.able_to_process_feedback() { - return HttpResponse::ServiceUnavailable() - .content_type("text/plain") - .body("Feedback is currently not configured on this server."); - } - - let secret = state.feedback_keys.jwt_key.clone().unwrap(); // we checked available - let token = encode( - &Header::default(), - &Claims::new(), - &EncodingKey::from_secret(secret.as_bytes()), - ); - - match token { - Ok(token) => HttpResponse::Created().json(token), - Err(e) => { - error!("Failed to generate token: {e:?}"); - HttpResponse::InternalServerError() - .content_type("text/plain") - .body("Failed to generate token.") - } - } -} - -#[post("/api/feedback/feedback")] -pub async fn send_feedback( - state: Data, - req_data: Json, -) -> HttpResponse { - // auth - if let Some(e) = tokens::validate_token(&state, &req_data.token).await { - return e; - } - - // validate request - if !req_data.privacy_checked { - return HttpResponse::UnavailableForLegalReasons() - .content_type("text/plain") - .body("Using this endpoint without accepting the privacy policy is not allowed"); - }; - let (title_category, labels) = parse_request(&req_data); - - let github_token = state - .feedback_keys - .github_token - .as_ref() - .unwrap() - .trim() - .to_string(); - github::post_feedback( - github_token, - title_category, - &req_data.subject, - &req_data.body, - labels, - ) - .await -} - -fn parse_request(req_data: &Json) -> (&str, Vec) { - let title_category = match req_data.category.as_str() { - "general" => "General", - "bug" => "Bug", - "feature" => "Feature", - "search" => "Search", - "entry" => "Entry", - _ => "Form", - }; - - let mut labels = vec!["webform".to_string()]; - if req_data.delete_issue_requested { - labels.push("delete-after-processing".to_string()); - } - match req_data.category.as_str() { - "general" | "bug" | "feature" | "search" | "entry" => { - labels.push(req_data.category.as_str().to_string()); - } - _ => {} - }; - (title_category, labels) -} diff --git a/server/feedback/src/core/tokens.rs b/server/feedback/src/core/tokens.rs deleted file mode 100644 index d491a2962..000000000 --- a/server/feedback/src/core/tokens.rs +++ /dev/null @@ -1,90 +0,0 @@ -use actix_web::web::Data; -use actix_web::HttpResponse; - -use jsonwebtoken::{decode, DecodingKey, Validation}; -use log::error; - -use serde::{Deserialize, Serialize}; - -use crate::core::{AppStateFeedback, TokenRecord}; - -// Additionally, there is a short delay until a token can be used. -// Clients need to wait that time if (for some reason) the user submitted -// faster than limited here. -const TOKEN_MIN_AGE: usize = 5; -const TOKEN_MAX_AGE: usize = 3600 * 12; // 12h - -#[derive(Debug, Serialize, Deserialize)] -pub struct Claims { - exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) - iat: usize, // Optional. Issued at (as UTC timestamp) - nbf: usize, // Optional. Not Before (as UTC timestamp) - kid: u64, // Optional. Key ID -} - -impl Claims { - pub fn new() -> Self { - let now = chrono::Utc::now().timestamp() as usize; - Self { - exp: now + TOKEN_MAX_AGE, - iat: now, - nbf: now + TOKEN_MIN_AGE, - kid: rand::random(), - } - } -} - -pub async fn validate_token( - state: &Data, - supplied_token: &str, -) -> Option { - if !state.able_to_process_feedback() { - return Some( - HttpResponse::ServiceUnavailable() - .content_type("text/plain") - .body("Feedback is currently not configured on this server."), - ); - } - - let secret = state.feedback_keys.jwt_key.clone().unwrap(); // we checked available - let x = DecodingKey::from_secret(secret.as_bytes()); - let jwt_token = decode::(supplied_token, &x, &Validation::default()); - let kid = match jwt_token { - Ok(token) => token.claims.kid, - Err(e) => { - error!("Failed to decode token: {:?}", e.kind()); - return Some(HttpResponse::Forbidden().content_type("text/plain").body( - match e.kind() { - jsonwebtoken::errors::ErrorKind::ImmatureSignature => "Token is not yet valid.", - jsonwebtoken::errors::ErrorKind::ExpiredSignature => "Token expired", - _ => "Invalid token", - }, - )); - } - }; - - // now we know from token-validity, that it is within our time limits and created by us. - // The problem is, that it could be used multiple times. - // To prevent this, we need to check if the token was already used. - // This is means that if this usage+our ratelimits are - // - neither synced across multiple feedback instances, nor - // - persisted between reboots - - let now = chrono::Utc::now().timestamp() as usize; - let mut tokens = state.token_record.lock().await; - // remove outdated tokens (no longer relevant for rate limit) - tokens.retain(|t| t.next_reset > now); - // check if token is already used - if tokens.iter().any(|r| r.kid == kid) { - return Some( - HttpResponse::Forbidden() - .content_type("text/plain") - .body("Token already used."), - ); - } - tokens.push(TokenRecord { - kid, - next_reset: now + TOKEN_MAX_AGE, - }); - None -} diff --git a/server/feedback/src/core/github.rs b/server/feedback/src/github.rs similarity index 89% rename from server/feedback/src/core/github.rs rename to server/feedback/src/github.rs index 70815058d..33bc5f2e8 100644 --- a/server/feedback/src/core/github.rs +++ b/server/feedback/src/github.rs @@ -4,15 +4,15 @@ use log::error; use octocrab::Octocrab; use regex::Regex; -pub async fn post_feedback( - github_token: String, - title_category: &str, - title: &str, - description: &str, - labels: Vec, -) -> HttpResponse { - let raw_title = format!("[{title_category}] {title}"); - let title = clean_feedback_data(&raw_title, 512); +fn github_token() -> String { + std::env::var("GITHUB_TOKEN") + .expect("GITHUB_TOKEN not set") + .trim() + .to_string() +} + +pub async fn open_issue(title: &str, description: &str, labels: Vec) -> HttpResponse { + let title = clean_feedback_data(title, 512); let description = clean_feedback_data(description, 1024 * 1024); if title.len() < 3 || description.len() < 10 { @@ -21,7 +21,7 @@ pub async fn post_feedback( .body("Subject or body missing or too short"); } - let octocrab = Octocrab::builder().personal_token(github_token).build(); + let octocrab = Octocrab::builder().personal_token(github_token()).build(); if octocrab.is_err() { error!("Error creating issue: {octocrab:?}"); return HttpResponse::InternalServerError().body("Could not create Octocrab instance"); @@ -67,6 +67,7 @@ fn clean_feedback_data(s: &str, len: usize) -> String { #[cfg(test)] mod description_tests { use super::*; + use std::assert_eq; #[test] fn newlines_whitespace() { diff --git a/server/feedback/src/main.rs b/server/feedback/src/main.rs index b9f78f532..afe77a03c 100644 --- a/server/feedback/src/main.rs +++ b/server/feedback/src/main.rs @@ -2,27 +2,15 @@ use actix_cors::Cors; use actix_governor::{GlobalKeyExtractor, Governor, GovernorConfigBuilder}; use std::collections::HashMap; +use crate::tokens::RecordedTokens; use actix_web::{get, middleware, web, App, HttpResponse, HttpServer}; use actix_web_prometheus::PrometheusMetricsBuilder; - -use structopt::StructOpt; - -mod core; +mod github; +mod post_feedback; +mod tokens; const MAX_JSON_PAYLOAD: usize = 1024 * 1024; // 1 MB -#[derive(StructOpt, Debug)] -#[structopt(name = "server")] -pub struct FeedbackKeys { - // Feedback - /// GitHub personal access token - #[structopt(short = "t", long)] - github_token: Option, - /// Secret for the feedback token generation - #[structopt(short = "jwt", long)] - jwt_key: Option, -} - #[get("/api/feedback/status")] async fn health_status_handler() -> HttpResponse { let github_link = match std::env::var("GIT_COMMIT_SHA") { @@ -39,14 +27,6 @@ const SECONDS_PER_DAY: u64 = 60 * 60 * 24; async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); - let mut opt = FeedbackKeys::from_args(); - if opt.github_token.is_none() { - opt.github_token = std::env::var("GITHUB_TOKEN").ok(); - } - if opt.jwt_key.is_none() { - opt.jwt_key = std::env::var("JWT_KEY").ok(); - } - let feedback_ratelimit = GovernorConfigBuilder::default() .key_extractor(GlobalKeyExtractor) .per_second(SECONDS_PER_DAY / 100) // replenish new token every .. seconds @@ -65,7 +45,7 @@ async fn main() -> std::io::Result<()> { .build() .unwrap(); - let state_feedback = web::Data::new(core::AppStateFeedback::from(opt)); + let recorded_tokens = web::Data::new(RecordedTokens::default()); HttpServer::new(move || { let cors = Cors::default() .allow_any_origin() @@ -79,12 +59,12 @@ async fn main() -> std::io::Result<()> { .wrap(middleware::Compress::default()) .app_data(web::JsonConfig::default().limit(MAX_JSON_PAYLOAD)) .service(health_status_handler) - .app_data(state_feedback.clone()) - .service(core::send_feedback) + .app_data(recorded_tokens.clone()) + .service(post_feedback::send_feedback) .service( web::scope("/api/feedback/get_token") .wrap(Governor::new(&feedback_ratelimit)) - .route("", web::post().to(core::get_token)), + .route("", web::post().to(tokens::get_token)), ) }) .bind(std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0:8070".to_string()))? diff --git a/server/feedback/src/post_feedback.rs b/server/feedback/src/post_feedback.rs new file mode 100644 index 000000000..e6f4cf884 --- /dev/null +++ b/server/feedback/src/post_feedback.rs @@ -0,0 +1,66 @@ +use crate::github; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; + +use crate::tokens::RecordedTokens; +use actix_web::post; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct FeedbackPostData { + token: String, + category: String, + subject: String, + body: String, + privacy_checked: bool, + deletion_requested: bool, +} + +#[post("/api/feedback/feedback")] +pub async fn send_feedback( + recorded_tokens: Data, + req_data: Json, +) -> HttpResponse { + // auth + if let Some(e) = recorded_tokens.validate(&req_data.token).await { + return e; + } + + // validate request + if !req_data.privacy_checked { + return HttpResponse::UnavailableForLegalReasons() + .content_type("text/plain") + .body("Using this endpoint without accepting the privacy policy is not allowed"); + }; + let (title_category, labels) = parse_request(&req_data); + + github::open_issue( + &format!("[{title_category}] {subject}", subject = req_data.subject), + &req_data.body, + labels, + ) + .await +} + +fn parse_request(req_data: &Json) -> (&str, Vec) { + let title_category = match req_data.category.as_str() { + "general" => "General", + "bug" => "Bug", + "feature" => "Feature", + "search" => "Search", + "entry" => "Entry", + _ => "Form", + }; + + let mut labels = vec!["webform".to_string()]; + if req_data.deletion_requested { + labels.push("delete-after-processing".to_string()); + } + match req_data.category.as_str() { + "general" | "bug" | "feature" | "search" | "entry" => { + labels.push(req_data.category.as_str().to_string()); + } + _ => {} + }; + (title_category, labels) +} diff --git a/server/feedback/src/tokens.rs b/server/feedback/src/tokens.rs new file mode 100644 index 000000000..6f404f44b --- /dev/null +++ b/server/feedback/src/tokens.rs @@ -0,0 +1,124 @@ +use actix_web::HttpResponse; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use log::error; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +#[derive(Default)] +pub struct RecordedTokens(Mutex>); + +pub struct TokenRecord { + kid: u64, + next_reset: usize, +} + +fn able_to_process_feedback() -> bool { + std::env::var("GITHUB_TOKEN").is_ok() && std::env::var("JWT_KEY").is_ok() +} + +// Additionally, there is a short delay until a token can be used. +// Clients need to wait that time if (for some reason) the user submitted +// faster than limited here. +const TOKEN_MIN_AGE: usize = 5; +const TOKEN_MAX_AGE: usize = 3600 * 12; // 12h + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) + iat: usize, // Optional. Issued at (as UTC timestamp) + nbf: usize, // Optional. Not Before (as UTC timestamp) + kid: u64, // Optional. Key ID +} + +impl Claims { + pub fn new() -> Self { + let now = chrono::Utc::now().timestamp() as usize; + Self { + exp: now + TOKEN_MAX_AGE, + iat: now, + nbf: now + TOKEN_MIN_AGE, + kid: rand::random(), + } + } +} + +impl RecordedTokens { + pub async fn validate(&self, token: &str) -> Option { + if !able_to_process_feedback() { + return Some( + HttpResponse::ServiceUnavailable() + .content_type("text/plain") + .body("Feedback is currently not configured on this server."), + ); + } + + let secret = std::env::var("JWT_KEY").unwrap(); // we checked the ability to process feedback + let x = DecodingKey::from_secret(secret.as_bytes()); + let jwt_token = decode::(token, &x, &Validation::default()); + let kid = match jwt_token { + Ok(token) => token.claims.kid, + Err(e) => { + error!("Failed to decode token: {:?}", e.kind()); + return Some(HttpResponse::Forbidden().content_type("text/plain").body( + match e.kind() { + jsonwebtoken::errors::ErrorKind::ImmatureSignature => { + "Token is not yet valid." + } + jsonwebtoken::errors::ErrorKind::ExpiredSignature => "Token expired", + _ => "Invalid token", + }, + )); + } + }; + + // now we know from token-validity, that it is within our time limits and created by us. + // The problem is, that it could be used multiple times. + // To prevent this, we need to check if the token was already used. + // This is means that if this usage+our ratelimits are + // - neither synced across multiple feedback instances, nor + // - persisted between reboots + + let now = chrono::Utc::now().timestamp() as usize; + let mut tokens = self.0.lock().await; + // remove outdated tokens (no longer relevant for rate limit) + tokens.retain(|t| t.next_reset > now); + // check if token is already used + if tokens.iter().any(|r| r.kid == kid) { + return Some( + HttpResponse::Forbidden() + .content_type("text/plain") + .body("Token already used."), + ); + } + tokens.push(TokenRecord { + kid, + next_reset: now + TOKEN_MAX_AGE, + }); + None + } +} + +pub async fn get_token() -> HttpResponse { + if !able_to_process_feedback() { + return HttpResponse::ServiceUnavailable() + .content_type("text/plain") + .body("Feedback is currently not configured on this server."); + } + + let secret = std::env::var("JWT_KEY").unwrap(); // we checked the ability to process feedback + let token = encode( + &Header::default(), + &Claims::new(), + &EncodingKey::from_secret(secret.as_bytes()), + ); + + match token { + Ok(token) => HttpResponse::Created().json(token), + Err(e) => { + error!("Failed to generate token: {e:?}"); + HttpResponse::InternalServerError() + .content_type("text/plain") + .body("Failed to generate token.") + } + } +} diff --git a/webclient/src/feedback.js b/webclient/src/feedback.js index c4e9cc829..946345c66 100644 --- a/webclient/src/feedback.js +++ b/webclient/src/feedback.js @@ -148,7 +148,7 @@ window.feedback = (() => { subject: subject, body: body, privacy_checked: privacy, - delete_issue_requested: deleteIssue, + deletion_requested: deleteIssue, }), (r) => { _showLoading(false);