Skip to main content

laterite_web/theme/admin/
mod.rs

1//! The Themes screen: every theme, its config and its templates, edited from
2//! the admin under the `web.manage_themes` permission.
3
4use super::{archive, engine, store, EditTheme, Kind, Revision, Template, Theme, ThemeError};
5use laterite_admin::axum::extract::{Form, Path, State};
6use laterite_admin::axum::http::{header, StatusCode};
7use laterite_admin::axum::response::{IntoResponse, Redirect, Response};
8use laterite_admin::axum::routing::{get, post};
9use laterite_admin::axum::{Extension, Router};
10use laterite_admin::html::Markup;
11use laterite_admin::routes::{RouteCtx, Screen, ScreenReg};
12use laterite_admin::VerifiedUpload;
13use laterite_admin::{FlashLevel, Permission, SessionHandle, Shell, ROLE_ADMIN};
14use laterite_auth::AuthenticatedUser;
15use laterite_core::{t, Text};
16use std::sync::Arc;
17
18/// The permission the screen and every write under it require.
19pub const PERMISSION: &str = "web.manage_themes";
20
21/// The screen's permission, as the module registers it.
22pub fn permission() -> Permission {
23    Permission::new(PERMISSION, t!("Manage themes"), t!("Website")).roles([ROLE_ADMIN])
24}
25
26/// The screen, as the module registers it: the Themes tab.
27pub fn screen() -> ScreenReg {
28    ScreenReg::new("/themes", PERMISSION, Arc::new(ThemeScreen))
29        .in_menu(t!("Themes"))
30        .menu(laterite_admin::nav::Placement::new().icon("palette"))
31}
32
33/// The Themes screen.
34pub struct ThemeScreen;
35
36impl Screen for ThemeScreen {
37    fn mount(&self, ctx: &RouteCtx) -> Router {
38        let state = Arc::new(ctx.clone());
39        Router::new()
40            .route("/", get(index).post(create))
41            .route("/import", post(import_new))
42            .route("/{code}/export", get(export))
43            .route("/{code}/import", post(import_into))
44            .route("/{code}", get(show))
45            .route("/{code}/config", post(update_config))
46            .route("/{code}/settings", post(save_settings))
47            .route("/{code}/activate", post(activate))
48            .route("/{code}/edit", post(edit))
49            .route("/{code}/duplicate", post(duplicate))
50            .route("/{code}/child", post(child))
51            .route("/{code}/lock", post(lock))
52            .route("/{code}/delete", post(delete))
53            .route("/{code}/templates", post(create_template))
54            .route("/{code}/templates/{id}", get(template))
55            .route("/{code}/templates/{id}/save", post(save_template))
56            .route("/{code}/templates/{id}/delete", post(delete_template))
57            .route("/{code}/templates/{id}/restore", post(restore))
58            .with_state(state)
59    }
60}
61
62type Ctx = State<Arc<RouteCtx>>;
63
64fn back(ctx: &RouteCtx, rest: &str) -> Response {
65    Redirect::to(&ctx.url(rest)).into_response()
66}
67
68fn flash_error(session: &SessionHandle, e: &ThemeError) {
69    session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
70}
71
72fn page<T: askama::Template>(shell: &Shell, title: Text, template: &T) -> Response {
73    match Markup::from_template(template) {
74        Ok(body) => shell.page(&title, body),
75        Err(e) => {
76            tracing::error!(error = %e, "rendering a theme page failed");
77            shell.page(&t!("Themes"), Markup::empty())
78        }
79    }
80}
81
82/// A theme as the list shows it.
83struct ThemeRow {
84    code: String,
85    name: String,
86    parent: String,
87    active: bool,
88    editing: bool,
89    locked: bool,
90    href: String,
91}
92
93#[derive(askama::Template)]
94#[template(path = "themes.html")]
95struct IndexTemplate {
96    shell: Shell,
97    base: String,
98    themes: Vec<ThemeRow>,
99}
100
101async fn index(
102    State(ctx): Ctx,
103    Extension(shell): Extension<Shell>,
104    Extension(user): Extension<AuthenticatedUser>,
105) -> Response {
106    let themes = store::list_themes(ctx.db()).await.unwrap_or_default();
107    let active = super::active_theme(ctx.db())
108        .await
109        .ok()
110        .flatten()
111        .map(|t| t.code);
112    // The operator's own choice alone: the active theme is what they edit
113    // otherwise, and the Active badge already says so.
114    let editing: Option<String> =
115        laterite_admin::settings::load_for::<EditTheme>(ctx.db(), user.user.id)
116            .await
117            .ok()
118            .and_then(|e| e.code);
119    let rows = themes
120        .into_iter()
121        .map(|t| ThemeRow {
122            href: ctx.url(&t.code),
123            active: active.as_deref() == Some(t.code.as_str()),
124            editing: editing.as_deref() == Some(t.code.as_str()),
125            parent: t.parent.clone().unwrap_or_default(),
126            name: t.config.name.clone(),
127            locked: t.locked,
128            code: t.code,
129        })
130        .collect();
131    page(
132        &shell,
133        t!("Themes"),
134        &IndexTemplate {
135            base: ctx.url(""),
136            shell: shell.clone(),
137            themes: rows,
138        },
139    )
140}
141
142#[derive(serde::Deserialize)]
143struct NewTheme {
144    code: String,
145    name: String,
146}
147
148async fn create(
149    State(ctx): Ctx,
150    Extension(session): Extension<SessionHandle>,
151    Extension(user): Extension<AuthenticatedUser>,
152    Form(form): Form<NewTheme>,
153) -> Response {
154    let config = super::ThemeConfig {
155        name: form.name.trim().to_string(),
156        ..Default::default()
157    };
158    let yaml = match config.to_yaml() {
159        Ok(y) => y,
160        Err(e) => {
161            flash_error(&session, &e);
162            return back(&ctx, "");
163        }
164    };
165    match store::create_theme(ctx.db(), form.code.trim(), &yaml).await {
166        Ok(theme) => {
167            ctx.audit(
168                &user,
169                "web.theme.create",
170                Some("theme"),
171                Some(&theme.code),
172                Some(&theme.config.name),
173                None,
174            )
175            .await;
176            session.push_flash(FlashLevel::Success, t!("Theme created."));
177            back(&ctx, &theme.code)
178        }
179        Err(e) => {
180            flash_error(&session, &e);
181            back(&ctx, "")
182        }
183    }
184}
185
186/// The most an uploaded archive may weigh.
187const UPLOAD_LIMIT: u64 = 32 * 1024 * 1024;
188
189async fn export(
190    State(ctx): Ctx,
191    Extension(session): Extension<SessionHandle>,
192    Path(code): Path<String>,
193) -> Response {
194    match archive::export(ctx.db(), &code).await {
195        Ok(bytes) => (
196            StatusCode::OK,
197            [
198                (header::CONTENT_TYPE, "application/zip".to_string()),
199                (
200                    header::CONTENT_DISPOSITION,
201                    format!("attachment; filename=\"{code}.zip\""),
202                ),
203            ],
204            bytes,
205        )
206            .into_response(),
207        Err(e) => {
208            flash_error(&session, &e);
209            back(&ctx, &code)
210        }
211    }
212}
213
214/// The fields of an import form: the code (for a new theme), the file, and
215/// whether to replace what exists.
216struct ImportForm {
217    code: String,
218    replace: bool,
219    file: Option<Vec<u8>>,
220    too_large: bool,
221}
222
223async fn read_import(upload: &mut VerifiedUpload) -> ImportForm {
224    let mut form = ImportForm {
225        code: String::new(),
226        replace: false,
227        file: None,
228        too_large: false,
229    };
230    while let Ok(Some(mut field)) = upload.next_field().await {
231        match field.name().unwrap_or_default() {
232            "code" => form.code = field.text().await.unwrap_or_default().trim().to_string(),
233            "replace" => form.replace = field.text().await.unwrap_or_default() == "1",
234            "file" => {
235                let mut bytes = Vec::new();
236                while let Ok(Some(chunk)) = field.chunk().await {
237                    if bytes.len() as u64 + chunk.len() as u64 > UPLOAD_LIMIT {
238                        form.too_large = true;
239                        break;
240                    }
241                    bytes.extend_from_slice(&chunk);
242                }
243                form.file = Some(bytes);
244            }
245            _ => {}
246        }
247    }
248    form
249}
250
251async fn run_import(
252    ctx: &RouteCtx,
253    session: &SessionHandle,
254    user: &AuthenticatedUser,
255    code: &str,
256    form: ImportForm,
257) -> Option<String> {
258    if form.too_large {
259        session.push_flash(FlashLevel::Error, t!("The archive is larger than 32 MB."));
260        return None;
261    }
262    let Some(bytes) = form.file.filter(|b| !b.is_empty()) else {
263        session.push_flash(FlashLevel::Error, t!("Choose a zip to import."));
264        return None;
265    };
266    match archive::import(ctx.db(), code, &bytes, form.replace, Some(user.user.id)).await {
267        Ok(report) => {
268            ctx.audit(
269                user,
270                "web.theme.import",
271                Some("theme"),
272                Some(code),
273                None,
274                Some(&format!(
275                    "{{\"created\":{},\"imported\":{},\"skipped\":{}}}",
276                    report.created, report.imported, report.skipped
277                )),
278            )
279            .await;
280            session.push_flash(
281                FlashLevel::Success,
282                t!(
283                    "Imported {imported} template(s), skipped {skipped}.",
284                    imported = report.imported as i64,
285                    skipped = report.skipped as i64
286                ),
287            );
288            Some(code.to_string())
289        }
290        Err(e) => {
291            flash_error(session, &e);
292            None
293        }
294    }
295}
296
297async fn import_new(
298    State(ctx): Ctx,
299    Extension(session): Extension<SessionHandle>,
300    Extension(user): Extension<AuthenticatedUser>,
301    mut upload: VerifiedUpload,
302) -> Response {
303    let form = read_import(&mut upload).await;
304    let code = form.code.clone();
305    if code.is_empty() {
306        session.push_flash(FlashLevel::Error, t!("Give the new theme a code."));
307        return back(&ctx, "");
308    }
309    match run_import(&ctx, &session, &user, &code, form).await {
310        Some(code) => back(&ctx, &code),
311        None => back(&ctx, ""),
312    }
313}
314
315async fn import_into(
316    State(ctx): Ctx,
317    Extension(session): Extension<SessionHandle>,
318    Extension(user): Extension<AuthenticatedUser>,
319    Path(code): Path<String>,
320    mut upload: VerifiedUpload,
321) -> Response {
322    let form = read_import(&mut upload).await;
323    run_import(&ctx, &session, &user, &code, form).await;
324    back(&ctx, &code)
325}
326
327/// A template as the theme page lists it: the theme's own, or an ancestor's.
328struct TemplateRow {
329    path: String,
330    from: String,
331    href: String,
332}
333
334struct KindGroup {
335    label: String,
336    rows: Vec<TemplateRow>,
337}
338
339#[derive(askama::Template)]
340#[template(path = "theme.html")]
341struct ShowTemplate {
342    shell: Shell,
343    base: String,
344    theme_url: String,
345    code: String,
346    locked: bool,
347    config: String,
348    /// The settings fields over the theme's values, when its config (or an
349    /// ancestor's) declares a form.
350    settings: Option<String>,
351    groups: Vec<KindGroup>,
352    kinds: Vec<(&'static str, String)>,
353}
354
355fn kind_label(shell: &Shell, kind: Kind) -> String {
356    match kind {
357        Kind::Layout => shell.t("Layouts"),
358        Kind::Page => shell.t("Pages"),
359        Kind::Partial => shell.t("Partials"),
360        Kind::Content => shell.t("Content"),
361        Kind::Asset => shell.t("Assets"),
362    }
363}
364
365/// Every template the theme renders with, by kind: its own rows and the
366/// ancestors' it does not override, each linked to the editor of the theme
367/// that holds it.
368async fn groups(
369    ctx: &RouteCtx,
370    shell: &Shell,
371    chain: &[Theme],
372) -> Result<Vec<KindGroup>, ThemeError> {
373    let mut seen: std::collections::HashMap<(Kind, String), (String, i64)> = Default::default();
374    for theme in chain {
375        for t in store::list_templates(ctx.db(), theme.id, None).await? {
376            seen.entry((t.kind, t.path.clone()))
377                .or_insert((theme.code.clone(), t.id));
378        }
379    }
380    let own = &chain[0].code;
381    Ok(Kind::ALL
382        .into_iter()
383        .map(|kind| {
384            let mut rows: Vec<TemplateRow> = seen
385                .iter()
386                .filter(|((k, _), _)| *k == kind)
387                .map(|((_, path), (from, id))| TemplateRow {
388                    path: path.clone(),
389                    from: if from == own {
390                        String::new()
391                    } else {
392                        from.clone()
393                    },
394                    href: ctx.url(&format!("{from}/templates/{id}")),
395                })
396                .collect();
397            rows.sort_by(|a, b| a.path.cmp(&b.path));
398            KindGroup {
399                label: kind_label(shell, kind),
400                rows,
401            }
402        })
403        .collect())
404}
405
406async fn show(
407    State(ctx): Ctx,
408    Extension(shell): Extension<Shell>,
409    Extension(session): Extension<SessionHandle>,
410    Path(code): Path<String>,
411) -> Response {
412    let chain = match store::chain(ctx.db(), &code).await {
413        Ok(chain) => chain,
414        Err(e) => {
415            flash_error(&session, &e);
416            return back(&ctx, "");
417        }
418    };
419    let groups = match groups(&ctx, &shell, &chain).await {
420        Ok(g) => g,
421        Err(e) => {
422            flash_error(&session, &e);
423            return back(&ctx, "");
424        }
425    };
426    let theme = &chain[0];
427    let kinds = Kind::ALL
428        .into_iter()
429        .map(|k| (k.as_str(), kind_label(&shell, k)))
430        .collect();
431    let fields = store::form_fields(&chain);
432    let settings = if fields.is_empty() {
433        None
434    } else {
435        match ctx.value_form(&fields, &theme.code) {
436            Ok(form) => Some(form.render(&theme.settings, &shell).into_string()),
437            Err(text) => {
438                session.push_flash(FlashLevel::Error, text);
439                None
440            }
441        }
442    };
443    page(
444        &shell,
445        Text::dynamic(theme.config.name.clone()),
446        &ShowTemplate {
447            base: ctx.url(""),
448            theme_url: ctx.url(&theme.code),
449            code: theme.code.clone(),
450            locked: theme.locked,
451            config: theme.config_source.clone(),
452            settings,
453            groups,
454            kinds,
455            shell: shell.clone(),
456        },
457    )
458}
459
460#[derive(serde::Deserialize)]
461struct ConfigForm {
462    config: String,
463}
464
465async fn update_config(
466    State(ctx): Ctx,
467    Extension(session): Extension<SessionHandle>,
468    Extension(user): Extension<AuthenticatedUser>,
469    Path(code): Path<String>,
470    Form(form): Form<ConfigForm>,
471) -> Response {
472    match store::update_theme_config(ctx.db(), &code, &form.config).await {
473        Ok(theme) => {
474            ctx.audit(
475                &user,
476                "web.theme.update",
477                Some("theme"),
478                Some(&theme.code),
479                Some(&theme.config.name),
480                None,
481            )
482            .await;
483            session.push_flash(FlashLevel::Success, t!("Theme config saved."));
484        }
485        Err(e) => flash_error(&session, &e),
486    }
487    back(&ctx, &code)
488}
489
490async fn save_settings(
491    State(ctx): Ctx,
492    Extension(session): Extension<SessionHandle>,
493    Extension(user): Extension<AuthenticatedUser>,
494    Path(code): Path<String>,
495    Form(data): Form<std::collections::HashMap<String, String>>,
496) -> Response {
497    let chain = match store::chain(ctx.db(), &code).await {
498        Ok(chain) => chain,
499        Err(e) => {
500            flash_error(&session, &e);
501            return back(&ctx, "");
502        }
503    };
504    let fields = store::form_fields(&chain);
505    let collected = ctx
506        .value_form(&fields, &code)
507        .and_then(|form| form.collect(&data));
508    match collected {
509        Ok(values) => match store::save_settings(ctx.db(), &code, &values).await {
510            Ok(()) => {
511                ctx.audit(
512                    &user,
513                    "web.theme.settings",
514                    Some("theme"),
515                    Some(&code),
516                    Some(&chain[0].config.name),
517                    None,
518                )
519                .await;
520                session.push_flash(FlashLevel::Success, t!("Theme settings saved."));
521            }
522            Err(e) => flash_error(&session, &e),
523        },
524        Err(text) => session.push_flash(FlashLevel::Error, text),
525    }
526    back(&ctx, &code)
527}
528
529async fn activate(
530    State(ctx): Ctx,
531    Extension(session): Extension<SessionHandle>,
532    Extension(user): Extension<AuthenticatedUser>,
533    Path(code): Path<String>,
534) -> Response {
535    match super::set_active_theme(ctx.db(), &code).await {
536        Ok(()) => {
537            ctx.audit(
538                &user,
539                "web.theme.activate",
540                Some("theme"),
541                Some(&code),
542                None,
543                None,
544            )
545            .await;
546            session.push_flash(FlashLevel::Success, t!("The site now uses this theme."));
547        }
548        Err(e) => flash_error(&session, &e),
549    }
550    back(&ctx, "")
551}
552
553async fn edit(
554    State(ctx): Ctx,
555    Extension(session): Extension<SessionHandle>,
556    Extension(user): Extension<AuthenticatedUser>,
557    Path(code): Path<String>,
558) -> Response {
559    let current: Option<String> = super::edit_theme(ctx.db(), user.user.id)
560        .await
561        .ok()
562        .flatten()
563        .map(|t| t.code);
564    let active = super::active_theme(ctx.db())
565        .await
566        .ok()
567        .flatten()
568        .map(|t| t.code);
569    // Choosing the active theme clears the preference, so a later activation
570    // follows the operator along.
571    let choice =
572        if current.as_deref() == Some(code.as_str()) || active.as_deref() == Some(code.as_str()) {
573            None
574        } else {
575            Some(code.as_str())
576        };
577    let _ = EditTheme::default();
578    match super::set_edit_theme(ctx.db(), user.user.id, choice).await {
579        Ok(()) => session.push_flash(
580            FlashLevel::Success,
581            if choice.is_some() {
582                t!("You are now editing this theme.")
583            } else {
584                t!("You are now editing the site's theme.")
585            },
586        ),
587        Err(e) => flash_error(&session, &e),
588    }
589    back(&ctx, "")
590}
591
592#[derive(serde::Deserialize)]
593struct CopyForm {
594    new_code: String,
595    #[serde(default)]
596    name: String,
597}
598
599async fn duplicate(
600    State(ctx): Ctx,
601    Extension(session): Extension<SessionHandle>,
602    Extension(user): Extension<AuthenticatedUser>,
603    Path(code): Path<String>,
604    Form(form): Form<CopyForm>,
605) -> Response {
606    match store::duplicate_theme(ctx.db(), &code, form.new_code.trim()).await {
607        Ok(copy) => {
608            ctx.audit(
609                &user,
610                "web.theme.duplicate",
611                Some("theme"),
612                Some(&copy.code),
613                Some(&copy.config.name),
614                Some(&format!("{{\"from\":\"{code}\"}}")),
615            )
616            .await;
617            session.push_flash(FlashLevel::Success, t!("Theme duplicated."));
618            back(&ctx, &copy.code)
619        }
620        Err(e) => {
621            flash_error(&session, &e);
622            back(&ctx, "")
623        }
624    }
625}
626
627async fn child(
628    State(ctx): Ctx,
629    Extension(session): Extension<SessionHandle>,
630    Extension(user): Extension<AuthenticatedUser>,
631    Path(code): Path<String>,
632    Form(form): Form<CopyForm>,
633) -> Response {
634    let name = if form.name.trim().is_empty() {
635        form.new_code.trim().to_string()
636    } else {
637        form.name.trim().to_string()
638    };
639    match store::create_child_theme(ctx.db(), &code, form.new_code.trim(), &name).await {
640        Ok(child) => {
641            ctx.audit(
642                &user,
643                "web.theme.create",
644                Some("theme"),
645                Some(&child.code),
646                Some(&child.config.name),
647                Some(&format!("{{\"parent\":\"{code}\"}}")),
648            )
649            .await;
650            session.push_flash(FlashLevel::Success, t!("Child theme created."));
651            back(&ctx, &child.code)
652        }
653        Err(e) => {
654            flash_error(&session, &e);
655            back(&ctx, "")
656        }
657    }
658}
659
660#[derive(serde::Deserialize)]
661struct LockForm {
662    locked: i32,
663}
664
665async fn lock(
666    State(ctx): Ctx,
667    Extension(session): Extension<SessionHandle>,
668    Extension(user): Extension<AuthenticatedUser>,
669    Path(code): Path<String>,
670    Form(form): Form<LockForm>,
671) -> Response {
672    let locked = form.locked != 0;
673    match store::set_locked(ctx.db(), &code, locked).await {
674        Ok(()) => {
675            ctx.audit(
676                &user,
677                if locked {
678                    "web.theme.lock"
679                } else {
680                    "web.theme.unlock"
681                },
682                Some("theme"),
683                Some(&code),
684                None,
685                None,
686            )
687            .await;
688            session.push_flash(
689                FlashLevel::Success,
690                if locked {
691                    t!("Theme locked.")
692                } else {
693                    t!("Theme unlocked.")
694                },
695            );
696        }
697        Err(e) => flash_error(&session, &e),
698    }
699    back(&ctx, "")
700}
701
702async fn delete(
703    State(ctx): Ctx,
704    Extension(session): Extension<SessionHandle>,
705    Extension(user): Extension<AuthenticatedUser>,
706    Path(code): Path<String>,
707) -> Response {
708    match store::delete_theme(ctx.db(), &code).await {
709        Ok(()) => {
710            ctx.audit(
711                &user,
712                "web.theme.delete",
713                Some("theme"),
714                Some(&code),
715                None,
716                None,
717            )
718            .await;
719            session.push_flash(FlashLevel::Success, t!("Theme deleted."));
720        }
721        Err(e) => flash_error(&session, &e),
722    }
723    back(&ctx, "")
724}
725
726#[derive(serde::Deserialize)]
727struct NewTemplate {
728    kind: String,
729    path: String,
730}
731
732async fn create_template(
733    State(ctx): Ctx,
734    Extension(session): Extension<SessionHandle>,
735    Extension(user): Extension<AuthenticatedUser>,
736    Path(code): Path<String>,
737    Form(form): Form<NewTemplate>,
738) -> Response {
739    let theme = match store::find_theme(ctx.db(), &code).await {
740        Ok(Some(t)) => t,
741        Ok(None) => {
742            flash_error(&session, &ThemeError::NotFound(code.clone()));
743            return back(&ctx, "");
744        }
745        Err(e) => {
746            flash_error(&session, &e);
747            return back(&ctx, "");
748        }
749    };
750    let kind: Kind = match form.kind.parse() {
751        Ok(k) => k,
752        Err(e) => {
753            flash_error(&session, &e);
754            return back(&ctx, &code);
755        }
756    };
757    let path = form.path.trim();
758    if let Ok(Some(existing)) = store::find_template(ctx.db(), theme.id, kind, path).await {
759        return back(&ctx, &format!("{code}/templates/{}", existing.id));
760    }
761    match store::save_template(ctx.db(), theme.id, kind, path, None, "", Some(user.user.id)).await {
762        Ok(t) => {
763            ctx.audit(
764                &user,
765                "web.theme.template.create",
766                Some("theme_template"),
767                Some(&t.id.to_string()),
768                Some(&engine::template_name(kind, path)),
769                Some(&format!("{{\"theme\":\"{code}\"}}")),
770            )
771            .await;
772            back(&ctx, &format!("{code}/templates/{}", t.id))
773        }
774        Err(e) => {
775            flash_error(&session, &e);
776            back(&ctx, &code)
777        }
778    }
779}
780
781struct RevisionRow {
782    id: i64,
783    saved_at: String,
784    saved_by: String,
785}
786
787#[derive(askama::Template)]
788#[template(path = "theme_template.html")]
789struct EditorTemplate {
790    shell: Shell,
791    theme_url: String,
792    theme_name: String,
793    template_url: String,
794    locked: bool,
795    settings: String,
796    markup: String,
797    revisions: Vec<RevisionRow>,
798}
799
800/// The template `id` under the theme `code`, or none: a template is reached
801/// only through the theme that holds it.
802async fn owned(ctx: &RouteCtx, code: &str, id: i64) -> Result<(Theme, Template), ThemeError> {
803    let theme = store::find_theme(ctx.db(), code)
804        .await?
805        .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
806    let template = store::template(ctx.db(), id)
807        .await?
808        .filter(|t| t.theme_id == theme.id)
809        .ok_or_else(|| ThemeError::TemplateNotFound(id.to_string()))?;
810    Ok((theme, template))
811}
812
813fn revision_rows(shell: &Shell, revisions: Vec<Revision>) -> Vec<RevisionRow> {
814    revisions
815        .into_iter()
816        .map(|r| RevisionRow {
817            id: r.id,
818            saved_at: shell.datetime(&r.saved_at),
819            saved_by: r.saved_by.map(|id| format!("#{id}")).unwrap_or_default(),
820        })
821        .collect()
822}
823
824async fn template(
825    State(ctx): Ctx,
826    Extension(shell): Extension<Shell>,
827    Extension(session): Extension<SessionHandle>,
828    Path((code, id)): Path<(String, i64)>,
829) -> Response {
830    let (theme, template) = match owned(&ctx, &code, id).await {
831        Ok(pair) => pair,
832        Err(e) => {
833            flash_error(&session, &e);
834            return back(&ctx, &code);
835        }
836    };
837    let revisions = store::revisions(ctx.db(), template.id)
838        .await
839        .unwrap_or_default();
840    page(
841        &shell,
842        Text::dynamic(engine::template_name(template.kind, &template.path)),
843        &EditorTemplate {
844            theme_url: ctx.url(&theme.code),
845            theme_name: theme.config.name.clone(),
846            template_url: ctx.url(&format!("{code}/templates/{id}")),
847            locked: theme.locked,
848            settings: serde_json::to_string_pretty(&template.settings).unwrap_or_default(),
849            markup: template.markup.clone(),
850            revisions: revision_rows(&shell, revisions),
851            shell: shell.clone(),
852        },
853    )
854}
855
856#[derive(serde::Deserialize)]
857struct SaveForm {
858    settings: String,
859    markup: String,
860}
861
862async fn save_template(
863    State(ctx): Ctx,
864    Extension(session): Extension<SessionHandle>,
865    Extension(user): Extension<AuthenticatedUser>,
866    Path((code, id)): Path<(String, i64)>,
867    Form(form): Form<SaveForm>,
868) -> Response {
869    let here = format!("{code}/templates/{id}");
870    let (theme, template) = match owned(&ctx, &code, id).await {
871        Ok(pair) => pair,
872        Err(e) => {
873            flash_error(&session, &e);
874            return back(&ctx, &code);
875        }
876    };
877    let settings: serde_json::Value = match serde_json::from_str(form.settings.trim()) {
878        Ok(v @ serde_json::Value::Object(_)) => v,
879        Ok(_) => {
880            session.push_flash(FlashLevel::Error, t!("The settings must be a JSON object."));
881            return back(&ctx, &here);
882        }
883        Err(e) => {
884            session.push_flash(
885                FlashLevel::Error,
886                Text::dynamic(format!("The settings do not parse: {e}")),
887            );
888            return back(&ctx, &here);
889        }
890    };
891    let markup = form.markup.replace("\r\n", "\n");
892    if template.kind != Kind::Content {
893        if let Err(e) = engine::check(&markup) {
894            session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
895            return back(&ctx, &here);
896        }
897    }
898    match store::save_template(
899        ctx.db(),
900        theme.id,
901        template.kind,
902        &template.path,
903        Some(settings),
904        &markup,
905        Some(user.user.id),
906    )
907    .await
908    {
909        Ok(saved) => {
910            ctx.audit(
911                &user,
912                "web.theme.template.save",
913                Some("theme_template"),
914                Some(&saved.id.to_string()),
915                Some(&engine::template_name(saved.kind, &saved.path)),
916                Some(&format!("{{\"theme\":\"{code}\"}}")),
917            )
918            .await;
919            session.push_flash(FlashLevel::Success, t!("Template saved."));
920        }
921        Err(e) => flash_error(&session, &e),
922    }
923    back(&ctx, &here)
924}
925
926async fn delete_template(
927    State(ctx): Ctx,
928    Extension(session): Extension<SessionHandle>,
929    Extension(user): Extension<AuthenticatedUser>,
930    Path((code, id)): Path<(String, i64)>,
931) -> Response {
932    let (_, template) = match owned(&ctx, &code, id).await {
933        Ok(pair) => pair,
934        Err(e) => {
935            flash_error(&session, &e);
936            return back(&ctx, &code);
937        }
938    };
939    match store::delete_template(ctx.db(), id).await {
940        Ok(()) => {
941            ctx.audit(
942                &user,
943                "web.theme.template.delete",
944                Some("theme_template"),
945                Some(&id.to_string()),
946                Some(&engine::template_name(template.kind, &template.path)),
947                Some(&format!("{{\"theme\":\"{code}\"}}")),
948            )
949            .await;
950            session.push_flash(FlashLevel::Success, t!("Template deleted."));
951            back(&ctx, &code)
952        }
953        Err(e) => {
954            flash_error(&session, &e);
955            back(&ctx, &format!("{code}/templates/{id}"))
956        }
957    }
958}
959
960#[derive(serde::Deserialize)]
961struct RestoreForm {
962    revision: i64,
963}
964
965async fn restore(
966    State(ctx): Ctx,
967    Extension(session): Extension<SessionHandle>,
968    Extension(user): Extension<AuthenticatedUser>,
969    Path((code, id)): Path<(String, i64)>,
970    Form(form): Form<RestoreForm>,
971) -> Response {
972    let here = format!("{code}/templates/{id}");
973    if let Err(e) = owned(&ctx, &code, id).await {
974        flash_error(&session, &e);
975        return back(&ctx, &code);
976    }
977    let belongs = store::revisions(ctx.db(), id)
978        .await
979        .map(|revs| revs.iter().any(|r| r.id == form.revision))
980        .unwrap_or(false);
981    if !belongs {
982        flash_error(
983            &session,
984            &ThemeError::TemplateNotFound(form.revision.to_string()),
985        );
986        return back(&ctx, &here);
987    }
988    match store::restore_revision(ctx.db(), form.revision, Some(user.user.id)).await {
989        Ok(saved) => {
990            ctx.audit(
991                &user,
992                "web.theme.template.restore",
993                Some("theme_template"),
994                Some(&saved.id.to_string()),
995                Some(&engine::template_name(saved.kind, &saved.path)),
996                Some(&format!("{{\"revision\":{}}}", form.revision)),
997            )
998            .await;
999            session.push_flash(FlashLevel::Success, t!("Revision restored."));
1000        }
1001        Err(e) => flash_error(&session, &e),
1002    }
1003    back(&ctx, &here)
1004}