1use 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
18pub const PERMISSION: &str = "web.manage_themes";
20
21pub fn permission() -> Permission {
23 Permission::new(PERMISSION, t!("Manage themes"), t!("Website")).roles([ROLE_ADMIN])
24}
25
26pub 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
33pub 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
82struct 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 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
186const 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
214struct 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
327struct 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 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
365async 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 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(©.code),
613 Some(©.config.name),
614 Some(&format!("{{\"from\":\"{code}\"}}")),
615 )
616 .await;
617 session.push_flash(FlashLevel::Success, t!("Theme duplicated."));
618 back(&ctx, ©.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
800async 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}