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, PageHead, 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}/properties", post(save_properties))
47            .route("/{code}/settings", post(save_settings))
48            .route("/{code}/activate", post(activate))
49            .route("/{code}/edit", post(edit))
50            .route("/{code}/duplicate", post(duplicate))
51            .route("/{code}/child", post(child))
52            .route("/{code}/lock", post(lock))
53            .route("/{code}/delete", post(delete))
54            .route("/{code}/templates", post(create_template))
55            .route("/{code}/templates/{id}", get(template))
56            .route("/{code}/templates/{id}/save", post(save_template))
57            .route("/{code}/templates/{id}/delete", post(delete_template))
58            .route("/{code}/templates/{id}/restore", post(restore))
59            .with_state(state)
60    }
61}
62
63type Ctx = State<Arc<RouteCtx>>;
64
65fn back(ctx: &RouteCtx, rest: &str) -> Response {
66    Redirect::to(&ctx.url(rest)).into_response()
67}
68
69fn flash_error(session: &SessionHandle, e: &ThemeError) {
70    session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
71}
72
73fn page<T: askama::Template>(shell: &Shell, title: Text, template: &T) -> Response {
74    page_with(shell, PageHead::new(title), template)
75}
76
77fn page_with<T: askama::Template>(shell: &Shell, head: PageHead, template: &T) -> Response {
78    match Markup::from_template(template) {
79        Ok(body) => shell.page_with(head, body),
80        Err(e) => {
81            tracing::error!(error = %e, "rendering a theme page failed");
82            shell.page(&t!("Themes"), Markup::empty())
83        }
84    }
85}
86
87/// Which theme is active, and which one this operator previews.
88async fn standing(ctx: &RouteCtx, user: &AuthenticatedUser) -> (Option<String>, Option<String>) {
89    let active = super::active_theme(ctx.db())
90        .await
91        .ok()
92        .flatten()
93        .map(|t| t.code);
94    // The operator's own choice alone: the active theme is what they edit
95    // otherwise, and the Active badge already says so.
96    let editing = laterite_admin::settings::load_for::<EditTheme>(ctx.db(), user.user.id)
97        .await
98        .ok()
99        .and_then(|e| e.code);
100    (active, editing)
101}
102
103/// Up to two initials of a name: the first letters of its first two words,
104/// or the first letter alone.
105fn initials(name: &str) -> String {
106    let mut words = name.split_whitespace().filter_map(|w| w.chars().next());
107    let mut out: String = words
108        .by_ref()
109        .take(2)
110        .flat_map(|c| c.to_uppercase())
111        .collect();
112    if out.is_empty() {
113        out.push('?');
114    }
115    out
116}
117
118/// A theme as the list shows it.
119struct ThemeRow {
120    code: String,
121    name: String,
122    /// The name's initials, for the thumbnail block when no preview is set.
123    initials: String,
124    preview: String,
125    author: String,
126    description: String,
127    parent: String,
128    active: bool,
129    editing: bool,
130    locked: bool,
131    href: String,
132}
133
134#[derive(askama::Template)]
135#[template(path = "themes.html")]
136struct IndexTemplate {
137    shell: Shell,
138    base: String,
139    themes: Vec<ThemeRow>,
140}
141
142async fn index(
143    State(ctx): Ctx,
144    Extension(shell): Extension<Shell>,
145    Extension(user): Extension<AuthenticatedUser>,
146) -> Response {
147    let themes = store::list_themes(ctx.db()).await.unwrap_or_default();
148    let (active, editing) = standing(&ctx, &user).await;
149    let rows = themes
150        .into_iter()
151        .map(|t| ThemeRow {
152            href: ctx.url(&t.code),
153            active: active.as_deref() == Some(t.code.as_str()),
154            editing: editing.as_deref() == Some(t.code.as_str()),
155            parent: t.parent.clone().unwrap_or_default(),
156            initials: initials(&t.config.name),
157            preview: t.config.preview.clone().unwrap_or_default(),
158            author: t.config.author.clone(),
159            description: t.config.description.clone(),
160            name: t.config.name.clone(),
161            locked: t.locked,
162            code: t.code,
163        })
164        .collect();
165    page(
166        &shell,
167        t!("Themes"),
168        &IndexTemplate {
169            base: ctx.url(""),
170            shell: shell.clone(),
171            themes: rows,
172        },
173    )
174}
175
176#[derive(serde::Deserialize)]
177struct NewTheme {
178    code: String,
179    name: String,
180}
181
182async fn create(
183    State(ctx): Ctx,
184    Extension(session): Extension<SessionHandle>,
185    Extension(user): Extension<AuthenticatedUser>,
186    Form(form): Form<NewTheme>,
187) -> Response {
188    let config = super::ThemeConfig {
189        name: form.name.trim().to_string(),
190        ..Default::default()
191    };
192    let yaml = match config.to_yaml() {
193        Ok(y) => y,
194        Err(e) => {
195            flash_error(&session, &e);
196            return back(&ctx, "");
197        }
198    };
199    match store::create_theme(ctx.db(), form.code.trim(), &yaml).await {
200        Ok(theme) => {
201            ctx.audit(
202                &user,
203                "web.theme.create",
204                Some("theme"),
205                Some(&theme.code),
206                Some(&theme.config.name),
207                None,
208            )
209            .await;
210            session.push_flash(FlashLevel::Success, t!("Theme created."));
211            back(&ctx, &theme.code)
212        }
213        Err(e) => {
214            flash_error(&session, &e);
215            back(&ctx, "")
216        }
217    }
218}
219
220/// The most an uploaded archive may weigh.
221const UPLOAD_LIMIT: u64 = 32 * 1024 * 1024;
222
223async fn export(
224    State(ctx): Ctx,
225    Extension(session): Extension<SessionHandle>,
226    Path(code): Path<String>,
227) -> Response {
228    match archive::export(ctx.db(), &code).await {
229        Ok(bytes) => (
230            StatusCode::OK,
231            [
232                (header::CONTENT_TYPE, "application/zip".to_string()),
233                (
234                    header::CONTENT_DISPOSITION,
235                    format!("attachment; filename=\"{code}.zip\""),
236                ),
237            ],
238            bytes,
239        )
240            .into_response(),
241        Err(e) => {
242            flash_error(&session, &e);
243            back(&ctx, &code)
244        }
245    }
246}
247
248/// The fields of an import form: the code (for a new theme), the file, and
249/// whether to replace what exists.
250struct ImportForm {
251    code: String,
252    replace: bool,
253    file: Option<Vec<u8>>,
254    too_large: bool,
255}
256
257async fn read_import(upload: &mut VerifiedUpload) -> ImportForm {
258    let mut form = ImportForm {
259        code: String::new(),
260        replace: false,
261        file: None,
262        too_large: false,
263    };
264    while let Ok(Some(mut field)) = upload.next_field().await {
265        match field.name().unwrap_or_default() {
266            "code" => form.code = field.text().await.unwrap_or_default().trim().to_string(),
267            "replace" => form.replace = field.text().await.unwrap_or_default() == "1",
268            "file" => {
269                let mut bytes = Vec::new();
270                while let Ok(Some(chunk)) = field.chunk().await {
271                    if bytes.len() as u64 + chunk.len() as u64 > UPLOAD_LIMIT {
272                        form.too_large = true;
273                        break;
274                    }
275                    bytes.extend_from_slice(&chunk);
276                }
277                form.file = Some(bytes);
278            }
279            _ => {}
280        }
281    }
282    form
283}
284
285async fn run_import(
286    ctx: &RouteCtx,
287    session: &SessionHandle,
288    user: &AuthenticatedUser,
289    code: &str,
290    form: ImportForm,
291) -> Option<String> {
292    if form.too_large {
293        session.push_flash(FlashLevel::Error, t!("The archive is larger than 32 MB."));
294        return None;
295    }
296    let Some(bytes) = form.file.filter(|b| !b.is_empty()) else {
297        session.push_flash(FlashLevel::Error, t!("Choose a zip to import."));
298        return None;
299    };
300    match archive::import(ctx.db(), code, &bytes, form.replace, Some(user.user.id)).await {
301        Ok(report) => {
302            ctx.audit(
303                user,
304                "web.theme.import",
305                Some("theme"),
306                Some(code),
307                None,
308                Some(&format!(
309                    "{{\"created\":{},\"imported\":{},\"skipped\":{}}}",
310                    report.created, report.imported, report.skipped
311                )),
312            )
313            .await;
314            session.push_flash(
315                FlashLevel::Success,
316                t!(
317                    "Imported {imported} template(s), skipped {skipped}.",
318                    imported = report.imported as i64,
319                    skipped = report.skipped as i64
320                ),
321            );
322            Some(code.to_string())
323        }
324        Err(e) => {
325            flash_error(session, &e);
326            None
327        }
328    }
329}
330
331async fn import_new(
332    State(ctx): Ctx,
333    Extension(session): Extension<SessionHandle>,
334    Extension(user): Extension<AuthenticatedUser>,
335    mut upload: VerifiedUpload,
336) -> Response {
337    let form = read_import(&mut upload).await;
338    let code = form.code.clone();
339    if code.is_empty() {
340        session.push_flash(FlashLevel::Error, t!("Give the new theme a code."));
341        return back(&ctx, "");
342    }
343    match run_import(&ctx, &session, &user, &code, form).await {
344        Some(code) => back(&ctx, &code),
345        None => back(&ctx, ""),
346    }
347}
348
349async fn import_into(
350    State(ctx): Ctx,
351    Extension(session): Extension<SessionHandle>,
352    Extension(user): Extension<AuthenticatedUser>,
353    Path(code): Path<String>,
354    mut upload: VerifiedUpload,
355) -> Response {
356    let form = read_import(&mut upload).await;
357    run_import(&ctx, &session, &user, &code, form).await;
358    back(&ctx, &code)
359}
360
361/// The layouts a theme and its ancestors hold, by path.
362async fn layouts_of(ctx: &RouteCtx, code: &str) -> Result<Vec<String>, ThemeError> {
363    let mut layouts = Vec::new();
364    for theme in store::chain(ctx.db(), code).await? {
365        for t in store::list_templates(ctx.db(), theme.id, Some(Kind::Layout)).await? {
366            if !layouts.contains(&t.path) {
367                layouts.push(t.path);
368            }
369        }
370    }
371    layouts.sort();
372    Ok(layouts)
373}
374
375/// The registered components, as the Components help lists them.
376fn components_help(ctx: &RouteCtx) -> String {
377    let regs = ctx.contributions::<super::component::ComponentReg>();
378    if regs.is_empty() {
379        return String::from("No component is registered.");
380    }
381    regs.iter()
382        .map(|r| {
383            let props: Vec<String> = r
384                .component
385                .properties()
386                .iter()
387                .map(|p| p.name.clone())
388                .collect();
389            if props.is_empty() {
390                r.code.clone()
391            } else {
392                format!("{} ({})", r.code, props.join(", "))
393            }
394        })
395        .collect::<Vec<_>>()
396        .join("; ")
397}
398
399/// A page's settings as the editor shows them: typed fields over the bag.
400fn page_fields(layouts: &[String], components_help: &str) -> Vec<laterite_admin::form::FormField> {
401    use laterite_admin::form::FormField;
402    let names: Vec<String> = layouts
403        .iter()
404        .map(|l| super::page::page_name(l).to_string())
405        .collect();
406    let mut choices: Vec<(&str, &str)> = vec![("", "")];
407    choices.extend(names.iter().map(|n| (n.as_str(), n.as_str())));
408    let components_help = Text::dynamic(format!(
409        "A JSON object keyed by alias: {{\"posts\": {{\"component\": \"acme.posts\", \"limit\": 5}}}}. Registered: {components_help}"
410    ));
411    vec![
412        FormField::text("url", t!("URL"))
413            .required()
414            .help(t!("A pattern: /blog/:slug, /docs/:path*, /page/:n?1")),
415        FormField::select("layout", t!("Layout"), choices),
416        FormField::text("title", t!("Title")),
417        FormField::textarea("description", t!("Description")),
418        FormField::text("meta_title", t!("Meta title")),
419        FormField::textarea("meta_description", t!("Meta description")),
420        FormField::text("meta_image", t!("Meta image")),
421        FormField::text("meta_type", t!("Meta type")),
422        FormField::text("meta_robots", t!("Meta robots")),
423        FormField::switch("hidden", t!("Hidden: shown only while previewing")),
424        FormField::textarea("components", t!("Components")).help(components_help),
425    ]
426}
427
428/// The bag as the fields show it: `components` as pretty JSON text.
429fn bag_for_fields(settings: &serde_json::Value) -> serde_json::Value {
430    let mut bag = settings.clone();
431    if let Some(obj) = bag.as_object_mut() {
432        let text = match obj.get("components") {
433            Some(serde_json::Value::Object(m)) if !m.is_empty() => {
434                serde_json::to_string_pretty(m).unwrap_or_default()
435            }
436            _ => String::new(),
437        };
438        obj.insert("components".into(), serde_json::Value::String(text));
439    }
440    bag
441}
442
443/// A page's settings as a save checks them: the URL parses and the layout
444/// is one the theme or an ancestor has.
445async fn check_page(
446    ctx: &RouteCtx,
447    code: &str,
448    settings: &serde_json::Value,
449) -> Result<(), ThemeError> {
450    let page = super::page::PageSettings::from_value(settings)?;
451    page.check(&layouts_of(ctx, code).await?)?;
452    let regs = ctx.contributions::<super::component::ComponentReg>();
453    super::component::check(settings, &regs)
454}
455
456/// A template as the theme page lists it: the theme's own, or an ancestor's.
457struct TemplateRow {
458    path: String,
459    from: String,
460    href: String,
461    /// A page's URL pattern; empty for every other kind.
462    url: String,
463    /// Whether this is the template open in the editor.
464    active: bool,
465}
466
467struct KindGroup {
468    kind: &'static str,
469    label: String,
470    rows: Vec<TemplateRow>,
471}
472
473/// The controls beside a theme's title.
474#[derive(askama::Template)]
475#[template(path = "theme_actions.html")]
476struct ActionsTemplate {
477    shell: Shell,
478    theme_url: String,
479    active: bool,
480    editing: bool,
481    locked: bool,
482}
483
484#[derive(askama::Template)]
485#[template(path = "theme.html")]
486struct ShowTemplate {
487    shell: Shell,
488    theme_url: String,
489    code: String,
490    name: String,
491    parent: String,
492    author: String,
493    homepage: String,
494    description: String,
495    active: bool,
496    editing: bool,
497    locked: bool,
498    preview: String,
499    /// The settings fields over the theme's values, when its config (or an
500    /// ancestor's) declares a form.
501    settings: Option<String>,
502    groups: Vec<KindGroup>,
503    kinds: Vec<(&'static str, String)>,
504}
505
506fn kind_label(shell: &Shell, kind: Kind) -> String {
507    match kind {
508        Kind::Layout => shell.t("Layouts"),
509        Kind::Page => shell.t("Pages"),
510        Kind::Partial => shell.t("Partials"),
511        Kind::Content => shell.t("Content"),
512        Kind::Asset => shell.t("Assets"),
513    }
514}
515
516/// Every template the theme renders with, by kind: its own rows and the
517/// ancestors' it does not override, each linked to the editor of the theme
518/// that holds it.
519async fn groups(
520    ctx: &RouteCtx,
521    shell: &Shell,
522    chain: &[Theme],
523    current: Option<i64>,
524) -> Result<Vec<KindGroup>, ThemeError> {
525    let mut seen: std::collections::HashMap<(Kind, String), (String, i64, String)> =
526        Default::default();
527    for theme in chain {
528        for t in store::list_templates(ctx.db(), theme.id, None).await? {
529            let url = match t.kind {
530                Kind::Page => t
531                    .settings
532                    .get("url")
533                    .and_then(|u| u.as_str())
534                    .unwrap_or_default()
535                    .to_string(),
536                _ => String::new(),
537            };
538            seen.entry((t.kind, t.path.clone()))
539                .or_insert((theme.code.clone(), t.id, url));
540        }
541    }
542    let own = &chain[0].code;
543    Ok(Kind::ALL
544        .into_iter()
545        .map(|kind| {
546            let mut rows: Vec<TemplateRow> = seen
547                .iter()
548                .filter(|((k, _), _)| *k == kind)
549                .map(|((_, path), (from, id, url))| TemplateRow {
550                    path: path.clone(),
551                    from: if from == own {
552                        String::new()
553                    } else {
554                        from.clone()
555                    },
556                    href: ctx.url(&format!("{from}/templates/{id}")),
557                    url: url.clone(),
558                    active: current == Some(*id),
559                })
560                .collect();
561            rows.sort_by(|a, b| a.path.cmp(&b.path));
562            KindGroup {
563                kind: kind.as_str(),
564                label: kind_label(shell, kind),
565                rows,
566            }
567        })
568        .collect())
569}
570
571async fn show(
572    State(ctx): Ctx,
573    Extension(shell): Extension<Shell>,
574    Extension(session): Extension<SessionHandle>,
575    Extension(user): Extension<AuthenticatedUser>,
576    Path(code): Path<String>,
577) -> Response {
578    let chain = match store::chain(ctx.db(), &code).await {
579        Ok(chain) => chain,
580        Err(e) => {
581            flash_error(&session, &e);
582            return back(&ctx, "");
583        }
584    };
585    let groups = match groups(&ctx, &shell, &chain, None).await {
586        Ok(g) => g,
587        Err(e) => {
588            flash_error(&session, &e);
589            return back(&ctx, "");
590        }
591    };
592    let theme = &chain[0];
593    let kinds = Kind::ALL
594        .into_iter()
595        .map(|k| (k.as_str(), kind_label(&shell, k)))
596        .collect();
597    let fields = store::form_fields(&chain);
598    let settings = if fields.is_empty() {
599        None
600    } else {
601        match ctx.value_form(&fields, &theme.code) {
602            Ok(form) => Some(form.render(&theme.settings, &shell).into_string()),
603            Err(text) => {
604                session.push_flash(FlashLevel::Error, text);
605                None
606            }
607        }
608    };
609    let (active, editing) = standing(&ctx, &user).await;
610    let active = active.as_deref() == Some(theme.code.as_str());
611    let editing = editing.as_deref() == Some(theme.code.as_str());
612    let actions = Markup::from_template(&ActionsTemplate {
613        shell: shell.clone(),
614        theme_url: ctx.url(&theme.code),
615        active,
616        editing,
617        locked: theme.locked,
618    })
619    .unwrap_or_else(|_| Markup::empty());
620    page_with(
621        &shell,
622        PageHead::new(Text::dynamic(theme.config.name.clone()))
623            .back(t!("Themes"), ctx.url(""))
624            .actions(actions),
625        &ShowTemplate {
626            theme_url: ctx.url(&theme.code),
627            code: theme.code.clone(),
628            name: theme.config.name.clone(),
629            parent: theme.parent.clone().unwrap_or_default(),
630            author: theme.config.author.clone(),
631            homepage: theme.config.homepage.clone(),
632            description: theme.config.description.clone(),
633            active,
634            editing,
635            locked: theme.locked,
636            preview: theme.config.preview.clone().unwrap_or_default(),
637            settings,
638            groups,
639            kinds,
640            shell: shell.clone(),
641        },
642    )
643}
644
645#[derive(serde::Deserialize)]
646struct ConfigForm {
647    config: String,
648}
649
650async fn update_config(
651    State(ctx): Ctx,
652    Extension(session): Extension<SessionHandle>,
653    Extension(user): Extension<AuthenticatedUser>,
654    Path(code): Path<String>,
655    Form(form): Form<ConfigForm>,
656) -> Response {
657    match store::update_theme_config(ctx.db(), &code, &form.config).await {
658        Ok(theme) => {
659            ctx.audit(
660                &user,
661                "web.theme.update",
662                Some("theme"),
663                Some(&theme.code),
664                Some(&theme.config.name),
665                None,
666            )
667            .await;
668            session.push_flash(FlashLevel::Success, t!("Theme config saved."));
669        }
670        Err(e) => flash_error(&session, &e),
671    }
672    back(&ctx, &code)
673}
674
675#[derive(serde::Deserialize)]
676struct PropertiesForm {
677    #[serde(default)]
678    name: String,
679    #[serde(default)]
680    author: String,
681    #[serde(default)]
682    homepage: String,
683    #[serde(default)]
684    description: String,
685    #[serde(default)]
686    preview: String,
687}
688
689/// The properties dialog: the human fields of theme.yaml, rewritten into the
690/// config with everything else (the form, the parent, the requirements) kept.
691async fn save_properties(
692    State(ctx): Ctx,
693    Extension(session): Extension<SessionHandle>,
694    Extension(user): Extension<AuthenticatedUser>,
695    Path(code): Path<String>,
696    Form(form): Form<PropertiesForm>,
697) -> Response {
698    let name = form.name.trim();
699    if name.is_empty() {
700        session.push_flash(FlashLevel::Error, t!("A theme needs a name."));
701        return back(&ctx, &code);
702    }
703    let theme = match store::find_theme(ctx.db(), &code).await {
704        Ok(Some(theme)) => theme,
705        Ok(None) => {
706            flash_error(&session, &ThemeError::NotFound(code.clone()));
707            return back(&ctx, "");
708        }
709        Err(e) => {
710            flash_error(&session, &e);
711            return back(&ctx, "");
712        }
713    };
714    let mut config = theme.config.clone();
715    config.name = name.to_string();
716    config.author = form.author.trim().to_string();
717    config.homepage = form.homepage.trim().to_string();
718    config.description = form.description.trim().to_string();
719    config.preview = Some(form.preview.trim().to_string()).filter(|p| !p.is_empty());
720    let yaml = match config.to_yaml() {
721        Ok(yaml) => yaml,
722        Err(e) => {
723            flash_error(&session, &e);
724            return back(&ctx, &code);
725        }
726    };
727    match store::update_theme_config(ctx.db(), &code, &yaml).await {
728        Ok(theme) => {
729            ctx.audit(
730                &user,
731                "web.theme.update",
732                Some("theme"),
733                Some(&theme.code),
734                Some(&theme.config.name),
735                None,
736            )
737            .await;
738            session.push_flash(FlashLevel::Success, t!("Theme properties saved."));
739        }
740        Err(e) => flash_error(&session, &e),
741    }
742    back(&ctx, &code)
743}
744
745async fn save_settings(
746    State(ctx): Ctx,
747    Extension(session): Extension<SessionHandle>,
748    Extension(user): Extension<AuthenticatedUser>,
749    Path(code): Path<String>,
750    Form(data): Form<std::collections::HashMap<String, String>>,
751) -> Response {
752    let chain = match store::chain(ctx.db(), &code).await {
753        Ok(chain) => chain,
754        Err(e) => {
755            flash_error(&session, &e);
756            return back(&ctx, "");
757        }
758    };
759    let fields = store::form_fields(&chain);
760    let collected = ctx
761        .value_form(&fields, &code)
762        .and_then(|form| form.collect(&data));
763    match collected {
764        Ok(values) => match store::save_settings(ctx.db(), &code, &values).await {
765            Ok(()) => {
766                ctx.audit(
767                    &user,
768                    "web.theme.settings",
769                    Some("theme"),
770                    Some(&code),
771                    Some(&chain[0].config.name),
772                    None,
773                )
774                .await;
775                session.push_flash(FlashLevel::Success, t!("Theme settings saved."));
776            }
777            Err(e) => flash_error(&session, &e),
778        },
779        Err(text) => session.push_flash(FlashLevel::Error, text),
780    }
781    back(&ctx, &code)
782}
783
784async fn activate(
785    State(ctx): Ctx,
786    Extension(session): Extension<SessionHandle>,
787    Extension(user): Extension<AuthenticatedUser>,
788    Path(code): Path<String>,
789) -> Response {
790    match super::set_active_theme(ctx.db(), &code).await {
791        Ok(()) => {
792            ctx.audit(
793                &user,
794                "web.theme.activate",
795                Some("theme"),
796                Some(&code),
797                None,
798                None,
799            )
800            .await;
801            session.push_flash(FlashLevel::Success, t!("The site now uses this theme."));
802        }
803        Err(e) => flash_error(&session, &e),
804    }
805    back(&ctx, "")
806}
807
808async fn edit(
809    State(ctx): Ctx,
810    Extension(session): Extension<SessionHandle>,
811    Extension(user): Extension<AuthenticatedUser>,
812    Path(code): Path<String>,
813) -> Response {
814    let current: Option<String> = super::edit_theme(ctx.db(), user.user.id)
815        .await
816        .ok()
817        .flatten()
818        .map(|t| t.code);
819    let active = super::active_theme(ctx.db())
820        .await
821        .ok()
822        .flatten()
823        .map(|t| t.code);
824    // Choosing the active theme clears the preference, so a later activation
825    // follows the operator along.
826    let choice =
827        if current.as_deref() == Some(code.as_str()) || active.as_deref() == Some(code.as_str()) {
828            None
829        } else {
830            Some(code.as_str())
831        };
832    let _ = EditTheme::default();
833    match super::set_edit_theme(ctx.db(), user.user.id, choice).await {
834        Ok(()) => session.push_flash(
835            FlashLevel::Success,
836            if choice.is_some() {
837                t!("You are now editing this theme.")
838            } else {
839                t!("You are now editing the site's theme.")
840            },
841        ),
842        Err(e) => flash_error(&session, &e),
843    }
844    back(&ctx, "")
845}
846
847#[derive(serde::Deserialize)]
848struct CopyForm {
849    new_code: String,
850    #[serde(default)]
851    name: String,
852}
853
854async fn duplicate(
855    State(ctx): Ctx,
856    Extension(session): Extension<SessionHandle>,
857    Extension(user): Extension<AuthenticatedUser>,
858    Path(code): Path<String>,
859    Form(form): Form<CopyForm>,
860) -> Response {
861    match store::duplicate_theme(ctx.db(), &code, form.new_code.trim()).await {
862        Ok(copy) => {
863            ctx.audit(
864                &user,
865                "web.theme.duplicate",
866                Some("theme"),
867                Some(&copy.code),
868                Some(&copy.config.name),
869                Some(&format!("{{\"from\":\"{code}\"}}")),
870            )
871            .await;
872            session.push_flash(FlashLevel::Success, t!("Theme duplicated."));
873            back(&ctx, &copy.code)
874        }
875        Err(e) => {
876            flash_error(&session, &e);
877            back(&ctx, "")
878        }
879    }
880}
881
882async fn child(
883    State(ctx): Ctx,
884    Extension(session): Extension<SessionHandle>,
885    Extension(user): Extension<AuthenticatedUser>,
886    Path(code): Path<String>,
887    Form(form): Form<CopyForm>,
888) -> Response {
889    let name = if form.name.trim().is_empty() {
890        form.new_code.trim().to_string()
891    } else {
892        form.name.trim().to_string()
893    };
894    match store::create_child_theme(ctx.db(), &code, form.new_code.trim(), &name).await {
895        Ok(child) => {
896            ctx.audit(
897                &user,
898                "web.theme.create",
899                Some("theme"),
900                Some(&child.code),
901                Some(&child.config.name),
902                Some(&format!("{{\"parent\":\"{code}\"}}")),
903            )
904            .await;
905            session.push_flash(FlashLevel::Success, t!("Child theme created."));
906            back(&ctx, &child.code)
907        }
908        Err(e) => {
909            flash_error(&session, &e);
910            back(&ctx, "")
911        }
912    }
913}
914
915#[derive(serde::Deserialize)]
916struct LockForm {
917    locked: i32,
918}
919
920async fn lock(
921    State(ctx): Ctx,
922    Extension(session): Extension<SessionHandle>,
923    Extension(user): Extension<AuthenticatedUser>,
924    Path(code): Path<String>,
925    Form(form): Form<LockForm>,
926) -> Response {
927    let locked = form.locked != 0;
928    match store::set_locked(ctx.db(), &code, locked).await {
929        Ok(()) => {
930            ctx.audit(
931                &user,
932                if locked {
933                    "web.theme.lock"
934                } else {
935                    "web.theme.unlock"
936                },
937                Some("theme"),
938                Some(&code),
939                None,
940                None,
941            )
942            .await;
943            session.push_flash(
944                FlashLevel::Success,
945                if locked {
946                    t!("Theme locked.")
947                } else {
948                    t!("Theme unlocked.")
949                },
950            );
951        }
952        Err(e) => flash_error(&session, &e),
953    }
954    back(&ctx, "")
955}
956
957async fn delete(
958    State(ctx): Ctx,
959    Extension(session): Extension<SessionHandle>,
960    Extension(user): Extension<AuthenticatedUser>,
961    Path(code): Path<String>,
962) -> Response {
963    match store::delete_theme(ctx.db(), &code).await {
964        Ok(()) => {
965            ctx.audit(
966                &user,
967                "web.theme.delete",
968                Some("theme"),
969                Some(&code),
970                None,
971                None,
972            )
973            .await;
974            session.push_flash(FlashLevel::Success, t!("Theme deleted."));
975        }
976        Err(e) => flash_error(&session, &e),
977    }
978    back(&ctx, "")
979}
980
981#[derive(serde::Deserialize)]
982struct NewTemplate {
983    kind: String,
984    path: String,
985}
986
987async fn create_template(
988    State(ctx): Ctx,
989    Extension(session): Extension<SessionHandle>,
990    Extension(user): Extension<AuthenticatedUser>,
991    Path(code): Path<String>,
992    Form(form): Form<NewTemplate>,
993) -> Response {
994    let theme = match store::find_theme(ctx.db(), &code).await {
995        Ok(Some(t)) => t,
996        Ok(None) => {
997            flash_error(&session, &ThemeError::NotFound(code.clone()));
998            return back(&ctx, "");
999        }
1000        Err(e) => {
1001            flash_error(&session, &e);
1002            return back(&ctx, "");
1003        }
1004    };
1005    let kind: Kind = match form.kind.parse() {
1006        Ok(k) => k,
1007        Err(e) => {
1008            flash_error(&session, &e);
1009            return back(&ctx, &code);
1010        }
1011    };
1012    let path = form.path.trim();
1013    if let Ok(Some(existing)) = store::find_template(ctx.db(), theme.id, kind, path).await {
1014        return back(&ctx, &format!("{code}/templates/{}", existing.id));
1015    }
1016    // A new page starts at the URL its name suggests, so it serves at once.
1017    let settings = (kind == Kind::Page)
1018        .then(|| serde_json::json!({ "url": format!("/{}", super::page::page_name(path)) }));
1019    match store::save_template(
1020        ctx.db(),
1021        theme.id,
1022        kind,
1023        path,
1024        settings,
1025        "",
1026        Some(user.user.id),
1027    )
1028    .await
1029    {
1030        Ok(t) => {
1031            ctx.audit(
1032                &user,
1033                "web.theme.template.create",
1034                Some("theme_template"),
1035                Some(&t.id.to_string()),
1036                Some(&engine::template_name(kind, path)),
1037                Some(&format!("{{\"theme\":\"{code}\"}}")),
1038            )
1039            .await;
1040            back(&ctx, &format!("{code}/templates/{}", t.id))
1041        }
1042        Err(e) => {
1043            flash_error(&session, &e);
1044            back(&ctx, &code)
1045        }
1046    }
1047}
1048
1049struct RevisionRow {
1050    id: i64,
1051    saved_at: String,
1052    saved_by: String,
1053}
1054
1055#[derive(askama::Template)]
1056#[template(path = "theme_template.html")]
1057struct EditorTemplate {
1058    shell: Shell,
1059    theme_url: String,
1060    template_url: String,
1061    path: String,
1062    locked: bool,
1063    settings: String,
1064    /// A page's settings as fields; none for every other kind.
1065    page_fields: Option<String>,
1066    markup: String,
1067    revisions: Vec<RevisionRow>,
1068    groups: Vec<KindGroup>,
1069    kinds: Vec<(&'static str, String)>,
1070}
1071
1072/// The controls beside a template's title.
1073#[derive(askama::Template)]
1074#[template(path = "theme_template_actions.html")]
1075struct TemplateActionsTemplate {
1076    shell: Shell,
1077    template_url: String,
1078    locked: bool,
1079}
1080
1081/// The template `id` under the theme `code`, or none: a template is reached
1082/// only through the theme that holds it.
1083async fn owned(ctx: &RouteCtx, code: &str, id: i64) -> Result<(Theme, Template), ThemeError> {
1084    let theme = store::find_theme(ctx.db(), code)
1085        .await?
1086        .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
1087    let template = store::template(ctx.db(), id)
1088        .await?
1089        .filter(|t| t.theme_id == theme.id)
1090        .ok_or_else(|| ThemeError::TemplateNotFound(id.to_string()))?;
1091    Ok((theme, template))
1092}
1093
1094fn revision_rows(shell: &Shell, revisions: Vec<Revision>) -> Vec<RevisionRow> {
1095    revisions
1096        .into_iter()
1097        .map(|r| RevisionRow {
1098            id: r.id,
1099            saved_at: shell.datetime(&r.saved_at),
1100            saved_by: r.saved_by.map(|id| format!("#{id}")).unwrap_or_default(),
1101        })
1102        .collect()
1103}
1104
1105async fn template(
1106    State(ctx): Ctx,
1107    Extension(shell): Extension<Shell>,
1108    Extension(session): Extension<SessionHandle>,
1109    Path((code, id)): Path<(String, i64)>,
1110) -> Response {
1111    let (theme, template) = match owned(&ctx, &code, id).await {
1112        Ok(pair) => pair,
1113        Err(e) => {
1114            flash_error(&session, &e);
1115            return back(&ctx, &code);
1116        }
1117    };
1118    let revisions = store::revisions(ctx.db(), template.id)
1119        .await
1120        .unwrap_or_default();
1121    let chain = store::chain(ctx.db(), &code).await.unwrap_or_default();
1122    let groups = groups(&ctx, &shell, &chain, Some(template.id))
1123        .await
1124        .unwrap_or_default();
1125    let kinds = Kind::ALL
1126        .into_iter()
1127        .map(|k| (k.as_str(), kind_label(&shell, k)))
1128        .collect();
1129    let page_fields = if template.kind == Kind::Page {
1130        let layouts = layouts_of(&ctx, &code).await.unwrap_or_default();
1131        match ctx.value_form(&page_fields(&layouts, &components_help(&ctx)), &code) {
1132            Ok(form) => Some(
1133                form.render(&bag_for_fields(&template.settings), &shell)
1134                    .into_string(),
1135            ),
1136            Err(text) => {
1137                session.push_flash(FlashLevel::Error, text);
1138                None
1139            }
1140        }
1141    } else {
1142        None
1143    };
1144    let template_url = ctx.url(&format!("{code}/templates/{id}"));
1145    let actions = Markup::from_template(&TemplateActionsTemplate {
1146        shell: shell.clone(),
1147        template_url: template_url.clone(),
1148        locked: theme.locked,
1149    })
1150    .unwrap_or_else(|_| Markup::empty());
1151    page_with(
1152        &shell,
1153        PageHead::new(Text::dynamic(template.path.clone()))
1154            .back(
1155                Text::dynamic(theme.config.name.clone()),
1156                ctx.url(&theme.code),
1157            )
1158            .actions(actions),
1159        &EditorTemplate {
1160            theme_url: ctx.url(&theme.code),
1161            template_url,
1162            path: engine::template_name(template.kind, &template.path),
1163            locked: theme.locked,
1164            settings: serde_json::to_string_pretty(&template.settings).unwrap_or_default(),
1165            page_fields,
1166            markup: template.markup.clone(),
1167            revisions: revision_rows(&shell, revisions),
1168            groups,
1169            kinds,
1170            shell: shell.clone(),
1171        },
1172    )
1173}
1174
1175async fn save_template(
1176    State(ctx): Ctx,
1177    Extension(session): Extension<SessionHandle>,
1178    Extension(user): Extension<AuthenticatedUser>,
1179    Path((code, id)): Path<(String, i64)>,
1180    Form(form): Form<std::collections::HashMap<String, String>>,
1181) -> Response {
1182    let here = format!("{code}/templates/{id}");
1183    let (theme, template) = match owned(&ctx, &code, id).await {
1184        Ok(pair) => pair,
1185        Err(e) => {
1186            flash_error(&session, &e);
1187            return back(&ctx, &code);
1188        }
1189    };
1190    // A page's settings arrive as fields, laid over the bag it has; any other
1191    // kind's arrive as the JSON box.
1192    let settings: serde_json::Value = if template.kind == Kind::Page {
1193        let layouts = layouts_of(&ctx, &code).await.unwrap_or_default();
1194        let collected = ctx
1195            .value_form(&page_fields(&layouts, &components_help(&ctx)), &code)
1196            .and_then(|f| f.collect(&form));
1197        match collected {
1198            Ok(serde_json::Value::Object(mut fields)) => {
1199                // The Components box is JSON text; empty means none.
1200                let text = fields
1201                    .remove("components")
1202                    .and_then(|v| v.as_str().map(str::to_string))
1203                    .unwrap_or_default();
1204                let components = if text.trim().is_empty() {
1205                    serde_json::json!({})
1206                } else {
1207                    match serde_json::from_str::<serde_json::Value>(&text) {
1208                        Ok(v @ serde_json::Value::Object(_)) => v,
1209                        _ => {
1210                            session.push_flash(
1211                                FlashLevel::Error,
1212                                t!("Components must be a JSON object keyed by alias."),
1213                            );
1214                            return back(&ctx, &here);
1215                        }
1216                    }
1217                };
1218                let mut bag = template.settings.as_object().cloned().unwrap_or_default();
1219                bag.extend(fields);
1220                bag.insert("components".into(), components);
1221                serde_json::Value::Object(bag)
1222            }
1223            Ok(_) => serde_json::json!({}),
1224            Err(text) => {
1225                session.push_flash(FlashLevel::Error, text);
1226                return back(&ctx, &here);
1227            }
1228        }
1229    } else {
1230        let raw = form.get("settings").map(String::as_str).unwrap_or("{}");
1231        match serde_json::from_str(raw.trim()) {
1232            Ok(v @ serde_json::Value::Object(_)) => v,
1233            Ok(_) => {
1234                session.push_flash(FlashLevel::Error, t!("The settings must be a JSON object."));
1235                return back(&ctx, &here);
1236            }
1237            Err(e) => {
1238                session.push_flash(
1239                    FlashLevel::Error,
1240                    Text::dynamic(format!("The settings do not parse: {e}")),
1241                );
1242                return back(&ctx, &here);
1243            }
1244        }
1245    };
1246    let markup = form
1247        .get("markup")
1248        .map(String::as_str)
1249        .unwrap_or_default()
1250        .replace("\r\n", "\n");
1251    if template.kind != Kind::Content {
1252        if let Err(e) = engine::check(&markup) {
1253            session.push_flash(FlashLevel::Error, Text::dynamic(e.to_string()));
1254            return back(&ctx, &here);
1255        }
1256    }
1257    if template.kind == Kind::Page {
1258        if let Err(e) = check_page(&ctx, &code, &settings).await {
1259            flash_error(&session, &e);
1260            return back(&ctx, &here);
1261        }
1262    }
1263    match store::save_template(
1264        ctx.db(),
1265        theme.id,
1266        template.kind,
1267        &template.path,
1268        Some(settings),
1269        &markup,
1270        Some(user.user.id),
1271    )
1272    .await
1273    {
1274        Ok(saved) => {
1275            ctx.audit(
1276                &user,
1277                "web.theme.template.save",
1278                Some("theme_template"),
1279                Some(&saved.id.to_string()),
1280                Some(&engine::template_name(saved.kind, &saved.path)),
1281                Some(&format!("{{\"theme\":\"{code}\"}}")),
1282            )
1283            .await;
1284            session.push_flash(FlashLevel::Success, t!("Template saved."));
1285        }
1286        Err(e) => flash_error(&session, &e),
1287    }
1288    back(&ctx, &here)
1289}
1290
1291async fn delete_template(
1292    State(ctx): Ctx,
1293    Extension(session): Extension<SessionHandle>,
1294    Extension(user): Extension<AuthenticatedUser>,
1295    Path((code, id)): Path<(String, i64)>,
1296) -> Response {
1297    let (_, template) = match owned(&ctx, &code, id).await {
1298        Ok(pair) => pair,
1299        Err(e) => {
1300            flash_error(&session, &e);
1301            return back(&ctx, &code);
1302        }
1303    };
1304    match store::delete_template(ctx.db(), id).await {
1305        Ok(()) => {
1306            ctx.audit(
1307                &user,
1308                "web.theme.template.delete",
1309                Some("theme_template"),
1310                Some(&id.to_string()),
1311                Some(&engine::template_name(template.kind, &template.path)),
1312                Some(&format!("{{\"theme\":\"{code}\"}}")),
1313            )
1314            .await;
1315            session.push_flash(FlashLevel::Success, t!("Template deleted."));
1316            back(&ctx, &code)
1317        }
1318        Err(e) => {
1319            flash_error(&session, &e);
1320            back(&ctx, &format!("{code}/templates/{id}"))
1321        }
1322    }
1323}
1324
1325#[derive(serde::Deserialize)]
1326struct RestoreForm {
1327    revision: i64,
1328}
1329
1330async fn restore(
1331    State(ctx): Ctx,
1332    Extension(session): Extension<SessionHandle>,
1333    Extension(user): Extension<AuthenticatedUser>,
1334    Path((code, id)): Path<(String, i64)>,
1335    Form(form): Form<RestoreForm>,
1336) -> Response {
1337    let here = format!("{code}/templates/{id}");
1338    if let Err(e) = owned(&ctx, &code, id).await {
1339        flash_error(&session, &e);
1340        return back(&ctx, &code);
1341    }
1342    let belongs = store::revisions(ctx.db(), id)
1343        .await
1344        .map(|revs| revs.iter().any(|r| r.id == form.revision))
1345        .unwrap_or(false);
1346    if !belongs {
1347        flash_error(
1348            &session,
1349            &ThemeError::TemplateNotFound(form.revision.to_string()),
1350        );
1351        return back(&ctx, &here);
1352    }
1353    match store::restore_revision(ctx.db(), form.revision, Some(user.user.id)).await {
1354        Ok(saved) => {
1355            ctx.audit(
1356                &user,
1357                "web.theme.template.restore",
1358                Some("theme_template"),
1359                Some(&saved.id.to_string()),
1360                Some(&engine::template_name(saved.kind, &saved.path)),
1361                Some(&format!("{{\"revision\":{}}}", form.revision)),
1362            )
1363            .await;
1364            session.push_flash(FlashLevel::Success, t!("Revision restored."));
1365        }
1366        Err(e) => flash_error(&session, &e),
1367    }
1368    back(&ctx, &here)
1369}