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