Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/console/publish.rs

218 KiB, 805 runs

created by r1870400018:14622, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Managing a site's posts, from within the site.
2//!
3//! The post half of the console: a list of what the site has written, an editor, a preview of a
4//! draft, and a way to read a directory of Markdown in. The operations themselves live in
5//! [`crate::srv::publish::store`] and are shared with the reader-facing pages; this is the surface
6//! over them, dressed in the site's own look and reached only by a site admin.
7//!
8//! It was once the composer, mounted in the operator's dashboard at `/admin/publish` and gated on the
9//! operator's session. That put a site's content behind the key to the whole host, which was the
10//! wrong tier: writing a post is a site's business, not the server's. It moved here, behind the
11//! site's own admins, and left the dashboard for the server's own concerns.
12//!
13//! # What a form may say
14//!
15//! A slug and a date arrive from a browser, which means they arrive from anywhere. Both are checked
16//! ([`valid_slug`], [`valid_date`]) before either reaches a key: a slug is pasted into
17//! `publish/post/<slug>` and into a URL, and a form's word for one is not a reason to trust it. The
18//! prose is not checked -- it is Markdown, and Markdown that will not parse is refused by the parser
19//! where it is rendered -- but it is escaped everywhere it is shown except the preview, which is the
20//! rendered HTML a reader would get.
21//!
22//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
23//! Anthropic Claude
24
25use crate::srv::{
26 cache,
27 console::{
28 SiteAdmin,
29 Theme,
30 page,
31 redirect,
32 },
33 publish::{
34 Author,
35 Markup,
36 PostState,
37 PublishConfig,
38 Source,
39 ai,
40 declare,
41 dest::{
42 DeliveryState,
43 Destination,
44 },
45 send::{
46 self,
47 MailSender,
48 },
49 comment,
50 store::{
51 self,
52 Record,
53 },
54 subscribe,
55 date_text,
56 normalise_date,
57 normalise_tag,
58 parse_tags,
59 render_source,
60 valid_date,
61 valid_slug,
62 },
63};
64
65use oxedyne_fe2o3_core::{
66 prelude::*,
67 rand::Rand,
68};
69use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
70use oxedyne_fe2o3_iop_db::api::Database;
71use oxedyne_fe2o3_iop_hash::api::Hasher;
72use oxedyne_fe2o3_jdat::{
73 prelude::*,
74 id::NumIdDat,
75 string::enc::EncoderConfig,
76};
77use oxedyne_fe2o3_net::http::{
78 data_url,
79 fields::{
80 HeaderFieldValue,
81 HeaderName,
82 },
83 msg::HttpMessage,
84 status::HttpStatus,
85};
86
87use tokio_rustls::rustls::ClientConfig;
88
89use std::{
90 collections::BTreeMap,
91 sync::{
92 Arc,
93 RwLock,
94 },
95};
96
97use super::html_escape;
98
99
100pub const PATH_ROOT: &str = "/manage";
101pub const PATH_EDIT: &str = "/manage/edit";
102// A member's own profile: the name, picture and description readers see, which their login
103// username cannot be.
104pub const PATH_PROFILE: &str = "/manage/profile";
105pub const PATH_PROFILE_JSON: &str = "/manage/profile.json";
106pub const PATH_PROFILE_SAVE: &str = "/manage/profile/save";
107pub const PATH_TAG_DELETE: &str = "/manage/tag/delete";
108// A draft as a reader would get it, if it were not a draft.
109pub const PATH_PREVIEW: &str = "/manage/preview";
110pub const PATH_SAVE: &str = "/manage/save";
111pub const PATH_DELETE: &str = "/manage/delete";
112pub const PATH_IMPORT: &str = "/manage/import";
113// Where the editor posts source to see it rendered, for a live preview.
114pub const PATH_RENDER: &str = "/manage/render";
115// The posts as JSON, every state, for a front-end that renders its own list.
116pub const PATH_LIST_JSON: &str = "/manage/list.json";
117pub const PATH_POST_JSON: &str = "/manage/post.json";
118pub const PATH_TAGS_JSON: &str = "/manage/tags.json";
119// A site's destination settings as JSON: the public fields and whether a secret is set, never the
120// secret itself.
121pub const PATH_CREDS_JSON: &str = "/manage/creds.json";
122pub const PATH_CREDS: &str = "/manage/creds";
123pub const PATH_DESTS: &str = "/manage/destinations";
124// The AI page: the model to call, the key to call it with, the two prompts, and the addresses
125// told when a comment needs a human. `fix` is the editor's Fix button; `test` checks the key
126// reaches the model.
127pub const PATH_AI: &str = "/manage/ai";
128pub const PATH_AI_SAVE: &str = "/manage/ai/save";
129pub const PATH_AI_FIX: &str = "/manage/ai/fix";
130pub const PATH_AI_TEST: &str = "/manage/ai/test";
131pub const PATH_AI_JSON: &str = "/manage/ai.json";
132// The declarations page: how much AI went into each of the things this site shows that are not
133// posts. A post declares in the composer, beside the prose it is a declaration about.
134pub const PATH_DECLARE: &str = "/manage/declare";
135pub const PATH_DECLARE_SAVE: &str = "/manage/declare/save";
136pub const PATH_DECLARE_JSON: &str = "/manage/declare.json";
137// The moderation queue, and where its approve, spam, remove, erase and block actions post.
138pub const PATH_COMMENTS: &str = "/manage/comments";
139pub const PATH_COMMENTS_ACTION: &str = "/manage/comments/action";
140pub const PATH_COMMENTS_JSON: &str = "/manage/comments.json";
141pub const PATH_SUBS_JSON: &str = "/manage/subscribers.json";
142pub const PATH_REPORTS_JSON: &str = "/manage/reports.json";
143pub const PATH_SUBS: &str = "/manage/subscribers";
144pub const PATH_SUBS_CSV: &str = "/manage/subscribers.csv";
145pub const PATH_REPORTS: &str = "/manage/reports";
146
147// The bucket a report counts a record under when it carries no month to file it by.
148const UNDATED: &str = "unknown";
149
150pub const PATH_NEWSLETTER: &str = "/manage/newsletter";
151// Where the per-subscriber unsubscribe and remove forms post. One endpoint, two actions: an
152// `action` of `unsubscribe` sets the address unsubscribed, and one of `delete` erases it
153// outright. The target is the `email` field, exactly as the admin sees it in the list.
154pub const PATH_SUBS_ACTION: &str = "/manage/subscribers/action";
155// Where the "send a test" form posts a slug and a single recipient.
156pub const PATH_NEWSLETTER_TEST: &str = "/manage/newsletter/test";
157
158
159/// Whether a path is one this module writes to.
160pub fn writes(path: &str) -> bool {
161 path == PATH_SAVE
162 || path == PATH_DELETE
163 || path == PATH_IMPORT
164 || path == PATH_CREDS
165 || path == PATH_NEWSLETTER
166 || path == PATH_SUBS_ACTION
167 || path == PATH_COMMENTS_ACTION
168 || path == PATH_NEWSLETTER_TEST
169 || path == PATH_PROFILE_SAVE
170 || path == PATH_TAG_DELETE
171 || path == PATH_AI_SAVE
172 || path == PATH_AI_FIX
173 || path == PATH_AI_TEST
174 || path == PATH_DECLARE_SAVE
175}
176
177/// Whether a path is a POST this module answers.
178///
179/// The writes, and the render -- which is a POST because an editor's whole draft is too much for a
180/// query string, but changes nothing: it reads source and hands back HTML. It is gated and
181/// token-checked with the writes all the same, so the server is not a rendering service for anyone
182/// who asks.
183pub fn posts(path: &str) -> bool {
184 writes(path) || path == PATH_RENDER
185}
186
187
188// ┌───────────────────────────────────────────────────────────────────────────┐
189// │ GET │
190// └───────────────────────────────────────────────────────────────────────────┘
191
192/// Serves the console's post pages. The gate ran before this: `admin` is a proven site admin.
193
194pub fn handle_get<
195 const UIDL: usize,
196 UID: NumIdDat<UIDL>,
197 ENC: Encrypter,
198 KH: Hasher,
199 DB: Database<UIDL, UID, ENC, KH>,
200>(
201 cfg: Option<&PublishConfig>,
202 theme: &Theme,
203 admin: &SiteAdmin,
204 csrf: &str,
205 db: Option<&(Arc<RwLock<DB>>, UID)>,
206 request_path: &str,
207 query: &str,
208 id: &str,
209)
210 -> Outcome<HttpMessage>
211{
212 debug!("{}: console: GET {}", id, request_path);
213
214 let cfg = match cfg {
215 Some(c) => c,
216 None => return Ok(page(theme, admin, "Manage", &notice(
217 "This site publishes nothing. Give it a <code>publish</code> block to manage posts here.",
218 ))),
219 };
220
221 match request_path {
222 PATH_ROOT => handle_list(cfg, theme, admin, csrf, db, query, id),
223 PATH_EDIT => handle_edit(cfg, theme, admin, csrf, true, db, query, id),
224 PATH_PROFILE => profile_page(cfg, theme, admin, csrf, db, id),
225 PATH_PREVIEW => handle_preview(cfg, theme, admin, db, query, id),
226 PATH_SUBS => subscribers_page(cfg, theme, admin, csrf, db, query, id),
227 PATH_SUBS_CSV => subscribers_csv(db, id),
228 PATH_REPORTS => reports_page(theme, admin, db, id),
229 PATH_DESTS => destinations_page(cfg, theme, admin, csrf, db, query, id),
230 PATH_AI => ai_page(theme, admin, csrf, db, query, id),
231 PATH_DECLARE => declare_page(cfg, theme, admin, csrf, db, query, id),
232 PATH_DECLARE_JSON => declare_json(cfg, db, id),
233 PATH_LIST_JSON => list_json(cfg, db, id),
234 PATH_POST_JSON => post_json(cfg, db, query, id),
235 PATH_TAGS_JSON => tags_json(cfg, db, id),
236 PATH_CREDS_JSON => creds_json(cfg, db, id),
237 PATH_AI_JSON => ai_json(db, id),
238 PATH_PROFILE_JSON => profile_json(cfg, admin, db, id),
239 PATH_SUBS_JSON => subs_json(db, id),
240 PATH_COMMENTS => comments_page(cfg.comments, theme, admin, csrf, db, query, id),
241 PATH_COMMENTS_JSON => comments_json(cfg.comments, db, query, id),
242 PATH_REPORTS_JSON => reports_json(db, id),
243 // Never held, on the same reasoning as every other console answer: RFC 9110 15.5.5 makes a
244 // `404` heuristically cacheable, and a route that does not exist in this version of the
245 // console may exist in the next one a reader is served.
246 _ => Ok(cache::generated(HttpMessage::respond_with_text(
247 HttpStatus::NotFound,
248 "Not found.",
249 ))),
250 }
251}
252
253/// The subscribers page: the newsletter's list, its count, an export, and a way to send a live post to
254/// the list.
255///
256/// The home of "own the list, own the send": the confirmed count is the reach, the table is the list,
257/// the CSV is the copy the site keeps, and the send form picks a live post and mails it to every
258/// confirmed address. A directory-backed site keeps its posts in files rather than the store, so it can
259/// still hold subscribers but has no store post to send; the send form is offered only where the store
260/// is the source.
261fn subscribers_page<
262 const UIDL: usize,
263 UID: NumIdDat<UIDL>,
264 ENC: Encrypter,
265 KH: Hasher,
266 DB: Database<UIDL, UID, ENC, KH>,
267>(
268 cfg: &PublishConfig,
269 theme: &Theme,
270 admin: &SiteAdmin,
271 csrf: &str,
272 db: Option<&(Arc<RwLock<DB>>, UID)>,
273 query: &str,
274 id: &str,
275)
276 -> Outcome<HttpMessage>
277{
278 let mut body = String::new();
279 body.push_str("<h1>Subscribers</h1>\n");
280
281 if let Some(said) = query_field(query, "said") {
282 body.push_str(&notice(&html_escape(&said)));
283 }
284
285 let db = match db {
286 Some(db) => db,
287 None => {
288 body.push_str(&notice(
289 "This site keeps its subscribers in its database, and has no database configured. Set \
290 <code>db_dir_rel</code> on the vhost.",
291 ));
292 return Ok(page(theme, admin, "Subscribers", &body));
293 }
294 };
295
296 let subs = match subscribe::list(db, id) {
297 Ok(s) => s,
298 Err(e) => {
299 error!(e, "{}: console: cannot list the subscribers", id);
300 body.push_str(&notice("The subscribers could not be listed. The log says why."));
301 return Ok(page(theme, admin, "Subscribers", &body));
302 }
303 };
304 let confirmed = subs.iter().filter(|s| s.state == subscribe::SubState::Confirmed).count();
305 let pending = subs.iter().filter(|s| s.state == subscribe::SubState::Pending).count();
306 let unsubbed = subs.iter().filter(|s| s.state == subscribe::SubState::Unsubscribed).count();
307 let bounced = subs.iter().filter(|s| s.state == subscribe::SubState::Bounced).count();
308
309 // The counts, said once and briefly. Which states receive a post is a rule of the system, not
310 // news about this list, and repeating it on every visit is how a page stops being read.
311 body.push_str(&fmt!(
312 "<p class=\"mc-muted\">{counts} &middot; <a href=\"{csv}\">Export CSV</a></p>\n",
313 counts = if subs.is_empty() {
314 fmt!("No subscribers yet")
315 } else {
316 fmt!("{} confirmed &middot; {} pending &middot; {} unsubscribed &middot; {} bounced",
317 confirmed, pending, unsubbed, bounced)
318 },
319 csv = PATH_SUBS_CSV,
320 ));
321
322 // The send form and the test-send form, where the store is the source and there is a live post to
323 // send. The test needs only a live post, not a confirmed subscriber, so it is offered even where the
324 // send set is empty.
325 // The list is what this page is about, so it comes first and the sending follows it. An empty
326 // list says so once, in the line above, and does not repeat itself in a box of its own.
327 if subs.is_empty() {
328 body.push_str(&send_section(cfg, csrf, db, confirmed, id));
329 return Ok(page(theme, admin, "Subscribers", &body));
330 }
331
332 // A list that outgrows a screen needs the same two things the posts do: a way to look for one
333 // address, and a way not to render ten thousand rows into one page.
334 let q = query_field(query, "q").unwrap_or_default();
335 let want = query_field(query, "state").unwrap_or_default();
336 let needle = q.to_lowercase();
337 let shown: Vec<&subscribe::Subscriber> = subs.iter()
338 .filter(|s| want.is_empty() || s.state.as_str() == want)
339 .filter(|s| needle.is_empty() || s.email.to_lowercase().contains(&needle))
340 .collect();
341
342 body.push_str(&subs_filter(&q, &want, shown.len(), subs.len()));
343
344 if shown.is_empty() {
345 body.push_str(&notice("No subscriber matches that."));
346 body.push_str(&send_section(cfg, csrf, db, confirmed, id));
347 return Ok(page(theme, admin, "Subscribers", &body));
348 }
349
350 let page_at = query_field(query, "page").and_then(|p| p.parse::<usize>().ok()).unwrap_or(1).max(1);
351 let pages = shown.len().div_ceil(PAGE_SIZE).max(1);
352 let page_at = page_at.min(pages);
353 let from = (page_at - 1) * PAGE_SIZE;
354 let upto = (from + PAGE_SIZE).min(shown.len());
355
356 body.push_str("<table class=\"mc-table\">\n<thead><tr>\
357 <th>Address</th><th>State</th><th>Since</th><th></th>\
358 </tr></thead>\n<tbody>\n");
359 for sub in &shown[from..upto] {
360 let state = match sub.state {
361 subscribe::SubState::Confirmed => fmt!("<span class=\"mc-tag mc-tag-live\">confirmed</span>"),
362 subscribe::SubState::Pending => fmt!("<span class=\"mc-tag\">pending</span>"),
363 subscribe::SubState::Unsubscribed =>
364 fmt!("<span class=\"mc-tag mc-tag-err\">unsubscribed</span>"),
365 subscribe::SubState::Bounced =>
366 fmt!("<span class=\"mc-tag mc-tag-err\">bounced</span>"),
367 };
368 body.push_str(&fmt!(
369 "<tr><td>{email}</td><td>{state}</td><td>{since}</td><td>{actions}</td></tr>\n",
370 email = html_escape(&sub.email),
371 state = state,
372 since = html_escape(sub.created.as_deref().unwrap_or("--")),
373 actions = subscriber_actions(csrf, sub),
374 ));
375 }
376 body.push_str("</tbody>\n</table>\n");
377 body.push_str(&pager(PATH_SUBS, &q, &want, page_at, pages));
378 body.push_str(&send_section(cfg, csrf, db, confirmed, id));
379
380 Ok(page(theme, admin, "Subscribers", &body))
381}
382
383/// The per-subscriber actions: unsubscribe where they still receive mail, and erase, always.
384///
385/// Two small CSRF-protected forms posting to [`PATH_SUBS_ACTION`] with the address as their target. The
386/// unsubscribe is offered only where it would change something -- an address already unsubscribed or
387/// bounced is past it -- while the erase is offered on every row, since a record in any state can be a
388/// thing a person has asked be forgotten.
389fn subscriber_actions(csrf: &str, sub: &subscribe::Subscriber) -> String {
390 let mut s = String::new();
391 s.push_str("<div class=\"mc-actions\">");
392 let receiving = matches!(
393 sub.state,
394 subscribe::SubState::Confirmed | subscribe::SubState::Pending);
395 if receiving {
396 s.push_str(&fmt!(
397 "<form method=\"POST\" action=\"{act}\" style=\"display:inline\">\
398 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
399 <input type=\"hidden\" name=\"action\" value=\"unsubscribe\">\
400 <input type=\"hidden\" name=\"email\" value=\"{email}\">\
401 <button type=\"submit\" class=\"mc-ico\" title=\"Unsubscribe\" \
402 aria-label=\"Unsubscribe\">{close}</button>\
403 </form>",
404 act = PATH_SUBS_ACTION,
405 csrf = html_escape(csrf),
406 email = html_escape(&sub.email),
407 close = icon("close"),
408 ));
409 }
410 s.push_str(&fmt!(
411 "<form method=\"POST\" action=\"{act}\" style=\"display:inline\" \
412 onsubmit=\"return confirm('Erase {email} for good? There is no undo.')\">\
413 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
414 <input type=\"hidden\" name=\"action\" value=\"delete\">\
415 <input type=\"hidden\" name=\"email\" value=\"{email}\">\
416 <button type=\"submit\" class=\"mc-ico mc-ico-danger\" title=\"Erase\" \
417 aria-label=\"Erase\">{trash}</button>\
418 </form>",
419 act = PATH_SUBS_ACTION,
420 csrf = html_escape(csrf),
421 email = html_escape(&sub.email),
422 trash = icon("trash"),
423 ));
424 s.push_str("</div>");
425 s
426}
427
428/// Everything to do with sending, under one heading and below the list.
429///
430/// The list is the page's subject and the sending is what is done with it, so the sending follows
431/// it rather than sitting on top of it. Grouped, because a send form, a test form and a history
432/// loose on a page read as three unrelated things.
433fn send_section<
434 const UIDL: usize,
435 UID: NumIdDat<UIDL>,
436 ENC: Encrypter,
437 KH: Hasher,
438 DB: Database<UIDL, UID, ENC, KH>,
439>(
440 cfg: &PublishConfig,
441 csrf: &str,
442 db: &(Arc<RwLock<DB>>, UID),
443 confirmed: usize,
444 id: &str,
445)
446 -> String
447{
448 let mut s = String::new();
449 s.push_str("<h2>Send a post</h2>\n<div class=\"mc-send\">\n");
450
451 if cfg.source == Source::Store {
452 s.push_str(&send_form(cfg, csrf, db, confirmed, id));
453 s.push_str(&test_form(csrf, db, id));
454 } else {
455 s.push_str(&notice(
456 "This site serves its posts from a directory, so a post is not in the database to send. \
457 Move to the store to mail a post to subscribers.",
458 ));
459 }
460
461 s.push_str("</div>\n");
462
463 // A read that fails costs the table, not the page, so it logs and carries on.
464 match send::send_history(db) {
465 Ok(hist) => s.push_str(&history_table(&hist)),
466 Err(e) => {
467 warn!("{}: console: cannot read the send history: {}", id, e);
468 s.push_str(&notice("The send history could not be read. The log says why."));
469 }
470 }
471 s
472}
473
474/// The search-and-filter row over the subscriber list, by address and by state.
475fn subs_filter(q: &str, want: &str, showing: usize, total: usize) -> String {
476 let count = if showing == total {
477 fmt!("{} subscribers", total)
478 } else {
479 fmt!("{} of {} subscribers", showing, total)
480 };
481 fmt!(
482 "<form class=\"mc-filter mc-form\" method=\"GET\" action=\"{subs}\">\n\
483 <div class=\"mc-f-text\"><label for=\"q\">Search</label>\
484 <input type=\"text\" id=\"q\" name=\"q\" value=\"{q}\" placeholder=\"address\"></div>\n\
485 <div class=\"mc-f-sel\"><label for=\"state\">State</label>\
486 <select id=\"state\" name=\"state\">\
487 <option value=\"\"{any}>Any</option>\
488 <option value=\"confirmed\"{conf}>Confirmed</option>\
489 <option value=\"pending\"{pend}>Pending</option>\
490 <option value=\"unsubscribed\"{unsub}>Unsubscribed</option>\
491 <option value=\"bounced\"{bounce}>Bounced</option>\
492 </select></div>\n\
493 <button type=\"submit\" class=\"mc-btn mc-btn-quiet\">Filter</button>\n\
494 <span class=\"mc-muted\" style=\"margin:0 0 0 auto\">{count}</span>\n\
495 </form>\n",
496 subs = PATH_SUBS,
497 q = html_escape(q),
498 any = selected(want.is_empty()),
499 conf = selected(want == "confirmed"),
500 pend = selected(want == "pending"),
501 unsub = selected(want == "unsubscribed"),
502 bounce = selected(want == "bounced"),
503 count = count,
504 )
505}
506
507/// The send-history table: post, when, and how each send's attempts ended, most recent first.
508///
509/// Says nothing where nothing has been sent, so a site that has not yet mailed a post shows no empty
510/// table. Every value is the site's own record, and the slug is escaped where it lands in markup.
511fn history_table(hist: &[send::SendEntry]) -> String {
512 if hist.is_empty() {
513 return String::new();
514 }
515 let mut s = String::new();
516 s.push_str("<h2>Send history</h2>\n");
517 s.push_str("<table class=\"mc-table\">\n<thead><tr>\
518 <th>Post</th><th>When</th><th>Attempted</th><th>Sent</th><th>Failed</th><th>Suppressed</th>\
519 </tr></thead>\n<tbody>\n");
520 for e in hist {
521 s.push_str(&fmt!(
522 "<tr><td><span class=\"mc-slug\">{slug}</span></td><td>{at}</td>\
523 <td>{attempted}</td><td>{sent}</td><td>{failed}</td><td>{suppressed}</td></tr>\n",
524 slug = html_escape(&e.slug),
525 at = html_escape(&e.at),
526 attempted = e.attempted,
527 sent = e.sent,
528 failed = e.failed,
529 suppressed = e.suppressed,
530 ));
531 }
532 s.push_str("</tbody>\n</table>\n");
533 s
534}
535
536/// The reports page: what the list is made of, how it grew, and what has been sent to it.
537///
538/// Two questions, answered from what the site already records: who is on the list, and what happened
539/// to the posts mailed to it. Both are aggregations over the subscriber store and the send history --
540/// nothing here is measured for the purpose, and nothing is asked of a reader. There is deliberately
541/// no open or click tracking: an open pixel and a rewritten link are surveillance of a person who
542/// only asked to be sent some prose, and the site does not do it.
543///
544/// The honest ceiling, stated on the page as well as here: a subscriber records the moment it signed
545/// up and nothing else, so growth is knowable and cohort behaviour is not. Where a rate would be a
546/// guess, a share of the list as it stands is given instead, and said to be that.
547fn reports_page<
548 const UIDL: usize,
549 UID: NumIdDat<UIDL>,
550 ENC: Encrypter,
551 KH: Hasher,
552 DB: Database<UIDL, UID, ENC, KH>,
553>(
554 theme: &Theme,
555 admin: &SiteAdmin,
556 db: Option<&(Arc<RwLock<DB>>, UID)>,
557 id: &str,
558)
559 -> Outcome<HttpMessage>
560{
561 let mut body = String::new();
562 body.push_str("<h1>Reports</h1>\n");
563
564 let db = match db {
565 Some(db) => db,
566 None => {
567 body.push_str(&notice(
568 "This site keeps its subscribers in its database, and has no database configured. Set \
569 <code>db_dir_rel</code> on the vhost.",
570 ));
571 return Ok(page(theme, admin, "Reports", &body));
572 }
573 };
574
575 // The list. A read that fails costs its half of the page, not the page, so the send half still
576 // renders.
577 match subscribe::list(db, id) {
578 Ok(subs) => body.push_str(&list_report(&subs)),
579 Err(e) => {
580 error!(e, "{}: console: cannot list the subscribers for the report", id);
581 body.push_str(&notice("The subscribers could not be listed. The log says why."));
582 }
583 }
584
585 // The sends.
586 match send::send_history(db) {
587 Ok(hist) => body.push_str(&send_report(&hist)),
588 Err(e) => {
589 warn!("{}: console: cannot read the send history for the report: {}", id, e);
590 body.push_str(&notice("The send history could not be read. The log says why."));
591 }
592 }
593
594 // The reads. Two reads that must both land, so a failure in either costs this section alone.
595 match (store::reads_all(db, id), store::list_records(db, id)) {
596 (Ok(reads), Ok(recs)) => body.push_str(&reads_report(&reads, &recs)),
597 (Err(e), _) => {
598 warn!("{}: console: cannot read the read tallies for the report: {}", id, e);
599 body.push_str(&notice("The read counts could not be read. The log says why."));
600 }
601 (_, Err(e)) => {
602 warn!("{}: console: cannot list the posts for the read report: {}", id, e);
603 body.push_str(&notice("The posts could not be listed. The log says why."));
604 }
605 }
606
607 Ok(page(theme, admin, "Reports", &body))
608}
609
610/// The reads half of the report: how often each post has been read, most-read first.
611///
612/// What is counted, said on the page rather than left to be inferred: a request that served the post
613/// to somebody who was neither carrying a management session nor an obvious machine. What is *not*
614/// counted is the more important half -- nothing identifies a reader, so this is a tally of readings
615/// and never of people, and it cannot answer "how many different readers" because it never knew.
616///
617/// A tally whose post no longer exists is folded into one line rather than listed. The count is real
618/// and dropping it silently would make the total disagree with the rows; naming each deleted slug
619/// would be a list of things the reader cannot act on.
620fn reads_report(reads: &BTreeMap<String, u64>, recs: &[Record]) -> String {
621 let mut s = String::new();
622 s.push_str("<h2>Reads</h2>\n");
623
624 if reads.is_empty() {
625 s.push_str(&notice(
626 "Nothing has been read yet. A read is counted when a post is served to somebody who is \
627 neither signed in to manage the site nor an obvious machine.",
628 ));
629 return s;
630 }
631
632 // The rows a reader can act on: a live post and its tally, most-read first. A post nobody has
633 // read yet is shown at nought rather than omitted -- "which of my posts is unread" is exactly
634 // the question this page should answer.
635 let mut rows: Vec<(&str, String, u64)> = Vec::new();
636 for rec in recs {
637 let title = match rec.render() {
638 Ok(p) => p.title,
639 Err(_) => rec.slug.clone(),
640 };
641 rows.push((&rec.slug, title, reads.get(&rec.slug).copied().unwrap_or(0)));
642 }
643 rows.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
644
645 let total: u64 = reads.values().sum();
646 let live: u64 = rows.iter().map(|r| r.2).sum();
647 let gone = total.saturating_sub(live);
648 let read_posts = rows.iter().filter(|r| r.2 > 0).count();
649
650 s.push_str(&stat_cards(&[
651 ("Reads", fmt!("{}", total), "posts served, all time"),
652 ("Posts read", fmt!("{}/{}", read_posts, rows.len()), "have been read at least once"),
653 ]));
654
655 s.push_str("<table class=\"mc-table\">\n<thead><tr>\
656 <th>Post</th><th>Reads</th><th>Share</th>\
657 </tr></thead>\n<tbody>\n");
658 for (slug, title, n) in &rows {
659 s.push_str(&fmt!(
660 "<tr><td>{title}<br><span class=\"mc-slug\">{slug}</span></td>\
661 <td>{n}</td><td>{share}</td></tr>\n",
662 title = html_escape(title),
663 slug = html_escape(slug),
664 n = n,
665 share = if live == 0 { fmt!("&mdash;") } else { pct(*n as usize, live as usize) },
666 ));
667 }
668 s.push_str("</tbody>\n</table>\n");
669
670 if gone > 0 {
671 s.push_str(&fmt!(
672 "<p class=\"mc-muted\">{} {} counted against posts that have since been deleted.</p>\n",
673 gone,
674 if gone == 1 { "read was" } else { "reads were" },
675 ));
676 }
677
678 // The ceiling, on the page, for the same reason the list report states its own: an absent figure
679 // reads as an oversight unless it is named as a decision.
680 s.push_str(&notice(
681 "A read is a reading, not a reader: nothing identifies who asked, so one person returning \
682 twice counts twice. There is no open or click tracking anywhere on this site.",
683 ));
684 s
685}
686
687/// The list half of the report: the states as they stand, the shares they make, and growth by month.
688fn list_report(subs: &[subscribe::Subscriber]) -> String {
689 let mut s = String::new();
690 s.push_str("<h2>The list</h2>\n");
691
692 if subs.is_empty() {
693 s.push_str(&notice("Nobody has subscribed yet, so there is nothing to report."));
694 return s;
695 }
696
697 let confirmed = subs.iter().filter(|x| x.state == subscribe::SubState::Confirmed).count();
698 let pending = subs.iter().filter(|x| x.state == subscribe::SubState::Pending).count();
699 let unsubbed = subs.iter().filter(|x| x.state == subscribe::SubState::Unsubscribed).count();
700 let bounced = subs.iter().filter(|x| x.state == subscribe::SubState::Bounced).count();
701 let total = subs.len();
702
703 s.push_str(&stat_cards(&[
704 ("Reach", fmt!("{}", confirmed), "confirmed, and receiving"),
705 ("Awaiting", fmt!("{}", pending), "signed up, not yet confirmed"),
706 ("Left", fmt!("{}", unsubbed), "unsubscribed"),
707 ("Suppressed", fmt!("{}", bounced), "bounced, never retried"),
708 ]));
709
710 s.push_str(&fmt!(
711 "<p class=\"mc-muted\">{total} addresses on record. {conf_pct} of them are confirmed and \
712 {pend_pct} are still to confirm. Of those who confirmed, {churn} have since unsubscribed.</p>\n",
713 total = total,
714 conf_pct = pct(confirmed, total),
715 pend_pct = pct(pending, total),
716 churn = pct(unsubbed, confirmed + unsubbed),
717 ));
718
719 s.push_str(&notice(
720 "These are shares of the list as it stands, not rates over time. A subscriber records when it \
721 signed up and nothing else -- there is no confirmed-on or unsubscribed-on date -- so an address \
722 that confirmed and later left counts only in <em>left</em>, and the confirmed share therefore \
723 understates how many ever confirmed.",
724 ));
725
726 s.push_str(&month_table(
727 "Signed up by month",
728 "Sign-ups",
729 &by_month(subs.iter().map(|x| x.created.as_deref().unwrap_or(""))),
730 ));
731 s
732}
733
734/// The send half of the report: the totals across every send, the rate they make, and the per-post
735/// and per-month rollups.
736fn send_report(hist: &[send::SendEntry]) -> String {
737 let mut s = String::new();
738 s.push_str("<h2>Newsletter sends</h2>\n");
739
740 if hist.is_empty() {
741 s.push_str(&notice("No post has been mailed to the list yet, so there is nothing to report."));
742 return s;
743 }
744
745 let attempted: usize = hist.iter().map(|e| e.attempted).sum();
746 let sent: usize = hist.iter().map(|e| e.sent).sum();
747 let failed: usize = hist.iter().map(|e| e.failed).sum();
748 let suppressed: usize = hist.iter().map(|e| e.suppressed).sum();
749
750 s.push_str(&stat_cards(&[
751 ("Sends", fmt!("{}", hist.len()), "posts mailed to the list"),
752 ("Accepted", fmt!("{}", sent), "taken by a receiving server"),
753 ("Delivery", pct(sent, attempted), "of every address attempted"),
754 ("Suppressed", fmt!("{}", suppressed), "hard failures, now off the list"),
755 ]));
756
757 s.push_str(&fmt!(
758 "<p class=\"mc-muted\">{attempted} addresses attempted across {sends} sends: {sent} accepted, \
759 {failed} failed for now and will be tried on the next send, {suppressed} refused for good and \
760 suppressed.</p>\n",
761 attempted = attempted,
762 sends = hist.len(),
763 sent = sent,
764 failed = failed,
765 suppressed = suppressed,
766 ));
767
768 s.push_str(&notice(
769 "<em>Accepted</em> is what a receiving server took, which is not the same as what a person read. \
770 Whether a message was opened, and whether a link in it was followed, are deliberately not \
771 recorded.",
772 ));
773
774 // Per post, most attempted first: which post reached the most people.
775 let mut per_post: BTreeMap<&str, (usize, usize, usize, usize, usize)> = BTreeMap::new();
776 for e in hist {
777 let row = per_post.entry(e.slug.as_str()).or_insert((0, 0, 0, 0, 0));
778 row.0 += 1;
779 row.1 += e.attempted;
780 row.2 += e.sent;
781 row.3 += e.failed;
782 row.4 += e.suppressed;
783 }
784 let mut rows: Vec<(&str, (usize, usize, usize, usize, usize))> = per_post.into_iter().collect();
785 rows.sort_by(|a, b| b.1.1.cmp(&a.1.1));
786
787 s.push_str("<h3>By post</h3>\n");
788 s.push_str("<table class=\"mc-table\">\n<thead><tr>\
789 <th>Post</th><th>Sends</th><th>Attempted</th><th>Accepted</th><th>Delivery</th>\
790 <th>Suppressed</th>\
791 </tr></thead>\n<tbody>\n");
792 for (slug, (sends, att, ok, _fail, supp)) in &rows {
793 s.push_str(&fmt!(
794 "<tr><td><span class=\"mc-slug\">{slug}</span></td><td>{sends}</td><td>{att}</td>\
795 <td>{ok}</td><td>{rate}</td><td>{supp}</td></tr>\n",
796 slug = html_escape(slug),
797 sends = sends,
798 att = att,
799 ok = ok,
800 rate = pct(*ok, *att),
801 supp = supp,
802 ));
803 }
804 s.push_str("</tbody>\n</table>\n");
805
806 s.push_str(&month_table(
807 "Sends by month",
808 "Sends",
809 &by_month(hist.iter().map(|e| e.at.as_str())),
810 ));
811 s
812}
813
814/// A row of headline numbers: a big figure, what it counts, and a word on what it means.
815///
816/// Four at most read well on a phone, which is the width this is built for.
817fn stat_cards(cards: &[(&str, String, &str)]) -> String {
818 let mut s = String::new();
819 s.push_str("<div class=\"mc-stats\">\n");
820 for (key, value, note) in cards {
821 s.push_str(&fmt!(
822 "<div class=\"mc-stat\"><div class=\"mc-stat-n\">{value}</div>\
823 <div class=\"mc-stat-k\">{key}</div><div class=\"mc-stat-note\">{note}</div></div>\n",
824 value = html_escape(value),
825 key = html_escape(key),
826 note = html_escape(note),
827 ));
828 }
829 s.push_str("</div>\n");
830 s
831}
832
833/// Counts by calendar month, newest first, from a run of ISO timestamps.
834///
835/// A timestamp this cannot read a month from is counted under `unknown` rather than dropped: a
836/// subscriber that predates the sign-up date being recorded is still a subscriber, and a total that
837/// quietly disagreed with the list above it would be worse than an honest bucket.
838fn by_month<'a, I: Iterator<Item = &'a str>>(stamps: I) -> Vec<(String, usize)> {
839 let mut months: BTreeMap<String, usize> = BTreeMap::new();
840 for stamp in stamps {
841 let key = if stamp.len() >= 7 && stamp.is_char_boundary(7) {
842 stamp[..7].to_string()
843 } else {
844 fmt!("{}", UNDATED)
845 };
846 *months.entry(key).or_insert(0) += 1;
847 }
848 // Newest month first -- and `unknown` last whatever it sorts as, since it is not a month and would
849 // otherwise sit above every real one on a plain descending sort.
850 let mut out: Vec<(String, usize)> = months.into_iter().collect();
851 out.sort_by(|a, b| {
852 let (a_odd, b_odd) = (a.0 == UNDATED, b.0 == UNDATED);
853 a_odd.cmp(&b_odd).then_with(|| b.0.cmp(&a.0))
854 });
855 out
856}
857
858/// A month-by-month table with a bar for the shape of it, the widest month full width.
859fn month_table(heading: &str, unit: &str, months: &[(String, usize)]) -> String {
860 if months.is_empty() {
861 return String::new();
862 }
863 let peak = months.iter().map(|(_, n)| *n).max().unwrap_or(0);
864 let mut s = String::new();
865 s.push_str(&fmt!("<h3>{}</h3>\n", html_escape(heading)));
866 s.push_str(&fmt!(
867 "<table class=\"mc-table\">\n<thead><tr><th>Month</th><th>{}</th><th></th></tr></thead>\n\
868 <tbody>\n",
869 html_escape(unit),
870 ));
871 for (month, n) in months {
872 // The bar is decoration over the number beside it, so a zero peak simply draws nothing.
873 let width = if peak > 0 { (n * 100) / peak } else { 0 };
874 s.push_str(&fmt!(
875 "<tr><td>{month}</td><td>{n}</td>\
876 <td><div class=\"mc-bar\"><div class=\"mc-bar-fill\" style=\"width:{width}%\"></div></div></td>\
877 </tr>\n",
878 month = html_escape(month),
879 n = n,
880 width = width,
881 ));
882 }
883 s.push_str("</tbody>\n</table>\n");
884 s
885}
886
887const PAGE_SIZE: usize = 20; // rows before a list pages
888
889/// The search-and-filter row over a list of posts.
890///
891/// Says how many of how many are being shown, because a filter that silently hides things is how a
892/// person concludes their work has been lost. Submits by GET, so a filtered list is a link.
893fn list_filter(q: &str, want: &str, showing: usize, total: usize) -> String {
894 let count = if showing == total {
895 fmt!("{} posts", total)
896 } else {
897 fmt!("{} of {} posts", showing, total)
898 };
899 fmt!(
900 "<form class=\"mc-filter mc-form\" method=\"GET\" action=\"{root}\">\n\
901 <div class=\"mc-f-text\"><label for=\"q\">Search</label>\
902 <input type=\"text\" id=\"q\" name=\"q\" value=\"{q}\" placeholder=\"title or name\"></div>\n\
903 <div class=\"mc-f-sel\"><label for=\"state\">State</label>\
904 <select id=\"state\" name=\"state\">\
905 <option value=\"\"{any}>Any</option>\
906 <option value=\"draft\"{draft}>Draft</option>\
907 <option value=\"live\"{live}>Live</option>\
908 </select></div>\n\
909 <button type=\"submit\" class=\"mc-btn mc-btn-quiet\">Filter</button>\n\
910 <span class=\"mc-muted\" style=\"margin:0 0 0 auto\">{count}</span>\n\
911 </form>\n",
912 root = PATH_ROOT,
913 q = html_escape(q),
914 any = selected(want.is_empty()),
915 draft = selected(want == "draft"),
916 live = selected(want == "live"),
917 count = count,
918 )
919}
920
921/// Previous and next over a paged list, and where in it the reader is.
922///
923/// Nothing at all where everything fits on one page: a pager under a list of four is furniture that
924/// says only that there is no more.
925fn pager(path: &str, q: &str, want: &str, at: usize, pages: usize) -> String {
926 if pages <= 1 {
927 return String::new();
928 }
929 let carry = fmt!("&q={}&state={}", url_encode(q), url_encode(want));
930 let mut s = String::from("<div class=\"mc-pager\">");
931 if at > 1 {
932 s.push_str(&fmt!("<a href=\"{}?page={}{}\">Previous</a>", path, at - 1, carry));
933 }
934 s.push_str(&fmt!("<span class=\"mc-pager-at\">Page {} of {}</span>", at, pages));
935 if at < pages {
936 s.push_str(&fmt!("<a href=\"{}?page={}{}\">Next</a>", path, at + 1, carry));
937 }
938 s.push_str("</div>\n");
939 s
940}
941
942/// A post's row actions: read it as a reader would, and delete it.
943///
944/// Icons, because a row is a place for a verb and not a sentence, and because two words per row
945/// across twenty rows is a wall of text where the eye wants the titles. Deleting asks first.
946fn post_actions(csrf: &str, slug: &str) -> String {
947 fmt!(
948 "<div class=\"mc-actions\">\
949 <a class=\"mc-ico\" href=\"{preview}?slug={slug}\" title=\"Preview as a reader\" \
950 aria-label=\"Preview as a reader\">{eye}</a>\
951 <form method=\"POST\" action=\"{del}\" style=\"display:inline\" \
952 onsubmit=\"return confirm('Delete &quot;{slug}&quot;? There is no undo.')\">\
953 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
954 <input type=\"hidden\" name=\"slug\" value=\"{slug}\">\
955 <button type=\"submit\" class=\"mc-ico mc-ico-danger\" title=\"Delete\" \
956 aria-label=\"Delete\">{trash}</button>\
957 </form>\
958 </div>",
959 preview = PATH_PREVIEW,
960 del = PATH_DELETE,
961 csrf = html_escape(csrf),
962 slug = html_escape(slug),
963 eye = icon("eye"),
964 trash = icon("trash"),
965 )
966}
967
968/// The live preview: the text as it will read, beside the box it is typed in.
969///
970/// The server renders it, over [`PATH_RENDER`], because there is one tested parser and it is in
971/// Rust. That costs a round trip, so it is debounced rather than run per keystroke -- and the delay
972/// is why the pane says nothing at all until the first render lands, rather than flashing empty.
973/// Changing the markup select re-renders too: the same source is a different document in Djot.
974///
975/// A failed render shows its complaint in the pane. Prose that will not parse is a thing the author
976/// wants to see immediately, and it is the one message the preview exists to deliver.
977fn preview_script(csrf: &str) -> String {
978 fmt!(
979 "<script>\n\
980 (function () {{\n\
981 \tvar src = document.getElementById('source');\n\
982 \tvar out = document.getElementById('mc-preview');\n\
983 \tvar mk = document.getElementById('markup');\n\
984 \tif (!src || !out) return;\n\
985 \tvar timer = null;\n\
986 \tfunction draw() {{\n\
987 \t\tvar body = 'csrf={csrf}&markup=' + encodeURIComponent(mk ? mk.value : 'markdown')\n\
988 \t\t\t+ '&source=' + encodeURIComponent(src.value);\n\
989 \t\tfetch('{render}', {{ method: 'POST', credentials: 'same-origin',\n\
990 \t\t\theaders: {{ 'Content-Type': 'application/x-www-form-urlencoded' }}, body: body }})\n\
991 \t\t\t.then(function (r) {{ return r.json(); }})\n\
992 \t\t\t.then(function (d) {{\n\
993 \t\t\t\tif (d && typeof d.html === 'string') out.innerHTML = d.html;\n\
994 \t\t\t\telse if (d && d.error) out.textContent = d.error;\n\
995 \t\t\t}})\n\
996 \t\t\t.catch(function () {{}});\n\
997 \t}}\n\
998 \tfunction soon() {{ clearTimeout(timer); timer = setTimeout(draw, 400); }}\n\
999 \tsrc.addEventListener('input', soon);\n\
1000 \tif (mk) mk.addEventListener('change', draw);\n\
1001 \tdraw();\n\
1002 }})();\n\
1003 </script>\n",
1004 csrf = html_escape(csrf),
1005 render = PATH_RENDER,
1006 )
1007}
1008
1009/// The close icon, for the page shell, which draws the way out of the console itself.
1010pub fn icon_close() -> &'static str {
1011 icon("close")
1012}
1013
1014/// An inline SVG icon, drawn in the current text colour at the size of the control holding it.
1015///
1016/// Inline rather than a file: the console is one response with no asset it can be separated from,
1017/// and a stylesheet that reaches for an image is a stylesheet that can arrive without one. Unknown
1018/// names give nothing, so a typo shows as a bare button rather than a broken glyph.
1019fn icon(name: &str) -> &'static str {
1020 match name {
1021 "close" => "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" \
1022 stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>",
1023 "trash" => "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" \
1024 stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\
1025 <path d=\"M2.5 4h11M6 4V2.5h4V4M4 4l.7 9.5h6.6L12 4M6.5 6.5v5M9.5 6.5v5\"/></svg>",
1026 "eye" => "<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" \
1027 stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\
1028 <path d=\"M1 8s2.6-4.5 7-4.5S15 8 15 8s-2.6 4.5-7 4.5S1 8 1 8z\"/>\
1029 <circle cx=\"8\" cy=\"8\" r=\"1.9\"/></svg>",
1030 _ => "",
1031 }
1032}
1033
1034/// A percentage of a total, to one decimal place, or a dash where the total is zero.
1035///
1036/// Nothing out of nothing is not zero per cent, and printing it as such would invent a fact.
1037fn pct(n: usize, d: usize) -> String {
1038 if d == 0 {
1039 return fmt!("--");
1040 }
1041 fmt!("{:.1}%", (n as f64 * 100.0) / d as f64)
1042}
1043
1044/// The "send a post to subscribers" form: a live post picked from a select, and the send.
1045///
1046/// Offered with a count of who will receive it, so the operator sends with their eyes open. Where no
1047/// post is live, or nobody is confirmed, it says so instead of offering a button that would do nothing.
1048fn send_form<
1049 const UIDL: usize,
1050 UID: NumIdDat<UIDL>,
1051 ENC: Encrypter,
1052 KH: Hasher,
1053 DB: Database<UIDL, UID, ENC, KH>,
1054>(
1055 _cfg: &PublishConfig,
1056 csrf: &str,
1057 db: &(Arc<RwLock<DB>>, UID),
1058 confirmed: usize,
1059 id: &str,
1060)
1061 -> String
1062{
1063 // The live posts, the only ones a newsletter may carry: a draft is sent to nobody.
1064 let live: Vec<Record> = match store::list_records(db, id) {
1065 Ok(recs) => recs.into_iter().filter(|r| r.state == PostState::Live).collect(),
1066 Err(e) => {
1067 warn!("{}: console: cannot list posts for the send form: {}", id, e);
1068 Vec::new()
1069 }
1070 };
1071 if live.is_empty() {
1072 return notice("No post is live to send. Publish a post first, then send it here.");
1073 }
1074 if confirmed == 0 {
1075 return notice("No confirmed subscribers to send to yet.");
1076 }
1077
1078 let mut opts = String::new();
1079 for rec in &live {
1080 let title = match rec.render() {
1081 Ok(p) => p.title,
1082 Err(_) => rec.slug.clone(),
1083 };
1084 opts.push_str(&fmt!(
1085 "<option value=\"{slug}\">{title}</option>\n",
1086 slug = html_escape(&rec.slug),
1087 title = html_escape(&title),
1088 ));
1089 }
1090
1091 fmt!(
1092 "<form class=\"mc-form\" method=\"POST\" action=\"{send}\" \
1093 onsubmit=\"return confirm('Send this post to {n} confirmed subscriber(s)? There is no undo.')\">\n\
1094 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
1095 <label for=\"mc-send-slug\">Send a post to {n} subscriber(s)</label>\n\
1096 <select id=\"mc-send-slug\" name=\"slug\">\n{opts}</select>\n\
1097 <div class=\"mc-actions\">\n\
1098 <button type=\"submit\" class=\"mc-btn\">Send to subscribers</button>\n\
1099 </div>\n\
1100 </form>\n",
1101 send = PATH_NEWSLETTER,
1102 csrf = html_escape(csrf),
1103 n = confirmed,
1104 opts = opts,
1105 )
1106}
1107
1108/// The "send a test" form: a live post, an address to send it to, and the send.
1109///
1110/// The operator's own preview -- it mails the chosen post to one address and touches nothing: no
1111/// subscriber, no state, no history. Offered wherever there is a live post, since a test needs no
1112/// confirmed subscriber. Says so where there is none, rather than a select with nothing to pick.
1113fn test_form<
1114 const UIDL: usize,
1115 UID: NumIdDat<UIDL>,
1116 ENC: Encrypter,
1117 KH: Hasher,
1118 DB: Database<UIDL, UID, ENC, KH>,
1119>(
1120 csrf: &str,
1121 db: &(Arc<RwLock<DB>>, UID),
1122 id: &str,
1123)
1124 -> String
1125{
1126 // The live posts, the only ones the test offers, since a test is a preview of what a subscriber gets
1127 // and a subscriber only ever gets a live post.
1128 let live: Vec<Record> = match store::list_records(db, id) {
1129 Ok(recs) => recs.into_iter().filter(|r| r.state == PostState::Live).collect(),
1130 Err(e) => {
1131 warn!("{}: console: cannot list posts for the test form: {}", id, e);
1132 Vec::new()
1133 }
1134 };
1135 if live.is_empty() {
1136 return String::new();
1137 }
1138
1139 let mut opts = String::new();
1140 for rec in &live {
1141 let title = match rec.render() {
1142 Ok(p) => p.title,
1143 Err(_) => rec.slug.clone(),
1144 };
1145 opts.push_str(&fmt!(
1146 "<option value=\"{slug}\">{title}</option>\n",
1147 slug = html_escape(&rec.slug),
1148 title = html_escape(&title),
1149 ));
1150 }
1151
1152 fmt!(
1153 "<form class=\"mc-form\" method=\"POST\" action=\"{test}\">\n\
1154 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
1155 <label for=\"mc-test-slug\">Send a test to one address</label>\n\
1156 <select id=\"mc-test-slug\" name=\"slug\">\n{opts}</select>\n\
1157 <input type=\"text\" id=\"mc-test-to\" name=\"test_to\" placeholder=\"you@example.com\" \
1158 autocomplete=\"off\" spellcheck=\"false\">\n\
1159 <div class=\"mc-actions\">\n\
1160 <button type=\"submit\" class=\"mc-btn mc-btn-quiet\">Send test</button>\n\
1161 </div>\n\
1162 </form>\n",
1163 test = PATH_NEWSLETTER_TEST,
1164 csrf = html_escape(csrf),
1165 opts = opts,
1166 )
1167}
1168
1169fn subscribers_csv<
1170 const UIDL: usize,
1171 UID: NumIdDat<UIDL>,
1172 ENC: Encrypter,
1173 KH: Hasher,
1174 DB: Database<UIDL, UID, ENC, KH>,
1175>(
1176 db: Option<&(Arc<RwLock<DB>>, UID)>,
1177 id: &str,
1178)
1179 -> Outcome<HttpMessage>
1180{
1181 let db = match db {
1182 Some(db) => db,
1183 None => return Ok(cache::generated(HttpMessage::respond_with_text(
1184 HttpStatus::NotFound, "Not found."))),
1185 };
1186 let csv = res!(subscribe::export(db, id));
1187 let mut resp = HttpMessage::ok_respond_with_text(csv);
1188 resp = resp.with_field(
1189 HeaderName::ContentType,
1190 HeaderFieldValue::Generic(fmt!("text/csv; charset=utf-8")),
1191 );
1192 // The list as it stands, and it stands behind a session.
1193 Ok(cache::generated(resp))
1194}
1195
1196/// The list of posts, drafts and all.
1197fn handle_list<
1198 const UIDL: usize,
1199 UID: NumIdDat<UIDL>,
1200 ENC: Encrypter,
1201 KH: Hasher,
1202 DB: Database<UIDL, UID, ENC, KH>,
1203>(
1204 cfg: &PublishConfig,
1205 theme: &Theme,
1206 admin: &SiteAdmin,
1207 csrf: &str,
1208 db: Option<&(Arc<RwLock<DB>>, UID)>,
1209 query: &str,
1210 id: &str,
1211)
1212 -> Outcome<HttpMessage>
1213{
1214 let mut body = String::new();
1215
1216 // The one thing this page is for, at the top right where the eye lands after the heading.
1217 body.push_str(&fmt!(
1218 "<div class=\"mc-head-row\"><h1>Posts</h1>\
1219 <div class=\"mc-actions\"><a class=\"mc-btn\" href=\"{edit}\">Write</a></div></div>\n\
1220 <p class=\"mc-muted\">Served at <a href=\"{path}\">{path}</a>.</p>\n",
1221 edit = PATH_EDIT,
1222 path = html_escape(&cfg.path),
1223 ));
1224
1225 // A write that could not go through said why, in the query it was redirected with. Shown here,
1226 // where the writer lands, rather than swallowed -- the composer this grew from redirected with
1227 // the reason and then never showed it.
1228 if let Some(said) = query_field(query, "said") {
1229 body.push_str(&notice(&html_escape(&said)));
1230 }
1231
1232 // A directory-backed site has nothing to edit here: the files are the posts. Say what to do
1233 // rather than leave the editor refusing to save with no reason.
1234 if cfg.source != Source::Store {
1235 body.push_str(&notice(&fmt!(
1236 "This site serves its posts from the directory <code>{dir}</code>, so they are edited by \
1237 editing those files, and there is nothing to write here yet. To move it into the database \
1238 and write here instead: import first, while the directory is still being served, then set \
1239 <code>source</code> to <code>\"store\"</code> in this site's <code>publish</code> block \
1240 and restart. That order keeps the site up; the other empties it until the import runs.",
1241 dir = html_escape(&cfg.dir),
1242 )));
1243 body.push_str(&import_form(csrf, &cfg.dir));
1244 return Ok(page(theme, admin, "Posts", &body));
1245 }
1246
1247 let db = match db {
1248 Some(db) => db,
1249 None => {
1250 body.push_str(&notice(
1251 "This site keeps its posts in its database, and has no database configured. Set \
1252 <code>db_dir_rel</code> on the vhost.",
1253 ));
1254 return Ok(page(theme, admin, "Posts", &body));
1255 }
1256 };
1257
1258 let recs = match store::list_records(db, id) {
1259 Ok(r) => r,
1260 Err(e) => {
1261 error!(e, "{}: console: cannot list the posts", id);
1262 body.push_str(&notice("The posts could not be listed. The log says why."));
1263 return Ok(page(theme, admin, "Posts", &body));
1264 }
1265 };
1266
1267 if recs.is_empty() {
1268 body.push_str(&notice("Nothing written yet."));
1269 body.push_str(&import_form(csrf, &cfg.dir));
1270 return Ok(page(theme, admin, "Posts", &body));
1271 }
1272
1273 // What the reader of this page asked to see. A site with three posts needs none of this; a site
1274 // with three hundred is unusable without it, and the same page has to serve both.
1275 let q = query_field(query, "q").unwrap_or_default();
1276 let want = query_field(query, "state").unwrap_or_default();
1277 let needle = q.to_lowercase();
1278
1279 // Titles cost a parse, so each record is rendered once here and the result carried: the filter
1280 // wants the title, the row wants the title, and parsing twice for one row would be paying twice.
1281 let mut rows: Vec<(&Record, String, bool)> = Vec::new();
1282 for rec in &recs {
1283 let (title, broken) = match rec.render() {
1284 Ok(p) => (p.title, false),
1285 Err(e) => {
1286 warn!("{}: console: '{}' will not render: {}", id, rec.slug, e);
1287 (rec.slug.clone(), true)
1288 }
1289 };
1290 let matches_state = match want.as_str() {
1291 "draft" => rec.state == PostState::Draft,
1292 "live" => rec.state == PostState::Live,
1293 _ => true,
1294 };
1295 let matches_text = needle.is_empty()
1296 || title.to_lowercase().contains(&needle)
1297 || rec.slug.to_lowercase().contains(&needle);
1298 if matches_state && matches_text {
1299 rows.push((rec, title, broken));
1300 }
1301 }
1302
1303 body.push_str(&list_filter(&q, &want, rows.len(), recs.len()));
1304
1305 if rows.is_empty() {
1306 body.push_str(&notice("No post matches that."));
1307 return Ok(page(theme, admin, "Posts", &body));
1308 }
1309
1310 // One page of them. Slicing after the filter, so a search reaches the whole site and not just
1311 // whatever happened to be on the page being looked at.
1312 let page_at = query_field(query, "page").and_then(|p| p.parse::<usize>().ok()).unwrap_or(1).max(1);
1313 let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
1314 let page_at = page_at.min(pages);
1315 let from = (page_at - 1) * PAGE_SIZE;
1316 let upto = (from + PAGE_SIZE).min(rows.len());
1317
1318 body.push_str("<table class=\"mc-table\">\n<thead><tr>\
1319 <th>Post</th><th>Categories</th><th>State</th><th>Date</th><th></th>\
1320 </tr></thead>\n<tbody>\n");
1321 for (rec, title, broken) in &rows[from..upto] {
1322 let rec = *rec;
1323 let broken = *broken;
1324 let title = html_escape(title);
1325 let slug = html_escape(&rec.slug);
1326 let state = if broken {
1327 fmt!("<span class=\"mc-tag mc-tag-err\">will not render</span>")
1328 } else {
1329 match rec.state {
1330 PostState::Live => fmt!("<span class=\"mc-tag mc-tag-live\">live</span>"),
1331 PostState::Draft => fmt!("<span class=\"mc-tag\">draft</span>"),
1332 }
1333 };
1334 body.push_str(&fmt!(
1335 "<tr>\
1336 <td><a href=\"{edit}?slug={slug}\">{title}</a><br><span class=\"mc-slug\">{slug}</span></td>\
1337 <td>{cats}</td>\
1338 <td>{state}</td>\
1339 <td>{date}</td>\
1340 <td>{actions}</td>\
1341 </tr>\n",
1342 edit = PATH_EDIT,
1343 slug = slug,
1344 title = title,
1345 cats = if rec.categories.is_empty() {
1346 fmt!("<span class=\"mc-slug\">--</span>")
1347 } else {
1348 html_escape(&rec.categories.join(", "))
1349 },
1350 state = state,
1351 date = html_escape(&rec.date.as_deref().map(date_text)
1352 .unwrap_or_else(|| fmt!("--"))),
1353 actions = post_actions(csrf, &rec.slug),
1354 ));
1355 }
1356 body.push_str("</tbody>\n</table>\n");
1357 body.push_str(&pager(PATH_ROOT, &q, &want, page_at, pages));
1358 body.push_str(&import_form(csrf, &cfg.dir));
1359
1360 Ok(page(theme, admin, "Posts", &body))
1361}
1362
1363/// The editor, for a post that exists or one that does not yet.
1364fn handle_edit<
1365 const UIDL: usize,
1366 UID: NumIdDat<UIDL>,
1367 ENC: Encrypter,
1368 KH: Hasher,
1369 DB: Database<UIDL, UID, ENC, KH>,
1370>(
1371 cfg: &PublishConfig,
1372 theme: &Theme,
1373 admin: &SiteAdmin,
1374 csrf: &str,
1375 curator: bool,
1376 db: Option<&(Arc<RwLock<DB>>, UID)>,
1377 query: &str,
1378 id: &str,
1379)
1380 -> Outcome<HttpMessage>
1381{
1382 if cfg.source != Source::Store {
1383 return Ok(page(theme, admin, "Posts", &notice(
1384 "This site serves its posts from a directory, so there is nothing here to edit them with.",
1385 )));
1386 }
1387
1388 let slug = query_field(query, "slug");
1389
1390 // No slug is a new post, which is the editor with nothing in it.
1391 let rec = match &slug {
1392 None => None,
1393 Some(slug) => {
1394 let db = match db {
1395 Some(db) => db,
1396 None => return Ok(page(theme, admin, "Posts", &notice(
1397 "This site has no database configured.",
1398 ))),
1399 };
1400 match store::get(db, slug) {
1401 Ok(Some(r)) => Some(r),
1402 Ok(None) => return Ok(page(theme, admin, "Posts", &notice(
1403 "There is no post by that name.",
1404 ))),
1405 Err(e) => {
1406 error!(e, "{}: console: cannot read '{}'", id, slug);
1407 return Ok(page(theme, admin, "Posts", &notice(
1408 "That post could not be read. The log says why.",
1409 )));
1410 }
1411 }
1412 }
1413 };
1414
1415 let heading = match &rec {
1416 Some(_) => "Edit a post",
1417 None => "Write a new post",
1418 };
1419 let r = rec.unwrap_or_default();
1420
1421 // The site's accumulating vocabulary, for the click-to-add palette. A read the composer already
1422 // pays for the list; a failure to read it costs the palette, not the editor, so it logs and
1423 // carries on with an empty one rather than refusing the page.
1424 let palette = match db {
1425 Some(db) => match store::tag_counts(db, id) {
1426 Ok(t) => t,
1427 Err(e) => {
1428 warn!("{}: console: cannot list the tag vocabulary: {}", id, e);
1429 Vec::new()
1430 }
1431 },
1432 None => Vec::new(),
1433 };
1434
1435 // Who the post is written as: its own author where it has one, otherwise whoever is composing --
1436 // a new post belongs to its writer until said otherwise.
1437 let author_user = if r.author.is_empty() { admin.username.clone() } else { r.author.clone() };
1438 // Resolved to a display name, falling back to `Anonymous` and NEVER to the username. A site
1439 // login's username is the SHA-256 of its passphrase, so drawing one on a page hands whoever
1440 // reads that page an offline verifier for a guess -- and on a blog more than one person writes,
1441 // the page an admin opens to edit somebody else's post is read by someone who is not its owner.
1442 // The same reasoning removed usernames from the reader's pages; the console is not exempt.
1443 let author_name = match db {
1444 Some(db) => store::get_profile(db, &author_user)
1445 .map(|p| if p.name.is_empty() { fmt!("Anonymous") } else { p.name })
1446 .unwrap_or_else(|_| fmt!("Anonymous")),
1447 None => fmt!("Anonymous"),
1448 };
1449 // And the name of whoever is composing, for the control that takes a post over. Resolved the same
1450 // way, so a signer who has set no profile is offered their own honest "Anonymous" rather than a
1451 // username.
1452 let signer_name = if admin.username == author_user {
1453 author_name.clone()
1454 } else {
1455 match db {
1456 Some(db) => store::get_profile(db, &admin.username)
1457 .map(|p| if p.name.is_empty() { fmt!("Anonymous") } else { p.name })
1458 .unwrap_or_else(|_| fmt!("Anonymous")),
1459 None => fmt!("Anonymous"),
1460 }
1461 };
1462
1463 // The title row: what this is, and the way out. The way out is the close, not a Cancel button --
1464 // leaving is not an action of the same weight as saving, and should not look like one.
1465 let mut body = fmt!(
1466 "<div class=\"mc-head-row\"><h1>{heading}</h1>\
1467 <a class=\"mc-close\" href=\"{root}\" title=\"Close\" aria-label=\"Close\">{close}</a></div>\n",
1468 heading = heading,
1469 root = PATH_ROOT,
1470 close = icon("close"),
1471 );
1472
1473 body.push_str(&fmt!(
1474 "<form class=\"mc-form\" method=\"POST\" action=\"{save}\">\n\
1475 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
1476 <input type=\"hidden\" name=\"was\" value=\"{was}\">\n\
1477 <div class=\"mc-row\">\n\
1478 <div>\n\
1479 <label for=\"slug\">Name in the URL</label>\n\
1480 <input type=\"text\" id=\"slug\" name=\"slug\" value=\"{slug}\" \
1481 placeholder=\"on-rent\" required>\n\
1482 </div>\n\
1483 <div>\n\
1484 <label for=\"date\">Date</label>\n\
1485 <input type=\"text\" id=\"date\" name=\"date\" value=\"{date}\" \
1486 placeholder=\"2026-07-17 14:30\">\n\
1487 </div>\n\
1488 <div>\n\
1489 <label for=\"state\">State</label>\n\
1490 <select id=\"state\" name=\"state\">\n\
1491 <option value=\"draft\"{draft_sel}>Draft</option>\n\
1492 <option value=\"live\"{live_sel}>Live</option>\n\
1493 </select>\n\
1494 </div>\n\
1495 <div>\n\
1496 <label for=\"markup\">Written in</label>\n\
1497 <select id=\"markup\" name=\"markup\">\n\
1498 <option value=\"markdown\"{md_sel}>Markdown</option>\n\
1499 <option value=\"djot\"{djot_sel}>Djot</option>\n\
1500 </select>\n\
1501 </div>\n\
1502 {declare_field}\
1503 {author_field}\
1504 </div>\n\
1505 {cats_block}\
1506 {tags_block}\
1507 <div class=\"mc-split\">\n\
1508 <div class=\"mc-pane\">\n\
1509 <div class=\"mc-pane-head\"><label for=\"source\">Text</label>\
1510 <button type=\"button\" class=\"mc-btn mc-btn-quiet mc-fix-btn\" id=\"mc-fix\">Fix\
1511 </button></div>\n\
1512 <textarea id=\"source\" name=\"source\" rows=\"24\" spellcheck=\"true\" \
1513 placeholder=\"# The title goes here, as the first heading\">{source}</textarea>\n\
1514 </div>\n\
1515 <div class=\"mc-pane\">\n\
1516 <label for=\"mc-preview\">Preview</label>\n\
1517 <div class=\"mc-preview\" id=\"mc-preview\"></div>\n\
1518 </div>\n\
1519 </div>\n\
1520 <div class=\"mc-fix-panel\" id=\"mc-fix-panel\" hidden>\n\
1521 <div class=\"mc-fix-head\"><strong>Suggested fixes</strong>\
1522 <span class=\"mc-note mc-inline\" id=\"mc-fix-note\"></span></div>\n\
1523 <div class=\"mc-fix-diff\" id=\"mc-fix-diff\"></div>\n\
1524 <div class=\"mc-actions\">\n\
1525 <button type=\"button\" class=\"mc-btn\" id=\"mc-fix-use\">Use this</button>\n\
1526 <button type=\"button\" class=\"mc-btn mc-btn-quiet\" id=\"mc-fix-discard\">Discard\
1527 </button>\n\
1528 </div>\n\
1529 </div>\n\
1530 <div class=\"mc-actions\">\n\
1531 <span class=\"mc-autosave\" id=\"mc-autosave\" aria-live=\"polite\"></span>\n\
1532 </div>\n\
1533 </form>\n",
1534 save = PATH_SAVE,
1535 csrf = html_escape(csrf),
1536 // What the post was called on the way in, so a renamed slug takes the old record with it.
1537 was = html_escape(&r.slug),
1538 slug = html_escape(&r.slug),
1539 // The readable form in the box: a person edits what a person reads, and the `T` goes back in
1540 // at the door on the way to the store.
1541 // A post that has a date shows it; one that has none is offered today, which is what an
1542 // author writing now means. Offered rather than imposed -- it is an ordinary field and
1543 // they may type over it or clear it, and a cleared field still saves as today.
1544 date = html_escape(&r.date.as_deref().map(date_text)
1545 .or_else(crate::srv::publish::today)
1546 .unwrap_or_default()),
1547 draft_sel = selected(r.state == PostState::Draft),
1548 live_sel = selected(r.state == PostState::Live),
1549 md_sel = selected(r.markup == Markup::Markdown),
1550 djot_sel = selected(r.markup == Markup::Djot),
1551 source = html_escape(&r.source),
1552 // How much the writing needed AI, where the site declares under a scheme at all.
1553 declare_field = declare_field(cfg, r.ai_level),
1554 // A hidden author field carrying the username, a line naming who the post is written as, and
1555 // -- where that is somebody else -- the one control that can take it over.
1556 author_field = author_field(&author_user, &author_name, &admin.username, &signer_name),
1557 // The category checkboxes, from the site's taxonomy, ticked where the post already sits.
1558 cats_block = cats_field(&cfg.categories, &r.categories),
1559 // Whole blocks, pre-built, so the inline scripts' braces never reach the format string.
1560 tags_block = tags_field(&r.tags, &palette, curator),
1561 ));
1562
1563 // Deleting is not an editing action: it belongs beside the post in the list, where a person is
1564 // choosing between posts, not in the editor, where a person is working on one. The editor's only
1565 // verb is Save.
1566 body.push_str(&preview_script(csrf));
1567 body.push_str(AUTOSAVE_SCRIPT);
1568 body.push_str(AUTHOR_SCRIPT);
1569 body.push_str(FIX_SCRIPT);
1570
1571 Ok(page(theme, admin, "Edit", &body))
1572}
1573
1574/// A member's own profile: the name and avatar readers see, which their login username -- a hash --
1575/// cannot be. The one place a member sets what a byline and the index's author row show for them.
1576fn profile_page<
1577 const UIDL: usize,
1578 UID: NumIdDat<UIDL>,
1579 ENC: Encrypter,
1580 KH: Hasher,
1581 DB: Database<UIDL, UID, ENC, KH>,
1582>(
1583 cfg: &PublishConfig,
1584 theme: &Theme,
1585 admin: &SiteAdmin,
1586 csrf: &str,
1587 db: Option<&(Arc<RwLock<DB>>, UID)>,
1588 id: &str,
1589)
1590 -> Outcome<HttpMessage>
1591{
1592 let profile = match db {
1593 Some(db) => store::get_profile(db, &admin.username).unwrap_or_default(),
1594 None => return Ok(page(theme, admin, "Profile", &notice(
1595 "This site has no database, so there is no profile to keep."))),
1596 };
1597 let preview = if profile.avatar.is_empty() {
1598 let initial = profile.name.chars().next()
1599 .or_else(|| admin.username.chars().next())
1600 .map(|c| c.to_uppercase().to_string()).unwrap_or_else(|| fmt!("?"));
1601 fmt!("<span class=\"mc-avatar-initial\">{}</span>", html_escape(&initial))
1602 } else {
1603 fmt!("<img class=\"mc-avatar-pic\" alt=\"\" src=\"{}\">", html_escape(&profile.avatar))
1604 };
1605 // Whether the picture in the form is one this site holds. A member who uploaded one is offered a
1606 // way to take it off again; one who gave a URL edits the URL.
1607 let uploaded = !profile.handle.is_empty() && profile.avatar == cfg.avatar_path(&profile.handle);
1608 debug!("{}: console: '{}' opened their profile", id, admin.username);
1609 let body = fmt!(
1610 "<div class=\"mc-head-row\"><h1>Your profile</h1>\
1611 <a class=\"mc-close\" href=\"{root}\" title=\"Close\" aria-label=\"Close\">{close}</a></div>\n\
1612 <p class=\"mc-muted\">The name, picture and description readers see. Your login is not shown to \
1613 anyone.</p>\n\
1614 <form class=\"mc-form\" method=\"POST\" action=\"{save}\">\n\
1615 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
1616 <div class=\"mc-avatar-row\" id=\"mcAvatarRow\">{preview}\n\
1617 <div class=\"mc-avatar-pick\">\n\
1618 <label class=\"mc-btn mc-btn-quiet\" for=\"picture\">Choose a picture</label>\n\
1619 <input type=\"file\" id=\"picture\" accept=\"image/png,image/jpeg,image/gif,image/webp\" hidden>\n\
1620 {drop}\
1621 <p class=\"mc-hint\" id=\"mcPicHint\">PNG, JPEG, GIF or WebP, up to {cap} KB. Kept by this \
1622 site and served from it.</p>\n</div>\n</div>\n\
1623 <input type=\"hidden\" name=\"picture_data\" id=\"mcPicData\" value=\"\">\n\
1624 <div>\n<label for=\"name\">Display name</label>\n\
1625 <input type=\"text\" id=\"name\" name=\"name\" value=\"{name}\" placeholder=\"Your name\">\n</div>\n\
1626 <div>\n<label for=\"bio\">What you write about</label>\n\
1627 <textarea id=\"bio\" name=\"bio\" rows=\"4\" \
1628 placeholder=\"A sentence or two. Readers meet this above your posts.\">{bio}</textarea>\n\
1629 <p class=\"mc-hint\">Shown at the top of the blog and under each of your posts. Where you are \
1630 the only person writing here, this is what the blog is about.</p>\n</div>\n\
1631 <div>\n<label for=\"avatar\">Or a picture's address</label>\n\
1632 <input type=\"text\" id=\"avatar\" name=\"avatar\" value=\"{avatar}\" \
1633 placeholder=\"/img/authors/you.jpg\">\n\
1634 <p class=\"mc-hint\">A path on this site, or a full URL. Choosing a picture above fills this \
1635 in. Left empty, your posts show your initial.</p>\n</div>\n\
1636 <div class=\"mc-actions\"><button type=\"submit\" class=\"mc-btn\">Save profile</button></div>\n\
1637 </form>\n{script}",
1638 root = PATH_ROOT,
1639 close = icon("close"),
1640 save = PATH_PROFILE_SAVE,
1641 csrf = html_escape(csrf),
1642 preview = preview,
1643 drop = if uploaded {
1644 fmt!("<button type=\"button\" class=\"mc-btn mc-btn-quiet\" id=\"mcPicDrop\">Remove \
1645 picture</button>\n")
1646 } else {
1647 String::new()
1648 },
1649 cap = AVATAR_MAX_BYTES / 1024,
1650 name = html_escape(&profile.name),
1651 bio = html_escape(&profile.bio),
1652 avatar = html_escape(&profile.avatar),
1653 script = PROFILE_SCRIPT,
1654 );
1655 Ok(page(theme, admin, "Profile", &body))
1656}
1657
1658// The largest picture a member may upload, before base64. A quarter of a megabyte is a generous
1659// avatar and a poor way to move a photograph, which is the balance wanted: the record lives in the
1660// site's own database beside its posts.
1661pub const AVATAR_MAX_BYTES: usize = 256 * 1024;
1662
1663// Wires the profile's picker: the chosen file becomes a data URL in a hidden field, and the preview
1664// changes at once so a member sees what they are about to save.
1665//
1666// Without it the form still works -- a member types an address into the field below, which is what
1667// the picker fills in for them. This is the enhancement, not the mechanism.
1668const PROFILE_SCRIPT: &str = r#"<script>
1669(function () {
1670 "use strict";
1671 var pick = document.getElementById("picture");
1672 var data = document.getElementById("mcPicData");
1673 var row = document.getElementById("mcAvatarRow");
1674 var url = document.getElementById("avatar");
1675 var hint = document.getElementById("mcPicHint");
1676 var drop = document.getElementById("mcPicDrop");
1677 if (!pick || !data || !row) { return; }
1678 var cap = 256 * 1024;
1679 pick.addEventListener("change", function () {
1680 var f = pick.files && pick.files[0];
1681 if (!f) { return; }
1682 if (f.size > cap) {
1683 if (hint) { hint.textContent = "That picture is too big. The most is 256 KB."; }
1684 pick.value = "";
1685 return;
1686 }
1687 var r = new FileReader();
1688 r.onload = function () {
1689 data.value = r.result;
1690 var img = row.querySelector("img");
1691 if (!img) {
1692 var old = row.querySelector(".mc-avatar-initial");
1693 img = document.createElement("img");
1694 img.className = "mc-avatar-pic";
1695 img.alt = "";
1696 if (old) { old.parentNode.replaceChild(img, old); }
1697 else { row.insertBefore(img, row.firstChild); }
1698 }
1699 img.src = r.result;
1700 // The address field says where the picture will be served from once it is saved, so the
1701 // two controls never disagree about which picture this is.
1702 if (url) { url.value = ""; }
1703 if (hint) { hint.textContent = "Ready to save."; }
1704 };
1705 r.readAsDataURL(f);
1706 });
1707 if (drop) {
1708 drop.addEventListener("click", function () {
1709 data.value = "";
1710 if (url) { url.value = ""; }
1711 if (hint) { hint.textContent = "The picture goes when you save."; }
1712 });
1713 }
1714})();
1715</script>
1716"#;
1717
1718/// A post as a reader would get it, whether or not a reader can.
1719///
1720/// A draft is served to nobody, so its author cannot see it by visiting it. Here the same rendering
1721/// runs behind the gate.
1722fn handle_preview<
1723 const UIDL: usize,
1724 UID: NumIdDat<UIDL>,
1725 ENC: Encrypter,
1726 KH: Hasher,
1727 DB: Database<UIDL, UID, ENC, KH>,
1728>(
1729 cfg: &PublishConfig,
1730 theme: &Theme,
1731 admin: &SiteAdmin,
1732 db: Option<&(Arc<RwLock<DB>>, UID)>,
1733 query: &str,
1734 id: &str,
1735)
1736 -> Outcome<HttpMessage>
1737{
1738 let slug = match query_field(query, "slug") {
1739 Some(s) => s,
1740 None => return Ok(page(theme, admin, "Posts", &notice("No post was named."))),
1741 };
1742
1743 if cfg.source != Source::Store {
1744 return Ok(page(theme, admin, "Posts", &notice(
1745 "This site serves its posts from a directory, so every post it has is already readable.",
1746 )));
1747 }
1748
1749 let db = match db {
1750 Some(db) => db,
1751 None => return Ok(page(theme, admin, "Posts", &notice(
1752 "This site has no database configured.",
1753 ))),
1754 };
1755
1756 let rec = match store::get(db, &slug) {
1757 Ok(Some(r)) => r,
1758 Ok(None) => return Ok(page(theme, admin, "Posts", &notice(
1759 "There is no post by that name.",
1760 ))),
1761 Err(e) => {
1762 error!(e, "{}: console: cannot read '{}'", id, slug);
1763 return Ok(page(theme, admin, "Posts", &notice(
1764 "That post could not be read. The log says why.",
1765 )));
1766 }
1767 };
1768
1769 // The prose is the author's own, rendered by the same renderer that serves it. It is not escaped,
1770 // because rendered Markdown is HTML and escaping it would show the reader the tags. Everything
1771 // else on this page is escaped.
1772 let post = match rec.render() {
1773 Ok(p) => p,
1774 Err(e) => {
1775 warn!("{}: console: '{}' will not render: {}", id, slug, e);
1776 return Ok(page(theme, admin, "Posts", &notice(
1777 "That post will not render as Markdown. The log says where it goes wrong.",
1778 )));
1779 }
1780 };
1781
1782 // The state is worth saying, because a draft looks identical here and is served to nobody. It is
1783 // a badge, as everywhere else a state is shown, rather than a sentence explaining itself.
1784 let body = fmt!(
1785 "<div class=\"mc-head-row\"><h1>Preview {state}</h1>\
1786 <a class=\"mc-close\" href=\"{edit}?slug={slug}\" title=\"Back to the editor\" \
1787 aria-label=\"Back to the editor\">{close}</a></div>\n\
1788 <article class=\"mc-prose aside\">{html}</article>\n",
1789 edit = PATH_EDIT,
1790 slug = html_escape(&slug),
1791 close = icon("close"),
1792 state = match rec.state {
1793 PostState::Live => fmt!("<span class=\"mc-tag mc-tag-live\">live</span>"),
1794 PostState::Draft => fmt!("<span class=\"mc-tag\">draft</span>"),
1795 },
1796 html = post.html,
1797 );
1798
1799 Ok(page(theme, admin, "Preview", &body))
1800}
1801
1802
1803// ┌───────────────────────────────────────────────────────────────────────────┐
1804// │ JSON, for a front-end that renders its own management surface │
1805// └───────────────────────────────────────────────────────────────────────────┘
1806
1807/// Every post the store holds, each state, as JSON.
1808///
1809/// The same list as the page, for a caller that draws its own: the app's Manage tab renders this in
1810/// the site's shell rather than send the operator to a page of its own. The reader's `index.json` is
1811/// the live posts only; this is the author's, so it carries the drafts too, and each post's state.
1812fn list_json<
1813 const UIDL: usize,
1814 UID: NumIdDat<UIDL>,
1815 ENC: Encrypter,
1816 KH: Hasher,
1817 DB: Database<UIDL, UID, ENC, KH>,
1818>(
1819 cfg: &PublishConfig,
1820 db: Option<&(Arc<RwLock<DB>>, UID)>,
1821 id: &str,
1822)
1823 -> Outcome<HttpMessage>
1824{
1825 if cfg.source != Source::Store {
1826 return Ok(json_body(&fmt!("{{\"posts\":[],\"source\":\"dir\"}}")));
1827 }
1828 let db = match db {
1829 Some(db) => db,
1830 None => return Ok(json_body("{\"posts\":[]}")),
1831 };
1832 let recs = res!(store::list_records(db, id));
1833 let mut items = Vec::new();
1834 for rec in &recs {
1835 // The title is the prose's own heading; where it will not parse, the slug stands in and the
1836 // state says the post is broken, exactly as the page does it.
1837 let (title, broken) = match rec.render() {
1838 Ok(p) => (p.title, false),
1839 Err(_) => (rec.slug.clone(), true),
1840 };
1841 let mut m = DaticleMap::new();
1842 m.insert(dat!("slug"), dat!(rec.slug.clone()));
1843 m.insert(dat!("title"), dat!(title));
1844 m.insert(dat!("markup"), dat!(rec.markup.as_str().to_string()));
1845 m.insert(dat!("state"), dat!(rec.state.as_str().to_string()));
1846 m.insert(dat!("broken"), Dat::Bool(broken));
1847 if let Some(d) = &rec.date {
1848 m.insert(dat!("date"), dat!(d.clone()));
1849 m.insert(dat!("date_text"), dat!(date_text(d)));
1850 }
1851 items.push(Dat::Map(m));
1852 }
1853 let body = create_dat_ordmap(vec![(dat!("posts"), Dat::List(items))]);
1854 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
1855}
1856
1857/// One post's source and rendering, as JSON.
1858///
1859/// What the editor loads to fill its fields, and what a preview shows: the Markdown as written, the
1860/// kind, the state, the date in the readable form a person edits, and the HTML a reader would get.
1861fn post_json<
1862 const UIDL: usize,
1863 UID: NumIdDat<UIDL>,
1864 ENC: Encrypter,
1865 KH: Hasher,
1866 DB: Database<UIDL, UID, ENC, KH>,
1867>(
1868 cfg: &PublishConfig,
1869 db: Option<&(Arc<RwLock<DB>>, UID)>,
1870 query: &str,
1871 id: &str,
1872)
1873 -> Outcome<HttpMessage>
1874{
1875 let slug = match query_field(query, "slug") {
1876 Some(s) => s,
1877 None => return Ok(json_error("no post was named")),
1878 };
1879 if cfg.source != Source::Store {
1880 return Ok(json_error("this site serves its posts from a directory"));
1881 }
1882 let db = match db {
1883 Some(db) => db,
1884 None => return Ok(json_error("this site has no database configured")),
1885 };
1886 let rec = match store::get(db, &slug) {
1887 Ok(Some(r)) => r,
1888 Ok(None) => return Ok(json_error("there is no post by that name")),
1889 Err(e) => {
1890 error!(e, "{}: console: cannot read '{}'", id, slug);
1891 return Ok(json_error("that post could not be read"));
1892 }
1893 };
1894 // The rendered HTML for a preview, where the prose parses; where it does not, the empty string
1895 // and a flag, so the editor can say so rather than show nothing and seem to have lost the post.
1896 let (html, broken) = match rec.render() {
1897 Ok(p) => (p.html, false),
1898 Err(_) => (String::new(), true),
1899 };
1900 let mut m = DaticleMap::new();
1901 m.insert(dat!("slug"), dat!(rec.slug.clone()));
1902 m.insert(dat!("source"), dat!(rec.source.clone()));
1903 m.insert(dat!("author"), dat!(rec.author.clone()));
1904 m.insert(dat!("categories"), Dat::List(rec.categories.iter().map(|c| dat!(c.clone())).collect()));
1905 m.insert(dat!("markup"), dat!(rec.markup.as_str().to_string()));
1906 m.insert(dat!("state"), dat!(rec.state.as_str().to_string()));
1907 // What the author declared about writing it. **Without this the app's composer opens every post
1908 // reading "Not declared" and the next autosave writes that back**, taking a declaration its
1909 // author had made. An empty string is the honest answer for a post with none, and is what the
1910 // chooser's first option carries.
1911 m.insert(dat!("ai_level"), dat!(rec.ai_level.map(|l| l.slug().to_string()).unwrap_or_default()));
1912 m.insert(dat!("html"), dat!(html));
1913 m.insert(dat!("broken"), Dat::Bool(broken));
1914 // The readable form in the field; the `T` goes back in at save.
1915 m.insert(dat!("date"), dat!(rec.date.as_deref().map(date_text).unwrap_or_default()));
1916 // Where the post has already been sent, so the composer's picker shows those destinations ticked
1917 // and their state -- and so re-saving does not silently drop a remote the post has already reached.
1918 let dlist: Vec<Dat> = rec.deliveries.iter().map(|d| {
1919 let (state, permalink) = match &d.state {
1920 DeliveryState::Queued => ("queued", String::new()),
1921 DeliveryState::Sent { permalink, .. } => ("sent", permalink.clone()),
1922 DeliveryState::Failed { .. } => ("failed", String::new()),
1923 };
1924 let mut dm = DaticleMap::new();
1925 dm.insert(dat!("dest"), dat!(d.dest.as_str().to_string()));
1926 dm.insert(dat!("state"), dat!(state.to_string()));
1927 if !permalink.is_empty() {
1928 dm.insert(dat!("permalink"), dat!(permalink));
1929 }
1930 Dat::Map(dm)
1931 }).collect();
1932 m.insert(dat!("deliveries"), Dat::List(dlist));
1933 // The post's tags, so the editor fills its chips from the record it is editing. Always an array,
1934 // empty for an untagged post, so the front-end need not ask whether the key is there.
1935 m.insert(dat!("tags"),
1936 Dat::List(rec.tags.iter().map(|t| dat!(t.clone())).collect()));
1937 Ok(json_body(&res!(Dat::Map(m).encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
1938}
1939
1940/// The site's tag vocabulary as JSON: every tag any post wears, sorted.
1941///
1942/// Feeds the composer's palette, so a tag is offered as soon as one post uses it. Gated as every
1943/// console read is -- the gate ran before this -- so a non-admin never reaches it.
1944fn tags_json<
1945 const UIDL: usize,
1946 UID: NumIdDat<UIDL>,
1947 ENC: Encrypter,
1948 KH: Hasher,
1949 DB: Database<UIDL, UID, ENC, KH>,
1950>(
1951 cfg: &PublishConfig,
1952 db: Option<&(Arc<RwLock<DB>>, UID)>,
1953 id: &str,
1954)
1955 -> Outcome<HttpMessage>
1956{
1957 // A directory-backed site keeps no tags, so its vocabulary is empty rather than an error.
1958 if cfg.source != Source::Store {
1959 return Ok(json_body("{\"tags\":[]}"));
1960 }
1961 let db = match db {
1962 Some(db) => db,
1963 None => return Ok(json_body("{\"tags\":[]}")),
1964 };
1965 // Each tag with how far it reaches: how many posts wear it and how many authors those posts
1966 // belong to, read in one pass. An app offering to delete a tag across the site shows that count
1967 // before it asks, which is the guard on the act -- see `do_tag_delete`.
1968 let list = Dat::List(res!(store::tag_counts(db, id)).into_iter()
1969 .map(|(t, posts, authors)| create_dat_ordmap(vec![
1970 (dat!("tag"), dat!(t)),
1971 (dat!("posts"), dat!(posts as u64)),
1972 (dat!("authors"), dat!(authors as u64)),
1973 ]))
1974 .collect());
1975 let body = create_dat_ordmap(vec![(dat!("tags"), list)]);
1976 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
1977}
1978
1979
1980/// The declarations page: a level for each thing the site shows that is not a post.
1981///
1982/// A post declares in the composer, beside the prose the declaration is about. Everything else a site
1983/// puts in front of a reader -- a book in a catalogue, a project on a front page -- is authored
1984/// somewhere else entirely, so this is where its one field lives. What may be declared for is the
1985/// config's business ([`crate::srv::publish::declare::DeclareConfig::items`]); what it says is
1986/// this page's.
1987fn declare_page<
1988 const UIDL: usize,
1989 UID: NumIdDat<UIDL>,
1990 ENC: Encrypter,
1991 KH: Hasher,
1992 DB: Database<UIDL, UID, ENC, KH>,
1993>(
1994 cfg: &PublishConfig,
1995 theme: &Theme,
1996 admin: &SiteAdmin,
1997 csrf: &str,
1998 db: Option<&(Arc<RwLock<DB>>, UID)>,
1999 query: &str,
2000 id: &str,
2001)
2002 -> Outcome<HttpMessage>
2003{
2004 let mut body = String::new();
2005 body.push_str("<h1>Declarations</h1>\n");
2006
2007 if let Some(said) = query_field(query, "said") {
2008 body.push_str(&notice(&html_escape(&said)));
2009 }
2010
2011 if !cfg.declare.is_on() {
2012 body.push_str(&notice(
2013 "This site declares nothing. Give the vhost's <code>publish</code> block a \
2014 <code>declare</code> section naming the scheme's site and where the marks are served \
2015 from.",
2016 ));
2017 return Ok(page(theme, admin, "Declarations", &body));
2018 }
2019
2020 let db = match db {
2021 Some(db) => db,
2022 None => {
2023 body.push_str(&notice(
2024 "This site keeps its declarations in its database, and has no database configured. \
2025 Set <code>db_dir_rel</code> on the vhost.",
2026 ));
2027 return Ok(page(theme, admin, "Declarations", &body));
2028 }
2029 };
2030
2031 if cfg.declare.items.is_empty() {
2032 body.push_str(&notice(
2033 "Nothing here but the posts, which declare in the composer. To declare for anything \
2034 else, name it under <code>declare.items</code> in the vhost's config.",
2035 ));
2036 body.push_str(&declare_site_note(cfg));
2037 return Ok(page(theme, admin, "Declarations", &body));
2038 }
2039
2040 let keys: Vec<String> = cfg.declare.items.iter().map(|i| i.key.clone()).collect();
2041 // A read that fails costs the forms, not the page: a form that cannot say what is stored would
2042 // show every level as unset, and one wrong save would then clear the lot.
2043 let levels = match store::get_levels(db, &keys, id) {
2044 Ok(l) => l,
2045 Err(e) => {
2046 error!(e, "{}: console: cannot read the declarations", id);
2047 body.push_str(&notice("The declarations could not be read. The log says why."));
2048 return Ok(page(theme, admin, "Declarations", &body));
2049 }
2050 };
2051
2052 body.push_str(
2053 "<p class=\"mc-muted\">How much each of these needed AI. A declaration is your word on the \
2054 record, so <em>Not declared</em> is a real answer and the one everything starts at.</p>\n");
2055
2056 for item in &cfg.declare.items {
2057 let on = levels.get(&item.key).copied();
2058 body.push_str(&fmt!(
2059 "<form class=\"mc-form mc-settings mc-declare\" method=\"POST\" action=\"{save}\">\n\
2060 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
2061 <input type=\"hidden\" name=\"key\" value=\"{key}\">\n\
2062 <div class=\"mc-f-text\"><label for=\"lvl-{key}\">{name}</label>\
2063 <select id=\"lvl-{key}\" name=\"ai_level\">\
2064 <option value=\"\"{none_sel}>Not declared</option>{options}</select></div>\n\
2065 </form>\n",
2066 save = PATH_DECLARE_SAVE,
2067 csrf = html_escape(csrf),
2068 key = html_escape(&item.key),
2069 name = html_escape(&item.name),
2070 none_sel = selected(on.is_none()),
2071 options = declare_options(on),
2072 ));
2073 }
2074 body.push_str("<p class=\"mc-autosave\" id=\"mc-declare-msg\" aria-live=\"polite\"></p>\n");
2075 body.push_str(&declare_site_note(cfg));
2076 body.push_str(DECLARE_SCRIPT);
2077
2078 Ok(page(theme, admin, "Declarations", &body))
2079}
2080
2081// Saves a declaration the moment it is chosen.
2082//
2083// There is nothing here for a Save button to do. A row is one field with one answer, its whole
2084// state is what the select says, and a button beside each row would be a column of buttons all
2085// doing the same single thing -- which is exactly the reasoning that took the Save button off the
2086// composer ([`AUTOSAVE_SCRIPT`]) and never put one on the app's own version of this screen.
2087//
2088// Static, like its siblings: the CSRF token rides in each form's hidden field and the save URL is
2089// the form's own `action`, so nothing is interpolated.
2090//
2091// **Nothing is said on success.** The box already shows the answer, so a sentence repeating it
2092// states the same fact twice -- and one status line under a column of rows reads as belonging to
2093// the last row rather than to whichever was just changed. A failure still speaks: that is the one
2094// thing the select cannot show, since it goes on displaying a value the server did not take.
2095const DECLARE_SCRIPT: &str = "<script>\n(function(){\n var status=document.getElementById('mc-declare-msg');\n var forms=document.querySelectorAll('form.mc-declare');\n if(!status||!forms.length){return;}\n function say(text,bad){\n status.className='mc-autosave'+(bad?' is-error':'');\n status.textContent=text;\n }\n forms.forEach(function(form){\n var sel=form.querySelector('select');\n if(!sel){return;}\n sel.addEventListener('change',function(){\n say('',false);\n fetch(form.action,{method:'POST',credentials:'same-origin',\n headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},\n body:new URLSearchParams(new FormData(form)).toString()})\n .then(function(r){return r.json();})\n .then(function(d){\n if(d&&d.said){say('',false);}\n else{say('Not saved \\u2014 '+((d&&d.error)||'try again'),true);}\n })\n .catch(function(){say('Not saved \\u2014 the server did not answer',true);});\n });\n });\n})();\n</script>\n";
2096
2097/// The five rungs as options, the one in force selected.
2098fn declare_options(on: Option<declare::Level>) -> String {
2099 let mut s = String::new();
2100 for level in declare::Level::ALL {
2101 s.push_str(&fmt!(
2102 "<option value=\"{slug}\"{sel}>{words}</option>\n",
2103 slug = html_escape(level.slug()),
2104 sel = selected(on == Some(level)),
2105 words = html_escape(level.words()),
2106 ));
2107 }
2108 s
2109}
2110
2111/// What the site says about itself, which is config rather than a control.
2112///
2113/// Said on the page all the same: an admin looking at what this site declares should see every
2114/// declaration it makes, including the one they cannot change here. A claim they cannot find is a
2115/// claim they cannot correct.
2116fn declare_site_note(cfg: &PublishConfig) -> String {
2117 match cfg.declare.site {
2118 Some(d) => fmt!(
2119 "<p class=\"mc-muted\">This site declares itself <strong>{words}</strong>, in its \
2120 footer, from the vhost's config.</p>\n",
2121 words = html_escape(d.level.words()),
2122 ),
2123 None => fmt!(
2124 "<p class=\"mc-muted\">This site declares nothing about itself. Set \
2125 <code>declare.site</code> on the vhost to have it say.</p>\n"),
2126 }
2127}
2128
2129/// The declarations as JSON, for an app that draws its own panel.
2130///
2131/// The whole vocabulary rides with them, so a client's chooser offers exactly the rungs this version
2132/// knows rather than a list copied into a second place to drift.
2133fn declare_json<
2134 const UIDL: usize,
2135 UID: NumIdDat<UIDL>,
2136 ENC: Encrypter,
2137 KH: Hasher,
2138 DB: Database<UIDL, UID, ENC, KH>,
2139>(
2140 cfg: &PublishConfig,
2141 db: Option<&(Arc<RwLock<DB>>, UID)>,
2142 id: &str,
2143)
2144 -> Outcome<HttpMessage>
2145{
2146 let keys: Vec<String> = cfg.declare.items.iter().map(|i| i.key.clone()).collect();
2147 let levels = match db {
2148 Some(db) => res!(store::get_levels(db, &keys, id)),
2149 None => BTreeMap::new(),
2150 };
2151 let items = cfg.declare.items.iter()
2152 .map(|item| {
2153 let mut f = vec![
2154 (dat!("key"), dat!(item.key.clone())),
2155 (dat!("name"), dat!(item.name.clone())),
2156 (dat!("medium"), dat!(item.medium.slug().to_string())),
2157 ];
2158 // No key where nobody has declared, the empty idiom the store keeps.
2159 if let Some(l) = levels.get(&item.key) {
2160 f.push((dat!("level"), dat!(l.slug().to_string())));
2161 }
2162 create_dat_ordmap(f)
2163 })
2164 .collect::<Vec<_>>();
2165 let vocabulary = declare::Level::ALL.iter()
2166 .map(|l| create_dat_ordmap(vec![
2167 (dat!("level"), dat!(l.slug().to_string())),
2168 (dat!("words"), dat!(l.words().to_string())),
2169 ]))
2170 .collect::<Vec<_>>();
2171
2172 let body = create_dat_ordmap(vec![
2173 (dat!("on"), Dat::Bool(cfg.declare.is_on())),
2174 (dat!("items"), Dat::List(items)),
2175 (dat!("levels"), Dat::List(vocabulary)),
2176 ]);
2177 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2178}
2179
2180async fn do_declare_save<
2181 const UIDL: usize,
2182 UID: NumIdDat<UIDL>,
2183 ENC: Encrypter,
2184 KH: Hasher,
2185 DB: Database<UIDL, UID, ENC, KH>,
2186>(
2187 cfg: &PublishConfig,
2188 db: &(Arc<RwLock<DB>>, UID),
2189 body: &[u8],
2190 json: bool,
2191 id: &str,
2192)
2193 -> Outcome<HttpMessage>
2194{
2195 let back = |said: &str| -> HttpMessage {
2196 if json {
2197 // Not an error: the app asked, the save happened, and the sentence is what to show for it.
2198 let m = create_dat_ordmap(vec![(dat!("said"), dat!(said.to_string()))]);
2199 match m.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)) {
2200 Ok(j) => json_body(&j),
2201 Err(_) => json_error("saved"),
2202 }
2203 } else {
2204 redirect(&fmt!("{}?said={}", PATH_DECLARE, url_encode(said)))
2205 }
2206 };
2207
2208 let key = super::form_field(body, "key").unwrap_or_default().trim().to_string();
2209 // Only what the config says may be declared for. Without this the form's word for a key is a key,
2210 // and a browser could write a record under any name it liked.
2211 let item = match cfg.declare.item(&key) {
2212 Some(item) => item.clone(),
2213 None => return Ok(back("this site declares nothing by that name"),),
2214 };
2215
2216 // An empty field is the answer "not declared", and it clears the record rather than storing a word
2217 // for saying nothing. An unknown word is the same answer, for the same reason it is everywhere
2218 // else here: a rung nobody defined must not become a claim.
2219 let level = super::form_field(body, "ai_level")
2220 .and_then(|s| declare::Level::of(s.trim()));
2221
2222 res!(store::put_level(db, &key, level));
2223 match level {
2224 Some(l) => {
2225 info!("{}: console: '{}' declared {}", id, key, l.slug());
2226 Ok(back(&fmt!("{} is declared {}.", item.name, l.words().to_lowercase())))
2227 },
2228 None => {
2229 info!("{}: console: '{}' declares nothing", id, key);
2230 Ok(back(&fmt!("{} declares nothing.", item.name)))
2231 },
2232 }
2233}
2234
2235/// The destinations page: the remotes this site can send a post on to, and what each needs to do
2236/// it.
2237///
2238/// The server-rendered twin of the app's Destinations panel, and the only one a site without the
2239/// app has. Every secret here is write-only, exactly as it is over JSON: a stored secret comes back
2240/// as the word that one is held and never as its value, and a field left blank keeps what is
2241/// stored, so a handle can be corrected without re-typing a password. A remote the config file also
2242/// provides is named as such, because a site whose credentials come from `{env:}` or `{file:}`
2243/// should not be told its destination is unset.
2244fn destinations_page<
2245 const UIDL: usize,
2246 UID: NumIdDat<UIDL>,
2247 ENC: Encrypter,
2248 KH: Hasher,
2249 DB: Database<UIDL, UID, ENC, KH>,
2250>(
2251 cfg: &PublishConfig,
2252 theme: &Theme,
2253 admin: &SiteAdmin,
2254 csrf: &str,
2255 db: Option<&(Arc<RwLock<DB>>, UID)>,
2256 query: &str,
2257 id: &str,
2258)
2259 -> Outcome<HttpMessage>
2260{
2261 let mut body = String::new();
2262 body.push_str("<h1>Destinations</h1>\n");
2263
2264 if let Some(said) = query_field(query, "said") {
2265 body.push_str(&notice(&html_escape(&said)));
2266 }
2267
2268 let db = match db {
2269 Some(db) => db,
2270 None => {
2271 body.push_str(&notice(
2272 "This site keeps its destination credentials in its database, and has no database \
2273 configured. Set <code>db_dir_rel</code> on the vhost.",
2274 ));
2275 return Ok(page(theme, admin, "Destinations", &body));
2276 }
2277 };
2278
2279 // A read that fails costs the forms, not the page: without knowing what is stored, a form cannot
2280 // honestly say whether a secret is held, and a form that guesses is worse than none.
2281 let stored = match send::get_creds(db) {
2282 Ok(c) => c,
2283 Err(e) => {
2284 error!(e, "{}: console: cannot read the destination credentials", id);
2285 body.push_str(&notice("The destination settings could not be read. The log says why."));
2286 return Ok(page(theme, admin, "Destinations", &body));
2287 }
2288 };
2289
2290 body.push_str(&fmt!(
2291 "<p class=\"mc-muted\">A post you save can be sent on to these. A secret is stored encrypted \
2292 and never shown again &mdash; leave a secret field blank to keep the one held.</p>\n",
2293 ));
2294
2295 // Mastodon: an instance to post to, and a token to post with.
2296 body.push_str(&dest_panel(
2297 "Mastodon",
2298 "mastodon",
2299 csrf,
2300 stored.mastodon.is_some(),
2301 cfg.creds.mastodon.is_some(),
2302 &fmt!(
2303 "<div class=\"mc-f-text\"><label for=\"base_url\">Instance URL</label>\
2304 <input type=\"text\" id=\"base_url\" name=\"base_url\" value=\"{url}\" \
2305 placeholder=\"https://mastodon.social\"></div>\n\
2306 <div class=\"mc-f-text\"><label for=\"token\">Access token</label>\
2307 <input type=\"password\" id=\"token\" name=\"token\" autocomplete=\"new-password\" \
2308 placeholder=\"{hint}\"></div>\n",
2309 url = html_escape(&stored.mastodon.as_ref().map(|c| c.base_url.clone()).unwrap_or_default()),
2310 hint = if stored.mastodon.is_some() { "kept" } else { "required" },
2311 ),
2312 ));
2313
2314 // Bluesky: a handle, an app password, and a host that almost always wants its default.
2315 body.push_str(&dest_panel(
2316 "Bluesky",
2317 "bluesky",
2318 csrf,
2319 stored.bluesky.is_some(),
2320 cfg.creds.bluesky.is_some(),
2321 &fmt!(
2322 "<div class=\"mc-f-text\"><label for=\"handle\">Handle</label>\
2323 <input type=\"text\" id=\"handle\" name=\"handle\" value=\"{handle}\" \
2324 placeholder=\"you.bsky.social\"></div>\n\
2325 <div class=\"mc-f-text\"><label for=\"host\">Host</label>\
2326 <input type=\"text\" id=\"host\" name=\"host\" value=\"{host}\" \
2327 placeholder=\"{default}\"></div>\n\
2328 <div class=\"mc-f-text\"><label for=\"app_password\">App password</label>\
2329 <input type=\"password\" id=\"app_password\" name=\"app_password\" \
2330 autocomplete=\"new-password\" placeholder=\"{hint}\"></div>\n",
2331 handle = html_escape(&stored.bluesky.as_ref().map(|c| c.handle.clone()).unwrap_or_default()),
2332 host = html_escape(&stored.bluesky.as_ref().map(|c| c.host.clone()).unwrap_or_default()),
2333 default = send::BLUESKY_HOST_DEFAULT,
2334 hint = if stored.bluesky.is_some() { "kept" } else { "required" },
2335 ),
2336 ));
2337
2338 Ok(page(theme, admin, "Destinations", &body))
2339}
2340
2341/// The AI settings: the model to call, the key to call it with, the two prompts, and the addresses
2342/// told when a comment is held.
2343///
2344/// One form, because these are one setting -- a site turns AI on by filling it in and off by clearing
2345/// the key. The key is write-only, exactly as a destination token is: a stored key comes back as the
2346/// word that one is held, never as its value, and a blank key field keeps what is stored, so the model
2347/// or a prompt can be changed without re-typing the key. The prompts prefill with their defaults where
2348/// none is stored, so an operator edits from a sensible starting point rather than a blank; clearing a
2349/// prompt box restores the default rather than sending none.
2350fn ai_page<
2351 const UIDL: usize,
2352 UID: NumIdDat<UIDL>,
2353 ENC: Encrypter,
2354 KH: Hasher,
2355 DB: Database<UIDL, UID, ENC, KH>,
2356>(
2357 theme: &Theme,
2358 admin: &SiteAdmin,
2359 csrf: &str,
2360 db: Option<&(Arc<RwLock<DB>>, UID)>,
2361 query: &str,
2362 id: &str,
2363)
2364 -> Outcome<HttpMessage>
2365{
2366 let mut body = String::new();
2367 body.push_str("<h1>AI</h1>\n");
2368
2369 if let Some(said) = query_field(query, "said") {
2370 body.push_str(&notice(&html_escape(&said)));
2371 }
2372
2373 let db = match db {
2374 Some(db) => db,
2375 None => {
2376 body.push_str(&notice(
2377 "This site keeps its AI settings in its database, and has no database configured. \
2378 Set <code>db_dir_rel</code> on the vhost.",
2379 ));
2380 return Ok(page(theme, admin, "AI", &body));
2381 }
2382 };
2383
2384 let s = match ai::get_settings(db) {
2385 Ok(s) => s,
2386 Err(e) => {
2387 error!(e, "{}: console: cannot read the AI settings", id);
2388 body.push_str(&notice("The AI settings could not be read. The log says why."));
2389 return Ok(page(theme, admin, "AI", &body));
2390 }
2391 };
2392
2393 body.push_str(&ai_form(&s, csrf));
2394 body.push_str(AI_TEST_SCRIPT);
2395 Ok(page(theme, admin, "AI", &body))
2396}
2397
2398// The AI page's Test button: it posts nothing of the operator's, asks the server to reach the model,
2399// and shows what came back beside the button. It reads the CSRF token from the settings form it sits
2400// in, so it needs nothing passed to it.
2401const AI_TEST_SCRIPT: &str = "<script>\n\
2402(function(){\n\
2403 var btn=document.getElementById('ai-test');\n\
2404 var msg=document.getElementById('ai-test-msg');\n\
2405 if(!btn||!msg){return;}\n\
2406 var form=btn.closest('form');\n\
2407 var csrfEl=form?form.querySelector('input[name=csrf]'):null;\n\
2408 btn.addEventListener('click',function(){\n\
2409 var was=btn.textContent;btn.disabled=true;btn.textContent='Testing\\u2026';\n\
2410 msg.textContent='';msg.className='mc-note';\n\
2411 var b='csrf='+encodeURIComponent(csrfEl?csrfEl.value:'');\n\
2412 fetch('/manage/ai/test',{method:'POST',credentials:'same-origin',\n\
2413 headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},body:b})\n\
2414 .then(function(r){return r.json();})\n\
2415 .then(function(d){\n\
2416 btn.disabled=false;btn.textContent=was;\n\
2417 if(d&&d.ok){msg.textContent='The model answered: '+(d.reply||'(nothing)');\n\
2418 msg.className='mc-note mc-ok';}\n\
2419 else{msg.textContent=(d&&d.error)||'The test did not go through.';\n\
2420 msg.className='mc-note mc-err';}\n\
2421 })\n\
2422 .catch(function(){btn.disabled=false;btn.textContent=was;\n\
2423 msg.textContent='The server did not answer.';msg.className='mc-note mc-err';});\n\
2424 });\n\
2425})();\n\
2426</script>\n";
2427
2428/// The AI settings form, built from the settings a site has stored. Pure, so it can be seen without a
2429/// database behind it: the provider is selected, the key shown only as held-or-not, the prompts
2430/// prefilled with their defaults, and the clear-key form offered only where a key is stored.
2431fn ai_form(s: &ai::AiSettings, csrf: &str) -> String {
2432 let mut body = String::new();
2433
2434 let opt = |val: &str, label: &str| fmt!(
2435 "<option value=\"{val}\"{sel}>{label}</option>",
2436 val = val, label = label,
2437 sel = if s.provider == val { " selected" } else { "" },
2438 );
2439
2440 body.push_str(&fmt!(
2441 "<p class=\"mc-muted\">Your own key, used to tidy a post you are editing and to sort a comment \
2442 before it waits for you. The key is stored encrypted and never shown again &mdash; leave it \
2443 blank to keep the one held. {status}</p>\n\
2444 <form class=\"mc-form mc-settings\" method=\"POST\" action=\"{save}\">\n\
2445 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
2446 <div class=\"mc-f-text\"><label for=\"provider\">Provider</label>\
2447 <select id=\"provider\" name=\"provider\">{none}{openrouter}{fireworks}{mistral}</select></div>\n\
2448 <div class=\"mc-f-text\"><label for=\"model\">Model</label>\
2449 <input type=\"text\" id=\"model\" name=\"model\" value=\"{model}\" \
2450 placeholder=\"mistral-large-latest\"></div>\n\
2451 <div class=\"mc-f-text\"><label for=\"api_key\">API key</label>\
2452 <input type=\"password\" id=\"api_key\" name=\"api_key\" autocomplete=\"new-password\" \
2453 placeholder=\"{hint}\"></div>\n\
2454 <div class=\"mc-f-text\"><label for=\"fix_prompt\">Fix prompt</label>\
2455 <textarea id=\"fix_prompt\" name=\"fix_prompt\" rows=\"5\">{fix}</textarea>\
2456 <span class=\"mc-note\">Sent with the text when you press Fix while editing a post.</span></div>\n\
2457 <div class=\"mc-f-text\"><label for=\"comment_prompt\">Comment prompt</label>\
2458 <textarea id=\"comment_prompt\" name=\"comment_prompt\" rows=\"4\">{comment}</textarea>\
2459 <span class=\"mc-note\">Sent with each posted comment. Ask for one word: APPROVE, SPAM or HOLD.\
2460 </span></div>\n\
2461 <div class=\"mc-f-text\"><label for=\"alert_emails\">Tell these addresses when a comment is held\
2462 </label>\
2463 <textarea id=\"alert_emails\" name=\"alert_emails\" rows=\"3\" \
2464 placeholder=\"you@example.com\">{emails}</textarea>\
2465 <span class=\"mc-note\">One per line, or separated by commas. Leave blank to be told nothing and \
2466 find held comments in the queue.</span></div>\n\
2467 <div class=\"mc-actions\">\
2468 <button type=\"submit\" class=\"mc-btn\">Save</button>\n\
2469 <button type=\"button\" class=\"mc-btn mc-btn-quiet\" id=\"ai-test\">Test the connection</button>\n\
2470 </div>\
2471 <p class=\"mc-note\" id=\"ai-test-msg\" role=\"status\"></p>\n\
2472 </form>\n",
2473 status = if s.api_key.is_empty() { "No key is set." } else { "A key is set." },
2474 save = PATH_AI_SAVE,
2475 csrf = html_escape(csrf),
2476 none = opt("", "\u{2014} none \u{2014}"),
2477 openrouter = opt("openrouter", "OpenRouter"),
2478 fireworks = opt("fireworks", "Fireworks"),
2479 mistral = opt("mistral", "Mistral"),
2480 model = html_escape(&s.model),
2481 hint = if s.api_key.is_empty() { "required" } else { "kept" },
2482 // Prefilled with the default where none is stored, so the box is edited from a sensible start;
2483 // the empty-is-default rule in `ai` means clearing the box later restores the default.
2484 fix = html_escape(s.fix_prompt()),
2485 comment = html_escape(s.comment_prompt()),
2486 emails = html_escape(&s.alert_emails.join("\n")),
2487 ));
2488
2489 // Clearing the key is a second form, so a save cannot turn AI off by accident; offered only where
2490 // a key is stored to clear.
2491 if !s.api_key.is_empty() {
2492 body.push_str(&fmt!(
2493 "<form class=\"mc-form mc-settings\" method=\"POST\" action=\"{save}\" \
2494 onsubmit=\"return confirm('Clear the stored API key? This turns AI off until a new one is set.')\">\n\
2495 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
2496 <input type=\"hidden\" name=\"clear\" value=\"key\">\n\
2497 <button type=\"submit\" class=\"mc-btn mc-btn-danger\">Clear the key</button>\n\
2498 </form>\n",
2499 save = PATH_AI_SAVE,
2500 csrf = html_escape(csrf),
2501 ));
2502 }
2503
2504 body
2505}
2506
2507/// One remote's settings: its fields, whether it is set, and the ways to set or clear it.
2508///
2509/// Clearing is a second form rather than a checkbox in the first, so that saving cannot clear by
2510/// accident, and it is offered only where something is stored to clear.
2511fn dest_panel(
2512 title: &str,
2513 dest: &str,
2514 csrf: &str,
2515 is_set: bool,
2516 in_config: bool,
2517 fields: &str,
2518)
2519 -> String
2520{
2521 let mut s = String::new();
2522 s.push_str(&fmt!("<h2>{}</h2>\n", html_escape(title)));
2523
2524 // What the site knows about this remote, said before the form asks anything of it.
2525 let state = match (is_set, in_config) {
2526 (true, true) => "Set here, and also in the configuration file.",
2527 (true, false) => "Set.",
2528 (false, true) => "Set in the configuration file, not here.",
2529 (false, false) => "Not set.",
2530 };
2531 s.push_str(&fmt!("<p class=\"mc-muted\">{}</p>\n", state));
2532
2533 s.push_str(&fmt!(
2534 "<form class=\"mc-form mc-settings\" method=\"POST\" action=\"{creds}\">\n\
2535 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
2536 <input type=\"hidden\" name=\"dest\" value=\"{dest}\">\n\
2537 {fields}\
2538 <button type=\"submit\" class=\"mc-btn\">Save</button>\n\
2539 </form>\n",
2540 creds = PATH_CREDS,
2541 csrf = html_escape(csrf),
2542 dest = html_escape(dest),
2543 fields = fields,
2544 ));
2545
2546 if is_set {
2547 s.push_str(&fmt!(
2548 "<form class=\"mc-form mc-settings\" method=\"POST\" action=\"{creds}\" \
2549 onsubmit=\"return confirm('Clear the {title} credentials?')\">\n\
2550 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
2551 <input type=\"hidden\" name=\"dest\" value=\"{dest}\">\n\
2552 <input type=\"hidden\" name=\"clear\" value=\"1\">\n\
2553 <button type=\"submit\" class=\"mc-btn mc-btn-danger\">Clear</button>\n\
2554 </form>\n",
2555 creds = PATH_CREDS,
2556 csrf = html_escape(csrf),
2557 dest = html_escape(dest),
2558 title = html_escape(title),
2559 ));
2560 }
2561 s
2562}
2563
2564/// The subscriber list as JSON.
2565///
2566/// The same data the subscribers page renders, for an app that would rather draw it in its own idiom
2567/// than open a page of the server's. The addresses are the site's own list and the session asking has
2568/// already been established as a site admin, so they are given in full -- this is the same admin
2569/// reading the same list, in a different surface.
2570fn subs_json<
2571 const UIDL: usize,
2572 UID: NumIdDat<UIDL>,
2573 ENC: Encrypter,
2574 KH: Hasher,
2575 DB: Database<UIDL, UID, ENC, KH>,
2576>(
2577 db: Option<&(Arc<RwLock<DB>>, UID)>,
2578 id: &str,
2579)
2580 -> Outcome<HttpMessage>
2581{
2582 let db = match db {
2583 Some(db) => db,
2584 None => return Ok(json_error("this site has no database configured")),
2585 };
2586 let subs = res!(subscribe::list(db, id));
2587
2588 let count = |s: subscribe::SubState| subs.iter().filter(|x| x.state == s).count();
2589 let mut counts = DaticleMap::new();
2590 counts.insert(dat!("confirmed"), dat!(count(subscribe::SubState::Confirmed) as u64));
2591 counts.insert(dat!("pending"), dat!(count(subscribe::SubState::Pending) as u64));
2592 counts.insert(dat!("unsubscribed"), dat!(count(subscribe::SubState::Unsubscribed) as u64));
2593 counts.insert(dat!("bounced"), dat!(count(subscribe::SubState::Bounced) as u64));
2594 counts.insert(dat!("total"), dat!(subs.len() as u64));
2595
2596 let mut items = Vec::new();
2597 for s in &subs {
2598 let mut m = DaticleMap::new();
2599 m.insert(dat!("email"), dat!(s.email.clone()));
2600 m.insert(dat!("state"), dat!(s.state.as_str().to_string()));
2601 m.insert(dat!("since"), dat!(s.created.clone().unwrap_or_default()));
2602 items.push(Dat::Map(m));
2603 }
2604
2605 let body = create_dat_ordmap(vec![
2606 (dat!("counts"), Dat::Map(counts)),
2607 (dat!("subscribers"), Dat::List(items)),
2608 ]);
2609 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2610}
2611
2612/// The reports as JSON.
2613///
2614/// The three halves the reports page renders -- the list, the sends and the reads -- as data rather
2615/// than as a table, so an app can draw them in its own idiom. The ceilings the page states in prose
2616/// are not repeated here: they are properties of the data that the drawing surface must state, and a
2617/// caller that omits them is showing figures without their caveats. Both surfaces in this tree do.
2618fn reports_json<
2619 const UIDL: usize,
2620 UID: NumIdDat<UIDL>,
2621 ENC: Encrypter,
2622 KH: Hasher,
2623 DB: Database<UIDL, UID, ENC, KH>,
2624>(
2625 db: Option<&(Arc<RwLock<DB>>, UID)>,
2626 id: &str,
2627)
2628 -> Outcome<HttpMessage>
2629{
2630 let db = match db {
2631 Some(db) => db,
2632 None => return Ok(json_error("this site has no database configured")),
2633 };
2634
2635 // The list.
2636 let subs = res!(subscribe::list(db, id));
2637 let count = |s: subscribe::SubState| subs.iter().filter(|x| x.state == s).count();
2638 let mut list = DaticleMap::new();
2639 list.insert(dat!("confirmed"), dat!(count(subscribe::SubState::Confirmed) as u64));
2640 list.insert(dat!("pending"), dat!(count(subscribe::SubState::Pending) as u64));
2641 list.insert(dat!("unsubscribed"), dat!(count(subscribe::SubState::Unsubscribed) as u64));
2642 list.insert(dat!("bounced"), dat!(count(subscribe::SubState::Bounced) as u64));
2643 list.insert(dat!("total"), dat!(subs.len() as u64));
2644 list.insert(dat!("by_month"), months_dat(&by_month(subs.iter().map(|x| x.created.as_deref().unwrap_or("")))));
2645
2646 // The sends.
2647 let hist = res!(send::send_history(db));
2648 let sent: usize = hist.iter().map(|h| h.sent).sum();
2649 let failed: usize = hist.iter().map(|h| h.failed).sum();
2650 let mut sends = DaticleMap::new();
2651 sends.insert(dat!("sends"), dat!(hist.len() as u64));
2652 sends.insert(dat!("accepted"), dat!(sent as u64));
2653 sends.insert(dat!("failed"), dat!(failed as u64));
2654 sends.insert(dat!("suppressed"), dat!(hist.iter().map(|e| e.suppressed).sum::<usize>() as u64));
2655 sends.insert(dat!("by_month"), months_dat(&by_month(hist.iter().map(|e| e.at.as_str()))));
2656
2657 // The reads.
2658 let reads = res!(store::reads_all(db, id));
2659 let recs = res!(store::list_records(db, id));
2660 let mut rows = Vec::new();
2661 let mut live_total: u64 = 0;
2662 let mut read_posts = 0usize;
2663 for rec in &recs {
2664 let title = match rec.render() {
2665 Ok(p) => p.title,
2666 Err(_) => rec.slug.clone(),
2667 };
2668 let n = reads.get(&rec.slug).copied().unwrap_or(0);
2669 live_total = live_total.saturating_add(n);
2670 if n > 0 {
2671 read_posts += 1;
2672 }
2673 let mut m = DaticleMap::new();
2674 m.insert(dat!("slug"), dat!(rec.slug.clone()));
2675 m.insert(dat!("title"), dat!(title));
2676 m.insert(dat!("reads"), dat!(n));
2677 rows.push((n, Dat::Map(m)));
2678 }
2679 rows.sort_by(|a, b| b.0.cmp(&a.0));
2680 let total: u64 = reads.values().sum();
2681 let mut reads_m = DaticleMap::new();
2682 reads_m.insert(dat!("total"), dat!(total));
2683 reads_m.insert(dat!("posts"), dat!(recs.len() as u64));
2684 reads_m.insert(dat!("posts_read"), dat!(read_posts as u64));
2685 // Reads counted against posts that no longer exist. Real, and named apart rather than folded in,
2686 // so a caller's rows and its total agree.
2687 reads_m.insert(dat!("deleted"), dat!(total.saturating_sub(live_total)));
2688 reads_m.insert(dat!("rows"), Dat::List(rows.into_iter().map(|(_, d)| d).collect()));
2689
2690 let body = create_dat_ordmap(vec![
2691 (dat!("list"), Dat::Map(list)),
2692 (dat!("sends"), Dat::Map(sends)),
2693 (dat!("reads"), Dat::Map(reads_m)),
2694 ]);
2695 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2696}
2697
2698fn months_dat(months: &[(String, usize)]) -> Dat {
2699 Dat::List(months.iter().map(|(m, n)| {
2700 let mut e = DaticleMap::new();
2701 e.insert(dat!("month"), dat!(m.clone()));
2702 e.insert(dat!("n"), dat!(*n as u64));
2703 Dat::Map(e)
2704 }).collect())
2705}
2706
2707/// A member's own profile as JSON: what an app drawing its own profile form needs to fill it in.
2708///
2709/// Their own and nobody else's, since the username read is the signed-in one. `uploaded` says whether
2710/// the picture is one this site holds, which is the difference between offering to remove it and
2711/// offering to edit an address.
2712fn profile_json<
2713 const UIDL: usize,
2714 UID: NumIdDat<UIDL>,
2715 ENC: Encrypter,
2716 KH: Hasher,
2717 DB: Database<UIDL, UID, ENC, KH>,
2718>(
2719 cfg: &PublishConfig,
2720 admin: &SiteAdmin,
2721 db: Option<&(Arc<RwLock<DB>>, UID)>,
2722 id: &str,
2723)
2724 -> Outcome<HttpMessage>
2725{
2726 let db = match db {
2727 Some(d) => d,
2728 None => return Ok(json_error("this site has no database configured")),
2729 };
2730 debug!("{}: console: GET profile.json", id);
2731 let p = store::get_profile(db, &admin.username).unwrap_or_default();
2732 let mut m = DaticleMap::new();
2733 m.insert(dat!("name"), dat!(p.name.clone()));
2734 m.insert(dat!("avatar"), dat!(p.avatar.clone()));
2735 m.insert(dat!("bio"), dat!(p.bio.clone()));
2736 m.insert(dat!("uploaded"),
2737 Dat::Bool(!p.handle.is_empty() && p.avatar == cfg.avatar_path(&p.handle)));
2738 m.insert(dat!("max_bytes"), dat!(AVATAR_MAX_BYTES as u64));
2739 // The initial a form draws where there is no picture, from the same rule the reader's pages use.
2740 m.insert(dat!("initial"), dat!(Author::from_profile(&admin.username, &p, "").initial()));
2741 Ok(json_body(&res!(Dat::Map(m).encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2742}
2743
2744
2745/// A site's destination settings as JSON, for the settings form.
2746///
2747/// Each remote's public fields -- an instance URL, a handle -- and whether its secret is set, and
2748/// **never the secret**. The secret is write-only: it goes in through [`do_creds`] and does not come
2749/// back out, so a session that should not have it cannot read it here. `in_config` says the config file
2750/// also provides the remote, so the form can show it is set even where the store holds nothing.
2751fn creds_json<
2752 const UIDL: usize,
2753 UID: NumIdDat<UIDL>,
2754 ENC: Encrypter,
2755 KH: Hasher,
2756 DB: Database<UIDL, UID, ENC, KH>,
2757>(
2758 cfg: &PublishConfig,
2759 db: Option<&(Arc<RwLock<DB>>, UID)>,
2760 id: &str,
2761)
2762 -> Outcome<HttpMessage>
2763{
2764 let db = match db {
2765 Some(d) => d,
2766 None => return Ok(json_error("this site has no database configured")),
2767 };
2768 debug!("{}: console: GET creds.json", id);
2769 let stored = res!(send::get_creds(db));
2770
2771 let mut mm = DaticleMap::new();
2772 mm.insert(dat!("base_url"),
2773 dat!(stored.mastodon.as_ref().map(|c| c.base_url.clone()).unwrap_or_default()));
2774 mm.insert(dat!("secret_set"), Dat::Bool(stored.mastodon.is_some()));
2775 mm.insert(dat!("in_config"), Dat::Bool(cfg.creds.mastodon.is_some()));
2776
2777 let mut bm = DaticleMap::new();
2778 bm.insert(dat!("host"),
2779 dat!(stored.bluesky.as_ref().map(|c| c.host.clone()).unwrap_or_default()));
2780 bm.insert(dat!("handle"),
2781 dat!(stored.bluesky.as_ref().map(|c| c.handle.clone()).unwrap_or_default()));
2782 bm.insert(dat!("secret_set"), Dat::Bool(stored.bluesky.is_some()));
2783 bm.insert(dat!("in_config"), Dat::Bool(cfg.creds.bluesky.is_some()));
2784
2785 let mut m = DaticleMap::new();
2786 m.insert(dat!("mastodon"), Dat::Map(mm));
2787 m.insert(dat!("bluesky"), Dat::Map(bm));
2788 Ok(json_body(&res!(Dat::Map(m).encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2789}
2790
2791/// The AI settings as JSON.
2792///
2793/// The same settings the AI page renders, for an app that would rather draw the panel itself. The key
2794/// is never among them -- it is write-only, and a page that showed it, even once, is a page a shoulder
2795/// reads -- so only whether one is *set* is told; the prompts are the resolved ones, so a blank box in
2796/// the app still starts from the default, exactly as the server form prefills it.
2797fn ai_json<
2798 const UIDL: usize,
2799 UID: NumIdDat<UIDL>,
2800 ENC: Encrypter,
2801 KH: Hasher,
2802 DB: Database<UIDL, UID, ENC, KH>,
2803>(
2804 db: Option<&(Arc<RwLock<DB>>, UID)>,
2805 id: &str,
2806)
2807 -> Outcome<HttpMessage>
2808{
2809 let db = match db {
2810 Some(d) => d,
2811 None => return Ok(json_error("this site has no database configured")),
2812 };
2813 debug!("{}: console: GET ai.json", id);
2814 let s = res!(ai::get_settings(db));
2815
2816 let mut m = DaticleMap::new();
2817 m.insert(dat!("provider"), dat!(s.provider.clone()));
2818 m.insert(dat!("model"), dat!(s.model.clone()));
2819 // Never the key itself, only whether one is held -- the app draws a "kept" placeholder from this.
2820 m.insert(dat!("key_set"), Dat::Bool(!s.api_key.is_empty()));
2821 m.insert(dat!("fix_prompt"), dat!(s.fix_prompt().to_string()));
2822 m.insert(dat!("comment_prompt"),dat!(s.comment_prompt().to_string()));
2823 m.insert(dat!("alert_emails"), dat!(s.alert_emails.join("\n")));
2824 Ok(json_body(&res!(Dat::Map(m).encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
2825}
2826
2827
2828// ┌───────────────────────────────────────────────────────────────────────────┐
2829// │ POST │
2830// └───────────────────────────────────────────────────────────────────────────┘
2831
2832/// Serves the console's writes. The gate and the CSRF check ran before this.
2833pub async fn handle_post<
2834 const UIDL: usize,
2835 UID: NumIdDat<UIDL>,
2836 ENC: Encrypter,
2837 KH: Hasher,
2838 DB: Database<UIDL, UID, ENC, KH>,
2839>(
2840 cfg: Option<&PublishConfig>,
2841 admin: &SiteAdmin,
2842 db: Option<&(Arc<RwLock<DB>>, UID)>,
2843 tls_client: &Option<Arc<ClientConfig>>,
2844 mail: &Option<Arc<MailSender>>,
2845 request_path: &str,
2846 body: &[u8],
2847 json: bool,
2848 id: &str,
2849)
2850 -> Outcome<HttpMessage>
2851{
2852 // A render touches no store and needs no config: it reads the source in the body and hands back
2853 // its HTML. It answers before the store checks below, since none of them bear on it.
2854 if request_path == PATH_RENDER {
2855 return do_render(body);
2856 }
2857
2858 let cfg = match cfg {
2859 Some(c) => c,
2860 None => return Ok(back_with("this site publishes nothing", json)),
2861 };
2862 // Editing what is not served would be writing into the dark, so the editor waits for the store to
2863 // be the source. Importing does not: it is how a site gets from one to the other, and must run
2864 // before the switch or the switch empties the site. Setting a destination's credentials does not
2865 // either: a remote is a remote whatever the posts are served from.
2866 // A subscriber lives in the database whatever the posts are served from, so unsubscribing or erasing
2867 // one does not wait on the store being the source -- only the writes that touch a post do.
2868 if cfg.source != Source::Store
2869 && request_path != PATH_IMPORT
2870 && request_path != PATH_CREDS
2871 && request_path != PATH_SUBS_ACTION
2872 && request_path != PATH_PROFILE_SAVE
2873 && request_path != PATH_AI_SAVE
2874 && request_path != PATH_AI_FIX
2875 && request_path != PATH_AI_TEST
2876 // A declaration is about a book or a project, not about a post, so it does not wait on the
2877 // posts being served from the store -- a site serving its prose from a directory still shows
2878 // the things it declares for.
2879 && request_path != PATH_DECLARE_SAVE
2880 {
2881 return Ok(back_with(
2882 "this site serves its posts from a directory, so there is nothing to write into; set \
2883 'source' to 'store' first",
2884 json,
2885 ));
2886 }
2887 let db = match db {
2888 Some(db) => db,
2889 None => return Ok(back_with("this site has no database configured", json)),
2890 };
2891
2892 match request_path {
2893 PATH_SAVE => do_save(cfg, db, tls_client, body, &admin.username, json, id).await,
2894 PATH_DELETE => do_delete(db, body, &admin.username, json, id),
2895 PATH_IMPORT => do_import(cfg, db, &admin.username, json, id),
2896 PATH_CREDS => do_creds(db, body, &admin.username, json, id),
2897 PATH_AI_SAVE => do_ai_save(db, body, &admin.username, json, id),
2898 PATH_DECLARE_SAVE => do_declare_save(cfg, db, body, json, id).await,
2899 PATH_AI_FIX => do_ai_fix(db, tls_client, body, id).await,
2900 PATH_AI_TEST => do_ai_test(db, tls_client, id).await,
2901 PATH_NEWSLETTER => do_newsletter(cfg, db, mail, body, &admin.username, json, id).await,
2902 PATH_NEWSLETTER_TEST => do_test_send(cfg, db, mail, body, &admin.username, json, id).await,
2903 PATH_SUBS_ACTION => do_subs_action(db, body, &admin.username, json, id),
2904 PATH_COMMENTS_ACTION => do_comment_action(db, body, &admin.username, json, id),
2905 PATH_PROFILE_SAVE => do_profile_save(cfg, db, body, &admin.username, json, id),
2906 PATH_TAG_DELETE => do_tag_delete(db, body, &admin.username, json, id),
2907 // Unreachable: `writes` names the same paths.
2908 _ => Ok(back(json)),
2909 }
2910}
2911
2912/// Sends a live post to every confirmed subscriber.
2913///
2914/// The console side of "own the send": it reads the slug the send form named, checks the post is live,
2915/// and hands off to [`send::send_newsletter`], which signs and delivers a message per confirmed
2916/// subscriber straight to their MX. Where mail is not configured on the host, it says so rather than
2917/// pretending to send. The reason -- how many went, how many failed, or why none could -- rides back in
2918/// the redirect the way every other console write's does.
2919async fn do_newsletter<
2920 const UIDL: usize,
2921 UID: NumIdDat<UIDL>,
2922 ENC: Encrypter,
2923 KH: Hasher,
2924 DB: Database<UIDL, UID, ENC, KH>,
2925>(
2926 cfg: &PublishConfig,
2927 db: &(Arc<RwLock<DB>>, UID),
2928 mail: &Option<Arc<MailSender>>,
2929 body: &[u8],
2930 who: &str,
2931 json: bool,
2932 id: &str,
2933)
2934 -> Outcome<HttpMessage>
2935{
2936 let sender = match mail {
2937 Some(m) => m,
2938 None => return Ok(subs_back_with(
2939 "email is not set up on this host, so there is nowhere to send from", json)),
2940 };
2941 if cfg.base_url.is_empty() {
2942 return Ok(subs_back_with(
2943 "this site has no base_url, so a post's online link and the unsubscribe link cannot be built",
2944 json));
2945 }
2946 let slug = super::form_field(body, "slug").unwrap_or_default();
2947 let slug = slug.trim().to_string();
2948 if !valid_slug(&slug) {
2949 return Ok(subs_back_with("that is not a post's name", json));
2950 }
2951 let from = cfg.newsletter_from(sender);
2952 match send::send_newsletter(sender, db, cfg, &from, &slug, id).await {
2953 Ok(report) => {
2954 info!("{}: console: '{}' sent newsletter '{}' ({} sent, {} failed, {} suppressed)",
2955 id, who, slug, report.sent, report.failed, report.suppressed);
2956 // One history entry per real send, stamped with the moment the way the mail's own Date header
2957 // is. A history that will not write does not fail the send -- the mail has gone -- so it logs
2958 // and carries on.
2959 let at = send::iso_now().unwrap_or_default();
2960 let entry = send::SendEntry::of(&slug, &at, &report);
2961 if let Err(e) = send::record_send(db, &entry) {
2962 warn!("{}: console: '{}' sent newsletter '{}' but the history would not record it: {}",
2963 id, who, slug, e);
2964 }
2965 Ok(subs_back_with(
2966 &fmt!("newsletter '{}' sent to {} subscriber(s), {} failed, {} suppressed",
2967 slug, report.sent, report.failed, report.suppressed),
2968 json))
2969 }
2970 Err(e) => {
2971 warn!("{}: console: '{}' newsletter '{}' failed: {}", id, who, slug, e);
2972 Ok(subs_back_with("the newsletter could not be sent; the log says why", json))
2973 }
2974 }
2975}
2976
2977/// Sends a live post to a single address, to preview what a subscriber gets.
2978///
2979/// The console side of the test-send: it reads the slug and the `test_to` address the form named and
2980/// hands off to [`send::send_test`], which delivers one message and touches no subscriber state and no
2981/// history. Where mail is not set up on the host, or the site has no origin to build the post's links
2982/// from, it says so rather than pretend to send.
2983async fn do_test_send<
2984 const UIDL: usize,
2985 UID: NumIdDat<UIDL>,
2986 ENC: Encrypter,
2987 KH: Hasher,
2988 DB: Database<UIDL, UID, ENC, KH>,
2989>(
2990 cfg: &PublishConfig,
2991 db: &(Arc<RwLock<DB>>, UID),
2992 mail: &Option<Arc<MailSender>>,
2993 body: &[u8],
2994 who: &str,
2995 json: bool,
2996 id: &str,
2997)
2998 -> Outcome<HttpMessage>
2999{
3000 let sender = match mail {
3001 Some(m) => m,
3002 None => return Ok(subs_back_with(
3003 "email is not set up on this host, so there is nowhere to send from", json)),
3004 };
3005 if cfg.base_url.is_empty() {
3006 return Ok(subs_back_with(
3007 "this site has no base_url, so a post's online link cannot be built", json));
3008 }
3009 let slug = super::form_field(body, "slug").unwrap_or_default();
3010 let slug = slug.trim().to_string();
3011 if !valid_slug(&slug) {
3012 return Ok(subs_back_with("that is not a post's name", json));
3013 }
3014 let to = super::form_field(body, "test_to").unwrap_or_default();
3015 if to.trim().is_empty() {
3016 return Ok(subs_back_with("type an address to send the test to", json));
3017 }
3018 let from = cfg.newsletter_from(sender);
3019 match send::send_test(sender, db, cfg, &from, &slug, &to, id).await {
3020 Ok(()) => {
3021 info!("{}: console: '{}' test-sent '{}'", id, who, slug);
3022 // The address is not echoed back: the reply lands on a page anyone at the console can read, and
3023 // the operator knows where they sent it.
3024 Ok(subs_back_with(&fmt!("a test of '{}' was sent", slug), json))
3025 }
3026 Err(e) => {
3027 warn!("{}: console: '{}' test-send of '{}' failed: {}", id, who, slug, e);
3028 Ok(subs_back_with("the test could not be sent; the log says why", json))
3029 }
3030 }
3031}
3032
3033/// Unsubscribes or erases one subscriber, by the address the admin named.
3034///
3035/// Two actions on one endpoint, told apart by the `action` field: `unsubscribe` sets the address
3036/// [`unsubscribed`](subscribe::SubState::Unsubscribed), keeping the record so a re-subscribe opts in
3037/// afresh; `delete` erases it outright, a GDPR removal that leaves nothing behind. Both name the target
3038/// in the `email` field. CSRF is checked upstream, as for every console write.
3039fn do_subs_action<
3040 const UIDL: usize,
3041 UID: NumIdDat<UIDL>,
3042 ENC: Encrypter,
3043 KH: Hasher,
3044 DB: Database<UIDL, UID, ENC, KH>,
3045>(
3046 db: &(Arc<RwLock<DB>>, UID),
3047 body: &[u8],
3048 who: &str,
3049 json: bool,
3050 id: &str,
3051)
3052 -> Outcome<HttpMessage>
3053{
3054 let email = super::form_field(body, "email").unwrap_or_default();
3055 if email.trim().is_empty() {
3056 return Ok(subs_back_with("no subscriber was named", json));
3057 }
3058 let action = super::form_field(body, "action").unwrap_or_default();
3059 match action.as_str() {
3060 "unsubscribe" => {
3061 let found = res!(subscribe::unsubscribe_email(db, &email, id));
3062 info!("{}: console: '{}' unsubscribed a subscriber (found: {})", id, who, found);
3063 Ok(subs_back_with(
3064 if found { "the subscriber was unsubscribed" } else { "no such subscriber" }, json))
3065 }
3066 "delete" => {
3067 let existed = res!(subscribe::remove(db, &email, id));
3068 info!("{}: console: '{}' erased a subscriber (existed: {})", id, who, existed);
3069 Ok(subs_back_with(
3070 if existed { "the subscriber was erased" } else { "no such subscriber" }, json))
3071 }
3072 other => Ok(subs_back_with(&fmt!("'{}' is not an action here", other), json)),
3073 }
3074}
3075
3076/// The answer to a newsletter write, carrying the reason back to the subscribers page rather than the
3077/// posts list, since that is where the operator sent it from.
3078fn subs_back_with(why: &str, json: bool) -> HttpMessage {
3079 if json {
3080 json_error(why)
3081 } else {
3082 redirect(&fmt!("{}?said={}", PATH_SUBS, url_encode(why)))
3083 }
3084}
3085
3086/// The answer to a destination write, landing back on the destinations page rather than the post list.
3087///
3088/// The app posts the same endpoint with `json` set and is unaffected: it wants a yes it can act on, not
3089/// a page. Only the form has somewhere to be returned to, and it is the page it was posted from.
3090fn dests_back(json: bool) -> HttpMessage {
3091 if json {
3092 json_body("{\"ok\":true}")
3093 } else {
3094 redirect(PATH_DESTS)
3095 }
3096}
3097
3098fn ai_back(json: bool) -> HttpMessage {
3099 if json {
3100 json_body("{\"ok\":true}")
3101 } else {
3102 redirect(PATH_AI)
3103 }
3104}
3105
3106fn ai_back_with(why: &str, json: bool) -> HttpMessage {
3107 if json {
3108 json_error(why)
3109 } else {
3110 redirect(&fmt!("{}?said={}", PATH_AI, url_encode(why)))
3111 }
3112}
3113
3114/// Saves a site's AI settings.
3115///
3116/// The key is kept where the field is left blank and one is already stored, so the model or a prompt
3117/// can be changed without re-typing it; a provider without a model or key is allowed, since a
3118/// half-filled panel is a site part-way through setting AI up, not an error. Clearing the key is a
3119/// distinct action that turns AI off. The stored line names no secret, deliberately.
3120fn do_ai_save<
3121 const UIDL: usize,
3122 UID: NumIdDat<UIDL>,
3123 ENC: Encrypter,
3124 KH: Hasher,
3125 DB: Database<UIDL, UID, ENC, KH>,
3126>(
3127 db: &(Arc<RwLock<DB>>, UID),
3128 body: &[u8],
3129 who: &str,
3130 json: bool,
3131 id: &str,
3132)
3133 -> Outcome<HttpMessage>
3134{
3135 let mut s = res!(ai::get_settings(db));
3136
3137 if super::form_field(body, "clear").as_deref() == Some("key") {
3138 s.api_key = String::new();
3139 res!(ai::put_settings(db, &s));
3140 info!("{}: console: '{}' cleared the AI key", id, who);
3141 return Ok(ai_back(json));
3142 }
3143
3144 // A provider must be one the client can reach, or nothing at all -- an unknown word is a typo the
3145 // operator should hear about rather than a call that fails later at a host that does not exist.
3146 let provider = super::form_field(body, "provider").unwrap_or_default().trim().to_string();
3147 if !provider.is_empty() && oxedyne_fe2o3_net::llm::Provider::of(&provider).is_err() {
3148 return Ok(ai_back_with(
3149 "the provider must be OpenRouter, Fireworks or Mistral", json));
3150 }
3151 s.provider = provider;
3152 s.model = super::form_field(body, "model").unwrap_or_default().trim().to_string();
3153
3154 // The key is kept where the field is blank and one is held, so the rest can be edited without it.
3155 if let Some(k) = super::form_field(body, "api_key") {
3156 if !k.trim().is_empty() {
3157 s.api_key = k.trim().to_string();
3158 }
3159 }
3160
3161 // The prompts are stored as typed; empty is not an error, it means "use the default" at call time.
3162 s.fix_prompt = super::form_field(body, "fix_prompt").unwrap_or_default();
3163 s.comment_prompt = super::form_field(body, "comment_prompt").unwrap_or_default();
3164 s.alert_emails = ai::parse_emails(&super::form_field(body, "alert_emails").unwrap_or_default());
3165
3166 res!(ai::put_settings(db, &s));
3167 info!("{}: console: '{}' saved the AI settings ({} provider, {} alert address(es))",
3168 id, who,
3169 if s.provider.is_empty() { "no" } else { &s.provider },
3170 s.alert_emails.len());
3171 Ok(ai_back(json))
3172}
3173
3174/// Tidies a post's text with the site's model, and returns the suggestion for the author to accept.
3175///
3176/// The one AI call the author makes by hand. It sends the fix prompt and the post's current source to
3177/// the model and hands back what it says, as JSON, for the editor to show beside the original -- it
3178/// changes nothing here, because the author decides whether the suggestion is better than what they
3179/// wrote. Every way it can fail -- no AI set up, no outbound TLS, a model that will not answer -- comes
3180/// back as a plain reason the editor can show, not a broken page.
3181async fn do_ai_fix<
3182 const UIDL: usize,
3183 UID: NumIdDat<UIDL>,
3184 ENC: Encrypter,
3185 KH: Hasher,
3186 DB: Database<UIDL, UID, ENC, KH>,
3187>(
3188 db: &(Arc<RwLock<DB>>, UID),
3189 tls_client: &Option<Arc<ClientConfig>>,
3190 body: &[u8],
3191 id: &str,
3192)
3193 -> Outcome<HttpMessage>
3194{
3195 let source = super::form_field(body, "source").unwrap_or_default();
3196 if source.trim().is_empty() {
3197 return Ok(json_error("There is nothing to fix yet."));
3198 }
3199
3200 let settings = res!(ai::get_settings(db));
3201 if !settings.ready() {
3202 return Ok(json_error(
3203 "AI is not set up. Set a provider, a model and a key on the AI page first."));
3204 }
3205 let tls = match tls_client {
3206 Some(t) => t.clone(),
3207 None => return Ok(json_error(
3208 "The server has no outbound connection, so it cannot reach the model.")),
3209 };
3210 let cfg = res!(settings.llm());
3211
3212 let fixed = match oxedyne_fe2o3_net::llm::complete(
3213 &cfg, settings.fix_prompt(), &source, tls).await
3214 {
3215 Ok(f) => f.trim().to_string(),
3216 Err(e) => {
3217 // The reason is logged for the operator; the reader gets a plain one, since a model's own
3218 // error can carry a key or a quota figure that does not belong on a page.
3219 warn!("{}: console: an AI fix could not be made: {}", id, e);
3220 return Ok(json_error(
3221 "The model could not be reached, or would not answer. The log says why."));
3222 }
3223 };
3224
3225 let mut m = DaticleMap::new();
3226 m.insert(dat!("ok"), Dat::Bool(true));
3227 m.insert(dat!("fixed"), dat!(fixed));
3228 info!("{}: console: an AI fix was suggested", id);
3229 Ok(json_body(&res!(Dat::Map(m).json())))
3230}
3231
3232/// Checks the site's AI settings actually reach the model, and says so.
3233///
3234/// The one call that finishes setting AI up: the operator has typed a provider, a model and a key, and
3235/// wants to know they were right before a real comment or a real post rides on them. It sends a tiny,
3236/// fixed exchange -- not the operator's prompts, not any post or comment -- and reports only whether the
3237/// model answered. Every way it can fail comes back as a plain reason, since a wrong key, a wrong model
3238/// name and no outbound connection are exactly what this is for and each wants a different fix. The
3239/// model's own words are trimmed to a short echo, so the operator sees it truly spoke, and a key or a
3240/// quota figure a provider might return does not sprawl onto the page.
3241async fn do_ai_test<
3242 const UIDL: usize,
3243 UID: NumIdDat<UIDL>,
3244 ENC: Encrypter,
3245 KH: Hasher,
3246 DB: Database<UIDL, UID, ENC, KH>,
3247>(
3248 db: &(Arc<RwLock<DB>>, UID),
3249 tls_client: &Option<Arc<ClientConfig>>,
3250 id: &str,
3251)
3252 -> Outcome<HttpMessage>
3253{
3254 let settings = res!(ai::get_settings(db));
3255 if !settings.ready() {
3256 return Ok(json_error(
3257 "AI is not set up. Set a provider, a model and a key first, then test."));
3258 }
3259 let tls = match tls_client {
3260 Some(t) => t.clone(),
3261 None => return Ok(json_error(
3262 "The server has no outbound connection, so it cannot reach the model.")),
3263 };
3264 let cfg = res!(settings.llm());
3265
3266 // A fixed, tiny exchange the operator's own prompts have no part in: this proves the connection,
3267 // not the wording, so the wording it uses is the module's, not the site's.
3268 let reply = match oxedyne_fe2o3_net::llm::complete(
3269 &cfg,
3270 "You are a connection test. Reply with exactly the word: OK.",
3271 "ping",
3272 tls,
3273 ).await {
3274 Ok(r) => r.trim().to_string(),
3275 Err(e) => {
3276 warn!("{}: console: the AI test call failed: {}", id, e);
3277 return Ok(json_error(
3278 "The model could not be reached, or would not answer. Check the provider, the model \
3279 name and the key. The log says more."));
3280 }
3281 };
3282
3283 // The model spoke; that is the whole of the test. Its words are echoed, trimmed short, so the
3284 // operator sees a real answer rather than taking "it worked" on faith.
3285 let echo: String = reply.chars().take(80).collect();
3286 let mut m = DaticleMap::new();
3287 m.insert(dat!("ok"), Dat::Bool(true));
3288 m.insert(dat!("reply"), dat!(echo));
3289 info!("{}: console: the AI test call reached the model", id);
3290 Ok(json_body(&res!(Dat::Map(m).json())))
3291}
3292
3293fn dests_back_with(why: &str, json: bool) -> HttpMessage {
3294 if json {
3295 json_error(why)
3296 } else {
3297 redirect(&fmt!("{}?said={}", PATH_DESTS, url_encode(why)))
3298 }
3299}
3300
3301/// Writes a post, and delivers it to the destinations the author ticked.
3302async fn do_save<
3303 const UIDL: usize,
3304 UID: NumIdDat<UIDL>,
3305 ENC: Encrypter,
3306 KH: Hasher,
3307 DB: Database<UIDL, UID, ENC, KH>,
3308>(
3309 cfg: &PublishConfig,
3310 db: &(Arc<RwLock<DB>>, UID),
3311 tls_client: &Option<Arc<ClientConfig>>,
3312 body: &[u8],
3313 who: &str,
3314 json: bool,
3315 id: &str,
3316)
3317 -> Outcome<HttpMessage>
3318{
3319 let slug = super::form_field(body, "slug").unwrap_or_default();
3320 let slug = slug.trim().to_string();
3321 if !valid_slug(&slug) {
3322 return Ok(back_with(
3323 "a post's name may hold letters, digits, hyphens and underscores, and nothing else",
3324 json,
3325 ));
3326 }
3327
3328 let date = normalise_date(&super::form_field(body, "date").unwrap_or_default());
3329 if !valid_date(&date) {
3330 return Ok(back_with(
3331 "a date is written 2026-07-17, or 2026-07-17 14:30 to say when in the day, or is left empty",
3332 json,
3333 ));
3334 }
3335
3336 let source = super::form_field(body, "source").unwrap_or_default();
3337 if source.trim().is_empty() {
3338 return Ok(back_with("a post with no prose in it is not a post", json));
3339 }
3340
3341 let state = PostState::of(&super::form_field(body, "state").unwrap_or_default());
3342 let markup = Markup::of(&super::form_field(body, "markup").unwrap_or_default());
3343 // An author who gave no date meant today. Left undated the post reaches the feed, which must say
3344 // when every entry was updated, and the only answer available was the epoch -- filing the piece
3345 // under 1970 in every reader that takes it. The form offers today already; this is for the save
3346 // that arrives with the field cleared.
3347 let date = if date.is_empty() { crate::srv::publish::today() } else { Some(date) };
3348
3349 // The author, by site-login username. The form carries it so an editor could reassign a post, but
3350 // it defaults to whoever is signed in -- a post's author is the person writing it unless said
3351 // otherwise. Empty falls back to the signer rather than to no author.
3352 let author = super::form_field(body, "author")
3353 .map(|a| a.trim().to_string())
3354 .filter(|a| !a.is_empty())
3355 .unwrap_or_else(|| who.to_string());
3356
3357 // The categories the author ticked, from the site's configured set. A comma-separated field like
3358 // the tags, but the case is kept: a category is matched against a fixed set where `Personal` and
3359 // `personal` are not the same entry, whereas a tag is folded. An unknown category is dropped.
3360 let categories: Vec<String> = super::form_field(body, "categories")
3361 .unwrap_or_default()
3362 .split(',')
3363 .map(|c| c.trim().to_string())
3364 .filter(|c| !c.is_empty() && cfg.categories.iter().any(|k| k == c))
3365 .collect();
3366
3367 // The comma-separated tags field, split and normalised and deduped. An invalid tag is dropped in
3368 // silence, on the same footing as a slug's small alphabet, so a stray character does not fail the
3369 // save. Empty or whitespace is no tags.
3370 let tags = parse_tags(&super::form_field(body, "tags").unwrap_or_default());
3371
3372 // How much the writing of it needed AI, where the author said. An empty field, an absent one and a
3373 // word this version does not know all read as no declaration -- which is what an author who has
3374 // not chosen means, and the only reading that cannot put a claim on a post nobody made.
3375 let ai_level = super::form_field(body, "ai_level")
3376 .and_then(|s| declare::Level::of(s.trim()));
3377
3378 // An edit must not lose where a post has already been sent. So the deliveries are carried forward
3379 // from the post as it stands -- under its old name where this save renames it, since the deliveries
3380 // move with the prose they belong to.
3381 let prior_slug = super::form_field(body, "was")
3382 .map(|s| s.trim().to_string())
3383 .filter(|s| !s.is_empty() && valid_slug(s))
3384 .unwrap_or_else(|| slug.clone());
3385 let carried = res!(store::get(db, &prior_slug))
3386 .map(|r| r.deliveries)
3387 .unwrap_or_default();
3388
3389 // The credentials that actually apply: what the console has set, over what the config names. The
3390 // picker offers these and a delivery is sent with them, so the two never disagree about which
3391 // remotes a site can reach.
3392 let creds = res!(send::effective_creds(db, cfg));
3393
3394 // The destinations the author ticked, kept to those the site actually has credentials for -- a
3395 // browser's word for a remote is not a reason to queue a post the site cannot send. A post is only
3396 // delivered once it is live: a draft goes nowhere, so ticking a destination on a draft queues
3397 // nothing until it is published.
3398 let chosen: Vec<Destination> = super::form_field(body, "destinations")
3399 .unwrap_or_default()
3400 .split(',')
3401 .filter_map(|w| Destination::of(w.trim()))
3402 .filter(|d| creds.has(*d))
3403 .collect();
3404
3405 let deliveries = if state == PostState::Live && !chosen.is_empty() {
3406 // The title and canonical link a social rendition is derived from. The title is the post's own
3407 // heading, as everywhere else; the link is where the post will live on this site.
3408 let title = match render_source(&source, slug.clone(), date.clone(), markup) {
3409 Ok(p) => p.title,
3410 Err(_) => slug.clone(),
3411 };
3412 let url = cfg.url_of(&cfg.path_of(&slug));
3413 send::queue_deliveries(&carried, &chosen, &title, &url)
3414 } else {
3415 carried
3416 };
3417
3418 let rec = Record {
3419 slug: slug.clone(),
3420 author,
3421 categories,
3422 state,
3423 markup,
3424 date,
3425 source,
3426 deliveries,
3427 tags,
3428 ai_level,
3429 };
3430
3431 res!(store::put(db, &rec, id));
3432
3433 // A renamed post is a new key; the old one would otherwise stay behind, served and indexed, a
3434 // second copy of prose the author believes they moved. The old name is what the editor was opened
3435 // with, not what the form now says, which is why the form carries both.
3436 if let Some(was) = super::form_field(body, "was") {
3437 let was = was.trim();
3438 if !was.is_empty() && was != slug && valid_slug(was) {
3439 match store::delete(db, was, id) {
3440 Ok(_) => info!("{}: console: '{}' renamed '{}' to '{}'", id, who, was, slug),
3441 Err(e) => warn!(
3442 "{}: console: '{}' was renamed to '{}' and the old one would not delete: {}",
3443 id, was, slug, e),
3444 }
3445 }
3446 }
3447
3448 info!("{}: console: '{}' saved '{}' ({})", id, who, rec.slug, rec.state.as_str());
3449
3450 // Deliver what is queued, now, while the request is in hand: the handler holds the outbound TLS
3451 // client and the database, which a save is the natural moment to use. A delivery that fails records
3452 // its failure on the post and does not fail the save -- the post is written either way, and the
3453 // send is best-effort with its own state to show for it. A site with no outbound TLS client cannot
3454 // reach a remote at all, and says so in the log rather than silently dropping the queue.
3455 if rec.state == PostState::Live && rec.deliveries.iter().any(|d| !d.state.is_terminal()) {
3456 match tls_client {
3457 Some(tls) => {
3458 match send::deliver_post(db, &creds, tls.clone(), &slug, id).await {
3459 Ok(n) => info!("{}: console: '{}' attempted {} deliver(y/ies)", id, slug, n),
3460 Err(e) => warn!("{}: console: '{}' delivery sweep failed: {}", id, slug, e),
3461 }
3462 }
3463 None => warn!(
3464 "{}: console: '{}' has queued deliveries but the server has no outbound TLS client",
3465 id, slug),
3466 }
3467 }
3468
3469 Ok(back(json))
3470}
3471
3472/// Sets or clears a remote's credentials, from the settings form.
3473///
3474/// Write-only. The secret arrives, is stored, and is never sent back; the log line names the remote and
3475/// whether it was set or cleared, never the value, on the same footing as a login passphrase. An empty
3476/// secret field with a secret already stored keeps the stored one -- so a handle can be changed without
3477/// re-typing a password -- but with none stored the secret is required, since a remote with no secret
3478/// cannot be reached. The whole set is read, the one remote changed, and the set written back.
3479fn do_creds<
3480 const UIDL: usize,
3481 UID: NumIdDat<UIDL>,
3482 ENC: Encrypter,
3483 KH: Hasher,
3484 DB: Database<UIDL, UID, ENC, KH>,
3485>(
3486 db: &(Arc<RwLock<DB>>, UID),
3487 body: &[u8],
3488 who: &str,
3489 json: bool,
3490 id: &str,
3491)
3492 -> Outcome<HttpMessage>
3493{
3494 let dest = super::form_field(body, "dest").unwrap_or_default();
3495 let clear = super::form_field(body, "clear").as_deref() == Some("1");
3496 let mut stored = res!(send::get_creds(db));
3497
3498 match dest.as_str() {
3499 "mastodon" => {
3500 if clear {
3501 stored.mastodon = None;
3502 } else {
3503 let base_url = super::form_field(body, "base_url").unwrap_or_default().trim().to_string();
3504 if base_url.is_empty() {
3505 return Ok(dests_back_with("the Mastodon instance URL is required", json));
3506 }
3507 // The token is kept where the form left it blank and one is already stored, so a public
3508 // field can be edited without re-entering the secret; it is required where none is held.
3509 let token = match super::form_field(body, "token") {
3510 Some(t) if !t.trim().is_empty() => t,
3511 _ => match &stored.mastodon {
3512 Some(c) => c.token.clone(),
3513 None => return Ok(dests_back_with(
3514 "the Mastodon access token is required", json)),
3515 },
3516 };
3517 stored.mastodon = Some(send::MastodonCreds { base_url, token });
3518 }
3519 }
3520 "bluesky" => {
3521 if clear {
3522 stored.bluesky = None;
3523 } else {
3524 let handle = super::form_field(body, "handle").unwrap_or_default().trim().to_string();
3525 if handle.is_empty() {
3526 return Ok(dests_back_with("the Bluesky handle is required", json));
3527 }
3528 let host = {
3529 let h = super::form_field(body, "host").unwrap_or_default().trim().to_string();
3530 if h.is_empty() { send::BLUESKY_HOST_DEFAULT.to_string() } else { h }
3531 };
3532 let app_password = match super::form_field(body, "app_password") {
3533 Some(p) if !p.trim().is_empty() => p,
3534 _ => match &stored.bluesky {
3535 Some(c) => c.app_password.clone(),
3536 None => return Ok(dests_back_with(
3537 "the Bluesky app password is required", json)),
3538 },
3539 };
3540 stored.bluesky = Some(send::BlueskyCreds { host, handle, app_password });
3541 }
3542 }
3543 other => return Ok(dests_back_with(
3544 &fmt!("'{}' is not a destination this site can set", other), json)),
3545 }
3546
3547 res!(send::put_creds(db, &stored));
3548 // No secret in this line, deliberately: it is what a journal keeps, and a journal is read by anyone
3549 // who can read the host.
3550 info!("{}: console: '{}' {} {} credentials",
3551 id, who, if clear { "cleared" } else { "set" }, dest);
3552 Ok(dests_back(json))
3553}
3554
3555/// Saves a member's own profile: the display name, picture and description readers see.
3556///
3557/// A member sets only their own -- the username the profile is stored against is the signed-in one,
3558/// never the form's word for it, so nobody edits another member's face. Empty fields are a profile of
3559/// defaults, which the store keeps as nothing rather than as a record of blanks.
3560///
3561/// A picture may arrive two ways. `avatar` is an address the member typed, kept as given. `picture_data`
3562/// is a file they chose, which the browser hands over as a data URL: it is decoded, refused unless it
3563/// is an image type a browser draws and inside [`AVATAR_MAX_BYTES`], stored in the site's own database,
3564/// and the profile then points at the path this module serves it from. A picture that will not be read
3565/// costs the picture and not the save, since a member editing their description should not lose it to
3566/// a bad file.
3567fn do_profile_save<
3568 const UIDL: usize,
3569 UID: NumIdDat<UIDL>,
3570 ENC: Encrypter,
3571 KH: Hasher,
3572 DB: Database<UIDL, UID, ENC, KH>,
3573>(
3574 cfg: &PublishConfig,
3575 db: &(Arc<RwLock<DB>>, UID),
3576 body: &[u8],
3577 who: &str,
3578 json: bool,
3579 id: &str,
3580)
3581 -> Outcome<HttpMessage>
3582{
3583 let mut said = fmt!("profile saved");
3584 let mut avatar = super::form_field(body, "avatar").unwrap_or_default().trim().to_string();
3585 // The handle this member wears in public, minted the first time they save and kept thereafter:
3586 // their username is the SHA-256 of their passphrase and must never reach a page, so the public
3587 // name for them is drawn from randomness instead of from anything of theirs.
3588 let held = store::get_profile(db, who).unwrap_or_default();
3589 let handle = if held.handle.is_empty() {
3590 Rand::generate_random_string(16, "abcdefghijklmnopqrstuvwxyz0123456789")
3591 } else {
3592 held.handle.clone()
3593 };
3594 // The chosen file, where the member chose one. It is decoded before anything is written, so a
3595 // picture that will not read never displaces the one already there.
3596 let chosen = super::form_field(body, "picture_data").unwrap_or_default();
3597 if !chosen.is_empty() {
3598 match data_url::parse(&chosen, AVATAR_MAX_BYTES) {
3599 Ok(u) if u.is_web_image() => {
3600 res!(store::put_avatar(db, &handle, &u.media_type, &u.bytes));
3601 avatar = cfg.avatar_path(&handle);
3602 info!("{}: console: '{}' uploaded a picture, {} bytes of {}",
3603 id, who, u.bytes.len(), u.media_type);
3604 }
3605 Ok(u) => {
3606 warn!("{}: console: '{}' sent a picture of type '{}', which is not an image this \
3607 site will serve", id, who, u.media_type);
3608 said = fmt!("profile saved, but that file is not a picture");
3609 }
3610 Err(e) => {
3611 warn!("{}: console: '{}' sent a picture that will not read: {}", id, who, e);
3612 said = fmt!("profile saved, but that picture would not read");
3613 }
3614 }
3615 }
3616 let profile = store::Profile {
3617 name: super::form_field(body, "name").unwrap_or_default().trim().to_string(),
3618 avatar,
3619 bio: super::form_field(body, "bio").unwrap_or_default().trim().to_string(),
3620 handle,
3621 };
3622 res!(store::put_profile(db, who, &profile));
3623 info!("{}: console: '{}' saved their profile", id, who);
3624 if json {
3625 Ok(json_body("{\"ok\":true}"))
3626 } else {
3627 Ok(redirect(&fmt!("{}?said={}", PATH_PROFILE, url_encode(&said))))
3628 }
3629}
3630
3631/// Deletes a tag across every author's posts. A site admin's act.
3632///
3633/// The gate above this path is the whole check: only a proven site admin reaches any console write,
3634/// and every site admin curates the shared vocabulary. The count of posts touched rides back so the
3635/// caller can say what happened -- and the composer shows that count before it asks, since the guard
3636/// that matters here is knowing how far the deletion reaches.
3637fn do_tag_delete<
3638 const UIDL: usize,
3639 UID: NumIdDat<UIDL>,
3640 ENC: Encrypter,
3641 KH: Hasher,
3642 DB: Database<UIDL, UID, ENC, KH>,
3643>(
3644 db: &(Arc<RwLock<DB>>, UID),
3645 body: &[u8],
3646 who: &str,
3647 json: bool,
3648 id: &str,
3649)
3650 -> Outcome<HttpMessage>
3651{
3652 let tag = normalise_tag(&super::form_field(body, "tag").unwrap_or_default());
3653 if tag.is_empty() {
3654 return Ok(back_with("no tag was named", json));
3655 }
3656 let n = res!(store::delete_tag(db, &tag, id));
3657 info!("{}: console: '{}' deleted tag '{}' from {} post(s)", id, who, tag, n);
3658 if json {
3659 Ok(json_body(&fmt!("{{\"ok\":true,\"posts\":{}}}", n)))
3660 } else {
3661 Ok(back_with(&fmt!("tag '{}' removed from {} post(s)", tag, n), json))
3662 }
3663}
3664
3665fn do_delete<
3666 const UIDL: usize,
3667 UID: NumIdDat<UIDL>,
3668 ENC: Encrypter,
3669 KH: Hasher,
3670 DB: Database<UIDL, UID, ENC, KH>,
3671>(
3672 db: &(Arc<RwLock<DB>>, UID),
3673 body: &[u8],
3674 who: &str,
3675 json: bool,
3676 id: &str,
3677)
3678 -> Outcome<HttpMessage>
3679{
3680 let slug = match super::form_field(body, "slug") {
3681 Some(s) => s,
3682 None => return Ok(back_with("no post was named", json)),
3683 };
3684 if !valid_slug(&slug) {
3685 return Ok(back_with("that is not a post's name", json));
3686 }
3687 let existed = res!(store::delete(db, &slug, id));
3688 if existed {
3689 info!("{}: console: '{}' deleted '{}'", id, who, slug);
3690 }
3691 Ok(back(json))
3692}
3693
3694/// Renders a run of source to HTML, for a live preview.
3695///
3696/// No store, no slug, no date: only the source and the syntax it is in. What comes back is the same
3697/// HTML a reader would get, so the box a `:::` makes and the class a `{...}` names are seen where
3698/// they will land. A source that will not parse answers with the reason, which the editor shows in
3699/// place of the preview rather than leaving the last good render on screen as though nothing were
3700/// wrong.
3701fn do_render(body: &[u8]) -> Outcome<HttpMessage> {
3702 let source = super::form_field(body, "source").unwrap_or_default();
3703 let markup = Markup::of(&super::form_field(body, "markup").unwrap_or_default());
3704 match crate::srv::publish::render_html(&source, markup) {
3705 Ok(html) => {
3706 let m = create_dat_ordmap(vec![(dat!("html"), dat!(html))]);
3707 Ok(json_body(&res!(m.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
3708 }
3709 Err(e) => Ok(json_error(&fmt!("that will not render: {}", e))),
3710 }
3711}
3712
3713fn do_import<
3714 const UIDL: usize,
3715 UID: NumIdDat<UIDL>,
3716 ENC: Encrypter,
3717 KH: Hasher,
3718 DB: Database<UIDL, UID, ENC, KH>,
3719>(
3720 cfg: &PublishConfig,
3721 db: &(Arc<RwLock<DB>>, UID),
3722 who: &str,
3723 json: bool,
3724 id: &str,
3725)
3726 -> Outcome<HttpMessage>
3727{
3728 let n = match store::import_dir(db, &cfg.dir, id) {
3729 Ok(n) => n,
3730 Err(e) => {
3731 error!(e, "{}: console: import from '{}' failed", id, cfg.dir);
3732 return Ok(back_with("the directory could not be read; the log says why", json));
3733 }
3734 };
3735 info!("{}: console: '{}' imported {} posts from '{}'", id, who, n, cfg.dir);
3736 Ok(back(json))
3737}
3738
3739
3740// ┌───────────────────────────────────────────────────────────────────────────┐
3741// │ HELPERS │
3742// └───────────────────────────────────────────────────────────────────────────┘
3743
3744/// The answer to a write that went through.
3745///
3746/// Two callers, two shapes. A form wants a redirect, so a reload does not write again; the app,
3747/// which asked for JSON, wants a plain yes it can act on without a page changing under it.
3748fn back(json: bool) -> HttpMessage {
3749 if json {
3750 json_body("{\"ok\":true}")
3751 } else {
3752 redirect(PATH_ROOT)
3753 }
3754}
3755
3756/// The answer to a write that did not, carrying the reason -- in the query a form lands with, or in
3757/// the JSON the app reads.
3758fn back_with(why: &str, json: bool) -> HttpMessage {
3759 if json {
3760 json_error(why)
3761 } else {
3762 redirect(&fmt!("{}?said={}", PATH_ROOT, url_encode(why)))
3763 }
3764}
3765
3766/// A JSON body, already encoded.
3767///
3768/// Never held: this is what an app draws its console from, so a store answering it would show a
3769/// post list taken before the post was saved.
3770fn json_body(body: &str) -> HttpMessage {
3771 let mut resp = HttpMessage::ok_respond_with_text(body.to_string());
3772 resp = resp.with_field(
3773 HeaderName::ContentType,
3774 HeaderFieldValue::Generic(fmt!("application/json")),
3775 );
3776 cache::generated(resp)
3777}
3778
3779/// A JSON error a caller can read, its reason escaped for a string literal.
3780fn json_error(why: &str) -> HttpMessage {
3781 let m = create_dat_ordmap(vec![(dat!("error"), dat!(why.to_string()))]);
3782 match m.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)) {
3783 Ok(j) => json_body(&j),
3784 // The error about the error. Say the plain thing rather than nothing.
3785 Err(_) => json_body("{\"error\":\"error\"}"),
3786 }
3787}
3788
3789fn notice(html: &str) -> String {
3790 fmt!("<p class=\"mc-notice\">{}</p>\n", html)
3791}
3792
3793fn import_form(csrf: &str, dir: &str) -> String {
3794 // Importing reads a directory of Markdown on the server into the store. It is the migration
3795 // path off `source: dir`, and once a site has made that move it is a control that can only
3796 // overwrite what the site now writes here. So it appears where there is something to import
3797 // and nowhere else, rather than sitting under every list explaining itself for ever.
3798 if !dir_has_files(dir) {
3799 return String::new();
3800 }
3801 fmt!(
3802 "<form class=\"mc-form\" method=\"POST\" action=\"{import}\">\n\
3803 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
3804 <p class=\"mc-muted\">Import the Markdown in <code>{dir}</code>. A post the store already holds \
3805 is overwritten, so importing twice is importing once.</p>\n\
3806 <div class=\"mc-actions\"><button type=\"submit\" class=\"mc-btn mc-btn-quiet\">Import the \
3807 directory</button></div>\n\
3808 </form>\n",
3809 import = PATH_IMPORT,
3810 csrf = html_escape(csrf),
3811 dir = html_escape(dir),
3812 )
3813}
3814
3815/// Whether a directory holds anything an import would read.
3816///
3817/// A directory that cannot be read is answered `false` rather than an error: the question is only
3818/// ever asked to decide whether to offer a control, and a site with no such directory is the
3819/// ordinary case, not a fault.
3820fn dir_has_files(dir: &str) -> bool {
3821 match std::fs::read_dir(dir) {
3822 Ok(entries) => entries.flatten().any(|e| match e.file_type() {
3823 Ok(t) => t.is_file(),
3824 Err(_) => false,
3825 }),
3826 Err(_) => false,
3827 }
3828}
3829
3830fn selected(yes: bool) -> &'static str {
3831 if yes { " selected" } else { "" }
3832}
3833
3834/// The author field: a hidden input carrying the username the post is stored against, and a line
3835/// naming who that is. Not a free text box, because an author is a member and a member is a login,
3836/// not a name typed at save time; reassigning a post is a job elsewhere, not a slip here.
3837fn author_field(username: &str, name: &str, signer: &str, signer_name: &str) -> String {
3838 // A post already written as the person composing has nothing to take over, so no control is
3839 // offered: a button that does nothing is a question a reader has to answer.
3840 let claim = if username == signer {
3841 String::new()
3842 } else {
3843 fmt!(
3844 " <button type=\"button\" class=\"mc-btn mc-btn-quiet\" id=\"mc-author-mine\" \
3845 data-name=\"{name}\">Write as me</button>",
3846 name = html_escape(signer_name),
3847 )
3848 };
3849 fmt!(
3850 "<div class=\"mc-author\">\n\
3851 <input type=\"hidden\" name=\"author\" value=\"{user}\" id=\"mc-author\">\n\
3852 <span class=\"mc-author-lbl\">Writing as</span> \
3853 <span class=\"mc-author-name\" id=\"mc-author-name\">{name}</span>{claim}\n\
3854 </div>\n",
3855 user = html_escape(username),
3856 name = html_escape(name),
3857 claim = claim,
3858 )
3859}
3860
3861// Takes a post over: the one way to re-attribute one from the console.
3862//
3863// A post carries the username of whoever saved it, and a console that could not change that had no
3864// answer for the ordinary case of a post written under an earlier identity -- an import, a member
3865// account since retired, an operator entry renamed. Its byline then reads *Anonymous* for ever,
3866// because the name it points at has no profile and no way to acquire one.
3867//
3868// Clearing the field is what does the work: the save handler attributes a post with no author named
3869// to whoever is signed in, so emptying the input and letting the ordinary autosave run is the whole
3870// mechanism. The `change` is dispatched by hand because setting a value in script fires no event,
3871// which is the same reason the chip scripts dispatch one.
3872const AUTHOR_SCRIPT: &str = "<script>\n(function(){\n var btn=document.getElementById('mc-author-mine');\n var hidden=document.getElementById('mc-author');\n var name=document.getElementById('mc-author-name');\n if(!btn||!hidden||!name){return;}\n btn.addEventListener('click',function(){\n hidden.value='';\n name.textContent=btn.getAttribute('data-name')||'you';\n btn.remove();\n hidden.dispatchEvent(new Event('change',{bubbles:true}));\n });\n})();\n</script>\n";
3873
3874/// The composer's AI-declaration field: how much the writing of this post needed a model.
3875///
3876/// A select rather than a row of chips, because the answers are one ladder and exactly one of them is
3877/// true. Its first option is **no declaration at all**, and it is what an unset post shows: the site
3878/// must be able to say nothing, and saying nothing has to be as easy as saying anything, or the field
3879/// quietly pressures an author into a claim to be rid of it.
3880///
3881/// Drawn only where the site declares
3882/// ([`crate::srv::publish::declare::DeclareConfig::is_on`]). A site not in a scheme has nothing
3883/// to put in the box and no page that would draw the answer.
3884fn declare_field(cfg: &PublishConfig, on: Option<declare::Level>) -> String {
3885 if !cfg.declare.is_on() {
3886 return String::new();
3887 }
3888 let mut s = fmt!(
3889 "<div>\n\
3890 <label for=\"ai_level\">AI used</label>\n\
3891 <select id=\"ai_level\" name=\"ai_level\">\n\
3892 <option value=\"\"{none_sel}>Not declared</option>\n",
3893 none_sel = selected(on.is_none()),
3894 );
3895 for level in declare::Level::ALL {
3896 s.push_str(&fmt!(
3897 "<option value=\"{slug}\"{sel}>{words}</option>\n",
3898 slug = html_escape(level.slug()),
3899 sel = selected(on == Some(level)),
3900 words = html_escape(level.words()),
3901 ));
3902 }
3903 s.push_str("</select>\n</div>\n");
3904 s
3905}
3906
3907/// The composer's category field: the same two-box chip widget as [`tags_field`], with this post's
3908/// categories in the Selected box and the rest of the site's taxonomy in the Available box, a chip
3909/// moved between them by a click or a drag.
3910///
3911/// One deliberate difference from the tag field: there is no search-or-create line and no delete
3912/// affordance. The category vocabulary is fixed by config, so a category can be neither minted nor
3913/// destroyed from here; only the post's membership of one changes.
3914///
3915/// The chips are the control; a hidden `categories` input, comma-joined, is what the form submits and
3916/// what [`CATS_SCRIPT`] keeps in step. So the field saves what it shows with the script, and re-saves
3917/// the post's existing categories without one -- the hidden field carries them whether or not a chip is
3918/// ever moved. A category may hold a space and capitals, so it is attribute-escaped and never folded.
3919/// A site that defines no categories draws nothing.
3920fn cats_field(categories: &[String], on: &[String]) -> String {
3921 if categories.is_empty() {
3922 return String::new();
3923 }
3924 let mut s = String::from("<div class=\"mc-cats-field\">\n<label>Categories</label>\n");
3925 // The submit source: comma-joined, server-rendered with the post's categories so it saves them with
3926 // no script, and kept in step by the script where one runs.
3927 s.push_str("<input type=\"hidden\" name=\"categories\" id=\"mc-cats\" value=\"");
3928 s.push_str(&html_escape(&on.join(",")));
3929 s.push_str("\">\n");
3930
3931 s.push_str("<div class=\"mc-cats-boxes\">\n");
3932 // Selected: the categories this post sits in, a closer on each.
3933 s.push_str("<div class=\"mc-catbox\">\n<span class=\"mc-catbox-lbl\">On this post</span>\n\
3934 <div class=\"mc-chips mc-chips-selected\" id=\"mc-cats-selected\" data-box=\"selected\">\n");
3935 for c in categories {
3936 if !on.iter().any(|x| x == c) {
3937 continue;
3938 }
3939 // No whitespace between the label and the closer: the chip is an inline flex box and a stray
3940 // text node becomes a stray gap.
3941 s.push_str(&fmt!(
3942 "<button type=\"button\" class=\"mc-chip mc-chip-cat\" draggable=\"true\" \
3943 data-cat=\"{cat}\">{cat}<span class=\"mc-chip-x\" aria-hidden=\"true\">\u{00d7}</span>\
3944 </button>\n",
3945 cat = html_escape(c)));
3946 }
3947 s.push_str("</div>\n</div>\n");
3948 // Available: the rest of the taxonomy. No search line, because the set is fixed and short.
3949 s.push_str("<div class=\"mc-catbox\">\n<span class=\"mc-catbox-lbl\">Available</span>\n\
3950 <div class=\"mc-chips mc-chips-source\" id=\"mc-cats-source\" data-box=\"source\">\n");
3951 for c in categories {
3952 if on.iter().any(|x| x == c) {
3953 continue;
3954 }
3955 s.push_str(&fmt!(
3956 "<button type=\"button\" class=\"mc-chip mc-chip-cat\" draggable=\"true\" \
3957 data-cat=\"{cat}\">{cat}</button>\n",
3958 cat = html_escape(c)));
3959 }
3960 s.push_str("</div>\n</div>\n</div>\n</div>\n");
3961
3962 s.push_str(CATS_SCRIPT);
3963 s
3964}
3965
3966/// The composer's tag field: this post's tags in a Selected box and the site's vocabulary in a Source
3967/// box, a chip moved between them by a click or a drag, and a search-or-create line that filters the
3968/// Source as a person types and mints a new tag where none matches. A hidden `tags` input, comma-
3969/// joined, is the source of truth the form submits.
3970///
3971/// A `curator` gets a closer on each Source chip: pressing it asks to delete that tag across every
3972/// author's post, the one destructive act here, held behind a confirmation that names the cost. A
3973/// non-curator's Source chips carry no closer, so ordinary authoring can only ever add a tag or take
3974/// one off the post in hand.
3975///
3976/// Built as one string, so the script's braces are data and never reach a format string.
3977fn tags_field(tags: &[String], palette: &[(String, usize, usize)], curator: bool) -> String {
3978 let mut s = String::new();
3979 s.push_str("<div class=\"mc-tags-field\">\n<label>Tags</label>\n");
3980 // The submit source: comma-joined, server-rendered with the post's tags so it saves them with no
3981 // script, and kept in step by the script where one runs.
3982 s.push_str("<input type=\"hidden\" name=\"tags\" id=\"mc-tags\" value=\"");
3983 s.push_str(&html_escape(&tags.join(",")));
3984 s.push_str("\">\n");
3985 s.push_str(&fmt!("<input type=\"hidden\" id=\"mc-tags-curator\" value=\"{}\">\n",
3986 if curator { "1" } else { "0" }));
3987 s.push_str(&fmt!("<input type=\"hidden\" id=\"mc-tags-delpath\" value=\"{}\">\n",
3988 html_escape(PATH_TAG_DELETE)));
3989
3990 s.push_str("<div class=\"mc-tags-boxes\">\n");
3991 // Selected: this post's tags, a closer on each. Server-rendered so they show without a script.
3992 s.push_str("<div class=\"mc-tagbox\">\n<span class=\"mc-tagbox-lbl\">On this post</span>\n\
3993 <div class=\"mc-chips mc-chips-selected\" id=\"mc-tags-selected\" data-box=\"selected\">\n");
3994 for t in tags {
3995 s.push_str(&fmt!(
3996 // No space before the closer: the chip is an inline-flex box whose `gap` already
3997 // separates the two, and a stray text node adds a second, uneven one. The category
3998 // chips next to these are drawn the same way, so the two widgets match.
3999 "<button type=\"button\" class=\"mc-chip\" draggable=\"true\" data-tag=\"{tag}\">{tag}\
4000 <span class=\"mc-chip-x\" aria-hidden=\"true\">\u{00d7}</span></button>\n",
4001 tag = html_escape(t)));
4002 }
4003 s.push_str("</div>\n</div>\n");
4004 // Source: the vocabulary not already on the post. A search-or-create line sits above it.
4005 s.push_str("<div class=\"mc-tagbox\">\n<span class=\"mc-tagbox-lbl\">Available</span>\n\
4006 <input type=\"search\" class=\"mc-tags-search\" id=\"mc-tags-search\" \
4007 placeholder=\"Search or add a tag, then Enter\" autocomplete=\"off\">\n\
4008 <div class=\"mc-chips mc-chips-source\" id=\"mc-tags-source\" data-box=\"source\">\n");
4009 for (t, posts, authors) in palette {
4010 if tags.iter().any(|x| x == t) {
4011 continue;
4012 }
4013 // How far the tag reaches, on the chip itself: the script asks with these numbers in hand, so
4014 // nobody is asked to confirm a deletion whose cost they have not been told.
4015 s.push_str(&fmt!(
4016 "<button type=\"button\" class=\"mc-chip\" draggable=\"true\" data-tag=\"{tag}\" \
4017 data-posts=\"{posts}\" data-authors=\"{authors}\">{tag}</button>\n",
4018 tag = html_escape(t), posts = posts, authors = authors));
4019 }
4020 s.push_str("</div>\n</div>\n</div>\n</div>\n");
4021
4022 s.push_str(TAG_SCRIPT);
4023 s
4024}
4025
4026// Moves a category chip between the two boxes on a click or a drag, and keeps the hidden `categories`
4027// input in step so the form submits the Selected set. Does nothing where it does not run: the hidden
4028// field already carries the post's categories.
4029//
4030// Every selector here is qualified by `[data-cat]` and every lookup is scoped to this field's own two
4031// boxes, so the category chips and the tag chips -- which share the `mc-chip` class and so the one
4032// look -- can never be picked up by each other's script.
4033const CATS_SCRIPT: &str = "<script>\n\
4034(function(){\n\
4035 var hidden=document.getElementById('mc-cats');\n\
4036 var sel=document.getElementById('mc-cats-selected');\n\
4037 var src=document.getElementById('mc-cats-source');\n\
4038 if(!hidden||!sel||!src){return;}\n\
4039 function esc(v){return window.CSS&&CSS.escape?CSS.escape(v):v;}\n\
4040 function sync(){\n\
4041 var on=[];\n\
4042 sel.querySelectorAll('.mc-chip[data-cat]').forEach(function(c){on.push(c.getAttribute('data-cat'));});\n\
4043 hidden.value=on.join(',');\n\
4044 hidden.dispatchEvent(new Event('change',{bubbles:true}));\n\
4045 }\n\
4046 function chip(cat,inSel){\n\
4047 var b=document.createElement('button');b.type='button';b.className='mc-chip mc-chip-cat';\n\
4048 b.setAttribute('draggable','true');b.setAttribute('data-cat',cat);b.textContent=cat;\n\
4049 if(inSel){var x=document.createElement('span');x.className='mc-chip-x';\n\
4050 x.setAttribute('aria-hidden','true');x.textContent='\\u00d7';b.appendChild(x);}\n\
4051 return b;\n\
4052 }\n\
4053 function move(b,toSel){\n\
4054 var cat=b.getAttribute('data-cat');b.parentNode.removeChild(b);\n\
4055 (toSel?sel:src).appendChild(chip(cat,toSel));sync();\n\
4056 }\n\
4057 sel.addEventListener('click',function(e){\n\
4058 var b=e.target.closest('.mc-chip[data-cat]');if(b){move(b,false);}\n\
4059 });\n\
4060 src.addEventListener('click',function(e){\n\
4061 var b=e.target.closest('.mc-chip[data-cat]');if(b){move(b,true);}\n\
4062 });\n\
4063 [sel,src].forEach(function(box){\n\
4064 box.addEventListener('dragover',function(e){e.preventDefault();box.classList.add('mc-drop');});\n\
4065 box.addEventListener('dragleave',function(){box.classList.remove('mc-drop');});\n\
4066 box.addEventListener('drop',function(e){e.preventDefault();box.classList.remove('mc-drop');\n\
4067 var cat=e.dataTransfer.getData('text/plain');if(!cat)return;\n\
4068 var q='.mc-chip[data-cat=\"'+esc(cat)+'\"]';\n\
4069 var b=sel.querySelector(q)||src.querySelector(q);\n\
4070 if(b&&b.parentNode!==box){move(b,box===sel);}\n\
4071 });\n\
4072 });\n\
4073 document.addEventListener('dragstart',function(e){\n\
4074 var b=e.target.closest&&e.target.closest('.mc-chip[data-cat]');\n\
4075 if(b&&e.dataTransfer){e.dataTransfer.setData('text/plain',b.getAttribute('data-cat'));}\n\
4076 });\n\
4077 sync();\n\
4078})();\n\
4079</script>\n";
4080
4081// The composer's autosave: the Save button is gone, and the form writes itself as the author works.
4082//
4083// The twin of the app's own composer autosave, and safe for the same reason: a draft save reaches
4084// nobody -- the server delivers nothing, mails nobody, syndicates to no one until a post is live --
4085// so persisting a draft as it is typed costs a reader nothing. Publishing stays a decision: the
4086// State selector set to Live is the one save that can reach the world, so it saves at once rather
4087// than after the usual pause, and the line says so.
4088//
4089// Static, like the sibling scripts: everything it needs is in the DOM. The CSRF token rides in the
4090// form's own hidden field, and the save URL is the form's `action`, so nothing is interpolated. The
4091// save asks for JSON (`Accept`), which the handler answers with `{ok}` or `{error}` rather than the
4092// redirect a plain form post would get. A `pagehide` beacon flushes a pending edit whichever way the
4093// author leaves the page, so nothing typed is lost to the gap before the next scheduled save.
4094const AUTOSAVE_SCRIPT: &str = "<script>\n\
4095(function(){\n\
4096 var form=document.querySelector('.mc-form');\n\
4097 var status=document.getElementById('mc-autosave');\n\
4098 if(!form||!status){return;}\n\
4099 var slugEl=document.getElementById('slug');\n\
4100 var srcEl=document.getElementById('source');\n\
4101 var stateEl=document.getElementById('state');\n\
4102 var wasEl=form.querySelector('input[name=was]');\n\
4103 var savedSlug=wasEl?wasEl.value:'';\n\
4104 var existing=!!savedSlug;\n\
4105 var savedAt=null,dirty=false,saving=false,timer=null,errMsg='';\n\
4106 function elapsed(since){\n\
4107 var s=Math.round((Date.now()-since)/1000);\n\
4108 if(s<3){return 'just now';}\n\
4109 if(s<60){return s+' [s] ago';}\n\
4110 var m=Math.round(s/60);if(m<60){return m+' [m] ago';}\n\
4111 var h=Math.round(m/60);if(h<24){return h+' [h] ago';}\n\
4112 return Math.round(h/24)+' [d] ago';\n\
4113 }\n\
4114 function paint(){\n\
4115 status.className='mc-autosave';\n\
4116 if(errMsg){status.textContent=errMsg;status.classList.add('is-error');return;}\n\
4117 if(saving){status.textContent='Saving\\u2026';status.classList.add('is-working');return;}\n\
4118 var live=stateEl&&stateEl.value==='live';\n\
4119 if(savedAt!=null){status.textContent=(live?'Published \\u00b7 saved ':'Saved ')+elapsed(savedAt);}\n\
4120 else if(existing){status.textContent=live?'Published \\u00b7 saved earlier':'Saved earlier';}\n\
4121 else{status.textContent=dirty?'Not saved yet':'Nothing to save yet';}\n\
4122 }\n\
4123 function valid(v){return /^[A-Za-z0-9_-]+$/.test(v);}\n\
4124 function body(){\n\
4125 var fd=new FormData(form);fd.set('was',savedSlug);\n\
4126 return new URLSearchParams(fd).toString();\n\
4127 }\n\
4128 function save(done){\n\
4129 if(!slugEl||!valid(slugEl.value)){errMsg='Add a name to save';paint();if(done){done(false);}return;}\n\
4130 if(!srcEl||!srcEl.value.trim()){errMsg='Write something to save';paint();if(done){done(false);}return;}\n\
4131 errMsg='';saving=true;paint();\n\
4132 fetch(form.action,{method:'POST',credentials:'same-origin',\n\
4133 headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},\n\
4134 body:body()})\n\
4135 .then(function(r){return r.json();})\n\
4136 .then(function(d){\n\
4137 saving=false;\n\
4138 if(d&&d.ok){savedSlug=slugEl.value;savedAt=Date.now();dirty=false;errMsg='';}\n\
4139 else{errMsg='Not saved \\u2014 '+((d&&d.error)||'try again');}\n\
4140 paint();if(done){done(!!(d&&d.ok));}\n\
4141 })\n\
4142 .catch(function(){saving=false;errMsg='Not saved \\u2014 the server did not answer';paint();if(done){done(false);}});\n\
4143 }\n\
4144 function schedule(){dirty=true;errMsg='';paint();clearTimeout(timer);timer=setTimeout(function(){save(null);},1500);}\n\
4145 form.addEventListener('input',schedule);\n\
4146 form.addEventListener('change',function(e){\n\
4147 if(e.target&&e.target.id==='state'){clearTimeout(timer);save(null);}else{schedule();}\n\
4148 });\n\
4149 form.addEventListener('submit',function(e){e.preventDefault();clearTimeout(timer);save(null);});\n\
4150 setInterval(paint,5000);\n\
4151 paint();\n\
4152 window.addEventListener('pagehide',function(){\n\
4153 if(dirty&&!saving&&slugEl&&valid(slugEl.value)&&srcEl&&srcEl.value.trim()){\n\
4154 try{navigator.sendBeacon(form.action,new Blob([body()],{type:'application/x-www-form-urlencoded'}));}catch(e){}\n\
4155 }\n\
4156 });\n\
4157})();\n\
4158</script>\n";
4159
4160// The composer's Fix button: ask the model to tidy the post, show what it changed, and change
4161// nothing until the author says so.
4162//
4163// Propose, then accept: the suggestion is shown as a line diff against the author's own text -- what
4164// the model would remove struck through, what it would add underlined -- so the author sees the
4165// change rather than a wall of text to re-read, and decides. "Use this" writes the suggestion into
4166// the editor (and dispatches an input so autosave picks it up); "Discard" leaves everything as it
4167// was. The diff is over lines, which is cheap for a post and enough to see a copy-edit; a very long
4168// post falls back to showing the suggestion plainly rather than building a table nobody waits for.
4169const FIX_SCRIPT: &str = "<script>\n\
4170(function(){\n\
4171 var form=document.querySelector('.mc-form');\n\
4172 var btn=document.getElementById('mc-fix');\n\
4173 var src=document.getElementById('source');\n\
4174 var panel=document.getElementById('mc-fix-panel');\n\
4175 var diff=document.getElementById('mc-fix-diff');\n\
4176 var note=document.getElementById('mc-fix-note');\n\
4177 var useBtn=document.getElementById('mc-fix-use');\n\
4178 var discardBtn=document.getElementById('mc-fix-discard');\n\
4179 if(!form||!btn||!src||!panel||!diff||!useBtn||!discardBtn){return;}\n\
4180 var csrfEl=form.querySelector('input[name=csrf]');\n\
4181 var suggestion='';\n\
4182 function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}\n\
4183 function close(){panel.hidden=true;diff.innerHTML='';suggestion='';}\n\
4184 // A line diff by longest common subsequence: cheap for a post, and it shows a copy-edit clearly.\n\
4185 function render(a,b){\n\
4186 var A=a.split('\\n'),B=b.split('\\n');\n\
4187 if(A.length>600||B.length>600){\n\
4188 // Too long to diff pleasantly; show the suggestion plainly.\n\
4189 diff.innerHTML='<pre class=\\\"mc-fix-plain\\\">'+esc(b)+'</pre>';return;\n\
4190 }\n\
4191 var n=A.length,m=B.length,i,j;\n\
4192 var dp=[];for(i=0;i<=n;i++){dp[i]=new Array(m+1).fill(0);}\n\
4193 for(i=n-1;i>=0;i--)for(j=m-1;j>=0;j--)\n\
4194 dp[i][j]=A[i]===B[j]?dp[i+1][j+1]+1:Math.max(dp[i+1][j],dp[i][j+1]);\n\
4195 var out='';i=0;j=0;\n\
4196 while(i<n&&j<m){\n\
4197 if(A[i]===B[j]){out+='<div>'+esc(A[i]||' ')+'</div>';i++;j++;}\n\
4198 else if(dp[i+1][j]>=dp[i][j+1]){out+='<del>'+esc(A[i]||' ')+'</del>';i++;}\n\
4199 else{out+='<ins>'+esc(B[j]||' ')+'</ins>';j++;}\n\
4200 }\n\
4201 while(i<n){out+='<del>'+esc(A[i]||' ')+'</del>';i++;}\n\
4202 while(j<m){out+='<ins>'+esc(B[j]||' ')+'</ins>';j++;}\n\
4203 diff.innerHTML=out;\n\
4204 }\n\
4205 btn.addEventListener('click',function(){\n\
4206 if(!src.value.trim()){note.textContent='';diff.innerHTML='';panel.hidden=false;\n\
4207 diff.innerHTML='<p class=\\\"mc-note\\\">Write something first.</p>';return;}\n\
4208 var was=btn.textContent;btn.disabled=true;btn.textContent='Fixing\\u2026';\n\
4209 var b='csrf='+encodeURIComponent(csrfEl?csrfEl.value:'')\n\
4210 +'&source='+encodeURIComponent(src.value);\n\
4211 fetch(form.action.replace(/\\/save$/,'')+'/ai/fix',{method:'POST',credentials:'same-origin',\n\
4212 headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},body:b})\n\
4213 .then(function(r){return r.json();})\n\
4214 .then(function(d){\n\
4215 btn.disabled=false;btn.textContent=was;\n\
4216 if(d&&d.ok&&typeof d.fixed==='string'){\n\
4217 suggestion=d.fixed;panel.hidden=false;\n\
4218 if(suggestion===src.value){note.textContent='No changes suggested.';diff.innerHTML='';}\n\
4219 else{note.textContent='';render(src.value,suggestion);}\n\
4220 }else{panel.hidden=false;diff.innerHTML='<p class=\\\"mc-note\\\">'\n\
4221 +esc((d&&d.error)||'The fix could not be made.')+'</p>';note.textContent='';}\n\
4222 })\n\
4223 .catch(function(){btn.disabled=false;btn.textContent=was;panel.hidden=false;\n\
4224 diff.innerHTML='<p class=\\\"mc-note\\\">The server did not answer.</p>';});\n\
4225 });\n\
4226 useBtn.addEventListener('click',function(){\n\
4227 if(suggestion){src.value=suggestion;src.dispatchEvent(new Event('input',{bubbles:true}));}\n\
4228 close();\n\
4229 });\n\
4230 discardBtn.addEventListener('click',close);\n\
4231})();\n\
4232</script>\n";
4233
4234// The composer's tag script: the two boxes, the search-or-create line, and a curator's tag deletion,
4235// all kept in step with the hidden `tags` input the form submits.
4236const TAG_SCRIPT: &str = "<script>\n\
4237(function(){\n\
4238 var hidden=document.getElementById('mc-tags');\n\
4239 var sel=document.getElementById('mc-tags-selected');\n\
4240 var src=document.getElementById('mc-tags-source');\n\
4241 var search=document.getElementById('mc-tags-search');\n\
4242 if(!hidden||!sel||!src){return;}\n\
4243 var curator=(document.getElementById('mc-tags-curator')||{}).value==='1';\n\
4244 var delpath=(document.getElementById('mc-tags-delpath')||{}).value||'';\n\
4245 var csrf=(document.querySelector('input[name=csrf]')||{}).value||'';\n\
4246 function norm(t){return t.trim().toLowerCase().replace(/\\s+/g,'-');}\n\
4247 function sync(){\n\
4248 var on=[];\n\
4249 sel.querySelectorAll('.mc-chip').forEach(function(c){on.push(c.getAttribute('data-tag'));});\n\
4250 hidden.value=on.join(',');\n\
4251 hidden.dispatchEvent(new Event('change',{bubbles:true}));\n\
4252 }\n\
4253 function reach(tag){\n\
4254 var b=document.querySelector('.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(tag):tag)+'\"][data-posts]');\n\
4255 return b?{posts:+b.getAttribute('data-posts')||0,authors:+b.getAttribute('data-authors')||0}:null;\n\
4256 }\n\
4257 function chip(tag,inSel){\n\
4258 var b=document.createElement('button');b.type='button';b.className='mc-chip';\n\
4259 b.setAttribute('draggable','true');b.setAttribute('data-tag',tag);b.textContent=tag+' ';\n\
4260 var r=reach(tag);\n\
4261 if(r){b.setAttribute('data-posts',r.posts);b.setAttribute('data-authors',r.authors);}\n\
4262 if(inSel){var x=document.createElement('span');x.className='mc-chip-x';\n\
4263 x.setAttribute('aria-hidden','true');x.textContent='\\u00d7';b.appendChild(x);}\n\
4264 else if(curator){var d=document.createElement('span');d.className='mc-chip-del';\n\
4265 d.setAttribute('title','Delete this tag everywhere');d.textContent='\\u00d7';b.appendChild(d);}\n\
4266 return b;\n\
4267 }\n\
4268 function has(box,tag){return !!box.querySelector('.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(tag):tag)+'\"]');}\n\
4269 function move(b,toSel){\n\
4270 var tag=b.getAttribute('data-tag');b.parentNode.removeChild(b);\n\
4271 (toSel?sel:src).appendChild(chip(tag,toSel));sync();\n\
4272 }\n\
4273 function delTag(tag){\n\
4274 if(!curator||!delpath){return;}\n\
4275 var r=reach(tag)||{posts:0,authors:0};\n\
4276 var cost=r.posts===0?'It is on no post yet.'\n\
4277 :('It is on '+r.posts+(r.posts===1?' post':' posts')+' by '+r.authors\n\
4278 +(r.authors===1?' author':' authors')+'.');\n\
4279 if(!window.confirm('Delete the tag \\u201c'+tag+'\\u201d everywhere? '+cost+' This cannot be undone.')){return;}\n\
4280 var f=new FormData();f.append('csrf',csrf);f.append('tag',tag);\n\
4281 fetch(delpath,{method:'POST',body:new URLSearchParams(f)}).then(function(r){\n\
4282 if(r.ok){var b=src.querySelector('.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(tag):tag)+'\"]');if(b){b.parentNode.removeChild(b);}\n\
4283 var s=sel.querySelector('.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(tag):tag)+'\"]');if(s){s.parentNode.removeChild(s);sync();}}\n\
4284 else{window.alert('The tag could not be deleted.');}\n\
4285 });\n\
4286 }\n\
4287 sel.addEventListener('click',function(e){var b=e.target.closest('.mc-chip');if(b){move(b,false);}});\n\
4288 src.addEventListener('click',function(e){\n\
4289 var b=e.target.closest('.mc-chip');if(!b){return;}\n\
4290 if(curator&&e.target.classList.contains('mc-chip-del')){delTag(b.getAttribute('data-tag'));return;}\n\
4291 move(b,true);\n\
4292 });\n\
4293 [sel,src].forEach(function(box){\n\
4294 box.addEventListener('dragover',function(e){e.preventDefault();box.classList.add('mc-drop');});\n\
4295 box.addEventListener('dragleave',function(){box.classList.remove('mc-drop');});\n\
4296 box.addEventListener('drop',function(e){e.preventDefault();box.classList.remove('mc-drop');\n\
4297 var tag=e.dataTransfer.getData('text/plain');if(!tag)return;\n\
4298 var q='.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(tag):tag)+'\"]';\n\
4299 var b=sel.querySelector(q)||src.querySelector(q);\n\
4300 if(b&&b.parentNode!==box){move(b,box===sel);}\n\
4301 });\n\
4302 });\n\
4303 document.addEventListener('dragstart',function(e){\n\
4304 var b=e.target.closest&&e.target.closest('.mc-chip[data-tag]');\n\
4305 if(b&&e.dataTransfer){e.dataTransfer.setData('text/plain',b.getAttribute('data-tag'));}\n\
4306 });\n\
4307 if(search){\n\
4308 search.addEventListener('input',function(){\n\
4309 var q=norm(search.value);\n\
4310 src.querySelectorAll('.mc-chip').forEach(function(c){\n\
4311 c.hidden=q.length>0&&c.getAttribute('data-tag').indexOf(q)<0;\n\
4312 });\n\
4313 });\n\
4314 search.addEventListener('keydown',function(e){\n\
4315 if(e.key!=='Enter')return;e.preventDefault();\n\
4316 var t=norm(search.value);if(!t)return;\n\
4317 if(!has(sel,t)){if(has(src,t)){move(src.querySelector('.mc-chip[data-tag=\"'+(window.CSS&&CSS.escape?CSS.escape(t):t)+'\"]'),true);}\n\
4318 else{sel.appendChild(chip(t,true));sync();}}\n\
4319 search.value='';src.querySelectorAll('.mc-chip').forEach(function(c){c.hidden=false;});\n\
4320 });\n\
4321 }\n\
4322 if(curator){src.querySelectorAll('.mc-chip').forEach(function(c){\n\
4323 if(!c.querySelector('.mc-chip-del')){var d=document.createElement('span');d.className='mc-chip-del';\n\
4324 d.setAttribute('title','Delete this tag everywhere');d.textContent=' \\u00d7';c.appendChild(d);}\n\
4325 });}\n\
4326 sync();\n\
4327})();\n\
4328</script>\n";
4329
4330/// One field out of a raw query substring, which has no leading `?`.
4331fn query_field(query: &str, key: &str) -> Option<String> {
4332 if query.is_empty() {
4333 return None;
4334 }
4335 for pair in query.split('&') {
4336 let mut kv = pair.splitn(2, '=');
4337 let k = ok!(kv.next());
4338 let v = kv.next().unwrap_or("");
4339 if k == key {
4340 let val = url_decode(v);
4341 if val.is_empty() {
4342 return None;
4343 }
4344 return Some(val);
4345 }
4346 }
4347 None
4348}
4349
4350/// Percent-encode a string for a query parameter, per RFC 3986 section 2.3.
4351fn url_encode(s: &str) -> String {
4352 let mut out = String::with_capacity(s.len());
4353 for b in s.as_bytes().iter() {
4354 match *b {
4355 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
4356 | b'-' | b'_' | b'.' | b'~' => out.push(*b as char),
4357 other => out.push_str(&fmt!("%{:02X}", other)),
4358 }
4359 }
4360 out
4361}
4362
4363/// Decode a percent-encoded value: `+` is a space, `%XX` a byte, a bad escape itself.
4364fn url_decode(s: &str) -> String {
4365 let bytes = s.as_bytes();
4366 let mut out = Vec::with_capacity(bytes.len());
4367 let mut i = 0;
4368 while i < bytes.len() {
4369 match bytes[i] {
4370 b'+' => {
4371 out.push(b' ');
4372 i += 1;
4373 }
4374 b'%' if i + 2 < bytes.len() => {
4375 match (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
4376 (Some(hi), Some(lo)) => {
4377 out.push((hi << 4) | lo);
4378 i += 3;
4379 }
4380 _ => {
4381 out.push(bytes[i]);
4382 i += 1;
4383 }
4384 }
4385 }
4386 b => {
4387 out.push(b);
4388 i += 1;
4389 }
4390 }
4391 }
4392 String::from_utf8_lossy(&out).into_owned()
4393}
4394
4395fn hex_nibble(b: u8) -> Option<u8> {
4396 match b {
4397 b'0'..=b'9' => Some(b - b'0'),
4398 b'a'..=b'f' => Some(b - b'a' + 10),
4399 b'A'..=b'F' => Some(b - b'A' + 10),
4400 _ => None,
4401 }
4402}
4403
4404
4405#[cfg(test)]
4406mod tests {
4407 use super::*;
4408
4409 /// The console writes to its mutation paths and reads the rest.
4410 #[test]
4411 fn test_writes_are_the_mutations_00() -> Outcome<()> {
4412 assert!(writes("/manage/save"));
4413 assert!(writes("/manage/delete"));
4414 assert!(writes("/manage/import"));
4415 assert!(writes("/manage/creds"));
4416 assert!(writes("/manage/newsletter"));
4417 assert!(writes("/manage/newsletter/test"));
4418 assert!(writes("/manage/subscribers/action"));
4419 assert!(!writes("/manage"));
4420 assert!(!writes("/manage/edit"));
4421 assert!(!writes("/manage/preview"));
4422 assert!(!writes("/manage/subscribers"));
4423 Ok(())
4424 }
4425
4426 /// A query field is read out of the raw substring, and an empty value names nothing.
4427 #[test]
4428 fn test_a_query_field_is_read_01() -> Outcome<()> {
4429 assert_eq!(query_field("slug=on-rent", "slug"), Some(fmt!("on-rent")));
4430 assert_eq!(query_field("a=1&slug=on-rent", "slug"), Some(fmt!("on-rent")));
4431 assert_eq!(query_field("slug=", "slug"), None);
4432 assert_eq!(query_field("", "slug"), None);
4433 Ok(())
4434 }
4435
4436 /// The tags field emits the hidden source-of-truth input, this post's tags as chips in the Selected
4437 /// box, and the rest of the vocabulary in the Source box -- a tag already on the post is not offered
4438 /// twice.
4439 #[test]
4440 fn test_the_tags_field_emits_two_boxes_03() -> Outcome<()> {
4441 let cur = vec![fmt!("rust"), fmt!("web")];
4442 let pal = vec![
4443 (fmt!("rust"), 3, 2),
4444 (fmt!("web"), 1, 1),
4445 (fmt!("ozone"), 4, 1),
4446 ];
4447 let s = tags_field(&cur, &pal, false);
4448 // The hidden input is the source of truth, comma-joined, no spaces.
4449 assert!(s.contains(r#"<input type="hidden" name="tags" id="mc-tags" value="rust,web">"#),
4450 "got: {}", s);
4451 // This post's tags sit in the Selected box, each with a closer.
4452 assert!(s.contains(r#"id="mc-tags-selected""#), "no selected box: {}", s);
4453 assert!(s.contains(r#"data-tag="rust"#), "no rust chip: {}", s);
4454 // The Source box offers only vocabulary not already on the post.
4455 assert!(s.contains(r#"data-tag="ozone"#), "ozone not offered: {}", s);
4456 assert_eq!(s.matches(r#"data-tag="web""#).count(), 1,
4457 "a tag on the post was also offered in the source box: {}", s);
4458 // A non-curator's flag is off, so the script wires no delete affordance.
4459 assert!(s.contains(r#"id="mc-tags-curator" value="0""#), "curator flag should be off: {}", s);
4460 Ok(())
4461 }
4462
4463 /// A curator's source chips carry a delete affordance and how far the tag reaches, since the
4464 /// confirmation names that cost; the search-or-create line is always present.
4465 #[test]
4466 fn test_a_curator_gets_source_deletes_04() -> Outcome<()> {
4467 let s = tags_field(&[], &[(fmt!("ozone"), 4, 2)], true);
4468 assert!(s.contains(r#"data-posts="4""#), "the chip does not say how many posts: {}", s);
4469 assert!(s.contains(r#"data-authors="2""#), "the chip does not say how many authors: {}", s);
4470 assert!(s.contains(r#"id="mc-tags-search""#), "no search-or-create box: {}", s);
4471 assert!(s.contains(r#"id="mc-tags-curator" value="1""#), "curator flag not set: {}", s);
4472 // The empty-post value is empty, and the source box holds the one vocabulary word.
4473 assert!(s.contains(r#"id="mc-tags" value="""#), "got: {}", s);
4474 assert!(s.contains(r#"data-tag="ozone"#), "the vocabulary word is missing: {}", s);
4475 Ok(())
4476 }
4477
4478 /// The category field puts the post's categories in the Selected box and the rest of the taxonomy in
4479 /// the Available box, and carries the selected set in the hidden input the form submits.
4480 #[test]
4481 fn test_the_cats_field_splits_the_two_boxes_08() -> Outcome<()> {
4482 let cats = vec![fmt!("Personal"), fmt!("Technical"), fmt!("Ideas")];
4483 let s = cats_field(&cats, &[fmt!("Technical")]);
4484 // The hidden input is the source of truth, comma-joined, no spaces.
4485 assert!(s.contains(r#"<input type="hidden" name="categories" id="mc-cats" value="Technical">"#),
4486 "got: {}", s);
4487 // The post's category sits in the Selected box, with a closer; the rest sit in Available.
4488 let cut = s.find(r#"id="mc-cats-source""#).unwrap_or(0);
4489 assert!(cut > 0, "no source box: {}", s);
4490 let (sel, src) = s.split_at(cut);
4491 assert!(sel.contains(r#"id="mc-cats-selected""#), "no selected box: {}", s);
4492 assert!(sel.contains(r#"data-cat="Technical">Technical<span class="mc-chip-x""#),
4493 "the post's category is not a selected chip: {}", s);
4494 assert!(!sel.contains(r#"data-cat="Personal""#), "an unselected category was selected: {}", s);
4495 assert!(src.contains(r#"data-cat="Personal">Personal</button>"#),
4496 "the rest of the taxonomy is not offered: {}", s);
4497 assert!(src.contains(r#"data-cat="Ideas">Ideas</button>"#), "got: {}", s);
4498 // A category is offered once, never in both boxes at once.
4499 assert_eq!(s.matches(r#"data-cat="Technical""#).count(), 1,
4500 "a selected category was also offered: {}", s);
4501 // The vocabulary is fixed by config, so there is no create line and no delete affordance.
4502 assert!(!s.contains("mc-cats-search"), "the category field grew a create line: {}", s);
4503 assert!(!s.contains("mc-chip-del"), "the category field grew a delete affordance: {}", s);
4504 // A site with no taxonomy draws nothing.
4505 assert!(cats_field(&[], &[]).is_empty(), "an empty taxonomy drew a field");
4506 Ok(())
4507 }
4508
4509 /// A category holding a space and capitals survives into the chip and the hidden input unfolded, and
4510 /// two of them comma-join without a space.
4511 #[test]
4512 fn test_a_category_with_a_space_survives_09() -> Outcome<()> {
4513 let cats = vec![fmt!("Long Reads"), fmt!("Field Notes"), fmt!("Ideas")];
4514 let on = vec![fmt!("Long Reads"), fmt!("Ideas")];
4515 let s = cats_field(&cats, &on);
4516 assert!(s.contains(r#"<input type="hidden" name="categories" id="mc-cats" value="Long Reads,Ideas">"#),
4517 "got: {}", s);
4518 assert!(s.contains(r#"data-cat="Long Reads">Long Reads<span"#),
4519 "the space or the case was lost: {}", s);
4520 let cut = s.find(r#"id="mc-cats-source""#).unwrap_or(0);
4521 assert!(cut > 0, "no source box: {}", s);
4522 let (sel, src) = s.split_at(cut);
4523 assert!(sel.contains(r#"data-cat="Ideas""#), "got: {}", s);
4524 assert!(src.contains(r#"data-cat="Field Notes">Field Notes</button>"#), "got: {}", s);
4525 Ok(())
4526 }
4527
4528 /// The username never reaches the page as a name. It is the SHA-256 of a passphrase, so a page
4529 /// carrying one is an offline verifier for whoever reads that page -- which on a multi-author
4530 /// blog is not only its owner.
4531 #[test]
4532 fn test_the_author_note_never_says_the_username_20() -> Outcome<()> {
4533 let user = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
4534 let s = author_field(user, "Anonymous", "oxedyne", "Jason");
4535 assert!(s.contains(r#"class="mc-author-name" id="mc-author-name">Anonymous</span>"#),
4536 "the note does not name the author: {}", s);
4537 // The hidden input still carries it -- the post is stored against it -- but nothing drawn does.
4538 let visible = s.split("</span>").filter(|part| !part.contains("type=\"hidden\""))
4539 .collect::<Vec<_>>().join("");
4540 assert!(!visible.contains(user), "the username is drawn on the page: {}", s);
4541 Ok(())
4542 }
4543
4544 /// A reason survives being carried in a redirect's query and read back out.
4545 #[test]
4546 fn test_a_reason_survives_the_redirect_02() -> Outcome<()> {
4547 let enc = url_encode("a post with no prose in it is not a post");
4548 assert!(!enc.contains(' '));
4549 assert_eq!(url_decode(&enc), "a post with no prose in it is not a post");
4550 Ok(())
4551 }
4552
4553 /// Nothing the console answers may be served from a store unasked -- a `404` included.
4554 ///
4555 /// RFC 9110 15.5.5 makes a `404` heuristically cacheable, so a store is free to invent a lifetime
4556 /// for a miss. That is the one class of answer that must never be held: a route the console does
4557 /// not know today it may know after the next deploy, and a picture that is not there yet is
4558 /// exactly what somebody is about to upload.
4559 #[test]
4560 fn test_nothing_the_console_answers_is_held_22() -> Outcome<()> {
4561 use crate::srv::console::{
4562 SiteAdmin,
4563 Theme,
4564 };
4565
4566 // The database type the answers below are instantiated over. None of them consults it: a
4567 // missing route is a missing route, and a site with no publish block has nothing to read.
4568 type TestDb = oxedyne_fe2o3_o3db_sync::O3db<
4569 { crate::srv::id::UID_LEN },
4570 crate::srv::id::Uid,
4571 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
4572 oxedyne_fe2o3_hash::hash::HashScheme,
4573 oxedyne_fe2o3_hash::hash::HashScheme,
4574 oxedyne_fe2o3_hash::csum::ChecksumScheme,
4575 >;
4576 let none: Option<&(Arc<RwLock<TestDb>>, crate::srv::id::Uid)> = None;
4577
4578 let theme = Theme {
4579 site_name: fmt!("Elearnity"),
4580 css: vec![],
4581 home: fmt!("/"),
4582 };
4583 let admin = SiteAdmin { username: "a".repeat(64) };
4584 let cfg = PublishConfig {
4585 path: fmt!("/asides"),
4586 dir: fmt!("/nonexistent"),
4587 source: crate::srv::publish::Source::Dir,
4588 title: fmt!("Asides"),
4589 site_name: fmt!("Elearnity"),
4590 base_url: fmt!("https://example.com"),
4591 css: vec![],
4592 creds: Default::default(),
4593 comments: true,
4594 comment_rate_secs: 0,
4595 comment_rate_hourly: 0,
4596 subscribe_rate_secs: 0,
4597 subscribe_rate_hourly: 0,
4598 newsletter_from: String::new(),
4599 categories: vec![],
4600 default_author: String::new(),
4601 logo: String::new(),
4602 home: String::new(),
4603 declare: Default::default(),
4604 };
4605
4606 // A route the console does not know.
4607 let resp = res!(handle_get(
4608 Some(&cfg), &theme, &admin, "csrf", none, "/manage/nonesuch", "", "test"));
4609 cache::assert_not_held(&resp, "a console route that does not exist");
4610
4611 // A JSON answer, which is what an app draws its console from.
4612 cache::assert_not_held(&json_body("{\"posts\":[]}"), "a console JSON answer");
4613 cache::assert_not_held(&json_error("no"), "a console JSON error");
4614
4615 // The export, asked of a site with no database to export from.
4616 cache::assert_not_held(&res!(subscribers_csv(none, "test")), "a CSV export that has no list");
4617
4618 // And the page a site with nothing to publish is shown.
4619 let resp = res!(handle_get(None, &theme, &admin, "csrf", none, "/manage", "", "test"));
4620 cache::assert_not_held(&resp, "the page a site with no publish block gets");
4621 Ok(())
4622 }
4623
4624 /// The AI form selects the stored provider, shows a held key as held and never as its value,
4625 /// prefills a blank prompt with its default, and offers the clear-key form only when a key is set.
4626 #[test]
4627 fn test_the_ai_form_hides_the_key_21() -> Outcome<()> {
4628 let set = ai::AiSettings {
4629 provider: fmt!("mistral"),
4630 model: fmt!("mistral-large-latest"),
4631 api_key: fmt!("sk-super-secret"),
4632 fix_prompt: String::new(),
4633 comment_prompt: fmt!("Say APPROVE or SPAM."),
4634 alert_emails: vec![fmt!("me@example.com")],
4635 };
4636 let s = ai_form(&set, "csrf0");
4637 // The stored provider is the selected option.
4638 assert!(s.contains(r#"<option value="mistral" selected>Mistral</option>"#),
4639 "provider not selected: {}", s);
4640 // The key is never rendered; the field says it is kept.
4641 assert!(!s.contains("sk-super-secret"), "the key was rendered into the page: {}", s);
4642 assert!(s.contains(r#"placeholder="kept""#), "no kept hint for a held key: {}", s);
4643 // A blank fix prompt prefills the default; a set comment prompt shows itself.
4644 assert!(s.contains("meticulous copy-editor"), "the default fix prompt was not prefilled: {}", s);
4645 assert!(s.contains("Say APPROVE or SPAM."), "the set comment prompt is missing: {}", s);
4646 // The alert address is prefilled, and clearing the key is offered because one is held.
4647 assert!(s.contains("me@example.com"), "the alert address is missing: {}", s);
4648 assert!(s.contains("Clear the key"), "no clear-key form for a held key: {}", s);
4649
4650 // With nothing set, the field asks for a key and offers no clear form.
4651 let empty = ai_form(&ai::AiSettings::default(), "csrf0");
4652 assert!(empty.contains(r#"placeholder="required""#), "no required hint for a missing key: {}", empty);
4653 assert!(!empty.contains("Clear the key"), "a clear form was offered with no key: {}", empty);
4654
4655 // The Test button and its status line ride in the settings form, so the script can read the
4656 // form's CSRF token; the button posts nothing and is not a submit.
4657 assert!(s.contains(r#"id="ai-test""#), "no test button: {}", s);
4658 assert!(s.contains(r#"type="button""#), "the test button is a submit: {}", s);
4659 assert!(s.contains(r#"id="ai-test-msg""#), "no test status line: {}", s);
4660 Ok(())
4661 }
4662
4663 /// A subscriber in a given state, signed up at a given moment, for the report tests.
4664 fn sub_at(email: &str, state: subscribe::SubState, created: Option<&str>) -> subscribe::Subscriber {
4665 subscribe::Subscriber {
4666 email: fmt!("{}", email),
4667 state: state,
4668 token: fmt!("t0000000000000000000000000000000"),
4669 created: created.map(|c| fmt!("{}", c)),
4670 }
4671 }
4672
4673 /// A send of a post, for the report tests.
4674 fn sent_at(slug: &str, at: &str, attempted: usize, sent: usize, failed: usize, suppressed: usize)
4675 -> send::SendEntry
4676 {
4677 send::SendEntry {
4678 slug: fmt!("{}", slug),
4679 at: fmt!("{}", at),
4680 attempted: attempted,
4681 sent: sent,
4682 failed: failed,
4683 suppressed: suppressed,
4684 }
4685 }
4686
4687 /// Nothing out of nothing is not zero per cent, and a share is given to one decimal place.
4688 #[test]
4689 fn test_a_share_of_nothing_is_a_dash_05() -> Outcome<()> {
4690 assert_eq!(pct(0, 0), fmt!("--"));
4691 assert_eq!(pct(7, 0), fmt!("--"));
4692 assert_eq!(pct(1, 3), fmt!("33.3%"));
4693 assert_eq!(pct(3, 3), fmt!("100.0%"));
4694 assert_eq!(pct(0, 4), fmt!("0.0%"));
4695 Ok(())
4696 }
4697
4698 /// Months group by their first seven characters, newest first, and a stamp with no month in it is
4699 /// counted under `unknown` rather than dropped.
4700 #[test]
4701 fn test_months_group_newest_first_06() -> Outcome<()> {
4702 let stamps = vec![
4703 "2026-07-19T08:00:00Z",
4704 "2026-07-01T00:00:00Z",
4705 "2026-06-30T23:59:59Z",
4706 "",
4707 "nope",
4708 ];
4709 let months = by_month(stamps.into_iter());
4710 assert_eq!(months.len(), 3);
4711 assert_eq!(months[0], (fmt!("2026-07"), 2));
4712 assert_eq!(months[1], (fmt!("2026-06"), 1));
4713 assert_eq!(months[2], (fmt!("unknown"), 2));
4714 // Nothing is lost: the buckets total what went in.
4715 let total: usize = months.iter().map(|(_, n)| *n).sum();
4716 assert_eq!(total, 5);
4717 Ok(())
4718 }
4719
4720 /// An empty month run draws no table at all, rather than an empty one.
4721 #[test]
4722 fn test_no_months_draw_no_table_07() -> Outcome<()> {
4723 assert_eq!(month_table("Signed up by month", "Sign-ups", &[]), fmt!(""));
4724 let one = vec![(fmt!("2026-07"), 3), (fmt!("2026-06"), 1)];
4725 let html = month_table("Signed up by month", "Sign-ups", &one);
4726 assert!(html.contains("Signed up by month"));
4727 // The peak month fills the bar and the lesser one is scaled against it.
4728 assert!(html.contains("width:100%"));
4729 assert!(html.contains("width:33%"));
4730 Ok(())
4731 }
4732
4733 /// The list report counts each state, and says so where there is nobody to count.
4734 #[test]
4735 fn test_the_list_report_counts_the_states_08() -> Outcome<()> {
4736 let empty = list_report(&[]);
4737 assert!(empty.contains("Nobody has subscribed yet"));
4738 assert!(!empty.contains("mc-stat-n"));
4739
4740 let subs = vec![
4741 sub_at("a@example.com", subscribe::SubState::Confirmed, Some("2026-07-19T08:00:00Z")),
4742 sub_at("b@example.com", subscribe::SubState::Confirmed, Some("2026-06-02T08:00:00Z")),
4743 sub_at("c@example.com", subscribe::SubState::Pending, Some("2026-07-18T08:00:00Z")),
4744 sub_at("d@example.com", subscribe::SubState::Unsubscribed, Some("2026-05-01T08:00:00Z")),
4745 sub_at("e@example.com", subscribe::SubState::Bounced, None),
4746 ];
4747 let html = list_report(&subs);
4748 assert!(html.contains("5 addresses on record"));
4749 // Two of five confirmed, one of five pending, and one of the three who confirmed has left.
4750 assert!(html.contains("40.0%"));
4751 assert!(html.contains("20.0%"));
4752 assert!(html.contains("33.3%"));
4753 // The undated subscriber still appears, under `unknown`.
4754 assert!(html.contains("unknown"));
4755 // The ceiling of the data is stated on the page, not only in the source.
4756 assert!(html.contains("shares of the list as it stands"));
4757 Ok(())
4758 }
4759
4760 /// The send report totals every send, rolls up by post, and never claims an open or a click.
4761 #[test]
4762 fn test_the_send_report_rolls_up_by_post_09() -> Outcome<()> {
4763 let empty = send_report(&[]);
4764 assert!(empty.contains("No post has been mailed"));
4765
4766 let hist = vec![
4767 sent_at("on-rent", "2026-07-19T08:00:00Z", 10, 8, 1, 1),
4768 sent_at("on-rent", "2026-07-18T08:00:00Z", 4, 4, 0, 0),
4769 sent_at("on-time", "2026-06-02T08:00:00Z", 6, 3, 3, 0),
4770 ];
4771 let html = send_report(&hist);
4772 // Three sends, twenty attempts, fifteen accepted.
4773 assert!(html.contains("20 addresses attempted across 3 sends"));
4774 assert!(html.contains("75.0%"));
4775 // The two sends of one post are one row carrying both.
4776 assert!(html.contains("on-rent"));
4777 assert!(html.contains("on-time"));
4778 assert!(html.contains("<td>14</td>"));
4779 // The privacy floor is stated where an operator would look for an open rate.
4780 assert!(html.contains("deliberately not"));
4781 Ok(())
4782 }
4783
4784 /// The reports page is a read: it is not a write, and not a POST.
4785 #[test]
4786 fn test_the_reports_page_is_a_read_10() -> Outcome<()> {
4787 assert!(!writes(PATH_REPORTS));
4788 assert!(!posts(PATH_REPORTS));
4789 Ok(())
4790 }
4791
4792 /// The destinations page is a read; the credentials endpoint it posts to is the write.
4793 #[test]
4794 fn test_the_destinations_page_is_a_read_11() -> Outcome<()> {
4795 assert!(!writes(PATH_DESTS));
4796 assert!(!posts(PATH_DESTS));
4797 assert!(writes(PATH_CREDS));
4798 Ok(())
4799 }
4800
4801 /// A panel names the remote it sets, carries the token, and offers a clear only where something
4802 /// is stored to clear.
4803 #[test]
4804 fn test_a_destination_panel_offers_a_clear_only_when_set_12() -> Outcome<()> {
4805 let unset = dest_panel("Mastodon", "mastodon", "tok", false, false, "");
4806 assert!(unset.contains("name=\"dest\" value=\"mastodon\""));
4807 assert!(unset.contains("name=\"csrf\" value=\"tok\""));
4808 assert!(unset.contains("Not set."));
4809 // Nothing is held, so there is nothing to clear and no button to do it.
4810 assert!(!unset.contains("value=\"1\""));
4811 assert!(!unset.contains("mc-btn-danger"));
4812
4813 let set = dest_panel("Bluesky", "bluesky", "tok", true, false, "");
4814 assert!(set.contains("Set."));
4815 assert!(set.contains("name=\"clear\" value=\"1\""));
4816 assert!(set.contains("mc-btn-danger"));
4817 Ok(())
4818 }
4819
4820 /// An unread site says so, and says what a read is rather than showing an empty table.
4821 #[test]
4822 fn test_the_reads_report_says_when_nothing_is_read_14() -> Outcome<()> {
4823 let s = reads_report(&BTreeMap::new(), &[]);
4824 assert!(s.contains("Nothing has been read yet"));
4825 assert!(!s.contains("<table"));
4826 Ok(())
4827 }
4828
4829 /// Posts are listed most-read first, an unread post is shown at nought rather than dropped, and
4830 /// the page states the ceiling on what a tally means.
4831 #[test]
4832 fn test_the_reads_report_ranks_by_reads_15() -> Outcome<()> {
4833 let mut recs = Vec::new();
4834 for slug in ["quiet", "popular", "middling"] {
4835 let mut r = Record::default();
4836 r.slug = fmt!("{}", slug);
4837 r.source = fmt!("# {}\n\nprose.\n", slug);
4838 recs.push(r);
4839 }
4840 let mut reads = BTreeMap::new();
4841 reads.insert(fmt!("popular"), 90u64);
4842 reads.insert(fmt!("middling"), 10u64);
4843
4844 let s = reads_report(&reads, &recs);
4845 let at = |n: &str| s.find(n).unwrap_or(usize::MAX);
4846 // Most-read first, and the unread post is present rather than omitted.
4847 assert!(at("popular") < at("middling"), "the most-read post comes first");
4848 assert!(at("middling") < at("quiet"), "an unread post sorts last, and is still shown");
4849 assert!(s.contains("100"), "the whole of the reads belongs to the two that were read");
4850 // One of three posts is unread, so two have been read.
4851 assert!(s.contains("2/3"));
4852 assert!(s.contains("a reading, not a reader"));
4853 Ok(())
4854 }
4855
4856 /// A tally whose post is gone is counted in the total and named once, not lost and not listed.
4857 #[test]
4858 fn test_the_reads_report_accounts_for_a_deleted_post_16() -> Outcome<()> {
4859 let mut rec = Record::default();
4860 rec.slug = fmt!("here");
4861 rec.source = fmt!("# here\n\nprose.\n");
4862 let mut reads = BTreeMap::new();
4863 reads.insert(fmt!("here"), 4u64);
4864 reads.insert(fmt!("deleted-long-ago"), 3u64);
4865
4866 let s = reads_report(&reads, &[rec]);
4867 assert!(s.contains("3 reads were counted against posts that have since been deleted"));
4868 // Named as a total, not listed as a row anybody could act on.
4869 assert!(!s.contains("deleted-long-ago"));
4870 Ok(())
4871 }
4872
4873 /// A remote the config file provides is named as such, so a site keyed from `{env:}` or `{file:}`
4874 /// is not told its destination is unset.
4875 #[test]
4876 fn test_a_destination_panel_names_a_config_credential_13() -> Outcome<()> {
4877 assert!(dest_panel("Mastodon", "mastodon", "t", false, true, "")
4878 .contains("Set in the configuration file, not here."));
4879 assert!(dest_panel("Mastodon", "mastodon", "t", true, true, "")
4880 .contains("Set here, and also in the configuration file."));
4881 Ok(())
4882 }
4883}
4884
4885
4886
4887// ┌───────────────────────────────────────────────────────────────────────────┐
4888// │ COMMENTS │
4889// └───────────────────────────────────────────────────────────────────────────┘
4890
4891/// The moderation queue.
4892///
4893/// What is waiting first, because that is the reason to open this page. Everything else is reachable
4894/// by the filter, since a decision already made is worth being able to revisit -- especially a wrong
4895/// one, which is the whole reason spam is kept rather than dropped.
4896fn comments_page<
4897 const UIDL: usize,
4898 UID: NumIdDat<UIDL>,
4899 ENC: Encrypter,
4900 KH: Hasher,
4901 DB: Database<UIDL, UID, ENC, KH>,
4902>(
4903 cfg_comments: bool,
4904 theme: &Theme,
4905 admin: &SiteAdmin,
4906 csrf: &str,
4907 db: Option<&(Arc<RwLock<DB>>, UID)>,
4908 query: &str,
4909 id: &str,
4910)
4911 -> Outcome<HttpMessage>
4912{
4913 let mut body = String::new();
4914 body.push_str("<h1>Comments</h1>\n");
4915
4916 if let Some(said) = query_field(query, "said") {
4917 body.push_str(&notice(&html_escape(&said)));
4918 }
4919
4920 let db = match db {
4921 Some(db) => db,
4922 None => {
4923 body.push_str(&notice(
4924 "This site keeps its comments in its database, and has no database configured. Set \
4925 <code>db_dir_rel</code> on the vhost.",
4926 ));
4927 return Ok(page(theme, admin, "Comments", &body));
4928 }
4929 };
4930
4931 let all = match comment::queue(db, None, id) {
4932 Ok(v) => v,
4933 Err(e) => {
4934 error!(e, "{}: console: cannot read the comment queue", id);
4935 body.push_str(&notice("The comments could not be read. The log says why."));
4936 return Ok(page(theme, admin, "Comments", &body));
4937 }
4938 };
4939
4940 // Whether the site is taking comments, and the control that changes it. First, because it is
4941 // the thing an operator comes here to change when something has gone wrong.
4942 let open = comment::comments_open(Some(db), cfg_comments);
4943 body.push_str(&comments_switch(open, csrf));
4944
4945 let count = |s: comment::CommentState| all.iter().filter(|c| c.state == s).count();
4946 let waiting = count(comment::CommentState::Pending);
4947 body.push_str(&fmt!(
4948 "<p class=\"mc-muted\">{} waiting &middot; {} published &middot; {} spam &middot; {} removed</p>\n",
4949 waiting,
4950 count(comment::CommentState::Approved),
4951 count(comment::CommentState::Spam),
4952 count(comment::CommentState::Removed),
4953 ));
4954
4955 // Waiting is the default view, because it is the only one that needs anybody.
4956 let want = query_field(query, "state").unwrap_or_else(|| fmt!("pending"));
4957 body.push_str(&comments_filter(&want, all.len()));
4958
4959 let shown: Vec<&comment::Comment> = all.iter()
4960 .filter(|c| want == "any" || c.state.as_str() == want)
4961 .collect();
4962
4963 if shown.is_empty() {
4964 body.push_str(&notice(if want == "pending" {
4965 "Nothing is waiting. Comments appear here when somebody who is not yet known writes one."
4966 } else {
4967 "No comment is in that state."
4968 }));
4969 return Ok(page(theme, admin, "Comments", &body));
4970 }
4971
4972 // Paged, and not as a nicety. Every card renders its comment's Markdown, so a page of all of
4973 // them is the most expensive page on the site -- and it is the page an operator opens *because*
4974 // something has flooded the queue. The recovery path must not be the thing that fails first.
4975 let page_at = query_field(query, "page").and_then(|p| p.parse::<usize>().ok()).unwrap_or(1).max(1);
4976 let pages = shown.len().div_ceil(PAGE_SIZE).max(1);
4977 let page_at = page_at.min(pages);
4978 let from = (page_at - 1) * PAGE_SIZE;
4979 let upto = (from + PAGE_SIZE).min(shown.len());
4980
4981 for c in &shown[from..upto] {
4982 body.push_str(&comment_card(c, csrf));
4983 }
4984 body.push_str(&comments_pager(&want, page_at, pages));
4985
4986 Ok(page(theme, admin, "Comments", &body))
4987}
4988
4989/// One comment in the queue, with what can be done to it.
4990///
4991/// The prose is shown **rendered**, through the same policy a reader's page uses, because a decision
4992/// about what to publish should be made looking at what would be published. The reason it is here is
4993/// shown too: a moderator deciding blind is a moderator guessing.
4994fn comment_card(c: &comment::Comment, csrf: &str) -> String {
4995 let mut s = String::new();
4996 s.push_str("<div class=\"mc-comment\">\n");
4997
4998 s.push_str(&fmt!(
4999 "<div class=\"mc-comment-by\"><strong>{who}</strong> on <a href=\"{post}\">{slug}</a> \
5000 <span class=\"mc-muted\">{when}</span> {tag}</div>\n",
5001 who = html_escape(c.author.display_name()),
5002 post = html_escape(&fmt!("{}?slug={}", PATH_PREVIEW, c.slug)),
5003 slug = html_escape(&c.slug),
5004 when = html_escape(&c.created[..10.min(c.created.len())]),
5005 tag = state_tag(c.state),
5006 ));
5007
5008 if let Some(r) = &c.reason {
5009 s.push_str(&fmt!("<p class=\"mc-muted mc-comment-why\">{}</p>\n", html_escape(r)));
5010 }
5011
5012 s.push_str("<div class=\"mc-comment-body mc-prose\">");
5013 match c.render() {
5014 Ok(html) => s.push_str(&html),
5015 Err(_) => s.push_str(&fmt!("<p>{}</p>", html_escape(&c.body))),
5016 }
5017 s.push_str("</div>\n");
5018
5019 s.push_str("<div class=\"mc-comment-acts\">\n");
5020 for (action, label, class, confirm) in [
5021 ("approve", "Approve", "mc-btn mc-btn-quiet", ""),
5022 ("spam", "Spam", "mc-btn mc-btn-quiet", ""),
5023 ("remove", "Remove", "mc-btn mc-btn-quiet", ""),
5024 ("erase", "Erase", "mc-btn mc-btn-danger",
5025 "Erase this comment entirely? This cannot be undone."),
5026 ] {
5027 s.push_str(&fmt!(
5028 "<form method=\"POST\" action=\"{path}\" class=\"mc-inline\"{on}>\
5029 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
5030 <input type=\"hidden\" name=\"slug\" value=\"{slug}\">\
5031 <input type=\"hidden\" name=\"id\" value=\"{id}\">\
5032 <input type=\"hidden\" name=\"action\" value=\"{action}\">\
5033 <button type=\"submit\" class=\"{class}\">{label}</button></form>\n",
5034 path = PATH_COMMENTS_ACTION,
5035 on = if confirm.is_empty() { String::new() }
5036 else { fmt!(" onsubmit=\"return confirm('{}')\"", confirm) },
5037 csrf = html_escape(csrf),
5038 slug = html_escape(&c.slug),
5039 id = html_escape(&c.id),
5040 action = action,
5041 class = class,
5042 label = label,
5043 ));
5044 }
5045 // Blocking needs somebody to block: an anonymous comment has no handle to attach it to.
5046 if c.author.handle().is_some() {
5047 s.push_str(&fmt!(
5048 "<form method=\"POST\" action=\"{path}\" class=\"mc-inline\" \
5049 onsubmit=\"return confirm('Block this commenter? Their comments will go straight to spam.')\">\
5050 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
5051 <input type=\"hidden\" name=\"slug\" value=\"{slug}\">\
5052 <input type=\"hidden\" name=\"id\" value=\"{id}\">\
5053 <input type=\"hidden\" name=\"action\" value=\"block\">\
5054 <button type=\"submit\" class=\"mc-btn mc-btn-danger\">Block</button></form>\n",
5055 path = PATH_COMMENTS_ACTION,
5056 csrf = html_escape(csrf),
5057 slug = html_escape(&c.slug),
5058 id = html_escape(&c.id),
5059 ));
5060 }
5061 s.push_str("</div>\n</div>\n");
5062 s
5063}
5064
5065fn state_tag(state: comment::CommentState) -> String {
5066 let (cls, word) = match state {
5067 comment::CommentState::Pending => ("mc-tag", "waiting"),
5068 comment::CommentState::Approved => ("mc-tag mc-tag-live", "published"),
5069 comment::CommentState::Spam => ("mc-tag mc-tag-err", "spam"),
5070 comment::CommentState::Removed => ("mc-tag", "removed"),
5071 };
5072 fmt!("<span class=\"{}\">{}</span>", cls, word)
5073}
5074
5075/// The site's comments switch: where it stands, and the one control that changes it.
5076///
5077/// A button and not a checkbox, because a checkbox that saves on change is a switch somebody flips
5078/// by scrolling past it. This says what it will do and does that when pressed.
5079fn comments_switch(open: bool, csrf: &str) -> String {
5080 fmt!(
5081 "<div class=\"mc-switch\">\n\
5082 <span class=\"mc-switch-state\">{state}</span>\n\
5083 <form method=\"POST\" action=\"{path}\" class=\"mc-inline\"{on}>\
5084 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
5085 <input type=\"hidden\" name=\"action\" value=\"{act}\">\
5086 <button type=\"submit\" class=\"mc-btn {cls}\">{label}</button></form>\n\
5087 <span class=\"mc-muted\">{note}</span>\n\
5088 </div>\n",
5089 state = if open {
5090 "<strong>Comments are open.</strong>"
5091 } else {
5092 "<strong>Comments are closed.</strong>"
5093 },
5094 path = PATH_COMMENTS_ACTION,
5095 on = if open {
5096 " onsubmit=\"return confirm('Close comments? The form disappears from every post and no new comment is taken. Nothing already here is lost.')\""
5097 } else {
5098 ""
5099 },
5100 csrf = html_escape(csrf),
5101 act = if open { "shut" } else { "open" },
5102 cls = if open { "mc-btn-danger" } else { "mc-btn" },
5103 label = if open { "Close comments" } else { "Open comments" },
5104 note = if open {
5105 "A reader sees the form on every post."
5106 } else {
5107 "No post shows a form, and a comment sent anyway is refused."
5108 },
5109 )
5110}
5111
5112fn comments_pager(want: &str, at: usize, pages: usize) -> String {
5113 if pages <= 1 {
5114 return String::new();
5115 }
5116 let mut s = String::from("<nav class=\"mc-pager\">");
5117 if at > 1 {
5118 s.push_str(&fmt!("<a href=\"{}?state={}&page={}\">Newer</a>",
5119 PATH_COMMENTS, html_escape(want), at - 1));
5120 }
5121 s.push_str(&fmt!("<span class=\"mc-muted\">{} of {}</span>", at, pages));
5122 if at < pages {
5123 s.push_str(&fmt!("<a href=\"{}?state={}&page={}\">Older</a>",
5124 PATH_COMMENTS, html_escape(want), at + 1));
5125 }
5126 s.push_str("</nav>\n");
5127 s
5128}
5129
5130fn comments_filter(want: &str, total: usize) -> String {
5131 fmt!(
5132 "<form class=\"mc-filter mc-form\" method=\"GET\" action=\"{path}\">\n\
5133 <div class=\"mc-f-sel\"><label for=\"state\">Showing</label>\
5134 <select id=\"state\" name=\"state\">\
5135 <option value=\"pending\"{p}>Waiting</option>\
5136 <option value=\"approved\"{a}>Published</option>\
5137 <option value=\"spam\"{s}>Spam</option>\
5138 <option value=\"removed\"{r}>Removed</option>\
5139 <option value=\"any\"{n}>Everything</option>\
5140 </select></div>\n\
5141 <button type=\"submit\" class=\"mc-btn mc-btn-quiet\">Filter</button>\n\
5142 <span class=\"mc-muted\" style=\"margin:0 0 0 auto\">{total} in all</span>\n\
5143 </form>\n",
5144 path = PATH_COMMENTS,
5145 p = selected(want == "pending"),
5146 a = selected(want == "approved"),
5147 s = selected(want == "spam"),
5148 r = selected(want == "removed"),
5149 n = selected(want == "any"),
5150 total = total,
5151 )
5152}
5153
5154/// The queue as JSON, for an app that draws it itself.
5155fn comments_json<
5156 const UIDL: usize,
5157 UID: NumIdDat<UIDL>,
5158 ENC: Encrypter,
5159 KH: Hasher,
5160 DB: Database<UIDL, UID, ENC, KH>,
5161>(
5162 cfg_comments: bool,
5163 db: Option<&(Arc<RwLock<DB>>, UID)>,
5164 query: &str,
5165 id: &str,
5166)
5167 -> Outcome<HttpMessage>
5168{
5169 let db = match db {
5170 Some(db) => db,
5171 None => return Ok(json_error("this site has no database configured")),
5172 };
5173 let want = query_field(query, "state").unwrap_or_else(|| fmt!("pending"));
5174 let all = res!(comment::queue(db, None, id));
5175 let open = comment::comments_open(Some(db), cfg_comments);
5176
5177 let count = |s: comment::CommentState| all.iter().filter(|c| c.state == s).count();
5178 let mut counts = DaticleMap::new();
5179 counts.insert(dat!("pending"), dat!(count(comment::CommentState::Pending) as u64));
5180 counts.insert(dat!("approved"), dat!(count(comment::CommentState::Approved) as u64));
5181 counts.insert(dat!("spam"), dat!(count(comment::CommentState::Spam) as u64));
5182 counts.insert(dat!("removed"), dat!(count(comment::CommentState::Removed) as u64));
5183
5184 let mut items = Vec::new();
5185 for c in all.iter().filter(|c| want == "any" || c.state.as_str() == want) {
5186 let mut m = DaticleMap::new();
5187 m.insert(dat!("id"), dat!(c.id.clone()));
5188 m.insert(dat!("slug"), dat!(c.slug.clone()));
5189 m.insert(dat!("who"), dat!(c.author.display_name().to_string()));
5190 m.insert(dat!("when"), dat!(c.created.clone()));
5191 m.insert(dat!("state"), dat!(c.state.as_str().to_string()));
5192 m.insert(dat!("body"), dat!(c.body.clone()));
5193 m.insert(dat!("html"), dat!(c.render().unwrap_or_default()));
5194 m.insert(dat!("blockable"), Dat::Bool(c.author.handle().is_some()));
5195 // The reason a moderator gave, which is for the moderator. **Never the address**: it is not
5196 // in this map and must not be added to it.
5197 if let Some(r) = &c.reason {
5198 m.insert(dat!("reason"), dat!(r.clone()));
5199 }
5200 items.push(Dat::Map(m));
5201 }
5202
5203 let body = create_dat_ordmap(vec![
5204 (dat!("open"), Dat::Bool(open)),
5205 (dat!("counts"), Dat::Map(counts)),
5206 (dat!("comments"), Dat::List(items)),
5207 ]);
5208 Ok(json_body(&res!(body.encode_string_with_config(&EncoderConfig::<(), ()>::json(None)))))
5209}
5210
5211/// Approves, bins, removes, erases a comment, or blocks its author.
5212fn do_comment_action<
5213 const UIDL: usize,
5214 UID: NumIdDat<UIDL>,
5215 ENC: Encrypter,
5216 KH: Hasher,
5217 DB: Database<UIDL, UID, ENC, KH>,
5218>(
5219 db: &(Arc<RwLock<DB>>, UID),
5220 body: &[u8],
5221 who: &str,
5222 json: bool,
5223 id: &str,
5224)
5225 -> Outcome<HttpMessage>
5226{
5227 let slug = super::form_field(body, "slug").unwrap_or_default();
5228 let cid = super::form_field(body, "id").unwrap_or_default();
5229 let action = super::form_field(body, "action").unwrap_or_default();
5230 // The switch acts on the site rather than on a comment, so it is taken before a comment is
5231 // required.
5232 if action == "open" || action == "shut" {
5233 res!(comment::set_comments_open(db, action == "open"));
5234 info!("{}: console: '{}' {} comments", id, who,
5235 if action == "open" { "opened" } else { "closed" });
5236 return Ok(comments_back(json));
5237 }
5238 if slug.is_empty() || cid.is_empty() {
5239 return Ok(comments_back_with("no comment was named", json));
5240 }
5241
5242 let done = match action.as_str() {
5243 "approve" => res!(comment::set_state(
5244 db, &slug, &cid, comment::CommentState::Approved, None)),
5245 "spam" => res!(comment::set_state(
5246 db, &slug, &cid, comment::CommentState::Spam, Some(fmt!("marked by {}", who)))),
5247 "remove" => res!(comment::set_state(
5248 db, &slug, &cid, comment::CommentState::Removed, Some(fmt!("taken down by {}", who)))),
5249 "erase" => res!(comment::erase(db, &slug, &cid)),
5250
5251 "block" => {
5252 // Blocking bins what is in hand as well as what comes next: leaving this one published
5253 // while blocking its author would be a decision that half applied.
5254 let c = match res!(comment::get(db, &slug, &cid)) {
5255 Some(c) => c,
5256 None => return Ok(comments_back_with("that comment is not there", json)),
5257 };
5258 match c.author.handle() {
5259 Some(h) => {
5260 res!(comment::set_blocked(db, &h, true, &c.created));
5261 res!(comment::set_state(db, &slug, &cid,
5262 comment::CommentState::Spam, Some(fmt!("blocked by {}", who))))
5263 }
5264 None => return Ok(comments_back_with(
5265 "that commenter gave nothing to recognise them by, so they cannot be blocked",
5266 json)),
5267 }
5268 }
5269 other => return Ok(comments_back_with(
5270 &fmt!("'{}' is not an action here", other), json)),
5271 };
5272
5273 if !done {
5274 return Ok(comments_back_with("that comment is not there", json));
5275 }
5276 info!("{}: console: '{}' {} comment '{}/{}'", id, who, action, slug, cid);
5277 Ok(comments_back(json))
5278}
5279
5280fn comments_back(json: bool) -> HttpMessage {
5281 if json {
5282 json_body("{\"ok\":true}")
5283 } else {
5284 redirect(PATH_COMMENTS)
5285 }
5286}
5287
5288fn comments_back_with(why: &str, json: bool) -> HttpMessage {
5289 if json {
5290 json_error(why)
5291 } else {
5292 redirect(&fmt!("{}?said={}", PATH_COMMENTS, url_encode(why)))
5293 }
5294}
5295
5296/// Rendering the console's pages to disk, so they can be looked at.
5297///
5298/// # Why this exists
5299///
5300/// Every other test in this module asserts on a substring of the markup -- that a chip carries
5301/// `data-tag`, that a box is `checked`. All of them pass with the styling completely broken, because
5302/// none of them draws a page: they call a fragment builder and read the string it returns. The
5303/// console is also the one surface a browser cannot be pointed at without a passphrase, so it was
5304/// never seen either. Between the two, a rule that shouts a label or a box that clips its own text
5305/// reaches production with a green suite behind it, which is exactly what happened.
5306///
5307/// So: set `STEEL_UI_DUMP` to a directory and run the suite, and each console screen is written
5308/// there as a whole page. `STEEL_UI_CSS` names a directory of the site's real stylesheets, linked by
5309/// absolute path so a `file://` render carries the site's own palette, fonts and metrics rather than
5310/// a browser's defaults -- an unstyled dump would be worse than none, since it would look wrong in
5311/// ways that mean nothing.
5312///
5313/// ```text
5314/// STEEL_UI_DUMP=/tmp/ui STEEL_UI_CSS=~/…/elearnity/www/public/css \
5315/// cargo test -p oxedyne_fe2o3_steel --lib ui_dump -- --nocapture
5316/// ```
5317///
5318/// Unset, it writes nothing and costs a directory lookup, so the ordinary suite is unaffected.
5319#[cfg(test)]
5320mod ui_dump {
5321 use super::*;
5322
5323 /// The database the renderers are named over. Every page here is drawn with `None`, so the type
5324 /// is needed and the value is not.
5325 type NoDb = oxedyne_fe2o3_o3db_sync::O3db<
5326 { crate::srv::id::UID_LEN },
5327 crate::srv::id::Uid,
5328 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
5329 oxedyne_fe2o3_hash::hash::HashScheme,
5330 oxedyne_fe2o3_hash::hash::HashScheme,
5331 oxedyne_fe2o3_hash::csum::ChecksumScheme,
5332 >;
5333
5334 /// The site whose console is being drawn: the stylesheets by absolute path, so the page carries
5335 /// the site's own look when it is opened from a file.
5336 fn theme() -> Theme {
5337 let dir = std::env::var("STEEL_UI_CSS").unwrap_or_default();
5338 let sheets = ["variables.css", "fonts.css", "base.css", "asides.css", "manage.css"];
5339 Theme {
5340 site_name: fmt!("Elearnity"),
5341 css: sheets.iter()
5342 .map(|f| fmt!("file://{}/{}", dir, f))
5343 .filter(|_| !dir.is_empty())
5344 .collect(),
5345 home: fmt!("https://example.com"),
5346 }
5347 }
5348
5349 fn admin() -> SiteAdmin {
5350 SiteAdmin { username: fmt!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") }
5351 }
5352
5353 /// A post written by somebody else offers the one control that can take it over, and a post
5354 /// already the signer's does not -- a button that would do nothing is a question a reader has to
5355 /// answer for no reason.
5356 ///
5357 /// The control works by CLEARING the author, because that is what the save handler reads as "no
5358 /// author named, use whoever is signed in". A test that only looked for the button would pass on
5359 /// a control that cleared nothing.
5360 #[test]
5361 fn test_a_post_can_be_taken_over_by_its_composer_22() -> Outcome<()> {
5362 let someone_else = author_field("older-identity", "Anonymous", "oxedyne", "Jason");
5363 assert!(someone_else.contains("id=\"mc-author-mine\""),
5364 "no way to take over a post written as somebody else: {}", someone_else);
5365 assert!(someone_else.contains("data-name=\"Jason\""),
5366 "the control does not say who it would attribute the post to: {}", someone_else);
5367 assert!(someone_else.contains("value=\"older-identity\""),
5368 "the post's own author is not carried: {}", someone_else);
5369 // The mechanism, not just the button: the script empties the field and lets the ordinary save
5370 // run, and the save handler is what turns an empty author into the signer.
5371 assert!(AUTHOR_SCRIPT.contains("hidden.value=''"),
5372 "the control does not clear the author, so a save would keep the old one");
5373 assert!(AUTHOR_SCRIPT.contains("dispatchEvent"),
5374 "clearing the field fires no event, so nothing would save it");
5375
5376 let already_mine = author_field("oxedyne", "Jason", "oxedyne", "Jason");
5377 assert!(!already_mine.contains("mc-author-mine"),
5378 "a post already the signer's offered to make it theirs: {}", already_mine);
5379 Ok(())
5380 }
5381
5382 fn dump_cfg() -> PublishConfig {
5383 let mut c = PublishConfig::default();
5384 c.path = fmt!("/asides");
5385 c.title = fmt!("Asides");
5386 c.site_name = fmt!("Elearnity");
5387 c.base_url = fmt!("https://example.com");
5388 c.source = Source::Store;
5389 c.categories = vec![
5390 fmt!("Personal"), fmt!("Technical"), fmt!("Ideas"),
5391 fmt!("Reviews"), fmt!("Projects"), fmt!("Announcements"),
5392 ];
5393 c
5394 }
5395
5396 fn put(dir: &str, name: &str, resp: &HttpMessage) -> Outcome<()> {
5397 let path = fmt!("{}/{}.html", dir, name);
5398 res!(std::fs::write(&path, &resp.body), IO, File);
5399 println!("ui-dump: {}", path);
5400 Ok(())
5401 }
5402
5403 #[test]
5404 fn ui_dump_the_console_screens() -> Outcome<()> {
5405 let dir = match std::env::var("STEEL_UI_DUMP") {
5406 Ok(d) if !d.is_empty() => d,
5407 _ => return Ok(()), // Not asked for; costs nothing.
5408 };
5409 res!(std::fs::create_dir_all(&dir), IO, File);
5410
5411 let cfg = dump_cfg();
5412 let t = theme();
5413 let a = admin();
5414 let csrf = "csrf0000000000000000000000000000";
5415
5416 // The composer for a new post: the screen an author spends their time in, and the one
5417 // nothing has ever rendered.
5418 let resp = res!(handle_edit::<
5419 { crate::srv::id::UID_LEN },
5420 crate::srv::id::Uid,
5421 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
5422 oxedyne_fe2o3_hash::hash::HashScheme,
5423 NoDb,
5424 >(&cfg, &t, &a, csrf, true, None, "", "ui-dump"));
5425 res!(put(&dir, "composer-new", &resp));
5426
5427 // The post list, with nothing in it -- a site's first sight of its own console.
5428 let resp = res!(handle_list::<
5429 { crate::srv::id::UID_LEN },
5430 crate::srv::id::Uid,
5431 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
5432 oxedyne_fe2o3_hash::hash::HashScheme,
5433 NoDb,
5434 >(&cfg, &t, &a, csrf, None, "", "ui-dump"));
5435 res!(put(&dir, "posts-empty", &resp));
5436
5437 // The report pages, built from the same fixtures the report tests use, so what is drawn
5438 // here is what a site with traffic sees rather than a set of zeroes.
5439 let sub = |email: &str, state: subscribe::SubState, created: &str| subscribe::Subscriber {
5440 email: fmt!("{}", email),
5441 state: state,
5442 token: fmt!("t0000000000000000000000000000000"),
5443 created: Some(fmt!("{}", created)),
5444 };
5445 let subs = vec![
5446 sub("a@example.com", subscribe::SubState::Confirmed, "2026-07-19T08:00:00Z"),
5447 sub("b@example.com", subscribe::SubState::Confirmed, "2026-06-02T08:00:00Z"),
5448 sub("c@example.com", subscribe::SubState::Pending, "2026-07-18T08:00:00Z"),
5449 sub("d@example.com", subscribe::SubState::Unsubscribed, "2026-05-01T08:00:00Z"),
5450 ];
5451 let sent = |slug: &str, at: &str, attempted: usize, ok: usize, failed: usize, sup: usize|
5452 send::SendEntry {
5453 slug: fmt!("{}", slug),
5454 at: fmt!("{}", at),
5455 attempted: attempted,
5456 sent: ok,
5457 failed: failed,
5458 suppressed: sup,
5459 };
5460 let hist = vec![
5461 sent("on-rent", "2026-07-19T09:00:00Z", 40, 38, 2, 0),
5462 sent("on-work", "2026-06-11T09:00:00Z", 22, 22, 0, 1),
5463 ];
5464 let mut body = String::new();
5465 body.push_str("<h1>Reports</h1>\n");
5466 body.push_str(&list_report(&subs));
5467 body.push_str(&send_report(&hist));
5468 res!(put(&dir, "reports", &page(&t, &a, "Reports", &body)));
5469
5470 // The AI panel, part-configured, so the provider is chosen, the key shows as held, and the
5471 // prompts show real text rather than an empty box.
5472 let ai_set = ai::AiSettings {
5473 provider: fmt!("mistral"),
5474 model: fmt!("mistral-large-latest"),
5475 api_key: fmt!("sk-held"),
5476 fix_prompt: String::new(),
5477 comment_prompt: String::new(),
5478 alert_emails: vec![fmt!("jason@example.com")],
5479 };
5480 let mut ai_body = String::from("<h1>AI</h1>\n");
5481 ai_body.push_str(&ai_form(&ai_set, csrf));
5482 ai_body.push_str(AI_TEST_SCRIPT);
5483 res!(put(&dir, "ai", &page(&t, &a, "AI", &ai_body)));
5484
5485 // The declarations page, with one thing declared and one not, so the render shows both
5486 // states of the only control on it.
5487 let dcfg = PublishConfig {
5488 declare: declare::DeclareConfig {
5489 url: fmt!("https://example.org"),
5490 marks: fmt!("/assets/marks"),
5491 site: Some(declare::Declaration::new(
5492 declare::Level::With, declare::Medium::Code)),
5493 items: vec![
5494 declare::Declarable {
5495 key: fmt!("first-book"),
5496 name: fmt!("The First Book"),
5497 medium: declare::Medium::Doc,
5498 },
5499 declare::Declarable {
5500 key: fmt!("second-book"),
5501 name: fmt!("The Second Book"),
5502 medium: declare::Medium::Doc,
5503 },
5504 ],
5505 },
5506 ..cfg.clone()
5507 };
5508 let mut d_body = String::from("<h1>Declarations</h1>\n");
5509 d_body.push_str(
5510 "<p class=\"mc-muted\">How much each of these needed AI. A declaration is your word on \
5511 the record, so <em>Not declared</em> is a real answer and the one everything starts \
5512 at.</p>\n");
5513 for (item, on) in dcfg.declare.items.iter()
5514 .zip([Some(declare::Level::Some), None])
5515 {
5516 d_body.push_str(&fmt!(
5517 "<form class=\"mc-form mc-settings mc-declare\" method=\"POST\" action=\"{save}\">\n\
5518 <div class=\"mc-f-text\"><label for=\"lvl-{key}\">{name}</label>\
5519 <select id=\"lvl-{key}\" name=\"ai_level\">\
5520 <option value=\"\"{none_sel}>Not declared</option>{options}</select></div>\n\
5521 </form>\n",
5522 save = PATH_DECLARE_SAVE,
5523 key = html_escape(&item.key),
5524 name = html_escape(&item.name),
5525 none_sel = selected(on.is_none()),
5526 options = declare_options(on),
5527 ));
5528 }
5529 d_body.push_str(
5530 "<p class=\"mc-autosave\" id=\"mc-declare-msg\" aria-live=\"polite\"></p>\n");
5531 d_body.push_str(&declare_site_note(&dcfg));
5532 d_body.push_str(DECLARE_SCRIPT);
5533 res!(put(&dir, "declarations", &page(&t, &a, "Declarations", &d_body)));
5534
5535 // The author row of a post written under another identity, which is the one state the
5536 // composer dump above cannot reach: a new post is always the composer's own.
5537 // Rendered inside the field row the composer really puts it in, so the photograph shows the
5538 // geometry a person sees rather than the row in isolation -- which is exactly the difference
5539 // that hid a crushed, three-line control from a render that looked fine.
5540 let mut a_body = String::from(
5541 "<h1>Edit a post</h1>\n<form class=\"mc-form\" method=\"POST\" action=\"/manage/save\">\n\
5542 <div class=\"mc-row\">\n\
5543 <div><label>Name in the URL</label><input type=\"text\" value=\"first-post\"></div>\n\
5544 <div><label>Date</label><input type=\"text\" value=\"2026-07-23\"></div>\n\
5545 <div><label>State</label><select><option>Live</option></select></div>\n\
5546 <div><label>Written in</label><select><option>Markdown</option></select></div>\n\
5547 <div><label>AI used</label><select><option>Made with no AI</option></select></div>\n");
5548 a_body.push_str(&author_field("older-identity", "Anonymous", "oxedyne", "Jason"));
5549 // The rest of what the composer's own autosave needs, because the take-over works by handing
5550 // the save to it -- a dump carrying only the author row proves the button draws and nothing
5551 // about whether pressing it saves anything.
5552 a_body.push_str(&fmt!(
5553 "</div>\n<input type=\"hidden\" name=\"was\" value=\"first-post\">\n\
5554 <input type=\"text\" id=\"slug\" name=\"slug\" value=\"first-post\">\n\
5555 <select id=\"state\" name=\"state\"><option value=\"live\">Live</option></select>\n\
5556 <textarea id=\"source\" name=\"source\">Words.</textarea>\n\
5557 <div class=\"mc-actions\"><span class=\"mc-autosave\" id=\"mc-autosave\"></span></div>\n\
5558 </form>\n"));
5559 a_body.push_str(AUTOSAVE_SCRIPT);
5560 a_body.push_str(AUTHOR_SCRIPT);
5561 res!(put(&dir, "author-takeover", &page(&t, &a, "Edit a post", &a_body)));
5562
5563 Ok(())
5564 }
5565}