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
//! The Settings page: six tabs, one saved form each.
//!
//! Each tab is its own URL rather than a client-side strip, so a person can
//! bookmark the mail settings, open them in another window, and land back on
//! them after saving. None of that survives a tab strip built in JavaScript,
//! and all of it is what somebody administering an application expects.
//!
//! Saving goes through [`Settings::put_all`], which refuses any key the
//! catalogue does not declare. A form that can write any key is a form that can
//! write anything, so the list of what exists lives in one place and this
//! controller cannot widen it.
use rustlavel::prelude::*;
use crate::support::page;
use crate::support::settings::{CATALOGUE, Kind, Settings, declared};
use super::users_controller::with_current_user;
/// The tabs, in the order they are drawn.
const TABS: &[(&str, &str, &str)] = &[
("general", "General", "app."),
("email", "Email", "mail."),
("security", "Security", "auth."),
// The tab has settings of its own now — a schedule, a retention window, a
// destination — alongside the actions that have routes of their own.
("backup", "Backup", "backup."),
// `app.` rather than `app.locale`: the tab also holds the number format,
// the currency and the first day of the week, and they are not under
// `locale`. Sharing the prefix with General is safe because the save path
// writes only the fields a form actually posted — a setting absent from
// the body is skipped, not blanked.
("language", "Language", "app."),
("appearance", "Appearance", "theme."),
];
pub struct AdminSettingsController;
impl AdminSettingsController {
/// `GET /admin/settings` — the first tab.
pub async fn index(req: Request) -> Result<Response> {
Self::show_tab(req, "general").await
}
/// `GET /admin/settings/{tab}`
pub async fn tab(req: Request) -> Result<Response> {
let slug = req.param("tab").unwrap_or("general").to_string();
Self::show_tab(req, &slug).await
}
async fn show_tab(req: Request, slug: &str) -> Result<Response> {
let Some((slug, _, prefix)) = TABS.iter().find(|(name, _, _)| *name == slug) else {
return Ok(Response::not_found());
};
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let settings = Self::store(&req)?;
let mut context = page::shell(&req, "settings").await;
context = with_current_user(context, &req, &db).await?;
context = context.with("tabs", Json::Array(Self::tab_list(slug)));
// Every tab reads its own values under one name, so a template says
// `s.app_name.value` rather than reaching for a variable the controller
// had to remember to pass.
context = context.with("s", settings.view(prefix).await?);
if *slug == "backup" {
context =
crate::controllers::admin::backup_controller::BackupController::context(&req, context)
.await?;
}
if *slug == "email" {
context = context.with("test_to", Json::from(""));
}
if *slug == "language" {
context = context.with("languages", Self::languages(&req, &settings).await);
}
req.view(&format!("settings/tabs/{slug}"), &context)
}
/// `POST /admin/settings/{tab}` — save whatever that tab declares.
///
/// The fields are taken from the catalogue rather than from the submitted
/// body: a browser sends nothing at all for an unticked checkbox, so a
/// handler that saved only what arrived could never turn a toggle *off*.
pub async fn save(mut req: Request) -> Result<Response> {
let slug = req.param("tab").unwrap_or("general").to_string();
let Some((_, label, prefix)) = TABS.iter().find(|(name, _, _)| *name == slug) else {
return Ok(Response::not_found());
};
let settings = Self::store(&req)?.clone();
let mut values = Vec::new();
for setting in CATALOGUE.iter().filter(|s| s.key.starts_with(prefix)) {
// A setting the environment decides is not editable here, and
// writing it would leave a stored row that nothing ever reads.
if Settings::overridden(setting.key) {
continue;
}
let submitted = req.input(setting.key);
let value = match setting.kind {
// Absent means off. This is the line that makes a toggle work.
Kind::Toggle => {
if submitted.is_some() { "true".to_string() } else { "false".to_string() }
}
// Coerced here as well as in the appearance handlers, because
// this path is reachable by anyone who can post a form and a
// stored value that is not a colour is junk in a stylesheet's
// clothing. `theme_controller::colour` sanitises again on the
// way out, so this is the second of two nets rather than the
// only one.
Kind::Colour => match submitted {
Some(value) => crate::controllers::theme_controller::colour(&value),
None => continue,
},
_ => match submitted {
Some(value) => value,
None => continue,
},
};
values.push((setting.key.to_string(), value));
}
let written = settings.put_all(&values).await?;
// The keys, not the values. A settings tab holds a mail password, and
// an audit trail that records what it was changed to is a place the
// password now lives in the clear.
if written > 0 {
if let Some(audit) = crate::support::audit::of(&req, "settings.updated") {
audit
.describe(format!("Updated the {} settings", label.to_lowercase()))
.with("tab", Json::from(slug.as_str()))
.with("changed", Json::from(written as i64))
.record()
.await;
}
}
page::flash(&req, "success", format!("{label} settings saved ({written} changed)."));
Ok(Response::see_other(format!("/admin/settings/{slug}")))
}
/// `POST /admin/settings/email/test` — prove the settings work.
///
/// Sends through the mailer the application booted with, which is the
/// honest thing to test: it is the one that will send the password resets.
/// That does mean the saved settings only take effect on restart, and the
/// page says so rather than letting somebody believe a failing test means
/// their new host is wrong.
pub async fn send_test(mut req: Request) -> Result<Response> {
let to = req.input("to").unwrap_or_default().trim().to_string();
if to.is_empty() || !to.contains('@') {
page::flash(&req, "error", "Give an address to send the test to.");
return Ok(Response::see_other("/admin/settings/email"));
}
if req.state::<rustlavel::mail::Mailer>().is_none() {
page::flash(&req, "error", "No mailer is configured, so there is nothing to test.");
return Ok(Response::see_other("/admin/settings/email"));
}
let settings = Self::store(&req)?;
let name = settings.get("app.name").await;
let driver = settings.get("mail.driver").await;
let message = rustlavel::mail::Message::new()
.to(to.as_str())
.subject(format!("{name}: test message"))
.text(format!(
"This is a test from {name}.\n\nIf you are reading it, the mail settings work.\n\n\
Driver: {driver}\nSent: {}\n",
crate::support::tokens::now()
));
// Through the same helper as every other message, so the test proves the
// From address on the Email tab as well as the transport.
match crate::support::mail::send(&req, message).await {
Ok(()) => page::flash(&req, "success", format!("A test message has gone to {to}.")),
// The reason is shown rather than swallowed: "it did not send" is
// not something anybody can act on, and the SMTP server's own words
// usually name the problem exactly.
Err(error) => page::flash(&req, "error", format!("The test did not send: {error}")),
}
Ok(Response::see_other("/admin/settings/email"))
}
/// `GET /admin/settings/export` — everything, as JSON, minus the secrets.
pub async fn export(req: Request) -> Result<Response> {
let settings = Self::store(&req)?;
let body = settings.export().await?;
Ok(Response::json(body)
.with_header("content-disposition", "attachment; filename=\"settings.json\""))
}
fn tab_list(current: &str) -> Vec<Json> {
TABS.iter()
.map(|(slug, label, _)| {
Json::object([
("slug", Json::from(*slug)),
("label", Json::from(*label)),
("current", Json::from(*slug == current)),
])
})
.collect()
}
/// Which languages have a translation file, for the Language tab.
async fn languages(req: &Request, settings: &Settings) -> Json {
let root = req.config().string("view.lang", "lang");
let chosen = settings.get("app.locale").await;
let _ = chosen;
let listed = declared("app.locale").map(|setting| setting.choices).unwrap_or(&[]);
Json::Array(
listed
.iter()
.map(|(code, label)| {
let path = std::path::Path::new(&root).join(format!("{code}.json"));
let phrases = std::fs::read_to_string(&path)
.ok()
.and_then(|source| Json::parse(&source).ok())
.and_then(|value| value.as_object().map(|fields| fields.len() as i64))
.unwrap_or(0);
Json::object([
("code", Json::from(*code)),
("label", Json::from(*label)),
("present", Json::from(path.is_file())),
("phrases", Json::from(phrases)),
])
})
.collect(),
)
}
/// The settings store, or a clear failure. Never a silent default.
pub fn store(req: &Request) -> Result<Settings> {
req.state::<Settings>().cloned().ok_or_else(|| {
Error::msg(
"the settings store is not registered. Add \
`.state(Settings::from_config(db.clone(), app.config())?)` in main.rs.",
)
})
}
}