headless_lms_server/controllers/main_frontend/
certificates.rs

1use crate::{controllers::helpers::file_uploading, prelude::*};
2use actix_multipart::form::{MultipartForm, tempfile::TempFile};
3use chrono::Utc;
4use headless_lms_certificates as certificates;
5use headless_lms_models::generated_certificates::CertificateUpdateRequest;
6use headless_lms_utils::{file_store::file_utils, icu4x::Icu4xBlob};
7
8use models::{
9    certificate_configurations::{
10        CertificateTextAnchor, DatabaseCertificateConfiguration, PaperSize,
11    },
12    generated_certificates::GeneratedCertificate,
13};
14
15#[derive(Debug, Deserialize)]
16#[cfg_attr(feature = "ts_rs", derive(TS))]
17pub struct CertificateConfigurationUpdate {
18    pub course_module_id: Uuid,
19    pub course_instance_id: Option<Uuid>,
20    pub certificate_owner_name_y_pos: Option<String>,
21    pub certificate_owner_name_x_pos: Option<String>,
22    pub certificate_owner_name_font_size: Option<String>,
23    pub certificate_owner_name_text_color: Option<String>,
24    pub certificate_owner_name_text_anchor: Option<CertificateTextAnchor>,
25    pub certificate_validate_url_y_pos: Option<String>,
26    pub certificate_validate_url_x_pos: Option<String>,
27    pub certificate_validate_url_font_size: Option<String>,
28    pub certificate_validate_url_text_color: Option<String>,
29    pub certificate_validate_url_text_anchor: Option<CertificateTextAnchor>,
30    pub certificate_date_y_pos: Option<String>,
31    pub certificate_date_x_pos: Option<String>,
32    pub certificate_date_font_size: Option<String>,
33    pub certificate_date_text_color: Option<String>,
34    pub certificate_date_text_anchor: Option<CertificateTextAnchor>,
35    pub certificate_locale: Option<String>,
36    pub paper_size: Option<PaperSize>,
37    pub background_svg_file_name: Option<String>,
38    pub overlay_svg_file_name: Option<String>,
39    pub clear_overlay_svg_file: bool,
40    pub render_certificate_grade: bool,
41    pub certificate_grade_y_pos: Option<String>,
42    pub certificate_grade_x_pos: Option<String>,
43    pub certificate_grade_font_size: Option<String>,
44    pub certificate_grade_text_color: Option<String>,
45    pub certificate_grade_text_anchor: Option<CertificateTextAnchor>,
46}
47
48#[derive(Debug, MultipartForm)]
49pub struct CertificateConfigurationUpdateForm {
50    metadata: actix_multipart::form::json::Json<CertificateConfigurationUpdate>,
51    #[multipart(rename = "file")]
52    files: Vec<TempFile>,
53}
54
55/**
56POST `/api/v0/main-frontend/certificates/`
57
58Updates the certificate configuration for a given module.
59*/
60
61#[instrument(skip(pool, payload, file_store))]
62pub async fn update_certificate_configuration(
63    pool: web::Data<PgPool>,
64    payload: MultipartForm<CertificateConfigurationUpdateForm>,
65    file_store: web::Data<dyn FileStore>,
66    user: AuthUser,
67) -> ControllerResult<web::Json<bool>> {
68    let mut conn = pool.acquire().await?;
69    let mut tx = conn.begin().await?;
70
71    let payload = payload.into_inner();
72
73    let course_id = models::course_modules::get_by_id(&mut tx, payload.metadata.course_module_id)
74        .await?
75        .course_id;
76    let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
77    let mut uploaded_files = vec![];
78    let result = update_certificate_configuration_inner(
79        &mut tx,
80        &mut uploaded_files,
81        course_id,
82        payload,
83        file_store.as_ref(),
84        user,
85    )
86    .await;
87    match result {
88        Ok(files_to_delete) => {
89            tx.commit().await?;
90            for file_to_delete in files_to_delete {
91                if let Err(err) = file_uploading::delete_file_from_storage(
92                    &mut conn,
93                    file_to_delete,
94                    file_store.as_ref(),
95                )
96                .await
97                {
98                    // do not propagate error so that we at least try to delete all of the files
99                    error!("Failed to delete file '{file_to_delete}': {err}");
100                }
101            }
102        }
103        Err(err) => {
104            // do not commit in error branch
105            drop(tx);
106            // clean up files that were uploaded before something went wrong
107            for uploaded_file in uploaded_files {
108                if let Err(err) = file_uploading::delete_file_from_storage(
109                    &mut conn,
110                    uploaded_file,
111                    file_store.as_ref(),
112                )
113                .await
114                {
115                    // do not propagate error so that we at least try to delete all of the files
116                    error!("Failed to delete file '{uploaded_file}' during cleanup: {err}");
117                }
118            }
119            return Err(err);
120        }
121    }
122    token.authorized_ok(web::Json(true))
123}
124
125// wrapper so that the parent function can do cleanup if anything goes wrong
126async fn update_certificate_configuration_inner(
127    conn: &mut PgConnection,
128    uploaded_files: &mut Vec<Uuid>,
129    course_id: Uuid,
130    payload: CertificateConfigurationUpdateForm,
131    file_store: &dyn FileStore,
132    user: AuthUser,
133) -> Result<Vec<Uuid>, ControllerError> {
134    let mut tx = conn.begin().await?;
135    let mut files_to_delete = vec![];
136
137    let metadata = payload.metadata.into_inner();
138    // save new svgs, if any
139    let mut new_background_svg_file: Option<(Uuid, String)> = None;
140    let mut new_overlay_svg_file: Option<(Uuid, String)> = None;
141    for file in payload.files {
142        let Some(file_name) = file.file_name else {
143            return Err(ControllerError::new(
144                ControllerErrorType::BadRequest,
145                "Missing file name in multipart request".to_string(),
146                None,
147            ));
148        };
149        let (file, _temp_path) = file.file.into_parts();
150        let content = file_utils::file_to_payload(file);
151        match (
152            metadata.background_svg_file_name.as_ref(),
153            metadata.overlay_svg_file_name.as_ref(),
154        ) {
155            (Some(background_svg_file_name), _) if background_svg_file_name == &file_name => {
156                info!("Saving new background svg file");
157                // upload new background svg
158                let (id, path) = file_uploading::upload_certificate_svg(
159                    &mut tx,
160                    background_svg_file_name,
161                    content,
162                    file_store,
163                    course_id,
164                    user,
165                )
166                .await?;
167                uploaded_files.push(id);
168                new_background_svg_file =
169                    Some((id, path.to_str().context("Invalid path")?.to_string()));
170            }
171            (_, Some(overlay_svg_file_name)) if overlay_svg_file_name == &file_name => {
172                info!("Saving new overlay svg file");
173                // upload new overlay svg
174                let (id, path) = file_uploading::upload_certificate_svg(
175                    &mut tx,
176                    overlay_svg_file_name,
177                    content,
178                    file_store,
179                    course_id,
180                    user,
181                )
182                .await?;
183                uploaded_files.push(id);
184                new_overlay_svg_file =
185                    Some((id, path.to_str().context("Invalid path")?.to_string()));
186            }
187            _ => {
188                return Err(ControllerError::new(
189                    ControllerErrorType::BadRequest,
190                    "Invalid field in multipart request".to_string(),
191                    None,
192                ));
193            }
194        }
195    }
196
197    let existing_configuration =
198        models::certificate_configurations::get_default_configuration_by_course_module(
199            &mut tx,
200            metadata.course_module_id,
201        )
202        .await
203        .optional()?;
204    // get new or existing background svg data for the update struct
205    // also ensure that a background svg already exists or a new one is uploaded and delete old image if replaced
206    let (background_svg_file_upload_id, background_svg_path) =
207        match (&existing_configuration, &new_background_svg_file) {
208            (Some(existing_configuration), None) => {
209                // configuration exists and no new background was uploaded, use old values
210                (
211                    existing_configuration.background_svg_file_upload_id,
212                    existing_configuration.background_svg_path.clone(),
213                )
214            }
215            (existing, Some(background_svg_file)) => {
216                // configuration exists and a new background was uploaded, delete old one
217                if let Some(existing) = existing {
218                    files_to_delete.push(existing.background_svg_file_upload_id);
219                }
220                // use new values
221                background_svg_file.clone()
222            }
223            (None, None) => {
224                // no existing config and no new upload, invalid request
225                return Err(ControllerError::new(
226                    ControllerErrorType::BadRequest,
227                    "Missing background SVG file".to_string(),
228                    None,
229                ));
230            }
231        };
232    // get new or existing overlay svg data for the update struct
233    // also check if the old overlay svgs need to be deleted
234    let overlay_data = match (
235        &existing_configuration,
236        &new_overlay_svg_file,
237        metadata.clear_overlay_svg_file,
238    ) {
239        (_, Some(new_overlay), _) => {
240            // new overlay was uploaded, use new values
241            Some(new_overlay.clone())
242        }
243        (Some(existing), None, false) => {
244            // no new overlay and no deletion requested, use old data
245            existing
246                .overlay_svg_file_upload_id
247                .zip(existing.overlay_svg_path.clone())
248        }
249        (Some(existing), None, true) => {
250            // requested deletion of old overlay
251            if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
252                files_to_delete.push(existing_overlay);
253            }
254            None
255        }
256        (None, None, _) => {
257            // no action needed
258            None
259        }
260    };
261    let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
262    let conf = DatabaseCertificateConfiguration {
263        id: existing_configuration
264            .as_ref()
265            .map(|c| c.id)
266            .unwrap_or(Uuid::new_v4()),
267        certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
268        certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
269        certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
270        certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
271        certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
272        certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
273        certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
274        certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
275        certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
276        certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
277        certificate_date_y_pos: metadata.certificate_date_y_pos,
278        certificate_date_x_pos: metadata.certificate_date_x_pos,
279        certificate_date_font_size: metadata.certificate_date_font_size,
280        certificate_date_text_color: metadata.certificate_date_text_color,
281        certificate_date_text_anchor: metadata.certificate_date_text_anchor,
282        certificate_locale: metadata.certificate_locale,
283        paper_size: metadata.paper_size,
284        background_svg_path,
285        background_svg_file_upload_id,
286        overlay_svg_path: overlay_svg_file_path,
287        overlay_svg_file_upload_id: overlay_svg_file_id,
288        render_certificate_grade: metadata.render_certificate_grade,
289        certificate_grade_y_pos: metadata.certificate_grade_y_pos,
290        certificate_grade_x_pos: metadata.certificate_grade_x_pos,
291        certificate_grade_font_size: metadata.certificate_grade_font_size,
292        certificate_grade_text_color: metadata.certificate_grade_text_color,
293        certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
294    };
295    if let Some(existing_configuration) = existing_configuration {
296        // update existing config
297        models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
298            .await?;
299    } else {
300        let inserted_configuration =
301            models::certificate_configurations::insert(&mut tx, &conf).await?;
302        models::certificate_configuration_to_requirements::insert(
303            &mut tx,
304            inserted_configuration.id,
305            Some(metadata.course_module_id),
306        )
307        .await?;
308    }
309    tx.commit().await?;
310    Ok(files_to_delete)
311}
312
313#[derive(Debug, Deserialize)]
314pub struct CertificateGenerationRequest {
315    pub certificate_configuration_id: Uuid,
316    pub name_on_certificate: String,
317    pub grade: Option<String>,
318}
319
320/**
321POST `/api/v0/main-frontend/certificates/generate`
322
323Generates a certificate for a given certificate configuration id.
324*/
325#[instrument(skip(pool))]
326pub async fn generate_generated_certificate(
327    request: web::Json<CertificateGenerationRequest>,
328    pool: web::Data<PgPool>,
329    user: AuthUser,
330) -> ControllerResult<web::Json<bool>> {
331    let mut conn = pool.acquire().await?;
332
333    let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
334        &mut conn,
335        request.certificate_configuration_id,
336    ).await?;
337
338    if !requirements
339        .has_user_completed_all_requirements(&mut conn, user.id)
340        .await?
341    {
342        return Err(ControllerError::new(
343            ControllerErrorType::BadRequest,
344            "Cannot generate certificate; user has not completed all the requirements to be eligible for this certificate."
345                .to_string(),
346            None,
347        ));
348    }
349    // Skip authorization: each user should be able to generate their own certificate for any module
350    let token = skip_authorize();
351    // generate_and_insert verifies that the user can generate the certificate
352    models::generated_certificates::generate_and_insert(
353        &mut conn,
354        user.id,
355        &request.name_on_certificate,
356        request.certificate_configuration_id,
357    )
358    .await?;
359
360    token.authorized_ok(web::Json(true))
361}
362
363/**
364GET `/api/v0/main-frontend/certificates/get-by-configuration-id/{certificate_configuration_id}`
365
366Fetches the user's certificate for the given course module and course instance.
367*/
368#[instrument(skip(pool))]
369pub async fn get_generated_certificate(
370    certificate_configuration_id: web::Path<Uuid>,
371    pool: web::Data<PgPool>,
372    user: AuthUser,
373) -> ControllerResult<web::Json<Option<GeneratedCertificate>>> {
374    let mut conn = pool.acquire().await?;
375
376    // Each user should be able to view their own certificate
377    let token = skip_authorize();
378    let certificate = models::generated_certificates::get_certificate_for_user(
379        &mut conn,
380        user.id,
381        certificate_configuration_id.into_inner(),
382    )
383    .await
384    .optional()?;
385
386    token.authorized_ok(web::Json(certificate))
387}
388
389#[derive(Debug, Deserialize)]
390pub struct CertificateQuery {
391    #[serde(default)]
392    debug: bool,
393    #[serde(default)]
394    /// If true, the certificate will be rendered using the course certificate configuration id instead of the certificate verification id.
395    /// In this case the certificate is just a test certificate that is not stored in the database.
396    /// This is intended for testing the certificate rendering works correctly.
397    test_certificate_configuration_id: Option<Uuid>,
398}
399
400/**
401GET `/api/v0/main-frontend/certificates/{certificate_verification_id}`
402
403Fetches the user's certificate using the verification id.
404
405Response: the certificate as a png.
406*/
407#[instrument(skip(pool, file_store))]
408pub async fn get_cerficate_by_verification_id(
409    certificate_verification_id: web::Path<String>,
410    pool: web::Data<PgPool>,
411    file_store: web::Data<dyn FileStore>,
412    query: web::Query<CertificateQuery>,
413    icu4x_blob: web::Data<Icu4xBlob>,
414) -> ControllerResult<HttpResponse> {
415    let mut conn = pool.acquire().await?;
416
417    // everyone needs to be able to view the certificate in order to verify its validity
418    let token = skip_authorize();
419
420    let certificate =
421        if let Some(test_certificate_configuration_id) = query.test_certificate_configuration_id {
422            // For testing the certificate
423            GeneratedCertificate {
424                id: Uuid::new_v4(),
425                created_at: Utc::now(),
426                updated_at: Utc::now(),
427                deleted_at: None,
428                user_id: Uuid::new_v4(),
429                certificate_configuration_id: test_certificate_configuration_id,
430                name_on_certificate: "Example user".to_string(),
431                verification_id: "test".to_string(),
432            }
433        } else {
434            models::generated_certificates::get_certificate_by_verification_id(
435                &mut conn,
436                &certificate_verification_id,
437            )
438            .await?
439        };
440
441    let data = certificates::generate_certificate(
442        &mut conn,
443        file_store.as_ref(),
444        &certificate,
445        query.debug,
446        **icu4x_blob,
447    )
448    .await?;
449    let max_age = if query.debug { 0 } else { 300 };
450
451    token.authorized_ok(
452        HttpResponse::Ok()
453            .content_type("image/png")
454            .insert_header(("Cache-Control", format!("max-age={max_age}")))
455            .body(data),
456    )
457}
458
459/**
460DELETE `/api/v0/main-frontend/certificates/configuration/{configuration_id}`
461
462Deletes the given configuration.
463*/
464#[instrument(skip(pool))]
465pub async fn delete_certificate_configuration(
466    configuration_id: web::Path<Uuid>,
467    pool: web::Data<PgPool>,
468    user: AuthUser,
469) -> ControllerResult<web::Json<bool>> {
470    let mut conn = pool.acquire().await?;
471    let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
472        &mut conn,
473        *configuration_id,
474    ).await?;
475
476    let course_module_ids = requirements.course_module_ids;
477    let mut token = None;
478    if course_module_ids.is_empty() {
479        token =
480            Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
481    }
482
483    for course_module_id in course_module_ids {
484        let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
485        let course_id = course_module.course_id;
486        token =
487            Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?);
488    }
489
490    models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
491    let token = token.ok_or_else(|| {
492        ControllerError::new(
493            ControllerErrorType::InternalServerError,
494            "Authorization token was not set".to_string(),
495            None,
496        )
497    })?;
498    token.authorized_ok(web::Json(true))
499}
500
501#[instrument(skip(pool))]
502pub async fn update_generated_certificate(
503    certificate_id: web::Path<Uuid>,
504    payload: web::Json<CertificateUpdateRequest>,
505    pool: web::Data<PgPool>,
506    user: AuthUser,
507) -> ControllerResult<web::Json<GeneratedCertificate>> {
508    let mut conn = pool.acquire().await?;
509
510    let cert = models::generated_certificates::get_by_id(&mut conn, *certificate_id).await?;
511
512    // find course_id for authorization
513    let req = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
514        &mut conn,
515        cert.certificate_configuration_id,
516    ).await?;
517
518    let course_module_id = req.course_module_ids.first().ok_or_else(|| {
519        ControllerError::new(
520            ControllerErrorType::BadRequest,
521            "Certificate has no associated course module",
522            None,
523        )
524    })?;
525
526    let course_module = models::course_modules::get_by_id(&mut conn, *course_module_id).await?;
527    let course_id = course_module.course_id;
528
529    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?;
530
531    let updated = models::generated_certificates::update_certificate(
532        &mut conn,
533        *certificate_id,
534        payload.date_issued,
535        payload.name_on_certificate.clone(),
536    )
537    .await?;
538
539    token.authorized_ok(web::Json(updated))
540}
541
542/**
543Add a route for each controller in this module.
544
545The name starts with an underline in order to appear before other functions in the module documentation.
546
547We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
548*/
549pub fn _add_routes(cfg: &mut ServiceConfig) {
550    cfg.route("", web::post().to(update_certificate_configuration))
551        .route("/generate", web::post().to(generate_generated_certificate))
552        .route(
553            "/get-by-configuration-id/{certificate_configuration_id}",
554            web::get().to(get_generated_certificate),
555        )
556        .route(
557            "/generated/{certificate_id}",
558            web::put().to(update_generated_certificate),
559        )
560        .route(
561            "/{certificate_verification_id}",
562            web::get().to(get_cerficate_by_verification_id),
563        )
564        .route(
565            "/configuration/{certificate_configuration_id}",
566            web::delete().to(delete_certificate_configuration),
567        );
568}