cadmus 0.1.4

A batteries-included web framework for Rust: models, admin site, REST API, migrations and accounts, on axum and SeaORM
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! Cadmus: a batteries-included web framework for axum and SeaORM.
//!
//! Register SeaORM entities as models and the crate serves admin pages (list, search,
//! create, edit, delete) and REST endpoints for each one, behind accounts with permissions,
//! and keeps their tables up to date with migrations. A project is configured with one
//! [`Settings`] value (see [`settings`]) and [`App::from_settings`]; the pieces below can also
//! be put together by hand. Look and feel
//! ([`UiSettings`]) and authentication ([`AuthConfig`], [`EnvUserStore`]) can be configured
//! entirely from environment variables.
//!
//! ```ignore
//! let admin = Admin::builder()
//!     .settings(UiSettings::from_env()?)
//!     .auth(AuthConfig::from_env()?, EnvUserStore::from_env()?)
//!     .register(SeaOrmResource::<post::Entity>::new(db.clone()))
//!     .build()?;
//! let app = axum::Router::new().merge(admin.router());
//! ```

mod access;
mod accounts;
mod api;
mod app;
mod auth;
mod docs;
mod error;
mod handlers;
mod home;
mod limit;
mod login;
mod mail;
pub mod migrations;
mod oauth;
mod resource;
mod seaorm;
mod server;
pub mod settings;
mod storage;
mod uploads;
mod users;

use std::sync::Arc;

pub use access::{Access, Action};
pub use app::App;
pub use auth::{
    AdminUser, ApiKeyOwner, AuthConfig, EnvUserStore, UserStore, hash_password,
    hash_password_async, totp_uri, verify_totp,
};
pub use docs::DocsConfig;
pub use error::{AdminError, ApiError, ApiResult};
pub use home::HomeConfig;
pub use mail::{Email, LogMailer, Mailer, SmtpMailer};
pub use resource::{
    Field, FieldKind, Filter, FilterOp, Input, LinkField, ListQuery, ModelOptions, Page, Reference,
    Resource, Row, WriteMode,
};
pub use seaorm::{SeaOrmResource, invalid};
pub use settings::{NavLink, Settings, Theme, UiSettings};
pub use storage::{Sink, Storage};
pub use uploads::{Progress, Uploads, file, session};
pub use users::{
    DbUserStore, change_password, create_superuser, disable_totp, enable_totp, group, identity,
    new_api_key, new_recovery_codes, promote, set_email, user,
};

/// Template names and their built-in sources.
const TEMPLATES: [(&str, &str); 16] = [
    ("base.html", include_str!("../templates/base.html")),
    ("login.html", include_str!("../templates/login.html")),
    ("index.html", include_str!("../templates/index.html")),
    ("list.html", include_str!("../templates/list.html")),
    ("form.html", include_str!("../templates/form.html")),
    ("error.html", include_str!("../templates/error.html")),
    ("home.html", include_str!("../templates/home.html")),
    (
        "permissions.html",
        include_str!("../templates/permissions.html"),
    ),
    (
        "accounts_list.html",
        include_str!("../templates/accounts_list.html"),
    ),
    ("api_keys.html", include_str!("../templates/api_keys.html")),
    (
        "user_form.html",
        include_str!("../templates/user_form.html"),
    ),
    ("password.html", include_str!("../templates/password.html")),
    (
        "group_form.html",
        include_str!("../templates/group_form.html"),
    ),
    (
        "two_factor.html",
        include_str!("../templates/two_factor.html"),
    ),
    (
        "docs_swagger.html",
        include_str!("../templates/docs_swagger.html"),
    ),
    (
        "docs_redoc.html",
        include_str!("../templates/docs_redoc.html"),
    ),
];

/// URL segments the admin uses itself; a model cannot take these as its slug.
const RESERVED_SLUGS: [&str; 10] = [
    "api",
    "auth",
    "docs",
    "files",
    "login",
    "logout",
    "openapi.json",
    "password",
    "redoc",
    "uploads",
];

pub(crate) struct AdminState {
    pub(crate) base: String,
    pub(crate) api_base: String,
    pub(crate) settings: UiSettings,
    pub(crate) auth: AuthConfig,
    pub(crate) limiter: limit::LoginLimiter,
    pub(crate) users: Arc<dyn UserStore>,
    pub(crate) resources: Vec<Arc<dyn Resource>>,
    pub(crate) uploads: Option<Uploads>,
    /// Where the built-in account tables live; `None` when accounts come from elsewhere.
    pub(crate) accounts: Option<sea_orm::DatabaseConnection>,
    /// The API documentation, unless it is turned off.
    pub(crate) docs: Option<docs::Docs>,
    pub(crate) templates: minijinja::Environment<'static>,
    /// Sends login links and password resets.
    pub(crate) mailer: Arc<dyn mail::Mailer>,
}

impl AdminState {
    pub(crate) fn resource(&self, slug: &str) -> Result<&Arc<dyn Resource>, AdminError> {
        self.resources
            .iter()
            .find(|r| r.slug() == slug)
            .ok_or_else(|| AdminError::NotFound(format!("model `{slug}`")))
    }
}

/// A configured admin panel. Create with [`Admin::builder`], mount with [`Admin::router`].
#[derive(Clone)]
pub struct Admin {
    pub(crate) state: Arc<AdminState>,
}

impl Admin {
    /// Starts building an admin.
    #[must_use]
    pub fn builder() -> AdminBuilder {
        AdminBuilder::default()
    }

    /// Routes for the panel, already prefixed with the base path; `merge` it into your app.
    ///
    /// Errors from these routes are JSON under the API path and pages under the admin path.
    /// To get the same for routes of your own, and to turn panics into `500`s, pass the
    /// merged router through [`Admin::handle_errors`].
    pub fn router(&self) -> axum::Router {
        self.handle_errors(self.routes())
    }

    /// Adds the central error handling to `router`: panics become `500`s, and error
    /// responses (including unknown URLs and wrong methods) are JSON under the API path and
    /// pages under the admin path. Responses outside both paths are left alone.
    pub fn handle_errors(&self, router: axum::Router) -> axum::Router {
        router.layer(axum::middleware::from_fn_with_state(
            Arc::clone(&self.state),
            error::middleware,
        ))
    }

    pub(crate) fn routes(&self) -> axum::Router {
        let router = self.page_routes();
        match self.upload_routes() {
            Some(uploads) => router.merge(uploads),
            None => router,
        }
    }

    /// The OpenAPI description of the REST API, as served at `openapi.json`; `None` if the
    /// documentation is turned off.
    #[must_use]
    pub fn openapi(&self) -> Option<&serde_json::Value> {
        self.state.docs.as_ref().map(docs::Docs::document)
    }

    /// Everything except file transfers: requests that should be answered quickly.
    pub(crate) fn page_routes(&self) -> axum::Router {
        let mut router =
            handlers::router(Arc::clone(&self.state)).merge(api::router(Arc::clone(&self.state)));
        if self.state.docs.is_some() {
            router = router.merge(docs::router(Arc::clone(&self.state)));
        }
        if !self.state.auth.methods.providers.is_empty() {
            router = router.merge(oauth::router(Arc::clone(&self.state)));
        }
        router = router.merge(login::router(Arc::clone(&self.state)));
        if self.state.accounts.is_some() {
            router.merge(accounts::router(Arc::clone(&self.state)))
        } else {
            router
        }
    }

    /// File uploads and downloads, which take as long as the files are big.
    pub(crate) fn upload_routes(&self) -> Option<axum::Router> {
        let service = self.state.uploads.as_ref()?;
        Some(uploads::router(Arc::clone(&self.state), service))
    }
}

/// Builder for [`Admin`].
#[derive(Default)]
pub struct AdminBuilder {
    base: Option<String>,
    api_base: Option<String>,
    uploads: Option<Uploads>,
    accounts: Option<sea_orm::DatabaseConnection>,
    docs: DocsConfig,
    settings: UiSettings,
    auth: Option<(AuthConfig, Arc<dyn UserStore>)>,
    resources: Vec<Arc<dyn Resource>>,
    mailer: Option<Arc<dyn Mailer>>,
}

impl AdminBuilder {
    /// URL prefix for the panel (default `/admin`).
    #[must_use]
    pub fn base_path(mut self, base: impl Into<String>) -> Self {
        self.base = Some(base.into());
        self
    }

    /// Enables file uploads and downloads under the API base path.
    #[must_use]
    pub fn uploads(mut self, uploads: Uploads) -> Self {
        self.uploads = Some(uploads);
        self
    }

    /// Adds the Users and Groups pages, for superusers to manage the built-in accounts in
    /// `db`. Use it together with [`DbUserStore`] over the same database.
    #[must_use]
    pub fn accounts(mut self, db: sea_orm::DatabaseConnection) -> Self {
        self.accounts = Some(db);
        self
    }

    /// Sends login links and password resets (by default they are written to the log).
    #[must_use]
    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
        self.mailer = Some(Arc::new(mailer));
        self
    }

    pub(crate) fn mailer_shared(mut self, mailer: Option<Arc<dyn Mailer>>) -> Self {
        self.mailer = mailer;
        self
    }

    /// Settings for the API documentation (on and open to everyone by default).
    #[must_use]
    pub fn docs(mut self, docs: DocsConfig) -> Self {
        self.docs = docs;
        self
    }

    /// URL prefix for the REST API (default `/api`).
    #[must_use]
    pub fn api_base_path(mut self, base: impl Into<String>) -> Self {
        self.api_base = Some(base.into());
        self
    }

    pub(crate) fn register_shared(mut self, resource: Arc<dyn Resource>) -> Self {
        self.resources.push(resource);
        self
    }

    pub(crate) fn auth_shared(mut self, config: AuthConfig, users: Arc<dyn UserStore>) -> Self {
        self.auth = Some((config, users));
        self
    }

    /// Look-and-feel settings.
    #[must_use]
    pub fn settings(mut self, settings: UiSettings) -> Self {
        self.settings = settings;
        self
    }

    /// Authentication settings and the account store. Required: there is no unauthenticated mode.
    #[must_use]
    pub fn auth(mut self, config: AuthConfig, users: impl UserStore + 'static) -> Self {
        self.auth = Some((config, Arc::new(users)));
        self
    }

    /// Adds a model to the panel.
    #[must_use]
    pub fn register(mut self, resource: impl Resource + 'static) -> Self {
        self.resources.push(Arc::new(resource));
        self
    }

    /// Validates the configuration and compiles the templates.
    ///
    /// # Errors
    /// Returns [`AdminError::Config`] if auth is missing, the base path is malformed, a model
    /// slug is reserved or duplicated, or a template override cannot be read; and
    /// [`AdminError::Template`] if a template does not parse.
    pub fn build(self) -> Result<Admin, AdminError> {
        let (auth, users) = self.auth.ok_or_else(|| {
            AdminError::Config(
                "call .auth(...): the admin cannot be served without authentication".into(),
            )
        })?;

        if (!auth.methods.providers.is_empty() || auth.methods.magic_link)
            && self.accounts.is_none()
        {
            return Err(AdminError::Config(
                "auth.methods: logging in through other sites or by e-mail uses the built-in \
                 accounts, which a custom UserStore replaces"
                    .into(),
            ));
        }

        let base = base_path(self.base, "/admin")?;
        let api_base = base_path(self.api_base, "/api")?;
        if base == api_base {
            return Err(AdminError::Config(
                "the admin and API base paths must differ".into(),
            ));
        }

        for (i, resource) in self.resources.iter().enumerate() {
            let slug = resource.slug();
            if RESERVED_SLUGS.contains(&slug) {
                return Err(AdminError::Config(format!(
                    "model slug `{slug}` is reserved"
                )));
            }
            if self.resources[..i].iter().any(|r| r.slug() == slug) {
                return Err(AdminError::Config(format!(
                    "model slug `{slug}` is registered twice"
                )));
            }
            check_actions(resource.as_ref())?;
            check_file_fields(resource.as_ref(), self.uploads.is_some())?;
        }

        let mut templates = minijinja::Environment::new();
        for (name, builtin) in TEMPLATES {
            let source = match &self.settings.template_dir {
                Some(dir) if dir.join(name).is_file() => std::fs::read_to_string(dir.join(name))
                    .map_err(|e| {
                        AdminError::Config(format!("cannot read template override {name}: {e}"))
                    })?,
                _ => builtin.to_owned(),
            };
            templates.add_template_owned(name, source)?;
        }

        let docs = self.docs.is_enabled().then(|| {
            docs::Docs::new(
                self.docs,
                &api_base,
                &self.settings,
                &self.resources,
                self.uploads.is_some(),
                auth.api_keys,
            )
        });

        Ok(Admin {
            state: Arc::new(AdminState {
                base,
                api_base,
                settings: self.settings,
                limiter: limit::LoginLimiter::new(auth.login_max_attempts, auth.login_lockout_secs),
                auth,
                users,
                resources: self.resources,
                uploads: self.uploads,
                accounts: self.accounts,
                docs,
                templates,
                mailer: self.mailer.unwrap_or_else(|| Arc::new(mail::LogMailer)),
            }),
        })
    }
}

/// Checks a model's bulk actions: distinct names, and columns that exist and can be edited.
fn check_actions(resource: &dyn Resource) -> Result<(), AdminError> {
    let slug = resource.slug();
    let actions = &resource.options().actions;
    for (i, action) in actions.iter().enumerate() {
        let error = |message: String| {
            AdminError::Config(format!(
                "model `{slug}`, action `{}`: {message}",
                action.name
            ))
        };
        let valid = !action.name.is_empty()
            && action
                .name
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
        if !valid || action.name == "delete" {
            return Err(error(
                "names use letters, digits, `_` and `-`, and `delete` is built in".into(),
            ));
        }
        if actions[..i].iter().any(|other| other.name == action.name) {
            return Err(error("listed twice".into()));
        }
        if let resource::ActionKind::Set { field, .. } = &action.kind {
            let options = resource.options();
            let editable = resource.fields().iter().any(|f| {
                &f.name == field
                    && f.editable
                    && !f.primary_key
                    && !options.readonly_fields.contains(field)
                    && !options.hidden_fields.contains(field)
            });
            if !editable {
                return Err(error(format!(
                    "`{field}` is not a column the admin can edit"
                )));
            }
        }
    }
    Ok(())
}

/// Checks a model's file fields: editable integer columns, and uploads turned on.
fn check_file_fields(resource: &dyn Resource, uploads: bool) -> Result<(), AdminError> {
    let options = resource.options();
    for name in &options.file_fields {
        let error = |message: &str| {
            AdminError::Config(format!(
                "model `{}`, file field `{name}`: {message}",
                resource.slug()
            ))
        };
        let usable = resource.fields().iter().any(|f| {
            &f.name == name
                && f.kind == FieldKind::Integer
                && f.editable
                && !f.primary_key
                && !options.hidden_fields.contains(name)
        });
        if !usable {
            return Err(error("must be an integer column the admin can edit"));
        }
        if !uploads {
            return Err(error("file uploads are off; set uploads.storage_url"));
        }
    }
    Ok(())
}

/// Normalises and validates a URL prefix.
fn base_path(configured: Option<String>, default: &str) -> Result<String, AdminError> {
    let base = configured.unwrap_or_else(|| default.to_owned());
    let base = base.trim_end_matches('/').to_owned();
    let valid = base.starts_with('/')
        && base.len() > 1
        && base
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_'));
    if !valid {
        return Err(AdminError::Config(format!(
            "base path must look like {default} (letters, digits, `-`, `_`, `/`), got `{base}`"
        )));
    }
    Ok(base)
}