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