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 | |
| 25 | use 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 | |
| 65 | use oxedyne_fe2o3_core::{ |
| 66 | prelude::*, |
| 67 | rand::Rand, |
| 68 | }; |
| 69 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 70 | use oxedyne_fe2o3_iop_db::api::Database; |
| 71 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 72 | use oxedyne_fe2o3_jdat::{ |
| 73 | prelude::*, |
| 74 | id::NumIdDat, |
| 75 | string::enc::EncoderConfig, |
| 76 | }; |
| 77 | use oxedyne_fe2o3_net::http::{ |
| 78 | data_url, |
| 79 | fields::{ |
| 80 | HeaderFieldValue, |
| 81 | HeaderName, |
| 82 | }, |
| 83 | msg::HttpMessage, |
| 84 | status::HttpStatus, |
| 85 | }; |
| 86 | |
| 87 | use tokio_rustls::rustls::ClientConfig; |
| 88 | |
| 89 | use std::{ |
| 90 | collections::BTreeMap, |
| 91 | sync::{ |
| 92 | Arc, |
| 93 | RwLock, |
| 94 | }, |
| 95 | }; |
| 96 | |
| 97 | use super::html_escape; |
| 98 | |
| 99 | |
| 100 | pub const PATH_ROOT: &str = "/manage"; |
| 101 | pub 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. |
| 104 | pub const PATH_PROFILE: &str = "/manage/profile"; |
| 105 | pub const PATH_PROFILE_JSON: &str = "/manage/profile.json"; |
| 106 | pub const PATH_PROFILE_SAVE: &str = "/manage/profile/save"; |
| 107 | pub const PATH_TAG_DELETE: &str = "/manage/tag/delete"; |
| 108 | // A draft as a reader would get it, if it were not a draft. |
| 109 | pub const PATH_PREVIEW: &str = "/manage/preview"; |
| 110 | pub const PATH_SAVE: &str = "/manage/save"; |
| 111 | pub const PATH_DELETE: &str = "/manage/delete"; |
| 112 | pub const PATH_IMPORT: &str = "/manage/import"; |
| 113 | // Where the editor posts source to see it rendered, for a live preview. |
| 114 | pub const PATH_RENDER: &str = "/manage/render"; |
| 115 | // The posts as JSON, every state, for a front-end that renders its own list. |
| 116 | pub const PATH_LIST_JSON: &str = "/manage/list.json"; |
| 117 | pub const PATH_POST_JSON: &str = "/manage/post.json"; |
| 118 | pub 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. |
| 121 | pub const PATH_CREDS_JSON: &str = "/manage/creds.json"; |
| 122 | pub const PATH_CREDS: &str = "/manage/creds"; |
| 123 | pub 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. |
| 127 | pub const PATH_AI: &str = "/manage/ai"; |
| 128 | pub const PATH_AI_SAVE: &str = "/manage/ai/save"; |
| 129 | pub const PATH_AI_FIX: &str = "/manage/ai/fix"; |
| 130 | pub const PATH_AI_TEST: &str = "/manage/ai/test"; |
| 131 | pub 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. |
| 134 | pub const PATH_DECLARE: &str = "/manage/declare"; |
| 135 | pub const PATH_DECLARE_SAVE: &str = "/manage/declare/save"; |
| 136 | pub const PATH_DECLARE_JSON: &str = "/manage/declare.json"; |
| 137 | // The moderation queue, and where its approve, spam, remove, erase and block actions post. |
| 138 | pub const PATH_COMMENTS: &str = "/manage/comments"; |
| 139 | pub const PATH_COMMENTS_ACTION: &str = "/manage/comments/action"; |
| 140 | pub const PATH_COMMENTS_JSON: &str = "/manage/comments.json"; |
| 141 | pub const PATH_SUBS_JSON: &str = "/manage/subscribers.json"; |
| 142 | pub const PATH_REPORTS_JSON: &str = "/manage/reports.json"; |
| 143 | pub const PATH_SUBS: &str = "/manage/subscribers"; |
| 144 | pub const PATH_SUBS_CSV: &str = "/manage/subscribers.csv"; |
| 145 | pub 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. |
| 148 | const UNDATED: &str = "unknown"; |
| 149 | |
| 150 | pub 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. |
| 154 | pub const PATH_SUBS_ACTION: &str = "/manage/subscribers/action"; |
| 155 | // Where the "send a test" form posts a slug and a single recipient. |
| 156 | pub const PATH_NEWSLETTER_TEST: &str = "/manage/newsletter/test"; |
| 157 | |
| 158 | |
| 159 | /// Whether a path is one this module writes to. |
| 160 | pub 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. |
| 183 | pub 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 | |
| 194 | pub 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", ¬ice( |
| 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. |
| 261 | fn 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(¬ice(&html_escape(&said))); |
| 283 | } |
| 284 | |
| 285 | let db = match db { |
| 286 | Some(db) => db, |
| 287 | None => { |
| 288 | body.push_str(¬ice( |
| 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(¬ice("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} · <a href=\"{csv}\">Export CSV</a></p>\n", |
| 313 | counts = if subs.is_empty() { |
| 314 | fmt!("No subscribers yet") |
| 315 | } else { |
| 316 | fmt!("{} confirmed · {} pending · {} unsubscribed · {} 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(¬ice("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. |
| 389 | fn 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. |
| 433 | fn 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(¬ice( |
| 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(¬ice("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. |
| 475 | fn 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. |
| 511 | fn 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. |
| 547 | fn 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(¬ice( |
| 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(¬ice("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(¬ice("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(¬ice("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(¬ice("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. |
| 620 | fn 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(¬ice( |
| 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!("—") } 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(¬ice( |
| 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. |
| 688 | fn 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(¬ice("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(¬ice( |
| 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. |
| 736 | fn 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(¬ice("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(¬ice( |
| 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. |
| 817 | fn 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. |
| 838 | fn 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. |
| 859 | fn 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 | |
| 887 | const 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. |
| 893 | fn 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. |
| 925 | fn 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. |
| 946 | fn 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 "{slug}"? 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. |
| 977 | fn 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. |
| 1010 | pub 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. |
| 1019 | fn 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. |
| 1037 | fn 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. |
| 1048 | fn 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. |
| 1113 | fn 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 | |
| 1169 | fn 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. |
| 1197 | fn 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(¬ice(&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(¬ice(&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(¬ice( |
| 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(¬ice("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(¬ice("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(¬ice("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. |
| 1364 | fn 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", ¬ice( |
| 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", ¬ice( |
| 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", ¬ice( |
| 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", ¬ice( |
| 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. |
| 1576 | fn 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", ¬ice( |
| 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. |
| 1661 | pub 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. |
| 1668 | const 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. |
| 1722 | fn 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", ¬ice("No post was named."))), |
| 1741 | }; |
| 1742 | |
| 1743 | if cfg.source != Source::Store { |
| 1744 | return Ok(page(theme, admin, "Posts", ¬ice( |
| 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", ¬ice( |
| 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", ¬ice( |
| 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", ¬ice( |
| 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", ¬ice( |
| 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. |
| 1812 | fn 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. |
| 1861 | fn 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. |
| 1944 | fn 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. |
| 1987 | fn 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(¬ice(&html_escape(&said))); |
| 2009 | } |
| 2010 | |
| 2011 | if !cfg.declare.is_on() { |
| 2012 | body.push_str(¬ice( |
| 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(¬ice( |
| 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(¬ice( |
| 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(¬ice("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. |
| 2095 | const 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. |
| 2098 | fn 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. |
| 2116 | fn 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. |
| 2133 | fn 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 | |
| 2180 | async 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. |
| 2244 | fn 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(¬ice(&html_escape(&said))); |
| 2266 | } |
| 2267 | |
| 2268 | let db = match db { |
| 2269 | Some(db) => db, |
| 2270 | None => { |
| 2271 | body.push_str(¬ice( |
| 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(¬ice("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 — 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. |
| 2350 | fn 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(¬ice(&html_escape(&said))); |
| 2371 | } |
| 2372 | |
| 2373 | let db = match db { |
| 2374 | Some(db) => db, |
| 2375 | None => { |
| 2376 | body.push_str(¬ice( |
| 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(¬ice("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. |
| 2401 | const 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. |
| 2431 | fn 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 — 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. |
| 2511 | fn 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. |
| 2570 | fn 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. |
| 2618 | fn 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 | |
| 2698 | fn 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. |
| 2712 | fn 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. |
| 2751 | fn 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. |
| 2797 | fn 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. |
| 2833 | pub 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. |
| 2919 | async 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. |
| 2983 | async 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. |
| 3039 | fn 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. |
| 3078 | fn 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. |
| 3090 | fn dests_back(json: bool) -> HttpMessage { |
| 3091 | if json { |
| 3092 | json_body("{\"ok\":true}") |
| 3093 | } else { |
| 3094 | redirect(PATH_DESTS) |
| 3095 | } |
| 3096 | } |
| 3097 | |
| 3098 | fn ai_back(json: bool) -> HttpMessage { |
| 3099 | if json { |
| 3100 | json_body("{\"ok\":true}") |
| 3101 | } else { |
| 3102 | redirect(PATH_AI) |
| 3103 | } |
| 3104 | } |
| 3105 | |
| 3106 | fn 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. |
| 3120 | fn 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. |
| 3181 | async 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. |
| 3241 | async 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 | |
| 3293 | fn 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. |
| 3302 | async 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. |
| 3479 | fn 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. |
| 3567 | fn 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. |
| 3637 | fn 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 | |
| 3665 | fn 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. |
| 3701 | fn 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 | |
| 3713 | fn 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. |
| 3748 | fn 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. |
| 3758 | fn 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. |
| 3770 | fn 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. |
| 3780 | fn 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 | |
| 3789 | fn notice(html: &str) -> String { |
| 3790 | fmt!("<p class=\"mc-notice\">{}</p>\n", html) |
| 3791 | } |
| 3792 | |
| 3793 | fn 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. |
| 3820 | fn 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 | |
| 3830 | fn 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. |
| 3837 | fn 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. |
| 3872 | const 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. |
| 3884 | fn 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. |
| 3920 | fn 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. |
| 3977 | fn 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. |
| 4033 | const 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. |
| 4094 | const 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. |
| 4169 | const 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,'&').replace(/</g,'<').replace(/>/g,'>');}\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. |
| 4236 | const 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 `?`. |
| 4331 | fn 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. |
| 4351 | fn 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. |
| 4364 | fn 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 | |
| 4395 | fn 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)] |
| 4406 | mod 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. |
| 4896 | fn 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(¬ice(&html_escape(&said))); |
| 4918 | } |
| 4919 | |
| 4920 | let db = match db { |
| 4921 | Some(db) => db, |
| 4922 | None => { |
| 4923 | body.push_str(¬ice( |
| 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(¬ice("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 · {} published · {} spam · {} 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(¬ice(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. |
| 4994 | fn 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 | |
| 5065 | fn 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. |
| 5079 | fn 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 | |
| 5112 | fn 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 | |
| 5130 | fn 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. |
| 5155 | fn 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. |
| 5212 | fn 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 | |
| 5280 | fn comments_back(json: bool) -> HttpMessage { |
| 5281 | if json { |
| 5282 | json_body("{\"ok\":true}") |
| 5283 | } else { |
| 5284 | redirect(PATH_COMMENTS) |
| 5285 | } |
| 5286 | } |
| 5287 | |
| 5288 | fn 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)] |
| 5320 | mod 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 | } |