Oregami
Repositories/oxedyne/fe2o3

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

49.9 KiB, 294 runs

created by r1870400018:14423, 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//! The posts in the vhost's database.
2//!
3//! The alternative to [a directory](super::read_all), and where posts end up once anything other than
4//! a text editor writes them. Same [`Post`] out either way, so nothing downstream knows which it is.
5//!
6//! # The source is what is kept
7//!
8//! A record holds the Markdown an author wrote, not the HTML a reader gets. The renderer improves --
9//! it gained tables this morning -- and a stored rendering would be a photograph of what the renderer
10//! used to do. Rendering on read costs a parse per request and is always right; caching it is an
11//! optimisation to make when a profile asks for one, not before.
12//!
13//! # An index, because scanning is not free
14//!
15//! [`Database::scan`] is documented as O(database size), and a vhost's database is not only posts --
16//! it is sessions, users, whatever else the app keeps. Walking all of it to list ten asides would tie
17//! the cost of a page to how busy the site has been.
18//!
19//! So the slugs live in one record under [`INDEX_KEY`], and a post under its own key. Listing is a
20//! read of the index and a read per post; nothing scans. The index is derived, so it can be rebuilt
21//! from a scan when it has to be ([`rebuild_index`]), but that is a repair rather than a code path
22//! anything normal takes.
23//!
24//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
25//! Anthropic Claude
26
27use crate::srv::publish::{
28 Author,
29 Post,
30 Markup,
31 PostState,
32 declare::Level,
33 dest::{
34 Delivery,
35 DeliveryState,
36 },
37 render_source,
38 split_date,
39};
40
41use oxedyne_fe2o3_core::prelude::*;
42use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
43use oxedyne_fe2o3_iop_db::api::{
44 Database,
45 ScanOpts,
46};
47use oxedyne_fe2o3_iop_hash::api::Hasher;
48use oxedyne_fe2o3_jdat::{
49 prelude::*,
50 id::NumIdDat,
51};
52
53use std::collections::BTreeMap;
54use std::sync::{
55 Arc,
56 RwLock,
57};
58
59
60pub const KEY_PREFIX: &str = "publish/post/";
61
62pub const INDEX_KEY: &str = "publish/index";
63
64// One key per post rather than one map for the site: a read touches only the post that was read,
65// so two posts being read at once do not contend, and a tally cannot take the whole site's counts
66// with it when it goes wrong.
67pub const READS_PREFIX: &str = "publish/reads/";
68
69// The list of database-granted site admins: the companion to the config's `site_admins`, the
70// admins a site grants itself from the browser, kept apart from the operator's failsafe list so
71// the two can be reasoned about separately. The functions in the ADMINS section below are its
72// only writers.
73pub const ADMINS_KEY: &str = "publish/admins";
74
75// The record at `publish/profile/<username>` holds the display name, avatar and description the
76// author shows to readers, which their login username -- the hash of a passphrase -- cannot. A
77// member with no profile record is drawn from their username alone.
78pub const PROFILE_PREFIX: &str = "publish/profile/";
79
80// A member's uploaded picture, kept at `publish/avatar/<handle>`. Apart from the profile because
81// it is bytes and the profile is fields: a page drawing a byline reads the profile and wants the
82// path, not a picture it will not show, and a browser asking for the picture wants the picture
83// and none of the rest. A member whose avatar is a URL somewhere else has no record here at all.
84//
85// Kept under the member's public handle rather than their username, because the path it is served
86// at is the key: a picture asked for by URL is found without the server having to map anything
87// back to a username, and no page ever carries one.
88pub const AVATAR_PREFIX: &str = "publish/avatar/";
89
90
91// The record at `publish/declare/<key>` holds the rung an admin chose for one of the things the
92// site's config says may carry a declaration. Apart from the posts because it is not a post: the
93// thing being declared for lives somewhere else entirely -- a book in a catalogue, a project on a
94// front page -- and the server holds nothing about it but this one word.
95pub const DECLARE_PREFIX: &str = "publish/declare/";
96
97
98fn declare_key_of(key: &str) -> Dat {
99 dat!(fmt!("{}{}", DECLARE_PREFIX, key))
100}
101
102fn reads_key_of(slug: &str) -> Dat {
103 let mut s = String::from(READS_PREFIX);
104 s.push_str(slug);
105 dat!(s)
106}
107
108fn profile_key_of(username: &str) -> Dat {
109 let mut s = String::from(PROFILE_PREFIX);
110 s.push_str(username);
111 dat!(s)
112}
113
114fn avatar_key_of(handle: &str) -> Dat {
115 let mut s = String::from(AVATAR_PREFIX);
116 s.push_str(handle);
117 dat!(s)
118}
119
120
121/// What a member shows to readers: a display name, an avatar and a description, apart from the opaque
122/// username their login is. Set by the member in the console; read wherever an author is drawn.
123///
124/// Every field is optional in effect: an empty name falls back to the username, an empty avatar to a
125/// drawn initial, and an empty description to nothing shown at all. A member who never sets a profile
126/// is still an author, drawn from their username.
127#[derive(Clone, Debug, Default, Eq, PartialEq)]
128pub struct Profile {
129 pub name: String, // e.g. `Jason Hoogland`; empty falls back to the username
130 pub avatar: String, // path or URL; empty draws an initial instead
131 // What the author writes about, in their own words: the line a reader is met with above the
132 // posts, and what stands as the description of a blog only one person writes. Plain text.
133 pub bio: String,
134 // The name this member wears in public: a random word minted the first time they save, and the
135 // only identifier of theirs that ever reaches a page.
136 //
137 // A member's login username is the SHA-256 of their passphrase. Publishing that would hand
138 // anyone who reads the page a verifier to guess passphrases against, offline and unwatched, so
139 // nothing public is ever derived from it -- not a prefix of it, not a hash of it, since either
140 // is still a thing a guess can be tested against. A random word is derived from nothing and
141 // tells a guesser nothing.
142 pub handle: String,
143}
144
145impl Profile {
146
147 /// The profile as a daticle, on the empty-idiom the records keep: a field that is empty writes no
148 /// key, so a profile that is all defaults writes an empty map and reads back the same.
149 pub fn to_dat(&self) -> Dat {
150 let mut m = DaticleMap::new();
151 if !self.name.is_empty() {
152 m.insert(dat!("name"), dat!(self.name.clone()));
153 }
154 if !self.avatar.is_empty() {
155 m.insert(dat!("avatar"), dat!(self.avatar.clone()));
156 }
157 if !self.bio.is_empty() {
158 m.insert(dat!("bio"), dat!(self.bio.clone()));
159 }
160 if !self.handle.is_empty() {
161 m.insert(dat!("handle"), dat!(self.handle.clone()));
162 }
163 Dat::Map(m)
164 }
165
166 /// The profile from a daticle. An absent or mistyped field is the default, on the same footing as
167 /// the post record: a reader that cannot read forwards makes every addition a migration.
168 pub fn from_dat(d: &Dat) -> Self {
169 let mut out = Self::default();
170 if let Dat::Map(m) = d {
171 if let Some(Dat::Str(s)) = m.get(&dat!("name")) {
172 out.name = s.clone();
173 }
174 if let Some(Dat::Str(s)) = m.get(&dat!("avatar")) {
175 out.avatar = s.clone();
176 }
177 if let Some(Dat::Str(s)) = m.get(&dat!("bio")) {
178 out.bio = s.clone();
179 }
180 if let Some(Dat::Str(s)) = m.get(&dat!("handle")) {
181 out.handle = s.clone();
182 }
183 }
184 out
185 }
186}
187
188fn key_of(slug: &str) -> Dat {
189 let mut s = String::from(KEY_PREFIX);
190 s.push_str(slug);
191 dat!(s)
192}
193
194/// What a post is, on the way in and out of the database.
195///
196/// Separate from [`Post`], which is the rendered view a reader gets. This is what an author wrote and
197/// what is kept; that is what is made of it.
198#[derive(Clone, Debug, Default, Eq, PartialEq)]
199pub struct Record {
200 pub slug: String, // the post's name in a URL
201 // The member who wrote it, by their site-login username. Empty for a post with no named author,
202 // which is what every record written before authorship was a field reads as, and what a post
203 // read from a directory carries unless a default author is configured.
204 pub author: String,
205 // The categories the post sits in, from the site's configured set, in first-seen order. Empty
206 // for an uncategorised post, which is what every record written before categories were a field
207 // reads as. The free-form counterpart is `tags`.
208 pub categories: Vec<String>,
209 pub state: PostState,
210 pub markup: Markup,
211 pub date: Option<String>, // where the author gave one
212 pub source: String, // the prose as written, in whatever markup `markup` names
213 // Where this post has been sent besides the site's own pages, one entry per remote. Empty for a
214 // post that lives only at home, which every post read from a directory does.
215 pub deliveries: Vec<Delivery>,
216 // The tags the author gave it, normalised and deduped, in first-seen order. Empty for an
217 // untagged post, which is what every record written before tags were a field reads as.
218 pub tags: Vec<String>,
219 // How much the writing of it needed AI, where the author declared. Nothing where they declared
220 // nothing, which is what every record written before the field existed reads as -- and what an
221 // author who has not chosen still means.
222 pub ai_level: Option<Level>,
223}
224
225impl Record {
226
227 /// The record as a daticle.
228 ///
229 /// A plain map, not an ordered one: a record is a set of named fields and nothing depends on the
230 /// order they were written in. A `BTreeMap` sorts them by name, so one record encodes one way.
231 pub fn to_dat(&self) -> Dat {
232 let mut m = DaticleMap::new();
233 m.insert(dat!("slug"), dat!(self.slug.clone()));
234 m.insert(dat!("state"), dat!(self.state.as_str().to_string()));
235 m.insert(dat!("markup"), dat!(self.markup.as_str().to_string()));
236 m.insert(dat!("source"), dat!(self.source.clone()));
237 // A post without a date carries no date key. A key saying nothing is a second way to say
238 // nothing, and two ways to say one thing is one too many.
239 if let Some(d) = &self.date {
240 m.insert(dat!("date"), dat!(d.clone()));
241 }
242 // A post with no named author carries no author key, on the empty-idiom the date and tags keep:
243 // an absent key and an empty string say the same thing, and one is enough.
244 if !self.author.is_empty() {
245 m.insert(dat!("author"), dat!(self.author.clone()));
246 }
247 // An uncategorised post carries no categories key, on the same idiom the tags below keep.
248 if !self.categories.is_empty() {
249 let list = Dat::List(self.categories.iter().map(|c| dat!(c.clone())).collect());
250 m.insert(dat!("categories"), list);
251 }
252 // A post sent nowhere carries no deliveries key, for the same reason it carries no empty date:
253 // an absent key and an empty list say the same thing, and one of them is enough.
254 if !self.deliveries.is_empty() {
255 let list = Dat::List(self.deliveries.iter().map(|d| d.to_dat()).collect());
256 m.insert(dat!("deliveries"), list);
257 }
258 // A post with no tags carries no tags key, for the same reason it carries no empty deliveries:
259 // an absent key and an empty list say the same thing, and one of them is enough.
260 if !self.tags.is_empty() {
261 let list = Dat::List(self.tags.iter().map(|t| dat!(t.clone())).collect());
262 m.insert(dat!("tags"), list);
263 }
264 // An undeclared post carries no level key, on the same idiom: a post whose author has said
265 // nothing about AI and a post written before the question was asked are the same post, and the
266 // store should not be able to tell them apart.
267 if let Some(l) = &self.ai_level {
268 m.insert(dat!("ai_level"), dat!(l.slug().to_string()));
269 }
270 Dat::Map(m)
271 }
272
273 pub fn from_dat(d: &Dat) -> Outcome<Self> {
274 let m = match d {
275 Dat::Map(m) => m,
276 _ => return Err(err!(
277 "publish: a post record must be a map, not {:?}.", d.kind();
278 Invalid, Input, Mismatch)),
279 };
280 let mut out = Self::default();
281 let mut date = None;
282 for (k, v) in m.iter() {
283 let key = match k {
284 Dat::Str(s) => s.clone(),
285 _ => continue,
286 };
287 let val = match v {
288 Dat::Str(s) => s.clone(),
289 _ => continue,
290 };
291 match key.as_str() {
292 "slug" => out.slug = val,
293 "author" => out.author = val,
294 "state" => out.state = PostState::of(&val),
295 // A record written before markup was a field carries none, and reads as Markdown --
296 // which is what every such post was. The default falls out of `Markup::of`.
297 "markup" => out.markup = Markup::of(&val),
298 "source" => out.source = val,
299 "date" => date = Some(val),
300 // A rung this version does not know reads as no declaration at all, which is the only
301 // safe reading: the alternative is showing a reader a claim the author did not make.
302 "ai_level" => out.ai_level = Level::of(&val),
303 // An unknown field is a field a later version wrote. Ignore it rather than refuse the
304 // record: a reader that cannot read forwards makes every addition a migration.
305 _ => {},
306 }
307 }
308 if out.slug.is_empty() {
309 return Err(err!(
310 "publish: a post record names no slug.";
311 Invalid, Input, Missing));
312 }
313 out.date = date;
314 // Deliveries are a list of maps, not a string, so they are read apart from the string fields
315 // above rather than in that loop. A list and a vek are both written as a list and both mean one,
316 // as everywhere else in this grammar. A delivery to a destination this version does not know is
317 // dropped, not fatal: a later version naming a new remote must not make its posts unreadable.
318 let items = match m.get(&dat!("deliveries")) {
319 Some(Dat::List(items)) => items.as_slice(),
320 Some(Dat::Vek(vek)) => vek.as_slice(),
321 _ => &[],
322 };
323 for item in items {
324 if let Some(d) = Delivery::from_dat(item) {
325 out.deliveries.push(d);
326 }
327 }
328 // Tags are a list of strings, read apart from the string fields above like the deliveries. An
329 // absent key is a post with no tags, which every record written before tags were a field is. A
330 // tag is taken as stored here; the composer is where one is normalised and checked.
331 let tags = match m.get(&dat!("tags")) {
332 Some(Dat::List(items)) => items.as_slice(),
333 Some(Dat::Vek(vek)) => vek.as_slice(),
334 _ => &[],
335 };
336 for item in tags {
337 if let Dat::Str(s) = item {
338 out.tags.push(s.clone());
339 }
340 }
341 // Categories are a list of strings, read like the tags. An absent key is an uncategorised post,
342 // which every record written before categories were a field is.
343 let cats = match m.get(&dat!("categories")) {
344 Some(Dat::List(items)) => items.as_slice(),
345 Some(Dat::Vek(vek)) => vek.as_slice(),
346 _ => &[],
347 };
348 for item in cats {
349 if let Dat::Str(s) = item {
350 out.categories.push(s.clone());
351 }
352 }
353 Ok(out)
354 }
355
356 pub fn render(&self) -> Outcome<Post> {
357 let mut post = res!(render_source(
358 &self.source, self.slug.clone(), self.date.clone(), self.markup));
359 // The remotes the post actually reached, with the address it landed at, for the backlinks. Only
360 // a sent delivery has a permalink; a queued or failed one has nowhere to point.
361 post.also_on = self.deliveries.iter().filter_map(|d| match &d.state {
362 DeliveryState::Sent { permalink, .. } if !permalink.is_empty() =>
363 Some((d.dest, permalink.clone())),
364 _ => None,
365 }).collect();
366 // The tags travel with the rendered post, so every read surface can show them without knowing a
367 // record from a directory.
368 post.tags = self.tags.clone();
369 // So do the author and the categories, for the same reason.
370 post.author = self.author.clone();
371 post.categories = self.categories.clone();
372 // And the author's declaration, which is a field of the record for the same reason the author
373 // is: prose alone cannot say who wrote it or what helped.
374 post.ai_level = self.ai_level;
375 Ok(post)
376 }
377}
378
379
380/// Writes a post, adding it to the index if it is new.
381pub fn put<
382 const UIDL: usize,
383 UID: NumIdDat<UIDL>,
384 ENC: Encrypter,
385 KH: Hasher,
386 DB: Database<UIDL, UID, ENC, KH>,
387>(
388 db: &(Arc<RwLock<DB>>, UID),
389 rec: &Record,
390 id: &str,
391)
392 -> Outcome<()>
393{
394 let (db_arc, user) = db;
395 {
396 let guard = lock_read!(db_arc);
397 res!(guard.insert(key_of(&rec.slug), rec.to_dat(), *user, None));
398 }
399 let mut slugs = res!(index(db, id));
400 if !slugs.iter().any(|s| s == &rec.slug) {
401 slugs.push(rec.slug.clone());
402 res!(put_index(db, &slugs));
403 }
404 debug!("{}: publish: wrote '{}'", id, rec.slug);
405 Ok(())
406}
407
408pub fn get<
409 const UIDL: usize,
410 UID: NumIdDat<UIDL>,
411 ENC: Encrypter,
412 KH: Hasher,
413 DB: Database<UIDL, UID, ENC, KH>,
414>(
415 db: &(Arc<RwLock<DB>>, UID),
416 slug: &str,
417)
418 -> Outcome<Option<Record>>
419{
420 let (db_arc, _) = db;
421 let guard = lock_read!(db_arc);
422 match res!(guard.get(&key_of(slug), None)) {
423 Some((val, _)) => Ok(Some(res!(Record::from_dat(&val)))),
424 None => Ok(None),
425 }
426}
427
428/// The declared level of one named thing on the site, or nothing where nobody has set one.
429///
430/// A post keeps its own declaration in its own record, because a post is written here. This is for
431/// the things a site shows that are authored elsewhere -- a book, a project -- where the level is the
432/// only field the server holds and the config names what may hold one.
433pub fn get_level<
434 const UIDL: usize,
435 UID: NumIdDat<UIDL>,
436 ENC: Encrypter,
437 KH: Hasher,
438 DB: Database<UIDL, UID, ENC, KH>,
439>(
440 db: &(Arc<RwLock<DB>>, UID),
441 key: &str,
442)
443 -> Outcome<Option<Level>>
444{
445 let (db_arc, _) = db;
446 let guard = lock_read!(db_arc);
447 match res!(guard.get(&declare_key_of(key), None)) {
448 Some((Dat::Str(s), _)) => Ok(Level::of(&s)),
449 // A record holding something other than a level is not a level. Undeclared is the safe reading
450 // of anything this version cannot place, here as everywhere else in the module.
451 _ => Ok(None),
452 }
453}
454
455/// The declared levels of the things a config names, keyed as the config keys them.
456///
457/// Only the keys asked for, so a stale record left behind by a config that no longer names its item
458/// cannot put a mark on a page. The config is the list of what may be declared; the store only
459/// remembers what was chosen.
460pub fn get_levels<
461 const UIDL: usize,
462 UID: NumIdDat<UIDL>,
463 ENC: Encrypter,
464 KH: Hasher,
465 DB: Database<UIDL, UID, ENC, KH>,
466>(
467 db: &(Arc<RwLock<DB>>, UID),
468 keys: &[String],
469 id: &str,
470)
471 -> Outcome<BTreeMap<String, Level>>
472{
473 let mut out = BTreeMap::new();
474 for key in keys {
475 match get_level(db, key) {
476 Ok(Some(level)) => {
477 out.insert(key.clone(), level);
478 },
479 Ok(None) => {},
480 // One unreadable record should not take the other declarations off the page: a missing
481 // mark is a mark not drawn, and drawing none of them is worse than drawing the rest.
482 Err(e) => warn!(
483 "{}: publish: declaration for '{}' would not read: {}", id, key, e),
484 }
485 }
486 Ok(out)
487}
488
489/// Sets or clears the declared level of one named thing.
490///
491/// `None` deletes the record rather than storing a word for "undeclared". An admin taking a
492/// declaration back means the site no longer says anything about that work, and the absence of a
493/// record is how this module has always spelled that.
494pub fn put_level<
495 const UIDL: usize,
496 UID: NumIdDat<UIDL>,
497 ENC: Encrypter,
498 KH: Hasher,
499 DB: Database<UIDL, UID, ENC, KH>,
500>(
501 db: &(Arc<RwLock<DB>>, UID),
502 key: &str,
503 level: Option<Level>,
504)
505 -> Outcome<()>
506{
507 let (db_arc, user) = db;
508 let guard = lock_read!(db_arc);
509 match level {
510 Some(l) => {
511 res!(guard.insert(declare_key_of(key), dat!(l.slug().to_string()), *user, None));
512 },
513 None => {
514 res!(guard.delete(&declare_key_of(key), *user, None));
515 },
516 }
517 Ok(())
518}
519
520/// Deletes a post and takes it out of the index.
521pub fn delete<
522 const UIDL: usize,
523 UID: NumIdDat<UIDL>,
524 ENC: Encrypter,
525 KH: Hasher,
526 DB: Database<UIDL, UID, ENC, KH>,
527>(
528 db: &(Arc<RwLock<DB>>, UID),
529 slug: &str,
530 id: &str,
531)
532 -> Outcome<bool>
533{
534 let (db_arc, user) = db;
535 let existed = {
536 let guard = lock_read!(db_arc);
537 res!(guard.delete(&key_of(slug), *user, None))
538 };
539 let slugs = res!(index(db, id));
540 let kept: Vec<String> = slugs.into_iter().filter(|s| s != slug).collect();
541 res!(put_index(db, &kept));
542 Ok(existed)
543}
544
545/// Every record the store holds, whatever its state, newest first.
546///
547/// What an author gets. [`list`] is what a reader gets and so passes over drafts, which is exactly
548/// what the author of a draft must be able to see: a composer that could not show the thing not yet
549/// published would be showing everything except the work in progress.
550///
551/// A record the index names but the database does not hold is passed over with a complaint, rather
552/// than failing the lot: one bad post should not take the others off the page.
553pub fn list_records<
554 const UIDL: usize,
555 UID: NumIdDat<UIDL>,
556 ENC: Encrypter,
557 KH: Hasher,
558 DB: Database<UIDL, UID, ENC, KH>,
559>(
560 db: &(Arc<RwLock<DB>>, UID),
561 id: &str,
562)
563 -> Outcome<Vec<Record>>
564{
565 let slugs = res!(index(db, id));
566 let mut recs = Vec::new();
567 for slug in &slugs {
568 match get(db, slug) {
569 Ok(Some(r)) => recs.push(r),
570 Ok(None) => {
571 // The index names a post the database does not hold. Derived data disagreeing with
572 // what it was derived from is worth saying out loud.
573 warn!("{}: publish: the index names '{}', which is not there", id, slug);
574 }
575 Err(e) => {
576 warn!("{}: publish: skipping '{}': {}", id, slug, e);
577 }
578 }
579 }
580 // Newest first, and among posts of one date, or of none, by slug. The date descending and the
581 // slug ascending are compared in opposite directions, so they are compared apart.
582 recs.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
583 Ok(recs)
584}
585
586/// Every tag any post carries, drafts included, sorted and deduped.
587///
588/// The site's accumulating vocabulary: a tag is offered as soon as one post wears it, so the
589/// composer's palette grows as the site does, and a draft counts -- a tag is available the moment it
590/// is first used.
591///
592/// Built from [`list_records`], which is the index read and a read per post the composer already
593/// pays, rather than [`Database::scan`], which is the expensive thing the index exists to avoid.
594pub fn all_tags<
595 const UIDL: usize,
596 UID: NumIdDat<UIDL>,
597 ENC: Encrypter,
598 KH: Hasher,
599 DB: Database<UIDL, UID, ENC, KH>,
600>(
601 db: &(Arc<RwLock<DB>>, UID),
602 id: &str,
603)
604 -> Outcome<Vec<String>>
605{
606 let recs = res!(list_records(db, id));
607 let mut out: Vec<String> = Vec::new();
608 for rec in &recs {
609 for t in &rec.tags {
610 if !out.iter().any(|s| s == t) {
611 out.push(t.clone());
612 }
613 }
614 }
615 out.sort();
616 Ok(out)
617}
618
619/// Every tag the site uses, each with how many posts wear it and how many authors those posts
620/// belong to, in the order [`all_tags`] gives.
621///
622/// One pass over the records for the whole vocabulary, where [`tag_usage`] is one pass per tag: a
623/// console listing thirty tags should read the posts once, not thirty times. Use that one for a single
624/// tag about to be deleted, and this one for a list.
625pub fn tag_counts<
626 const UIDL: usize,
627 UID: NumIdDat<UIDL>,
628 ENC: Encrypter,
629 KH: Hasher,
630 DB: Database<UIDL, UID, ENC, KH>,
631>(
632 db: &(Arc<RwLock<DB>>, UID),
633 id: &str,
634)
635 -> Outcome<Vec<(String, usize, usize)>>
636{
637 let recs = res!(list_records(db, id));
638 // Tag -> (posts, the distinct authors seen wearing it).
639 let mut seen: BTreeMap<String, (usize, Vec<String>)> = BTreeMap::new();
640 for rec in &recs {
641 for t in &rec.tags {
642 let e = seen.entry(t.clone()).or_insert((0, Vec::new()));
643 e.0 += 1;
644 if !rec.author.is_empty() && !e.1.iter().any(|a| a == &rec.author) {
645 e.1.push(rec.author.clone());
646 }
647 }
648 }
649 Ok(seen.into_iter().map(|(t, (posts, authors))| (t, posts, authors.len())).collect())
650}
651
652/// How far a tag reaches: how many posts wear it, and how many distinct authors those posts belong
653/// to. What a curator is shown before deleting one, so the cost of the act is named before it is
654/// done.
655pub fn tag_usage<
656 const UIDL: usize,
657 UID: NumIdDat<UIDL>,
658 ENC: Encrypter,
659 KH: Hasher,
660 DB: Database<UIDL, UID, ENC, KH>,
661>(
662 db: &(Arc<RwLock<DB>>, UID),
663 tag: &str,
664 id: &str,
665)
666 -> Outcome<(usize, usize)>
667{
668 let recs = res!(list_records(db, id));
669 let mut posts = 0;
670 let mut authors: Vec<String> = Vec::new();
671 for rec in &recs {
672 if rec.tags.iter().any(|t| t == tag) {
673 posts += 1;
674 if !rec.author.is_empty() && !authors.iter().any(|a| a == &rec.author) {
675 authors.push(rec.author.clone());
676 }
677 }
678 }
679 Ok((posts, authors.len()))
680}
681
682/// Deletes a tag from every post that wears it, rewriting each, and returns how many were touched.
683///
684/// The one destructive act on the shared vocabulary, a curator's alone. It reaches across authors, so
685/// it is a deliberate rewrite of every affected record rather than a flag: a tag exists only through
686/// the posts wearing it, so taking it off all of them is what deleting it means. A post that will not
687/// rewrite is logged and passed over rather than failing the sweep -- the others still lose the tag.
688pub fn delete_tag<
689 const UIDL: usize,
690 UID: NumIdDat<UIDL>,
691 ENC: Encrypter,
692 KH: Hasher,
693 DB: Database<UIDL, UID, ENC, KH>,
694>(
695 db: &(Arc<RwLock<DB>>, UID),
696 tag: &str,
697 id: &str,
698)
699 -> Outcome<usize>
700{
701 let recs = res!(list_records(db, id));
702 let mut n = 0;
703 for mut rec in recs {
704 if rec.tags.iter().any(|t| t == tag) {
705 rec.tags.retain(|t| t != tag);
706 match put(db, &rec, id) {
707 Ok(()) => n += 1,
708 Err(e) => warn!(
709 "{}: publish: deleting tag '{}' but '{}' would not rewrite: {}", id, tag, rec.slug, e),
710 }
711 }
712 }
713 if n > 0 {
714 info!("{}: publish: deleted tag '{}' from {} post(s)", id, tag, n);
715 }
716 Ok(n)
717}
718
719/// One member's profile, or the default where they have set none.
720///
721/// The default is not an error: a member who never opened their profile is still an author, and asks
722/// to be drawn from their username. Only a database that will not read is a fault.
723pub fn get_profile<
724 const UIDL: usize,
725 UID: NumIdDat<UIDL>,
726 ENC: Encrypter,
727 KH: Hasher,
728 DB: Database<UIDL, UID, ENC, KH>,
729>(
730 db: &(Arc<RwLock<DB>>, UID),
731 username: &str,
732)
733 -> Outcome<Profile>
734{
735 let (db_arc, _) = db;
736 let guard = lock_read!(db_arc);
737 match res!(guard.get(&profile_key_of(username), None)) {
738 Some((val, _)) => Ok(Profile::from_dat(&val)),
739 None => Ok(Profile::default()),
740 }
741}
742
743pub fn put_profile<
744 const UIDL: usize,
745 UID: NumIdDat<UIDL>,
746 ENC: Encrypter,
747 KH: Hasher,
748 DB: Database<UIDL, UID, ENC, KH>,
749>(
750 db: &(Arc<RwLock<DB>>, UID),
751 username: &str,
752 profile: &Profile,
753)
754 -> Outcome<()>
755{
756 let (db_arc, user) = db;
757 let guard = lock_read!(db_arc);
758 res!(guard.insert(profile_key_of(username), profile.to_dat(), *user, None));
759 Ok(())
760}
761
762/// A member's uploaded picture, and what it is, or nothing where they uploaded none.
763///
764/// The bytes go out as they came in, under the media type they were stored with -- which is why only
765/// the types a browser draws are ever stored. See [`put_avatar`].
766pub fn get_avatar<
767 const UIDL: usize,
768 UID: NumIdDat<UIDL>,
769 ENC: Encrypter,
770 KH: Hasher,
771 DB: Database<UIDL, UID, ENC, KH>,
772>(
773 db: &(Arc<RwLock<DB>>, UID),
774 handle: &str,
775)
776 -> Outcome<Option<(String, Vec<u8>)>>
777{
778 let (db_arc, _) = db;
779 let guard = lock_read!(db_arc);
780 match res!(guard.get(&avatar_key_of(handle), None)) {
781 Some((Dat::Map(m), _)) => {
782 let kind = match m.get(&dat!("type")) {
783 Some(Dat::Str(s)) => s.clone(),
784 _ => return Ok(None),
785 };
786 // The bytes are a `BU64`, whose length is a `u64`: a `BU8` would take a picture of more
787 // than 255 bytes and keep its length modulo 256, which is to say lose it.
788 match m.get(&dat!("data")) {
789 Some(Dat::BU64(b)) => Ok(Some((kind, b.clone()))),
790 _ => Ok(None),
791 }
792 }
793 _ => Ok(None),
794 }
795}
796
797/// Writes a member's picture, replacing whatever they had.
798///
799/// The media type is stored beside the bytes because it is what they will be served as, and a caller
800/// must have satisfied itself that the type is one a browser draws -- see
801/// [`DataUrl::is_web_image`](oxedyne_fe2o3_net::http::data_url::DataUrl::is_web_image). Serving bytes
802/// under a type the sender chose freely is how a picture becomes a page.
803pub fn put_avatar<
804 const UIDL: usize,
805 UID: NumIdDat<UIDL>,
806 ENC: Encrypter,
807 KH: Hasher,
808 DB: Database<UIDL, UID, ENC, KH>,
809>(
810 db: &(Arc<RwLock<DB>>, UID),
811 handle: &str,
812 media_type: &str,
813 bytes: &[u8],
814)
815 -> Outcome<()>
816{
817 let (db_arc, user) = db;
818 let mut m = DaticleMap::new();
819 m.insert(dat!("type"), dat!(media_type.to_string()));
820 m.insert(dat!("data"), Dat::BU64(bytes.to_vec()));
821 let guard = lock_read!(db_arc);
822 res!(guard.insert(avatar_key_of(handle), Dat::Map(m), *user, None));
823 Ok(())
824}
825
826/// The authors a set of usernames names, each resolved to what a reader is shown.
827///
828/// One read per distinct username, the profile where it has one and the defaults where it does not.
829/// Order follows the input, deduped: a filter draws one face per author, in the order the posts first
830/// named them. A username whose profile will not read is drawn from the defaults rather than failing
831/// the page -- a broken profile costs a name, not the index.
832///
833/// A member with no profile is given a handle made from their position here (`author-1`, `author-2`),
834/// which distinguishes them within one page and means nothing outside it. It is not derived from the
835/// username, which is the SHA-256 of a passphrase and must not reach a page in any form.
836pub fn resolve_authors<
837 const UIDL: usize,
838 UID: NumIdDat<UIDL>,
839 ENC: Encrypter,
840 KH: Hasher,
841 DB: Database<UIDL, UID, ENC, KH>,
842>(
843 db: &(Arc<RwLock<DB>>, UID),
844 usernames: &[String],
845)
846 -> Vec<Author>
847{
848 let mut out: Vec<Author> = Vec::new();
849 for u in usernames {
850 if u.is_empty() || out.iter().any(|a| &a.username == u) {
851 continue;
852 }
853 let profile = get_profile(db, u).unwrap_or_default();
854 let spare = fmt!("author-{}", out.len() + 1);
855 out.push(Author::from_profile(u, &profile, &spare));
856 }
857 out
858}
859
860/// Every live post, newest first, rendered.
861///
862/// A record that will not render is passed over with a complaint in the log rather than failing the
863/// lot, on the same reasoning a directory's unreadable file is: one bad post should not take the
864/// others off the page. The order is [`list_records`]'s.
865pub fn list<
866 const UIDL: usize,
867 UID: NumIdDat<UIDL>,
868 ENC: Encrypter,
869 KH: Hasher,
870 DB: Database<UIDL, UID, ENC, KH>,
871>(
872 db: &(Arc<RwLock<DB>>, UID),
873 id: &str,
874)
875 -> Outcome<Vec<Post>>
876{
877 let recs = res!(list_records(db, id));
878 let mut posts = Vec::new();
879 for rec in &recs {
880 if rec.state != PostState::Live {
881 continue;
882 }
883 match rec.render() {
884 Ok(p) => posts.push(p),
885 Err(e) => warn!("{}: publish: '{}' will not render: {}", id, rec.slug, e),
886 }
887 }
888 Ok(posts)
889}
890
891fn index<
892 const UIDL: usize,
893 UID: NumIdDat<UIDL>,
894 ENC: Encrypter,
895 KH: Hasher,
896 DB: Database<UIDL, UID, ENC, KH>,
897>(
898 db: &(Arc<RwLock<DB>>, UID),
899 _id: &str,
900)
901 -> Outcome<Vec<String>>
902{
903 let (db_arc, _) = db;
904 let guard = lock_read!(db_arc);
905 let val = match res!(guard.get(&dat!(INDEX_KEY), None)) {
906 Some((v, _)) => v,
907 // No index is an empty store, not an error: a site that has published nothing is a site, and
908 // its index is the empty list it never wrote.
909 None => return Ok(Vec::new()),
910 };
911 let items = match &val {
912 Dat::List(items) => items.clone(),
913 Dat::Vek(vek) => vek.as_slice().to_vec(),
914 _ => return Err(err!(
915 "publish: the index must be a list, not {:?}.", val.kind();
916 Invalid, Input, Mismatch)),
917 };
918 let mut out = Vec::new();
919 for item in &items {
920 if let Dat::Str(s) = item {
921 out.push(s.clone());
922 }
923 }
924 Ok(out)
925}
926
927fn put_index<
928 const UIDL: usize,
929 UID: NumIdDat<UIDL>,
930 ENC: Encrypter,
931 KH: Hasher,
932 DB: Database<UIDL, UID, ENC, KH>,
933>(
934 db: &(Arc<RwLock<DB>>, UID),
935 slugs: &[String],
936)
937 -> Outcome<()>
938{
939 let (db_arc, user) = db;
940 let list = Dat::List(slugs.iter().map(|s| dat!(s.clone())).collect());
941 let guard = lock_read!(db_arc);
942 res!(guard.insert(dat!(INDEX_KEY), list, *user, None));
943 Ok(())
944}
945
946// ┌───────────────────────────────────────────────────────────────────────────┐
947// │ READS │
948// └───────────────────────────────────────────────────────────────────────────┘
949
950/// Reads a post's tally.
951///
952/// An absent key is a post nobody has read yet, which is nought rather than an error -- the same
953/// reasoning [`index`] takes for a store that has published nothing.
954pub fn reads_get<
955 const UIDL: usize,
956 UID: NumIdDat<UIDL>,
957 ENC: Encrypter,
958 KH: Hasher,
959 DB: Database<UIDL, UID, ENC, KH>,
960>(
961 db: &(Arc<RwLock<DB>>, UID),
962 slug: &str,
963)
964 -> Outcome<u64>
965{
966 let (db_arc, _) = db;
967 let guard = lock_read!(db_arc);
968 match res!(guard.get(&reads_key_of(slug), None)) {
969 Some((Dat::U64(n), _)) => Ok(n),
970 Some((v, _)) => Err(err!(
971 "publish: a read tally must be a count, not {:?}.", v.kind();
972 Invalid, Input, Mismatch)),
973 None => Ok(0),
974 }
975}
976
977/// Adds one to a post's tally and answers the new total.
978///
979/// Read-add-write rather than an atomic increment, because the store offers no increment. Two reads
980/// landing together can therefore lose one of the two. That is accepted deliberately: this counts
981/// roughly how many people read a post, a question that does not become better answered by a lock
982/// held across the render path of every request. It is not billing.
983pub fn reads_bump<
984 const UIDL: usize,
985 UID: NumIdDat<UIDL>,
986 ENC: Encrypter,
987 KH: Hasher,
988 DB: Database<UIDL, UID, ENC, KH>,
989>(
990 db: &(Arc<RwLock<DB>>, UID),
991 slug: &str,
992)
993 -> Outcome<u64>
994{
995 let now = res!(reads_get(db, slug)).saturating_add(1);
996 let (db_arc, user) = db;
997 let guard = lock_read!(db_arc);
998 res!(guard.insert(reads_key_of(slug), dat!(now), *user, None));
999 Ok(now)
1000}
1001
1002/// Every post's tally, by slug.
1003///
1004/// What the reports page aggregates. A tally whose post has since been deleted is still returned --
1005/// the caller knows which slugs it published and can decide whether a count without a post is worth
1006/// showing; throwing it away here would be this function deciding that on their behalf.
1007pub fn reads_all<
1008 const UIDL: usize,
1009 UID: NumIdDat<UIDL>,
1010 ENC: Encrypter,
1011 KH: Hasher,
1012 DB: Database<UIDL, UID, ENC, KH>,
1013>(
1014 db: &(Arc<RwLock<DB>>, UID),
1015 id: &str,
1016)
1017 -> Outcome<BTreeMap<String, u64>>
1018{
1019 let (db_arc, _) = db;
1020 // The scan selects keys and the reads fetch values, which is not an optimisation to undo: scan v1
1021 // answers `Dat::Empty` for every value whatever `include_values` asks for, and says so in a log
1022 // line rather than an error. Asking it for values yields a tally of nothing, silently. This is the
1023 // same shape `rebuild_index` takes, for the same reason.
1024 let found = {
1025 let guard = lock_read!(db_arc);
1026 let mut opts = ScanOpts::default();
1027 opts.prefix = Some(dat!(READS_PREFIX));
1028 opts.include_values = false;
1029 res!(guard.scan(&opts, None))
1030 };
1031 let mut out = BTreeMap::new();
1032 for (k, _, _) in &found {
1033 let s = match k {
1034 Dat::Str(s) => s,
1035 _ => continue,
1036 };
1037 let slug = match s.strip_prefix(READS_PREFIX) {
1038 Some(slug) => slug,
1039 None => continue,
1040 };
1041 // A tally that will not read is a bug elsewhere, and losing the whole page over one bad key
1042 // would be the wrong trade. It is logged and passed over.
1043 match reads_get(db, slug) {
1044 Ok(n) => { out.insert(slug.to_string(), n); }
1045 Err(e) => debug!(
1046 "{}: publish: the read tally for '{}' will not read: {}", id, slug, e),
1047 }
1048 }
1049 Ok(out)
1050}
1051
1052// ┌───────────────────────────────────────────────────────────────────────────┐
1053// │ ADMINS │
1054// └───────────────────────────────────────────────────────────────────────────┘
1055
1056/// The database-granted admins: every member id-hash the console has added.
1057///
1058/// The read that lets a site bootstrap its own administration without a config edit. An absent key is
1059/// a site that has granted none -- which every site is until the first admin claims it -- and reads as
1060/// the empty list, not an error, on the same reasoning [`index`] takes for a store that has published
1061/// nothing.
1062pub fn admins_get<
1063 const UIDL: usize,
1064 UID: NumIdDat<UIDL>,
1065 ENC: Encrypter,
1066 KH: Hasher,
1067 DB: Database<UIDL, UID, ENC, KH>,
1068>(
1069 db: &(Arc<RwLock<DB>>, UID),
1070 _id: &str,
1071)
1072 -> Outcome<Vec<String>>
1073{
1074 let (db_arc, _) = db;
1075 let guard = lock_read!(db_arc);
1076 let val = match res!(guard.get(&dat!(ADMINS_KEY), None)) {
1077 Some((v, _)) => v,
1078 // No key is a site that has granted no admins from the browser, which is not an error: its
1079 // database admin list is the empty one it never wrote.
1080 None => return Ok(Vec::new()),
1081 };
1082 let items = match &val {
1083 Dat::List(items) => items.clone(),
1084 Dat::Vek(vek) => vek.as_slice().to_vec(),
1085 _ => return Err(err!(
1086 "publish: the admin list must be a list, not {:?}.", val.kind();
1087 Invalid, Input, Mismatch)),
1088 };
1089 let mut out = Vec::new();
1090 for item in &items {
1091 if let Dat::Str(s) = item {
1092 out.push(s.clone());
1093 }
1094 }
1095 Ok(out)
1096}
1097
1098/// Adds an id-hash to the database admin list, once.
1099///
1100/// Idempotent: a hash the list already holds is left as it is, so granting the same admin twice grants
1101/// them once. The caller owns validating the hash's shape; this stores what it is given.
1102pub fn admins_add<
1103 const UIDL: usize,
1104 UID: NumIdDat<UIDL>,
1105 ENC: Encrypter,
1106 KH: Hasher,
1107 DB: Database<UIDL, UID, ENC, KH>,
1108>(
1109 db: &(Arc<RwLock<DB>>, UID),
1110 id: &str,
1111 hash: &str,
1112)
1113 -> Outcome<()>
1114{
1115 let mut hashes = res!(admins_get(db, id));
1116 if !hashes.iter().any(|h| h == hash) {
1117 hashes.push(hash.to_string());
1118 res!(put_admins(db, &hashes));
1119 debug!("{}: publish: granted site admin to '{}'", id, hash);
1120 }
1121 Ok(())
1122}
1123
1124/// Removes an id-hash from the database admin list.
1125///
1126/// Only the database list: a hash the operator has pinned in config is not here to remove and stays an
1127/// admin regardless, which is the point of the config list being the failsafe. Removing a hash the
1128/// list does not hold is a no-op, not an error.
1129pub fn admins_remove<
1130 const UIDL: usize,
1131 UID: NumIdDat<UIDL>,
1132 ENC: Encrypter,
1133 KH: Hasher,
1134 DB: Database<UIDL, UID, ENC, KH>,
1135>(
1136 db: &(Arc<RwLock<DB>>, UID),
1137 id: &str,
1138 hash: &str,
1139)
1140 -> Outcome<()>
1141{
1142 let hashes = res!(admins_get(db, id));
1143 let kept: Vec<String> = hashes.into_iter().filter(|h| h != hash).collect();
1144 res!(put_admins(db, &kept));
1145 debug!("{}: publish: revoked site admin from '{}'", id, hash);
1146 Ok(())
1147}
1148
1149/// Writes the database admin list.
1150///
1151/// Private, and the only writer of [`ADMINS_KEY`] besides the two above that go through it: the list is
1152/// derived from nothing, so it is written whole where it changes and nowhere else.
1153fn put_admins<
1154 const UIDL: usize,
1155 UID: NumIdDat<UIDL>,
1156 ENC: Encrypter,
1157 KH: Hasher,
1158 DB: Database<UIDL, UID, ENC, KH>,
1159>(
1160 db: &(Arc<RwLock<DB>>, UID),
1161 hashes: &[String],
1162)
1163 -> Outcome<()>
1164{
1165 let (db_arc, user) = db;
1166 let list = Dat::List(hashes.iter().map(|s| dat!(s.clone())).collect());
1167 let guard = lock_read!(db_arc);
1168 res!(guard.insert(dat!(ADMINS_KEY), list, *user, None));
1169 Ok(())
1170}
1171
1172
1173/// Rebuilds the index from what the database actually holds.
1174///
1175/// The repair. Scans, which is the expensive thing the index exists to avoid, so this is for putting
1176/// the index right after something has gone wrong with it -- not for serving a page.
1177///
1178/// # A scan is not a list of what is there
1179///
1180/// [`Database::delete`] "deletes the given key ... **or at least marks it for deletion**", and a
1181/// marked key still comes back from a scan. So every key the scan offers is confirmed with a read,
1182/// and one that does not read back is one that is gone.
1183///
1184/// Without that read this would resurrect every post ever deleted, the next time anything repaired
1185/// the index, silently and long after the deletion. The extra read per key is the price of the
1186/// repair being a repair.
1187pub fn rebuild_index<
1188 const UIDL: usize,
1189 UID: NumIdDat<UIDL>,
1190 ENC: Encrypter,
1191 KH: Hasher,
1192 DB: Database<UIDL, UID, ENC, KH>,
1193>(
1194 db: &(Arc<RwLock<DB>>, UID),
1195 id: &str,
1196)
1197 -> Outcome<usize>
1198{
1199 let (db_arc, _) = db;
1200 let found = {
1201 let guard = lock_read!(db_arc);
1202 let mut opts = ScanOpts::default();
1203 opts.prefix = Some(dat!(KEY_PREFIX));
1204 opts.include_values = false;
1205 res!(guard.scan(&opts, None))
1206 };
1207 let mut slugs = Vec::new();
1208 let mut marked = 0;
1209 for (k, _, _) in &found {
1210 let s = match k {
1211 Dat::Str(s) => s,
1212 _ => continue,
1213 };
1214 let slug = match s.strip_prefix(KEY_PREFIX) {
1215 Some(slug) => slug,
1216 None => continue,
1217 };
1218 // The scan said the key is there; the read says whether it still means anything.
1219 match res!(get(db, slug)) {
1220 Some(_) => slugs.push(slug.to_string()),
1221 None => marked += 1,
1222 }
1223 }
1224 slugs.sort();
1225 let n = slugs.len();
1226 res!(put_index(db, &slugs));
1227 if marked > 0 {
1228 debug!("{}: publish: the scan offered {} deleted keys, which were not taken", id, marked);
1229 }
1230 info!("{}: publish: index rebuilt, {} posts", id, n);
1231 Ok(n)
1232}
1233
1234/// The date and slug a file's name means to an import, dated today where the name gave none.
1235///
1236/// A file called `on-rent.md` says nothing about when it was written, and an undated post is not a
1237/// post without a date on the page -- it is a post the feed has to invent one for, and the invention
1238/// is the epoch, which files the piece under 1970 in every reader that takes it. The composer's save
1239/// path has dated an empty field to today since that was found; an import writes the same records and
1240/// has to do the same thing, or the bug simply comes back in through the other door.
1241fn imported_date(stem: &str) -> (Option<String>, String) {
1242 let (date, slug) = split_date(stem);
1243 (date.or_else(super::today), slug)
1244}
1245
1246/// Reads a directory of Markdown into the store.
1247///
1248/// How prose that already exists gets in, and the reason a directory stays a first-class way to write
1249/// even once the store is the source: a file is still where most prose starts.
1250///
1251/// A slug the store already holds is overwritten, so importing twice is importing once. That makes the
1252/// import safe to repeat, which is what anyone will do with it.
1253pub fn import_dir<
1254 const UIDL: usize,
1255 UID: NumIdDat<UIDL>,
1256 ENC: Encrypter,
1257 KH: Hasher,
1258 DB: Database<UIDL, UID, ENC, KH>,
1259>(
1260 db: &(Arc<RwLock<DB>>, UID),
1261 dir: &str,
1262 id: &str,
1263)
1264 -> Outcome<usize>
1265{
1266 let sources = res!(super::read_sources(dir, id));
1267 let mut n = 0;
1268 for (stem, source) in sources {
1269 let (date, slug) = imported_date(&stem);
1270 let rec = Record {
1271 slug,
1272 // A directory carries no author or categories: there is no front matter to hold them, and
1273 // a filename is the slug and the date, nothing else. An imported post is attributed and
1274 // categorised afterwards in the composer, or left as it came.
1275 author: String::new(),
1276 categories: Vec::new(),
1277 // Everything a directory holds is live: a file on disk is not a draft, or it would not be
1278 // on the disk of a server.
1279 state: PostState::Live,
1280 markup: Markup::Markdown,
1281 // And nothing a directory holds is declared: an import must not put a claim about AI on
1282 // somebody's prose. The author declares in the composer, afterwards, or not at all.
1283 ai_level: None,
1284 date,
1285 source,
1286 // A file has been sent nowhere: a directory is prose, not a record of where it went.
1287 deliveries: Vec::new(),
1288 tags: Vec::new(),
1289 };
1290 res!(put(db, &rec, id));
1291 n += 1;
1292 }
1293 info!("{}: publish: imported {} posts from '{}'", id, n, dir);
1294 Ok(n)
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300 use crate::srv::publish::dest::{
1301 Delivery,
1302 DeliveryState,
1303 Destination,
1304 Rendition,
1305 };
1306
1307 /// A record survives the trip through a daticle, including a date it does not have and the
1308 /// deliveries it does.
1309 #[test]
1310 fn test_a_record_round_trips_00() -> Outcome<()> {
1311 let rec = Record {
1312 slug: fmt!("on-rent"),
1313 author: fmt!("jason"),
1314 categories: vec![fmt!("Personal"), fmt!("Technical")],
1315 state: PostState::Draft,
1316 markup: Markup::Djot,
1317 date: Some(fmt!("2026-07-17")),
1318 ai_level: Some(Level::Some),
1319 source: fmt!("# On rent\n\nWords.\n"),
1320 deliveries: vec![
1321 Delivery {
1322 dest: Destination::Mastodon,
1323 rendition: Rendition { text: fmt!("On rent https://x"), auto: false },
1324 state: DeliveryState::Sent {
1325 at: fmt!("2026-07-18T10:00:00Z"),
1326 permalink: fmt!("https://m.example/1"),
1327 },
1328 },
1329 Delivery::new(Destination::Bluesky, Rendition::default()),
1330 ],
1331 tags: vec![fmt!("rust"), fmt!("web")],
1332 };
1333 let back = res!(Record::from_dat(&rec.to_dat()));
1334 assert_eq!(back, rec);
1335
1336 let undated = Record { date: None, ..rec };
1337 let back = res!(Record::from_dat(&undated.to_dat()));
1338 assert_eq!(back, undated);
1339 assert_eq!(back.date, None);
1340 Ok(())
1341 }
1342
1343 /// An imported file whose name says no date is dated today, not left for the feed to date to 1970.
1344 ///
1345 /// The pairing that matters: the composer's save has dated an empty field to today since the
1346 /// epoch bug was found, and an import writes the same records by another route. A fallback on one
1347 /// path and not the other is the same bug with a different way in.
1348 #[test]
1349 fn test_an_imported_file_with_no_date_is_dated_today_08() -> Outcome<()> {
1350 // A name that says when: taken as written, and today has nothing to do with it.
1351 let (date, slug) = imported_date("2026-07-17-on-rent");
1352 assert_eq!(date, Some(fmt!("2026-07-17")));
1353 assert_eq!(slug, fmt!("on-rent"));
1354
1355 // A name that says nothing: today, and never `None`.
1356 let (date, slug) = imported_date("on-rent");
1357 assert_eq!(slug, fmt!("on-rent"));
1358 assert_eq!(date, crate::srv::publish::today(),
1359 "an imported file with no date prefix was left undated");
1360 let date = res!(date.ok_or_else(|| err!(
1361 "The clock gave no date, so this run cannot say what the import would have written.";
1362 Missing)));
1363 assert!(crate::srv::publish::valid_date(&date), "'{}' is not a date the store takes", date);
1364
1365 // A prefix that is not a date is part of the name, and the post is still dated.
1366 let (date, slug) = imported_date("2026-13-on-rent");
1367 assert_eq!(slug, fmt!("2026-13-on-rent"));
1368 assert!(date.is_some(), "a name with no date prefix was left undated");
1369 Ok(())
1370 }
1371
1372 /// An untagged post writes no tags key, and a record with no tags key reads as untagged -- the
1373 /// absent key and the empty list saying the one thing.
1374 #[test]
1375 fn test_tags_follow_the_empty_list_idiom_05() -> Outcome<()> {
1376 let rec = Record {
1377 slug: fmt!("on-rent"),
1378 source: fmt!("Words."),
1379 tags: Vec::new(),
1380 ..Default::default()
1381 };
1382 // No tags, no key.
1383 if let Dat::Map(m) = rec.to_dat() {
1384 assert!(m.get(&dat!("tags")).is_none(), "an untagged post wrote a tags key");
1385 }
1386 // A record with no tags key reads as untagged.
1387 let mut m = DaticleMap::new();
1388 m.insert(dat!("slug"), dat!("on-rent"));
1389 m.insert(dat!("source"), dat!("Words."));
1390 let back = res!(Record::from_dat(&Dat::Map(m)));
1391 assert!(back.tags.is_empty(), "a record with no tags key read as tagged");
1392
1393 // Tags given survive the trip, in order.
1394 let tagged = Record { tags: vec![fmt!("rust"), fmt!("web")], ..rec };
1395 let back = res!(Record::from_dat(&tagged.to_dat()));
1396 assert_eq!(back.tags, vec![fmt!("rust"), fmt!("web")]);
1397 Ok(())
1398 }
1399
1400 /// A profile round-trips, and a profile of all defaults writes an empty map and reads back the same:
1401 /// a member who set nothing is stored as nothing, not as a record of blanks.
1402 #[test]
1403 fn test_a_profile_round_trips_and_keeps_the_empty_idiom_06() -> Outcome<()> {
1404 let p = Profile {
1405 name: fmt!("Jason Hoogland"),
1406 avatar: fmt!("/img/jason.jpg"),
1407 bio: fmt!("Writes about rent, housing and the things that follow from them."),
1408 handle: fmt!("k3n8x1qv7m2ab9dz"),
1409 };
1410 assert_eq!(Profile::from_dat(&p.to_dat()), p);
1411
1412 let empty = Profile::default();
1413 if let Dat::Map(m) = empty.to_dat() {
1414 assert!(m.is_empty(), "a default profile wrote a key");
1415 } else {
1416 panic!("a profile did not encode as a map");
1417 }
1418 assert_eq!(Profile::from_dat(&empty.to_dat()), empty);
1419 Ok(())
1420 }
1421
1422 /// An author falls back where a profile named nothing, and the username -- which is the SHA-256 of
1423 /// a passphrase -- reaches none of it: not the name, not the handle, not the initial. A member who
1424 /// has set nothing is Anonymous, under the handle the caller made for the page.
1425 #[test]
1426 fn test_an_author_never_wears_its_username_07() -> Outcome<()> {
1427 let named = Author::from_profile("jason", &Profile {
1428 name: fmt!("Jason"),
1429 avatar: fmt!("/a.jpg"),
1430 bio: fmt!("A line about the writing."),
1431 handle: fmt!("k3n8x1qv7m2ab9dz"),
1432 }, "author-1");
1433 assert_eq!(named.name, "Jason");
1434 assert_eq!(named.avatar, "/a.jpg");
1435 assert_eq!(named.bio, "A line about the writing.");
1436 assert_eq!(named.handle, "k3n8x1qv7m2ab9dz", "a saved handle should be kept");
1437 assert_eq!(named.initial(), "J");
1438
1439 let bare = Author::from_profile("mel99", &Profile::default(), "author-2");
1440 assert_eq!(bare.name, "Anonymous", "an unset profile showed the login username");
1441 assert_eq!(bare.handle, "author-2", "an unset profile took no handle for the page");
1442 assert!(bare.avatar.is_empty());
1443 assert_eq!(bare.initial(), "A");
1444 // Nothing public carries the username in any form.
1445 assert!(!bare.name.contains("mel99") && !bare.handle.contains("mel99"),
1446 "the username reached something a page draws");
1447 Ok(())
1448 }
1449
1450 /// A field a later version wrote does not stop this one reading the record.
1451 #[test]
1452 fn test_an_unknown_field_is_ignored_01() -> Outcome<()> {
1453 let mut m = DaticleMap::new();
1454 m.insert(dat!("slug"), dat!("on-rent"));
1455 m.insert(dat!("kind"), dat!("note"));
1456 m.insert(dat!("state"), dat!("live"));
1457 m.insert(dat!("source"), dat!("Words."));
1458 m.insert(dat!("mood"), dat!("wistful"));
1459 let rec = res!(Record::from_dat(&Dat::Map(m)));
1460 assert_eq!(rec.slug, "on-rent");
1461 assert_eq!(rec.source, "Words.");
1462 assert_eq!(rec.state, PostState::Live);
1463 // A record written before markup was a field carries no markup key, and reads as Markdown --
1464 // which every such post was.
1465 assert_eq!(rec.markup, Markup::Markdown);
1466 Ok(())
1467 }
1468
1469 /// A state this version cannot read is a draft, not a publication. The safe reading of a word
1470 /// nobody understands is that the post is not ready.
1471 #[test]
1472 fn test_an_unreadable_state_is_a_draft_04() -> Outcome<()> {
1473 let mut m = DaticleMap::new();
1474 m.insert(dat!("slug"), dat!("on-rent"));
1475 m.insert(dat!("state"), dat!("scheduled-for-tuesday"));
1476 m.insert(dat!("source"), dat!("Words."));
1477 let rec = res!(Record::from_dat(&Dat::Map(m)));
1478 assert_eq!(rec.state, PostState::Draft);
1479 Ok(())
1480 }
1481
1482 /// A record with no slug is not a record: nothing could address it.
1483 #[test]
1484 fn test_a_record_without_a_slug_is_refused_02() -> Outcome<()> {
1485 let d = create_dat_ordmap(vec![(dat!("source"), dat!("Words."))]);
1486 assert!(Record::from_dat(&d).is_err());
1487 Ok(())
1488 }
1489
1490 /// A key is the prefix and the slug, so a scan for the prefix finds posts and nothing else.
1491 #[test]
1492 fn test_a_key_is_prefixed_03() -> Outcome<()> {
1493 assert_eq!(key_of("on-rent"), dat!("publish/post/on-rent"));
1494 Ok(())
1495 }
1496}