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(
197            &mut tx,
198            metadata.course_module_id,
199        )
200        .await
201        .optional()?;
202    // get new or existing background svg data for the update struct
203    // also ensure that a background svg already exists or a new one is uploaded and delete old image if replaced
204    let (background_svg_file_upload_id, background_svg_path) =
205        match (&existing_configuration, &new_background_svg_file) {
206            (Some(existing_configuration), None) => {
207                // configuration exists and no new background was uploaded, use old values
208                (
209                    existing_configuration.background_svg_file_upload_id,
210                    existing_configuration.background_svg_path.clone(),
211                )
212            }
213            (existing, Some(background_svg_file)) => {
214                // configuration exists and a new background was uploaded, delete old one
215                if let Some(existing) = existing {
216                    files_to_delete.push(existing.background_svg_file_upload_id);
217                }
218                // use new values
219                background_svg_file.clone()
220            }
221            (None, None) => {
222                // no existing config and no new upload, invalid request
223                return Err(ControllerError::new(
224                    ControllerErrorType::BadRequest,
225                    "Missing background SVG file".to_string(),
226                    None,
227                ));
228            }
229        };
230    // get new or existing overlay svg data for the update struct
231    // also check if the old overlay svgs need to be deleted
232    let overlay_data = match (
233        &existing_configuration,
234        &new_overlay_svg_file,
235        metadata.clear_overlay_svg_file,
236    ) {
237        (_, Some(new_overlay), _) => {
238            // new overlay was uploaded, use new values
239            Some(new_overlay.clone())
240        }
241        (Some(existing), None, false) => {
242            // no new overlay and no deletion requested, use old data
243            existing
244                .overlay_svg_file_upload_id
245                .zip(existing.overlay_svg_path.clone())
246        }
247        (Some(existing), None, true) => {
248            // requested deletion of old overlay
249            if let Some(existing_overlay) = existing.overlay_svg_file_upload_id {
250                files_to_delete.push(existing_overlay);
251            }
252            None
253        }
254        (None, None, _) => {
255            // no action needed
256            None
257        }
258    };
259    let (overlay_svg_file_id, overlay_svg_file_path) = overlay_data.unzip();
260    let conf = DatabaseCertificateConfiguration {
261        id: existing_configuration
262            .as_ref()
263            .map(|c| c.id)
264            .unwrap_or(Uuid::new_v4()),
265        certificate_owner_name_y_pos: metadata.certificate_owner_name_y_pos,
266        certificate_owner_name_x_pos: metadata.certificate_owner_name_x_pos,
267        certificate_owner_name_font_size: metadata.certificate_owner_name_font_size,
268        certificate_owner_name_text_color: metadata.certificate_owner_name_text_color,
269        certificate_owner_name_text_anchor: metadata.certificate_owner_name_text_anchor,
270        certificate_validate_url_y_pos: metadata.certificate_validate_url_y_pos,
271        certificate_validate_url_x_pos: metadata.certificate_validate_url_x_pos,
272        certificate_validate_url_font_size: metadata.certificate_validate_url_font_size,
273        certificate_validate_url_text_color: metadata.certificate_validate_url_text_color,
274        certificate_validate_url_text_anchor: metadata.certificate_validate_url_text_anchor,
275        certificate_date_y_pos: metadata.certificate_date_y_pos,
276        certificate_date_x_pos: metadata.certificate_date_x_pos,
277        certificate_date_font_size: metadata.certificate_date_font_size,
278        certificate_date_text_color: metadata.certificate_date_text_color,
279        certificate_date_text_anchor: metadata.certificate_date_text_anchor,
280        certificate_locale: metadata.certificate_locale,
281        paper_size: metadata.paper_size,
282        background_svg_path,
283        background_svg_file_upload_id,
284        overlay_svg_path: overlay_svg_file_path,
285        overlay_svg_file_upload_id: overlay_svg_file_id,
286        render_certificate_grade: metadata.render_certificate_grade,
287        certificate_grade_y_pos: metadata.certificate_grade_y_pos,
288        certificate_grade_x_pos: metadata.certificate_grade_x_pos,
289        certificate_grade_font_size: metadata.certificate_grade_font_size,
290        certificate_grade_text_color: metadata.certificate_grade_text_color,
291        certificate_grade_text_anchor: metadata.certificate_grade_text_anchor,
292    };
293    if let Some(existing_configuration) = existing_configuration {
294        // update existing config
295        models::certificate_configurations::update(&mut tx, existing_configuration.id, &conf)
296            .await?;
297    } else {
298        let inserted_configuration =
299            models::certificate_configurations::insert(&mut tx, &conf).await?;
300        models::certificate_configuration_to_requirements::insert(
301            &mut tx,
302            inserted_configuration.id,
303            Some(metadata.course_module_id),
304        )
305        .await?;
306    }
307    tx.commit().await?;
308    Ok(files_to_delete)
309}
310
311#[derive(Debug, Deserialize)]
312pub struct CertificateGenerationRequest {
313    pub certificate_configuration_id: Uuid,
314    pub name_on_certificate: String,
315    pub grade: Option<String>,
316}
317
318/**
319POST `/api/v0/main-frontend/certificates/generate`
320
321Generates a certificate for a given certificate configuration id.
322*/
323#[instrument(skip(pool))]
324pub async fn generate_generated_certificate(
325    request: web::Json<CertificateGenerationRequest>,
326    pool: web::Data<PgPool>,
327    user: AuthUser,
328) -> ControllerResult<web::Json<bool>> {
329    let mut conn = pool.acquire().await?;
330
331    let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
332        &mut conn,
333        request.certificate_configuration_id,
334    ).await?;
335
336    if !requirements
337        .has_user_completed_all_requirements(&mut conn, user.id)
338        .await?
339    {
340        return Err(ControllerError::new(
341            ControllerErrorType::BadRequest,
342            "Cannot generate certificate; user has not completed all the requirements to be eligible for this certificate."
343                .to_string(),
344            None,
345        ));
346    }
347    // Skip authorization: each user should be able to generate their own certificate for any module
348    let token = skip_authorize();
349    // generate_and_insert verifies that the user can generate the certificate
350    models::generated_certificates::generate_and_insert(
351        &mut conn,
352        user.id,
353        &request.name_on_certificate,
354        request.certificate_configuration_id,
355    )
356    .await?;
357
358    token.authorized_ok(web::Json(true))
359}
360
361/**
362GET `/api/v0/main-frontend/certificates/get-by-configuration-id/{certificate_configuration_id}`
363
364Fetches the user's certificate for the given course module and course instance.
365*/
366#[instrument(skip(pool))]
367pub async fn get_generated_certificate(
368    certificate_configuration_id: web::Path<Uuid>,
369    pool: web::Data<PgPool>,
370    user: AuthUser,
371) -> ControllerResult<web::Json<Option<GeneratedCertificate>>> {
372    let mut conn = pool.acquire().await?;
373
374    // Each user should be able to view their own certificate
375    let token = skip_authorize();
376    let certificate = models::generated_certificates::get_certificate_for_user(
377        &mut conn,
378        user.id,
379        certificate_configuration_id.into_inner(),
380    )
381    .await
382    .optional()?;
383
384    token.authorized_ok(web::Json(certificate))
385}
386
387#[derive(Debug, Deserialize)]
388pub struct CertificateQuery {
389    #[serde(default)]
390    debug: bool,
391    #[serde(default)]
392    /// If true, the certificate will be rendered using the course certificate configuration id instead of the certificate verification id.
393    /// In this case the certificate is just a test certificate that is not stored in the database.
394    /// This is intended for testing the certificate rendering works correctly.
395    test_certificate_configuration_id: Option<Uuid>,
396}
397
398/**
399GET `/api/v0/main-frontend/certificates/{certificate_verification_id}`
400
401Fetches the user's certificate using the verification id.
402
403Response: the certificate as a png.
404*/
405#[instrument(skip(pool, file_store))]
406pub async fn get_cerficate_by_verification_id(
407    certificate_verification_id: web::Path<String>,
408    pool: web::Data<PgPool>,
409    file_store: web::Data<dyn FileStore>,
410    query: web::Query<CertificateQuery>,
411    icu4x_blob: web::Data<Icu4xBlob>,
412) -> ControllerResult<HttpResponse> {
413    let mut conn = pool.acquire().await?;
414
415    // everyone needs to be able to view the certificate in order to verify its validity
416    let token = skip_authorize();
417
418    let certificate =
419        if let Some(test_certificate_configuration_id) = query.test_certificate_configuration_id {
420            // For testing the certificate
421            GeneratedCertificate {
422                id: Uuid::new_v4(),
423                created_at: Utc::now(),
424                updated_at: Utc::now(),
425                deleted_at: None,
426                user_id: Uuid::new_v4(),
427                certificate_configuration_id: test_certificate_configuration_id,
428                name_on_certificate: "Example user".to_string(),
429                verification_id: "test".to_string(),
430            }
431        } else {
432            models::generated_certificates::get_certificate_by_verification_id(
433                &mut conn,
434                &certificate_verification_id,
435            )
436            .await?
437        };
438
439    let data = certificates::generate_certificate(
440        &mut conn,
441        file_store.as_ref(),
442        &certificate,
443        query.debug,
444        **icu4x_blob,
445    )
446    .await?;
447    let max_age = if query.debug { 0 } else { 300 };
448
449    token.authorized_ok(
450        HttpResponse::Ok()
451            .content_type("image/png")
452            .insert_header(("Cache-Control", format!("max-age={max_age}")))
453            .body(data),
454    )
455}
456
457/**
458DELETE `/api/v0/main-frontend/certificates/configuration/{configuration_id}`
459
460Deletes the given configuration.
461*/
462#[instrument(skip(pool))]
463pub async fn delete_certificate_configuration(
464    configuration_id: web::Path<Uuid>,
465    pool: web::Data<PgPool>,
466    user: AuthUser,
467) -> ControllerResult<web::Json<bool>> {
468    let mut conn = pool.acquire().await?;
469    let requirements = models::certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
470        &mut conn,
471        *configuration_id,
472    ).await?;
473
474    let course_module_ids = requirements.course_module_ids;
475    let mut token = None;
476    if course_module_ids.is_empty() {
477        token =
478            Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::GlobalPermissions).await?);
479    }
480
481    for course_module_id in course_module_ids {
482        let course_module = models::course_modules::get_by_id(&mut conn, course_module_id).await?;
483        let course_id = course_module.course_id;
484        token =
485            Some(authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?);
486    }
487
488    models::certificate_configurations::delete(&mut conn, *configuration_id).await?;
489    token
490        .expect("Never None at this point")
491        .authorized_ok(web::Json(true))
492}
493
494/**
495Add a route for each controller in this module.
496
497The name starts with an underline in order to appear before other functions in the module documentation.
498
499We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
500*/
501pub fn _add_routes(cfg: &mut ServiceConfig) {
502    cfg.route("", web::post().to(update_certificate_configuration))
503        .route("/generate", web::post().to(generate_generated_certificate))
504        .route(
505            "/get-by-configuration-id/{certificate_configuration_id}",
506            web::get().to(get_generated_certificate),
507        )
508        .route(
509            "/{certificate_verification_id}",
510            web::get().to(get_cerficate_by_verification_id),
511        )
512        .route(
513            "/configuration/{certificate_configuration_id}",
514            web::delete().to(delete_certificate_configuration),
515        );
516}