Skip to main content

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