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 | |
| 27 | use 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 | |
| 41 | use oxedyne_fe2o3_core::prelude::*; |
| 42 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 43 | use oxedyne_fe2o3_iop_db::api::{ |
| 44 | Database, |
| 45 | ScanOpts, |
| 46 | }; |
| 47 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 48 | use oxedyne_fe2o3_jdat::{ |
| 49 | prelude::*, |
| 50 | id::NumIdDat, |
| 51 | }; |
| 52 | |
| 53 | use std::collections::BTreeMap; |
| 54 | use std::sync::{ |
| 55 | Arc, |
| 56 | RwLock, |
| 57 | }; |
| 58 | |
| 59 | |
| 60 | pub const KEY_PREFIX: &str = "publish/post/"; |
| 61 | |
| 62 | pub 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. |
| 67 | pub 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. |
| 73 | pub 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. |
| 78 | pub 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. |
| 88 | pub 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. |
| 95 | pub const DECLARE_PREFIX: &str = "publish/declare/"; |
| 96 | |
| 97 | |
| 98 | fn declare_key_of(key: &str) -> Dat { |
| 99 | dat!(fmt!("{}{}", DECLARE_PREFIX, key)) |
| 100 | } |
| 101 | |
| 102 | fn reads_key_of(slug: &str) -> Dat { |
| 103 | let mut s = String::from(READS_PREFIX); |
| 104 | s.push_str(slug); |
| 105 | dat!(s) |
| 106 | } |
| 107 | |
| 108 | fn profile_key_of(username: &str) -> Dat { |
| 109 | let mut s = String::from(PROFILE_PREFIX); |
| 110 | s.push_str(username); |
| 111 | dat!(s) |
| 112 | } |
| 113 | |
| 114 | fn 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)] |
| 128 | pub 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 | |
| 145 | impl 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 | |
| 188 | fn 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)] |
| 199 | pub 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 | |
| 225 | impl 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. |
| 381 | pub 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 | |
| 408 | pub 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. |
| 433 | pub 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. |
| 460 | pub 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. |
| 494 | pub 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. |
| 521 | pub 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. |
| 553 | pub 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. |
| 594 | pub 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. |
| 625 | pub 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. |
| 655 | pub 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. |
| 688 | pub 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. |
| 723 | pub 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 | |
| 743 | pub 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`]. |
| 766 | pub 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. |
| 803 | pub 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. |
| 836 | pub 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. |
| 865 | pub 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 | |
| 891 | fn 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 | |
| 927 | fn 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. |
| 954 | pub 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. |
| 983 | pub 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. |
| 1007 | pub 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. |
| 1062 | pub 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. |
| 1102 | pub 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. |
| 1129 | pub 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. |
| 1153 | fn 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. |
| 1187 | pub 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. |
| 1241 | fn 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. |
| 1253 | pub 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)] |
| 1298 | mod 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 | } |