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_utils::{file_store::file_utils, icu4x::Icu4xBlob};
6use models::{
7    certificate_configurations::{
8        CertificateTextAnchor, DatabaseCertificateConfiguration, PaperSize,
9    },
10    generated_certificates::GeneratedCertificate,
11};
12
13#[derive(Debug, Deserialize)]
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15pub struct CertificateConfigurationUpdate {
16    pub course_module_id: Uuid,
17    pub course_instance_id: Option<Uuid>,
18    pub certificate_owner_name_y_pos: Option<String>,
19    pub certificate_owner_name_x_pos: Option<String>,
20    pub certificate_owner_name_font_size: Option<String>,
21    pub certificate_owner_name_text_color: Option<String>,
22    pub certificate_owner_name_text_anchor: Option<CertificateTextAnchor>,
23    pub certificate_validate_url_y_pos: Option<String>,
24    pub certificate_validate_url_x_pos: Option<String>,
25    pub certificate_validate_url_font_size: Option<String>,
26    pub certificate_validate_url_text_color: Option<String>,
27    pub certificate_validate_url_text_anchor: Option<CertificateTextAnchor>,
28    pub certificate_date_y_pos: Option<String>,
29    pub certificate_date_x_pos: Option<String>,
30    pub certificate_date_font_size: Option<String>,
31    pub certificate_date_text_color: Option<String>,
32    pub certificate_date_text_anchor: Option<CertificateTextAnchor>,
33    pub certificate_locale: Option<String>,
34    pub paper_size: Option<PaperSize>,
35    pub background_svg_file_name: Option<String>,
36    pub overlay_svg_file_name: Option<String>,
37    pub clear_overlay_svg_file: bool,
38    pub render_certificate_grade: bool,
39    pub certificate_grade_y_pos: Option<String>,
40    pub certificate_grade_x_pos: Option<String>,
41    pub certificate_grade_font_size: Option<String>,
42    pub certificate_grade_text_color: Option<String>,
43    pub certificate_grade_text_anchor: Option<CertificateTextAnchor>,
44}
45
46#[derive(Debug, MultipartForm)]
47pub struct CertificateConfigurationUpdateForm {
48    metadata: actix_multipart::form::json::Json<CertificateConfigurationUpdate>,
49    #[multipart(rename = "file")]
50    files: Vec<TempFile>,
51}
52
53/**
54POST `/api/v0/main-frontend/certificates/`
55
56Updates the certificate configuration for a given module.
57*/
58
59#[instrument(skip(pool, payload, file_store))]
60pub async fn update_certificate_configuration(
61    pool: web::Data<PgPool>,
62    payload: MultipartForm<CertificateConfigurationUpdateForm>,
63    file_store: web::Data<dyn FileStore>,
64    user: AuthUser,
65) -> ControllerResult<web::Json<bool>> {
66    let mut conn = pool.acquire().await?;
67    let mut tx = conn.begin().await?;
68
69    let payload = payload.into_inner();
70
71    let course_id = models::course_modules::get_by_id(&mut tx, payload.metadata.course_module_id)
72        .await?
73        .course_id;
74    let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Course(course_id)).await?;
75    let mut uploaded_files = vec![];
76    let result = update_certificate_configuration_inner(
77        &mut tx,
78        &mut uploaded_files,
79        course_id,
80        payload,
81        file_store.as_ref(),
82        user,
83    )
84    .await;
85    match result {
86        Ok(files_to_delete) => {
87            tx.commit().await?;
88            for file_to_delete in files_to_delete {
89                if let Err(err) = file_uploading::delete_file_from_storage(
90                    &mut conn,
91                    file_to_delete,
92                    file_store.as_ref(),
93                )
94                .await
95                {
96                    // do not propagate error so that we at least try to delete all of the files
97                    error!("Failed to delete file '{file_to_delete}': {err}");
98                }
99            }
100        }
101        Err(err) => {
102            // do not commit in error branch
103            drop(tx);
104            // clean up files that were uploaded before something went wrong
105            for uploaded_file in uploaded_files {
106                if let Err(err) = file_uploading::delete_file_from_storage(
107                    &mut conn,
108                    uploaded_file,
109                    file_store.as_ref(),
110                )
111                .await
112                {
113                    // do not propagate error so that we at least try to delete all of the files
114                    error!("Failed to delete file '{uploaded_file}' during cleanup: {err}");
115                }
116            }
117            return Err(err);
118        }
119    }
120    token.authorized_ok(web::Json(true))
121}
122
123// wrapper so that the parent function can do cleanup if anything goes wrong
124async fn update_certificate_configuration_inner(
125    conn: &mut PgConnection,
126    uploaded_files: &mut Vec<Uuid>,
127    course_id: Uuid,
128    payload: CertificateConfigurationUpdateForm,
129    file_store: &dyn FileStore,
130    user: AuthUser,
131) -> Result<Vec<Uuid>, ControllerError> {
132    let mut tx = conn.begin().await?;
133    let mut files_to_delete = vec![];
134
135    let metadata = payload.metadata.into_inner();
136    // save new svgs, if any
137    let mut new_background_svg_file: Option<(Uuid, String)> = None;
138    let mut new_overlay_svg_file: Option<(Uuid, String)> = None;
139    for file in payload.files {
140        let Some(file_name) = file.file_name else {
141            return Err(ControllerError::new(
142                ControllerErrorType::BadRequest,
143                "Missing file name in multipart request".to_string(),
144                None,
145            ));
146        };
147        let (file, _temp_path) = file.file.into_parts();
148        let content = file_utils::file_to_payload(file);
149        match (
150            metadata.background_svg_file_name.as_ref(),
151            metadata.overlay_svg_file_name.as_ref(),
152        ) {
153            (Some(background_svg_file_name), _) if background_svg_file_name == &file_name => {
154                info!("Saving new background svg file");
155                // upload new background svg
156                let (id, path) = file_uploading::upload_certificate_svg(
157                    &mut tx,
158                    background_svg_file_name,
159                    content,
160                    file_store,
161                    course_id,
162                    user,
163                )
164                .await?;
165                uploaded_files.push(id);
166                new_background_svg_file =
167                    Some((id, path.to_str().context("Invalid path")?.to_string()));
168            }
169            (_, Some(overlay_svg_file_name)) if overlay_svg_file_name == &file_name => {
170                info!("Saving new overlay svg file");
171                // upload new overlay svg
172                let (id, path) = file_uploading::upload_certificate_svg(
173                    &mut tx,
174                    overlay_svg_file_name,
175                    content,
176                    file_store,
177                    course_id,
178                    user,
179                )
180                .await?;
181                uploaded_files.push(id);
182                new_overlay_svg_file =
183                    Some((id, path.to_str().context("Invalid path")?.to_string()));
184            }
185            _ => {
186                return Err(ControllerError::new(
187                    ControllerErrorType::BadRequest,
188                    "Invalid field in multipart request".to_string(),
189                    None,
190                ));
191            }
192        }
193    }
194
195    let existing_configuration =
196        models::certificate_configurations::get_default_configuration_by_course_module_and_course_instance(
197            &mut tx,
198            metadata.course_module_id,
199            metadata.course_instance_id,
200        )
201        .await
202        .optional()?;
203    // get new or existing background svg data for the update struct
204    // also ensure that a background svg already exists or a new one is uploaded and delete old image if replaced
205    let (background_svg_file_upload_id, background_svg_path) =
206        match (&existing_configuration, &new_background_svg_file) {
207            (Some(existing_configuration), None) => {
208                // configuration exists and no new background was uploaded, use old values
209                (
210                    existing_configuration.background_svg_file_upload_id,
211                    existing_configuration.background_svg_path.clone(),
212                )
213            }
214            (existing, Some(background_svg_file)) => {
215                // configuration exists and a new background was uploaded, delete old one
216                if let Some(existing) = existing {
217                    files_to_delete.push(existing.background_svg_file_upload_id);
218                }
219                // use new values
220                background_svg_file.clone()
221            }
222            (None, None) => {
223                // no existing config and no new upload, invalid request
224                return Err(ControllerError::new(
225                    ControllerErrorType::BadRequest,
226                    "Missing background SVG file".to_string(),
227                    None,
228                ));
229            }
230        };
231    // get new or existing overlay svg data for the update struct
232    // also check if the old overlay svgs need to be deleted
233    let overlay_data = match (
234        &existing_configuration,
235        &new_overlay_svg_file,
236        metadata.clear_overlay_svg_file,
237    ) {
238        (_, Some(new_overlay), _) => {
239            // new overlay was uploaded, use new values
240            Some(new_overlay.clone())
241        }
242        (Some(existing), None, false) => {
243            // no new overlay and no deletion requested, use old data
244            existing
245                .overlay_svg_file_upload_id
246                .zip(existing.overlay_svg_path.clone())
247        }
248        (Some(existing), None, true) => {
249            // requested deletion of old overlay
250            if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
251                files_to_delete.push(existing_overlay);
252            }
253            None
254        }
255        (None, None, _) => {
256            // no action needed
257            None
258        }
259    };
260    let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
261    let conf = DatabaseCertificateConfiguration {
262        id: existing_configuration
263            .as_ref()
264            .map(|c| c.id)
265            .unwrap_or(Uuid::new_v4()),
266        certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
267        certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
268        certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
269        certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
270        certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
271        certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
272        certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
273        certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
274        certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
275        certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
276        certificate_date_y_pos: metadata.certificate_date_y_pos,
277        certificate_date_x_pos: metadata.certificate_date_x_pos,
278        certificate_date_font_size: metadata.certificate_date_font_size,
279        certificate_date_text_color: metadata.certificate_date_text_color,
280        certificate_date_text_anchor: metadata.certificate_date_text_anchor,
281        certificate_locale: metadata.certificate_locale,
282        paper_size: metadata.paper_size,
283        background_svg_path,
284        background_svg_file_upload_id,
285        overlay_svg_path: overlay_svg_file_path,
286        overlay_svg_file_upload_id: overlay_svg_file_id,
287        render_certificate_grade: metadata.render_certificate_grade,
288        certificate_grade_y_pos: metadata.certificate_grade_y_pos,
289        certificate_grade_x_pos: metadata.certificate_grade_x_pos,
290        certificate_grade_font_size: metadata.certificate_grade_font_size,
291        certificate_grade_text_color: metadata.certificate_grade_text_color,
292        certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
293    };
294    if let Some(existing_configuration) = existing_configuration {
295        // update existing config
296        models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
297            .await?;
298    } else {
299        let inserted_configuration =
300            models::certificate_configurations::insert(&mut tx, &conf).await?;
301        models::certificate_configuration_to_requirements::insert(
302            &mut tx,
303            inserted_configuration.id,
304            Some(metadata.course_module_id),
305            metadata.course_instance_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 related_course_instance_ids =
472        models::certificate_configurations::get_required_course_instance_ids(
473            &mut conn,
474            *configuration_id,
475        )
476        .await?;
477    let mut token = None;
478    if related_course_instance_ids.is_empty() {
479        token =
480            Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
481    }
482    for course_instance_id in related_course_instance_ids {
483        token = Some(
484            authorize(
485                &mut conn,
486                Act::Teach,
487                Some(user.id),
488                Res::CourseInstance(course_instance_id),
489            )
490            .await?,
491        );
492    }
493    models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
494    token
495        .expect("Never None at this point")
496        .authorized_ok(web::Json(true))
497}
498
499/**
500Add a route for each controller in this module.
501
502The name starts with an underline in order to appear before other functions in the module documentation.
503
504We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
505*/
506pub fn _add_routes(cfg: &mut ServiceConfig) {
507    cfg.route("", web::post().to(update_certificate_configuration))
508        .route("/generate", web::post().to(generate_generated_certificate))
509        .route(
510            "/get-by-configuration-id/{certificate_configuration_id}",
511            web::get().to(get_generated_certificate),
512        )
513        .route(
514            "/{certificate_verification_id}",
515            web::get().to(get_cerficate_by_verification_id),
516        )
517        .route(
518            "/configuration/{certificate_configuration_id}",
519            web::delete().to(delete_certificate_configuration),
520        );
521}