1use crate::prelude::*;
4use actix_http::Payload;
5use actix_web::{FromRequest, HttpRequest};
6use chrono::{DateTime, Duration, Utc};
7use futures::{
8 FutureExt,
9 future::{BoxFuture, Ready, ready},
10};
11use headless_lms_models::{
12 HttpErrorType, ModelError, ModelErrorType, ModelResult,
13 exercise_service_info::ExerciseServiceInfoApi,
14 exercise_task_gradings::{ExerciseTaskGradingRequest, ExerciseTaskGradingResult},
15 exercise_task_submissions::ExerciseTaskSubmission,
16 exercise_tasks::ExerciseTask,
17};
18
19use headless_lms_utils::error::backend_error::BackendError;
20use hmac::{Hmac, Mac};
21use jwt::{SignWithKey, VerifyWithKey};
22use models::SpecFetcher;
23use sha2::Sha256;
24use std::collections::HashMap;
25use std::sync::{Arc, Mutex};
26use std::{borrow::Cow, fmt::Debug};
27use url::Url;
28
29use super::error::{ControllerError, ControllerErrorType};
30
31const EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER: &str = "exercise-service-grading-update-claim";
33const EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER: &str = "exercise-service-upload-claim";
34
35type SpecCache = HashMap<(String, String, Option<String>), serde_json::Value>;
37
38#[derive(Clone, Debug)]
39pub struct JwtKey(Hmac<Sha256>);
40
41impl JwtKey {
42 pub fn try_from_env() -> anyhow::Result<Self> {
43 let jwt_password = std::env::var("JWT_PASSWORD").context("JWT_PASSWORD must be defined")?;
44 let jwt_key = Self::new(&jwt_password)?;
45 Ok(jwt_key)
46 }
47
48 pub fn new(key: &str) -> Result<Self, sha2::digest::InvalidLength> {
49 let key: Hmac<Sha256> = Hmac::new_from_slice(key.as_bytes())?;
50 Ok(Self(key))
51 }
52
53 #[cfg(test)]
54 pub fn test_key() -> Self {
55 let test_jwt_key = "sMG87WlKnNZoITzvL2+jczriTR7JRsCtGu/bSKaSIvw=asdfjklasd***FSDfsdASDFDS";
56 Self::new(test_jwt_key).unwrap()
57 }
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61pub struct UploadClaim<'a> {
62 exercise_service_slug: Cow<'a, str>,
63 expiration_time: DateTime<Utc>,
64}
65
66impl<'a> UploadClaim<'a> {
67 pub fn exercise_service_slug(&self) -> &str {
68 self.exercise_service_slug.as_ref()
69 }
70
71 pub fn expiration_time(&self) -> &DateTime<Utc> {
72 &self.expiration_time
73 }
74
75 pub fn expiring_in_1_day(exercise_service_slug: Cow<'a, str>) -> Self {
76 Self {
77 exercise_service_slug,
78 expiration_time: Utc::now() + Duration::days(1),
79 }
80 }
81
82 pub fn sign(self, key: &JwtKey) -> String {
83 self.sign_with_key(&key.0)
84 .expect("JWT signing failed - this should never happen with a valid key")
85 }
86
87 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
88 let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
89 ControllerError::new(
90 ControllerErrorType::BadRequest,
91 format!("Invalid jwt key: {}", err),
92 Some(err.into()),
93 )
94 })?;
95 if claim.expiration_time < Utc::now() {
96 return Err(ControllerError::new(
97 ControllerErrorType::BadRequest,
98 "Upload claim has expired".to_string(),
99 None,
100 ));
101 }
102 Ok(claim)
103 }
104}
105
106impl FromRequest for UploadClaim<'_> {
107 type Error = ControllerError;
108 type Future = Ready<Result<Self, Self::Error>>;
109
110 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
111 let try_from_request = move || {
112 let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
113 ControllerError::new(
114 ControllerErrorType::InternalServerError,
115 "Missing JwtKey in app data - server configuration error".to_string(),
116 None,
117 )
118 })?;
119 let header = req
120 .headers()
121 .get(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER)
122 .ok_or_else(|| {
123 ControllerError::new(
124 ControllerErrorType::BadRequest,
125 format!("Missing header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER}",),
126 None,
127 )
128 })?;
129 let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
130 ControllerError::new(
131 ControllerErrorType::BadRequest,
132 format!(
133 "Invalid header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER} = {}",
134 String::from_utf8_lossy(header.as_bytes())
135 ),
136 Some(err.into()),
137 )
138 })?;
139 let claim = UploadClaim::validate(header, jwt_key)?;
140 Result::<_, Self::Error>::Ok(claim)
141 };
142 ready(try_from_request())
143 }
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct GradingUpdateClaim {
148 submission_id: Uuid,
149 expiration_time: DateTime<Utc>,
150}
151
152impl GradingUpdateClaim {
153 pub fn submission_id(&self) -> Uuid {
154 self.submission_id
155 }
156
157 pub fn expiration_time(&self) -> &DateTime<Utc> {
158 &self.expiration_time
159 }
160
161 pub fn expiring_in_1_day(submission_id: Uuid) -> Self {
162 Self {
163 submission_id,
164 expiration_time: Utc::now() + Duration::days(1),
165 }
166 }
167
168 pub fn sign(self, key: &JwtKey) -> String {
169 self.sign_with_key(&key.0)
170 .expect("JWT signing failed - this should never happen with a valid key")
171 }
172
173 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
174 let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
175 ControllerError::new(
176 ControllerErrorType::BadRequest,
177 format!("Invalid jwt key: {}", err),
178 Some(err.into()),
179 )
180 })?;
181 if claim.expiration_time < Utc::now() {
182 return Err(ControllerError::new(
183 ControllerErrorType::BadRequest,
184 "Grading update claim has expired".to_string(),
185 None,
186 ));
187 }
188 Ok(claim)
189 }
190}
191
192impl FromRequest for GradingUpdateClaim {
193 type Error = ControllerError;
194 type Future = Ready<Result<Self, Self::Error>>;
195
196 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
197 let try_from_request = move || {
198 let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
199 ControllerError::new(
200 ControllerErrorType::InternalServerError,
201 "Missing JwtKey in app data - server configuration error".to_string(),
202 None,
203 )
204 })?;
205 let header = req
206 .headers()
207 .get(EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER)
208 .ok_or_else(|| {
209 ControllerError::new(
210 ControllerErrorType::BadRequest,
211 format!("Missing header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER}",),
212 None,
213 )
214 })?;
215 let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
216 ControllerError::new(
217 ControllerErrorType::BadRequest,
218 format!(
219 "Invalid header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER} = {}",
220 String::from_utf8_lossy(header.as_bytes())
221 ),
222 Some(err.into()),
223 )
224 })?;
225 let claim = GradingUpdateClaim::validate(header, jwt_key)?;
226 Result::<_, Self::Error>::Ok(claim)
227 };
228 ready(try_from_request())
229 }
230}
231
232#[derive(Debug, Serialize)]
234#[cfg_attr(feature = "ts_rs", derive(TS))]
235pub struct SpecRequest<'a> {
236 request_id: Uuid,
237 private_spec: Option<&'a serde_json::Value>,
238 upload_url: Option<String>,
239}
240
241#[derive(Debug, Serialize)]
242pub struct ExerciseServiceCsvExportRequest<'a, T: Serialize> {
243 pub items: &'a [T],
244}
245
246#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
248pub struct ExerciseServiceCsvExportColumn {
249 pub key: String,
250 pub header: String,
251}
252
253#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
255pub struct ExerciseServiceCsvExportResult {
256 pub rows: Vec<HashMap<String, serde_json::Value>>,
257}
258
259#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
261pub struct ExerciseServiceCsvExportResponse {
262 pub columns: Vec<ExerciseServiceCsvExportColumn>,
263 pub results: Vec<ExerciseServiceCsvExportResult>,
264}
265
266pub fn make_spec_fetcher(
270 base_url: String,
271 request_id: Uuid,
272 jwt_key: Arc<JwtKey>,
273) -> impl SpecFetcher {
274 move |url, exercise_service_slug, private_spec| {
275 let client = reqwest::Client::new();
276 let upload_claim = UploadClaim::expiring_in_1_day(exercise_service_slug.into());
277 let upload_url = Some(format!("{base_url}/api/v0/files/{exercise_service_slug}"));
278 let req = client
279 .post(url.clone())
280 .header(
281 EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER,
282 upload_claim.sign(&jwt_key),
283 )
284 .timeout(std::time::Duration::from_secs(120))
285 .json(&SpecRequest {
286 request_id,
287 private_spec,
288 upload_url,
289 })
290 .send();
291 async move {
292 let res = req.await.map_err(ModelError::from)?;
293 let status_code = res.status();
294 if !status_code.is_success() {
295 let error_text = res.text().await;
296 let error = error_text.as_deref().unwrap_or("(No text in response)");
297 error!(
298 ?url,
299 ?exercise_service_slug,
300 ?private_spec,
301 ?status_code,
302 "Exercise service returned an error while generating a spec: {}",
303 error
304 );
305 return Err(ModelError::new(
306 ModelErrorType::HttpRequest {
307 status_code: status_code.as_u16(),
308 response_body: error.to_string(),
309 },
310 format!(
311 "Failed to generate spec for exercise for {exercise_service_slug}: {error}."
312 ),
313 None,
314 ));
315 }
316 let json = parse_response_json(res).await?;
317 Ok(json)
318 }
319 .boxed()
320 }
321}
322
323pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
325 fetch_service_info_with_timeout(url, 1000 * 120)
326}
327
328pub fn fetch_service_info_fast(
330 url: Url,
331) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
332 fetch_service_info_with_timeout(url, 1000 * 5)
333}
334
335fn fetch_service_info_with_timeout(
336 url: Url,
337 timeout_ms: u64,
338) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
339 async move {
340 let client = reqwest::Client::new();
341 let res = client
342 .get(url) .timeout(std::time::Duration::from_millis(timeout_ms))
344 .send()
345 .await
346 .map_err(ModelError::from)?;
347 let status = res.status();
348 if !status.is_success() {
349 let response_url = res.url().to_string();
350 let body = res.text().await.map_err(ModelError::from)?;
351 warn!(url=?response_url, status=?status, body=?body, "Could not fetch service info.");
352 return Err(ModelError::new(
353 ModelErrorType::HttpRequest {
354 status_code: status.as_u16(),
355 response_body: body,
356 },
357 "Could not fetch service info.".to_string(),
358 None,
359 ));
360 }
361 let res = parse_response_json(res).await?;
362 Ok(res)
363 }
364 .boxed()
365}
366
367pub fn make_grading_request_sender(
368 jwt_key: Arc<JwtKey>,
369) -> impl Fn(
370 Url,
371 &ExerciseTask,
372 &ExerciseTaskSubmission,
373) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>> {
374 move |grade_url, exercise_task, submission| {
375 let client = reqwest::Client::new();
376 let grading_update_url = format!(
378 "http://project-331.local/api/v0/exercise-services/grading/grading-update/{}",
379 submission.id
380 );
381 let grading_update_claim = GradingUpdateClaim::expiring_in_1_day(submission.id);
382 let req = client
383 .post(grade_url)
384 .header(
385 EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER,
386 grading_update_claim.sign(&jwt_key),
387 )
388 .timeout(std::time::Duration::from_secs(120))
389 .json(&ExerciseTaskGradingRequest {
390 grading_update_url: &grading_update_url,
391 exercise_spec: &exercise_task.private_spec,
392 submission_data: &submission.data_json,
393 });
394 async move {
395 let res = req.send().await.map_err(ModelError::from)?;
396 let status = res.status();
397 if !status.is_success() {
398 let status_code = status.as_u16();
399 let response_body = res.text().await.unwrap_or_default();
400 error!(
401 ?response_body,
402 status_code = %status_code,
403 "Grading request returned an unsuccesful status code"
404 );
405
406 return Err(ModelError::new(
407 ModelErrorType::HttpRequest {
408 status_code,
409 response_body: response_body.clone(),
410 },
411 format!(
412 "Grading failed with status: {} response: {}",
413 status_code, response_body
414 ),
415 None,
416 ));
417 }
418 let obj = parse_response_json(res).await?;
419 info!("Received a grading result: {:#?}", &obj);
420 Ok(obj)
421 }
422 .boxed()
423 }
424}
425
426pub async fn post_exercise_service_csv_export_request<T: Serialize>(
427 url: Url,
428 items: &[T],
429) -> ModelResult<ExerciseServiceCsvExportResponse> {
430 let client = reqwest::Client::new();
431 let response = client
432 .post(url.clone())
433 .timeout(std::time::Duration::from_secs(120))
434 .json(&ExerciseServiceCsvExportRequest { items })
435 .send()
436 .await
437 .map_err(ModelError::from)?;
438
439 let status = response.status();
440 if !status.is_success() {
441 let status_code = status.as_u16();
442 let response_body = response.text().await.unwrap_or_default();
443 error!(
444 ?response_body,
445 status_code = %status_code,
446 "Exercise service CSV export request returned an unsuccessful status code"
447 );
448
449 return Err(ModelError::new(
450 ModelErrorType::HttpRequest {
451 status_code,
452 response_body: response_body.clone(),
453 },
454 format!(
455 "CSV export request failed with status: {} response: {}",
456 status_code, response_body
457 ),
458 None,
459 ));
460 }
461
462 parse_response_json(response).await
463}
464
465#[derive(Debug, Serialize, Deserialize)]
466pub struct GivePeerReviewClaim {
467 pub exercise_slide_submission_id: Uuid,
468 pub peer_or_self_review_config_id: Uuid,
469 expiration_time: DateTime<Utc>,
470}
471
472impl GivePeerReviewClaim {
473 pub fn expiring_in_1_day(
474 exercise_slide_submission_id: Uuid,
475 peer_or_self_review_config_id: Uuid,
476 ) -> Self {
477 Self {
478 exercise_slide_submission_id,
479 peer_or_self_review_config_id,
480 expiration_time: Utc::now() + Duration::days(1),
481 }
482 }
483
484 pub fn sign(self, key: &JwtKey) -> String {
485 self.sign_with_key(&key.0)
486 .expect("JWT signing failed - this should never happen with a valid key")
487 }
488
489 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
490 let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
491 ControllerError::new(
492 ControllerErrorType::BadRequest,
493 format!("Invalid claim: {}", err),
494 Some(err.into()),
495 )
496 })?;
497 if claim.expiration_time < Utc::now() {
498 return Err(ControllerError::new(
499 ControllerErrorType::BadRequest,
500 "The review has expired.".to_string(),
501 None,
502 ));
503 }
504 Ok(claim)
505 }
506}
507
508pub fn make_seed_spec_fetcher_with_cache(
512 base_url: String,
513 request_id: Uuid,
514 jwt_key: Arc<JwtKey>,
515) -> impl SpecFetcher {
516 let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
518
519 let base_fetcher = Arc::new(make_spec_fetcher(base_url, request_id, jwt_key));
521
522 move |url, exercise_service_slug, private_spec| {
523 let url_str = url.to_string();
524 let service_slug = exercise_service_slug.to_string();
525 let private_spec_str =
527 private_spec.map(|spec| serde_json::to_string(&spec).unwrap_or_default());
528 let key = (url_str.clone(), service_slug.clone(), private_spec_str);
529 let cache = Arc::clone(&cache);
530 let base_fetcher = Arc::clone(&base_fetcher);
531
532 async move {
533 if let Some(cached_spec) = cache
535 .lock()
536 .expect("Seed spec fetcher cache lock poisoned")
537 .get(&key)
538 {
539 return Ok(cached_spec.clone());
540 }
541
542 let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
544
545 cache
547 .lock()
548 .expect("Seed spec fetcher cache lock poisoned")
549 .insert(key, fetched_spec.clone());
550
551 Ok(fetched_spec)
552 }
553 .boxed()
554 }
555}
556
557async fn parse_response_json<T>(response: reqwest::Response) -> ModelResult<T>
559where
560 T: serde::de::DeserializeOwned,
561{
562 let status = response.status();
563 let response_text = response.text().await.map_err(ModelError::from)?;
564
565 serde_json::from_str(&response_text).map_err(|err| {
566 ModelError::new(
567 ModelErrorType::HttpError {
568 error_type: HttpErrorType::ResponseDecodeFailed,
569 reason: err.to_string(),
570 status_code: Some(status.as_u16()),
571 response_body: Some(response_text),
572 },
573 format!("Failed to decode JSON response: {}", err),
574 None,
575 )
576 })
577}