Skip to main content

laterite_web/theme/
store.rs

1//! Queries over the theme tables.
2//!
3//! A write that an operator makes from the admin is audited there; this layer
4//! keeps the revision that lets a template save be undone, and refuses what the
5//! rows forbid: a locked theme, a parent cycle, deleting the active theme.
6
7use super::schema::{ThemeTemplateRevisions, ThemeTemplates, Themes};
8use super::{valid_code, valid_path, Kind, Revision, Template, Theme, ThemeConfig, ThemeError};
9use laterite_core::query::{bind_values, build, insert_returning_id};
10use laterite_core::{AnyRowExt, Db};
11use sea_query::{Expr, Order, Query};
12use sqlx::any::AnyRow;
13
14fn now() -> String {
15    chrono::Utc::now().to_rfc3339()
16}
17
18fn theme_from_row(row: &AnyRow) -> Result<Theme, ThemeError> {
19    let config_source = row.get_text("config")?;
20    let settings = row
21        .get_text_opt("settings")?
22        .and_then(|text| serde_json::from_str(&text).ok())
23        .filter(serde_json::Value::is_object)
24        .unwrap_or_else(|| serde_json::json!({}));
25    Ok(Theme {
26        id: row.get_int("id")?,
27        code: row.get_text("code")?,
28        parent: row.get_text_opt("parent")?,
29        locked: row.get_bool("locked")?,
30        config: ThemeConfig::parse(&config_source)?,
31        config_source,
32        settings,
33        created_at: row.get_text("created_at")?,
34        updated_at: row.get_text("updated_at")?,
35    })
36}
37
38fn template_from_row(row: &AnyRow) -> Result<Template, ThemeError> {
39    let settings = row.get_text("settings")?;
40    Ok(Template {
41        id: row.get_int("id")?,
42        theme_id: row.get_int("theme_id")?,
43        kind: row.get_text("kind")?.parse()?,
44        path: row.get_text("path")?,
45        settings: serde_json::from_str(&settings).unwrap_or(serde_json::Value::Null),
46        markup: row.get_text("markup")?,
47        created_at: row.get_text("created_at")?,
48        updated_at: row.get_text("updated_at")?,
49    })
50}
51
52fn revision_from_row(row: &AnyRow) -> Result<Revision, ThemeError> {
53    let settings = row.get_text("settings")?;
54    Ok(Revision {
55        id: row.get_int("id")?,
56        template_id: row.get_int("template_id")?,
57        settings: serde_json::from_str(&settings).unwrap_or(serde_json::Value::Null),
58        markup: row.get_text("markup")?,
59        saved_at: row.get_text("saved_at")?,
60        saved_by: row.get_int_opt("saved_by")?,
61    })
62}
63
64const THEME_COLUMNS: [Themes; 8] = [
65    Themes::Id,
66    Themes::Code,
67    Themes::Parent,
68    Themes::Locked,
69    Themes::Config,
70    Themes::Settings,
71    Themes::CreatedAt,
72    Themes::UpdatedAt,
73];
74
75const TEMPLATE_COLUMNS: [ThemeTemplates; 8] = [
76    ThemeTemplates::Id,
77    ThemeTemplates::ThemeId,
78    ThemeTemplates::Kind,
79    ThemeTemplates::Path,
80    ThemeTemplates::Settings,
81    ThemeTemplates::Markup,
82    ThemeTemplates::CreatedAt,
83    ThemeTemplates::UpdatedAt,
84];
85
86/// Every theme, by code.
87pub async fn list_themes(db: &Db) -> Result<Vec<Theme>, ThemeError> {
88    let (sql, values) = build(
89        db.backend,
90        Query::select()
91            .columns(THEME_COLUMNS)
92            .from(Themes::Table)
93            .order_by(Themes::Code, Order::Asc)
94            .to_owned(),
95    );
96    let rows = bind_values(sqlx::query(&sql), values)
97        .fetch_all(&db.pool)
98        .await?;
99    rows.iter().map(theme_from_row).collect()
100}
101
102/// The theme with this code, if any.
103pub async fn find_theme(db: &Db, code: &str) -> Result<Option<Theme>, ThemeError> {
104    let (sql, values) = build(
105        db.backend,
106        Query::select()
107            .columns(THEME_COLUMNS)
108            .from(Themes::Table)
109            .and_where(Expr::col(Themes::Code).eq(code))
110            .to_owned(),
111    );
112    let row = bind_values(sqlx::query(&sql), values)
113        .fetch_optional(&db.pool)
114        .await?;
115    row.as_ref().map(theme_from_row).transpose()
116}
117
118async fn require_theme(db: &Db, code: &str) -> Result<Theme, ThemeError> {
119    find_theme(db, code)
120        .await?
121        .ok_or_else(|| ThemeError::NotFound(code.to_string()))
122}
123
124/// Refuses a parent that does not exist or that leads back to `code`.
125async fn check_parent(db: &Db, code: &str, parent: Option<&str>) -> Result<(), ThemeError> {
126    let Some(parent) = parent else {
127        return Ok(());
128    };
129    let mut next = Some(parent.to_string());
130    let mut hops = 0;
131    while let Some(current) = next {
132        if current == code {
133            return Err(ThemeError::Parent(format!(
134                "the parent {parent:?} leads back to {code:?}"
135            )));
136        }
137        hops += 1;
138        if hops > 32 {
139            return Err(ThemeError::Parent(format!(
140                "the parent chain from {parent:?} is too long"
141            )));
142        }
143        let theme = find_theme(db, &current)
144            .await?
145            .ok_or_else(|| ThemeError::Parent(format!("no parent theme {current:?}")))?;
146        next = theme.parent;
147    }
148    Ok(())
149}
150
151/// Creates a theme from its code and YAML config.
152pub async fn create_theme(db: &Db, code: &str, config_yaml: &str) -> Result<Theme, ThemeError> {
153    if !valid_code(code) {
154        return Err(ThemeError::Code(code.to_string()));
155    }
156    let config = ThemeConfig::parse(config_yaml)?;
157    if find_theme(db, code).await?.is_some() {
158        return Err(ThemeError::Exists(code.to_string()));
159    }
160    check_parent(db, code, config.parent.as_deref()).await?;
161    let now = now();
162    let insert = Query::insert()
163        .into_table(Themes::Table)
164        .columns([
165            Themes::Code,
166            Themes::Parent,
167            Themes::Locked,
168            Themes::Config,
169            Themes::CreatedAt,
170            Themes::UpdatedAt,
171        ])
172        .values_panic([
173            code.into(),
174            config.parent.clone().into(),
175            false.into(),
176            config_yaml.into(),
177            now.clone().into(),
178            now.into(),
179        ])
180        .to_owned();
181    insert_returning_id(db, insert, Themes::Id).await?;
182    require_theme(db, code).await
183}
184
185/// Replaces a theme's YAML config; the parent column follows it.
186pub async fn update_theme_config(
187    db: &Db,
188    code: &str,
189    config_yaml: &str,
190) -> Result<Theme, ThemeError> {
191    let theme = require_theme(db, code).await?;
192    if theme.locked {
193        return Err(ThemeError::Locked(code.to_string()));
194    }
195    let config = ThemeConfig::parse(config_yaml)?;
196    check_parent(db, code, config.parent.as_deref()).await?;
197    let (sql, values) = build(
198        db.backend,
199        Query::update()
200            .table(Themes::Table)
201            .value(Themes::Config, config_yaml)
202            .value(Themes::Parent, config.parent.clone())
203            .value(Themes::UpdatedAt, now())
204            .and_where(Expr::col(Themes::Id).eq(theme.id))
205            .to_owned(),
206    );
207    bind_values(sqlx::query(&sql), values)
208        .execute(&db.pool)
209        .await?;
210    require_theme(db, code).await
211}
212
213/// Replaces a theme's settings values. `settings` must be a JSON object.
214pub async fn save_settings(
215    db: &Db,
216    code: &str,
217    settings: &serde_json::Value,
218) -> Result<(), ThemeError> {
219    if !settings.is_object() {
220        return Err(ThemeError::Config(
221            "the settings must be a JSON object".into(),
222        ));
223    }
224    let theme = require_theme(db, code).await?;
225    if theme.locked {
226        return Err(ThemeError::Locked(code.to_string()));
227    }
228    let (sql, values) = build(
229        db.backend,
230        Query::update()
231            .table(Themes::Table)
232            .value(Themes::Settings, settings.to_string())
233            .value(Themes::UpdatedAt, now())
234            .and_where(Expr::col(Themes::Id).eq(theme.id))
235            .to_owned(),
236    );
237    bind_values(sqlx::query(&sql), values)
238        .execute(&db.pool)
239        .await?;
240    Ok(())
241}
242
243/// The settings form in force for a chain (nearest first): the first theme
244/// that declares one, as the config falls through to the parent.
245pub fn form_fields(chain: &[Theme]) -> Vec<laterite_admin::form::FormField> {
246    chain
247        .iter()
248        .find_map(|t| t.config.form.as_ref())
249        .map(|f| f.fields.clone())
250        .unwrap_or_default()
251}
252
253/// Locks or unlocks a theme.
254pub async fn set_locked(db: &Db, code: &str, locked: bool) -> Result<(), ThemeError> {
255    let theme = require_theme(db, code).await?;
256    let (sql, values) = build(
257        db.backend,
258        Query::update()
259            .table(Themes::Table)
260            .value(Themes::Locked, locked)
261            .value(Themes::UpdatedAt, now())
262            .and_where(Expr::col(Themes::Id).eq(theme.id))
263            .to_owned(),
264    );
265    bind_values(sqlx::query(&sql), values)
266        .execute(&db.pool)
267        .await?;
268    Ok(())
269}
270
271/// The codes of the themes whose parent is `code`.
272pub async fn children(db: &Db, code: &str) -> Result<Vec<String>, ThemeError> {
273    let (sql, values) = build(
274        db.backend,
275        Query::select()
276            .column(Themes::Code)
277            .from(Themes::Table)
278            .and_where(Expr::col(Themes::Parent).eq(code))
279            .order_by(Themes::Code, Order::Asc)
280            .to_owned(),
281    );
282    let rows = bind_values(sqlx::query(&sql), values)
283        .fetch_all(&db.pool)
284        .await?;
285    rows.iter().map(|r| Ok(r.get_text("code")?)).collect()
286}
287
288/// Deletes a theme and its templates. Refuses the active theme, a locked
289/// theme, and a theme other themes name as their parent.
290pub async fn delete_theme(db: &Db, code: &str) -> Result<(), ThemeError> {
291    let theme = require_theme(db, code).await?;
292    if theme.locked {
293        return Err(ThemeError::Locked(code.to_string()));
294    }
295    let settings: super::ThemeSettings = laterite_admin::settings::load(db).await?;
296    if settings.active.as_deref() == Some(code) {
297        return Err(ThemeError::Active(code.to_string()));
298    }
299    let dependants = children(db, code).await?;
300    if !dependants.is_empty() {
301        return Err(ThemeError::Parent(format!(
302            "{code:?} is the parent of {}",
303            dependants.join(", ")
304        )));
305    }
306    let (sql, values) = build(
307        db.backend,
308        Query::delete()
309            .from_table(Themes::Table)
310            .and_where(Expr::col(Themes::Id).eq(theme.id))
311            .to_owned(),
312    );
313    bind_values(sqlx::query(&sql), values)
314        .execute(&db.pool)
315        .await?;
316    Ok(())
317}
318
319/// Copies a theme, templates included, under a new code. The copy is unlocked
320/// and keeps the original's parent.
321pub async fn duplicate_theme(db: &Db, code: &str, new_code: &str) -> Result<Theme, ThemeError> {
322    let source = require_theme(db, code).await?;
323    let copy = create_theme(db, new_code, &source.config_source).await?;
324    for t in list_templates(db, source.id, None).await? {
325        save_template(
326            db,
327            copy.id,
328            t.kind,
329            &t.path,
330            Some(t.settings),
331            &t.markup,
332            None,
333        )
334        .await?;
335    }
336    Ok(copy)
337}
338
339/// Creates an empty theme whose parent is `code`, named `name`.
340pub async fn create_child_theme(
341    db: &Db,
342    code: &str,
343    new_code: &str,
344    name: &str,
345) -> Result<Theme, ThemeError> {
346    let parent = require_theme(db, code).await?;
347    let config = ThemeConfig {
348        name: name.to_string(),
349        author: parent.config.author.clone(),
350        homepage: parent.config.homepage.clone(),
351        description: String::new(),
352        preview: None,
353        parent: Some(code.to_string()),
354        require: parent.config.require.clone(),
355        form: None,
356    };
357    create_theme(db, new_code, &config.to_yaml()?).await
358}
359
360/// A theme's own templates, by kind and path, or those of one kind.
361pub async fn list_templates(
362    db: &Db,
363    theme_id: i64,
364    kind: Option<Kind>,
365) -> Result<Vec<Template>, ThemeError> {
366    // Built and rendered in one block: a statement builder is not `Send`, and
367    // one left in scope across the await makes the whole future not `Send`.
368    let (sql, values) = {
369        let mut query = Query::select()
370            .columns(TEMPLATE_COLUMNS)
371            .from(ThemeTemplates::Table)
372            .and_where(Expr::col(ThemeTemplates::ThemeId).eq(theme_id))
373            .order_by(ThemeTemplates::Kind, Order::Asc)
374            .order_by(ThemeTemplates::Path, Order::Asc)
375            .to_owned();
376        if let Some(kind) = kind {
377            query.and_where(Expr::col(ThemeTemplates::Kind).eq(kind.as_str()));
378        }
379        build(db.backend, query)
380    };
381    let rows = bind_values(sqlx::query(&sql), values)
382        .fetch_all(&db.pool)
383        .await?;
384    rows.iter().map(template_from_row).collect()
385}
386
387/// A theme's own template at `path`, if any.
388pub async fn find_template(
389    db: &Db,
390    theme_id: i64,
391    kind: Kind,
392    path: &str,
393) -> Result<Option<Template>, ThemeError> {
394    let (sql, values) = build(
395        db.backend,
396        Query::select()
397            .columns(TEMPLATE_COLUMNS)
398            .from(ThemeTemplates::Table)
399            .and_where(Expr::col(ThemeTemplates::ThemeId).eq(theme_id))
400            .and_where(Expr::col(ThemeTemplates::Kind).eq(kind.as_str()))
401            .and_where(Expr::col(ThemeTemplates::Path).eq(path))
402            .to_owned(),
403    );
404    let row = bind_values(sqlx::query(&sql), values)
405        .fetch_optional(&db.pool)
406        .await?;
407    row.as_ref().map(template_from_row).transpose()
408}
409
410/// A template by id.
411pub async fn template(db: &Db, id: i64) -> Result<Option<Template>, ThemeError> {
412    let (sql, values) = build(
413        db.backend,
414        Query::select()
415            .columns(TEMPLATE_COLUMNS)
416            .from(ThemeTemplates::Table)
417            .and_where(Expr::col(ThemeTemplates::Id).eq(id))
418            .to_owned(),
419    );
420    let row = bind_values(sqlx::query(&sql), values)
421        .fetch_optional(&db.pool)
422        .await?;
423    row.as_ref().map(template_from_row).transpose()
424}
425
426/// The template at `path` for the theme `code`: its own row, else the nearest
427/// ancestor's. The theme it came from is returned with it.
428pub async fn resolve_template(
429    db: &Db,
430    code: &str,
431    kind: Kind,
432    path: &str,
433) -> Result<Option<(Theme, Template)>, ThemeError> {
434    let mut theme = require_theme(db, code).await?;
435    let mut hops = 0;
436    loop {
437        if let Some(t) = find_template(db, theme.id, kind, path).await? {
438            return Ok(Some((theme, t)));
439        }
440        let Some(parent) = theme.parent.clone() else {
441            return Ok(None);
442        };
443        hops += 1;
444        if hops > 32 {
445            return Ok(None);
446        }
447        theme = match find_theme(db, &parent).await? {
448            Some(p) => p,
449            None => return Ok(None),
450        };
451    }
452}
453
454/// A theme and its ancestors, nearest first: the theme, its parent, the
455/// parent's parent. A missing ancestor ends the chain; a cycle is cut at 32.
456pub async fn chain(db: &Db, code: &str) -> Result<Vec<Theme>, ThemeError> {
457    let mut out = vec![require_theme(db, code).await?];
458    while let Some(parent) = out.last().and_then(|t| t.parent.clone()) {
459        if out.len() > 32 {
460            break;
461        }
462        match find_theme(db, &parent).await? {
463            Some(p) => out.push(p),
464            None => break,
465        }
466    }
467    Ok(out)
468}
469
470async fn theme_by_id(db: &Db, id: i64) -> Result<Theme, ThemeError> {
471    let (sql, values) = build(
472        db.backend,
473        Query::select()
474            .columns(THEME_COLUMNS)
475            .from(Themes::Table)
476            .and_where(Expr::col(Themes::Id).eq(id))
477            .to_owned(),
478    );
479    let row = bind_values(sqlx::query(&sql), values)
480        .fetch_optional(&db.pool)
481        .await?;
482    match row {
483        Some(r) => theme_from_row(&r),
484        None => Err(ThemeError::NotFound(id.to_string())),
485    }
486}
487
488fn touch_theme(
489    db: &Db,
490    id: i64,
491) -> impl std::future::Future<Output = Result<(), sqlx::Error>> + Send + '_ {
492    let (sql, values) = build(
493        db.backend,
494        Query::update()
495            .table(Themes::Table)
496            .value(Themes::UpdatedAt, now())
497            .and_where(Expr::col(Themes::Id).eq(id))
498            .to_owned(),
499    );
500    async move {
501        bind_values(sqlx::query(&sql), values)
502            .execute(&db.pool)
503            .await?;
504        Ok(())
505    }
506}
507
508/// Creates or replaces the template at `path`. `settings` of `None` keeps the
509/// current bag (an empty object for a new template). A replaced template's
510/// previous content is kept as a revision attributed to `saved_by`. Refused for
511/// a locked theme.
512pub async fn save_template(
513    db: &Db,
514    theme_id: i64,
515    kind: Kind,
516    path: &str,
517    settings: Option<serde_json::Value>,
518    markup: &str,
519    saved_by: Option<i64>,
520) -> Result<Template, ThemeError> {
521    if !valid_path(path) {
522        return Err(ThemeError::Path(path.to_string()));
523    }
524    let theme = theme_by_id(db, theme_id).await?;
525    if theme.locked {
526        return Err(ThemeError::Locked(theme.code));
527    }
528    let now = now();
529    let id = match find_template(db, theme_id, kind, path).await? {
530        Some(current) => {
531            record_revision(db, &current, &now, saved_by).await?;
532            let settings = settings.unwrap_or(current.settings);
533            let (sql, values) = build(
534                db.backend,
535                Query::update()
536                    .table(ThemeTemplates::Table)
537                    .value(ThemeTemplates::Settings, settings.to_string())
538                    .value(ThemeTemplates::Markup, markup)
539                    .value(ThemeTemplates::UpdatedAt, now.clone())
540                    .and_where(Expr::col(ThemeTemplates::Id).eq(current.id))
541                    .to_owned(),
542            );
543            bind_values(sqlx::query(&sql), values)
544                .execute(&db.pool)
545                .await?;
546            current.id
547        }
548        None => {
549            let settings = settings.unwrap_or_else(|| serde_json::json!({}));
550            let insert = Query::insert()
551                .into_table(ThemeTemplates::Table)
552                .columns([
553                    ThemeTemplates::ThemeId,
554                    ThemeTemplates::Kind,
555                    ThemeTemplates::Path,
556                    ThemeTemplates::Settings,
557                    ThemeTemplates::Markup,
558                    ThemeTemplates::CreatedAt,
559                    ThemeTemplates::UpdatedAt,
560                ])
561                .values_panic([
562                    theme_id.into(),
563                    kind.as_str().into(),
564                    path.into(),
565                    settings.to_string().into(),
566                    markup.into(),
567                    now.clone().into(),
568                    now.clone().into(),
569                ])
570                .to_owned();
571            insert_returning_id(db, insert, ThemeTemplates::Id).await?
572        }
573    };
574    touch_theme(db, theme_id).await?;
575    template(db, id)
576        .await?
577        .ok_or_else(|| ThemeError::TemplateNotFound(path.to_string()))
578}
579
580async fn record_revision(
581    db: &Db,
582    current: &Template,
583    saved_at: &str,
584    saved_by: Option<i64>,
585) -> Result<(), ThemeError> {
586    let insert = Query::insert()
587        .into_table(ThemeTemplateRevisions::Table)
588        .columns([
589            ThemeTemplateRevisions::TemplateId,
590            ThemeTemplateRevisions::Settings,
591            ThemeTemplateRevisions::Markup,
592            ThemeTemplateRevisions::SavedAt,
593            ThemeTemplateRevisions::SavedBy,
594        ])
595        .values_panic([
596            current.id.into(),
597            current.settings.to_string().into(),
598            current.markup.clone().into(),
599            saved_at.into(),
600            saved_by.into(),
601        ])
602        .to_owned();
603    insert_returning_id(db, insert, ThemeTemplateRevisions::Id).await?;
604    Ok(())
605}
606
607/// Deletes a template and its revisions. Refused for a locked theme.
608pub async fn delete_template(db: &Db, id: i64) -> Result<(), ThemeError> {
609    let Some(current) = template(db, id).await? else {
610        return Err(ThemeError::TemplateNotFound(id.to_string()));
611    };
612    let theme = theme_by_id(db, current.theme_id).await?;
613    if theme.locked {
614        return Err(ThemeError::Locked(theme.code));
615    }
616    let (sql, values) = build(
617        db.backend,
618        Query::delete()
619            .from_table(ThemeTemplates::Table)
620            .and_where(Expr::col(ThemeTemplates::Id).eq(id))
621            .to_owned(),
622    );
623    bind_values(sqlx::query(&sql), values)
624        .execute(&db.pool)
625        .await?;
626    touch_theme(db, theme.id).await?;
627    Ok(())
628}
629
630/// A template's revisions, newest first.
631pub async fn revisions(db: &Db, template_id: i64) -> Result<Vec<Revision>, ThemeError> {
632    let (sql, values) = build(
633        db.backend,
634        Query::select()
635            .columns([
636                ThemeTemplateRevisions::Id,
637                ThemeTemplateRevisions::TemplateId,
638                ThemeTemplateRevisions::Settings,
639                ThemeTemplateRevisions::Markup,
640                ThemeTemplateRevisions::SavedAt,
641                ThemeTemplateRevisions::SavedBy,
642            ])
643            .from(ThemeTemplateRevisions::Table)
644            .and_where(Expr::col(ThemeTemplateRevisions::TemplateId).eq(template_id))
645            .order_by(ThemeTemplateRevisions::Id, Order::Desc)
646            .to_owned(),
647    );
648    let rows = bind_values(sqlx::query(&sql), values)
649        .fetch_all(&db.pool)
650        .await?;
651    rows.iter().map(revision_from_row).collect()
652}
653
654/// Puts a revision's content back as the template's current content. The
655/// content it replaces becomes a revision in turn.
656pub async fn restore_revision(
657    db: &Db,
658    revision_id: i64,
659    saved_by: Option<i64>,
660) -> Result<Template, ThemeError> {
661    let (sql, values) = build(
662        db.backend,
663        Query::select()
664            .columns([
665                ThemeTemplateRevisions::Id,
666                ThemeTemplateRevisions::TemplateId,
667                ThemeTemplateRevisions::Settings,
668                ThemeTemplateRevisions::Markup,
669                ThemeTemplateRevisions::SavedAt,
670                ThemeTemplateRevisions::SavedBy,
671            ])
672            .from(ThemeTemplateRevisions::Table)
673            .and_where(Expr::col(ThemeTemplateRevisions::Id).eq(revision_id))
674            .to_owned(),
675    );
676    let row = bind_values(sqlx::query(&sql), values)
677        .fetch_optional(&db.pool)
678        .await?;
679    let revision = match row {
680        Some(r) => revision_from_row(&r)?,
681        None => return Err(ThemeError::TemplateNotFound(revision_id.to_string())),
682    };
683    let current = template(db, revision.template_id)
684        .await?
685        .ok_or_else(|| ThemeError::TemplateNotFound(revision.template_id.to_string()))?;
686    save_template(
687        db,
688        current.theme_id,
689        current.kind,
690        &current.path,
691        Some(revision.settings),
692        &revision.markup,
693        saved_by,
694    )
695    .await
696}