max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+2174 insertions,
-30 deletions
| @@ -244,6 +244,17 @@ | |||
| 244 | 244 | s3: seed_client(config.storage.as_ref(), &config.host_url).await, | |
| 245 | 245 | public_s3: seed_client(config.public_storage.as_ref(), &config.host_url).await, | |
| 246 | 246 | cdn_base_url: Some(config.cdn_base_url.clone()), | |
| 247 | + | assets: makenotwork::seed::manifest::ResolvedAssets::default(), | |
| 248 | + | }; | |
| 249 | + | ||
| 250 | + | // Fetch the curated public-domain media before touching the database, so | |
| 251 | + | // a manifest that cannot be resolved leaves the existing catalog alone. | |
| 252 | + | let media = match media.with_manifest().await { | |
| 253 | + | Ok(media) => media, | |
| 254 | + | Err(e) => { | |
| 255 | + | tracing::error!(error = %e, "example seed: media manifest could not be resolved"); | |
| 256 | + | std::process::exit(1); | |
| 257 | + | } | |
| 247 | 258 | }; | |
| 248 | 259 | ||
| 249 | 260 | match makenotwork::seed::run(&db, &opts, &media).await { |
| @@ -38,3 +38,72 @@ | |||
| 38 | 38 | } | |
| 39 | 39 | Ok(()) | |
| 40 | 40 | } | |
| 41 | + | ||
| 42 | + | /// Project the media-credits post is published under. Commonshare is the benefit | |
| 43 | + | /// account whose whole premise is the commons, so the attribution for the box's | |
| 44 | + | /// public-domain media belongs there rather than on a creator's storefront. | |
| 45 | + | const CREDITS_PROJECT_SLUG: &str = "commons-sampler"; | |
| 46 | + | ||
| 47 | + | /// Title (and, slugified, the URL) of the media-credits post. | |
| 48 | + | const CREDITS_TITLE: &str = "Media credits and sources"; | |
| 49 | + | ||
| 50 | + | /// Publish a credits post listing every curated asset on the box, with its | |
| 51 | + | /// licence and a link to the page that substantiates the claim. | |
| 52 | + | /// | |
| 53 | + | /// A licence field in `media-manifest.toml` is a note to ourselves; a visitor | |
| 54 | + | /// looking at a CC0 photograph on a public site needs somewhere to go. This is | |
| 55 | + | /// that somewhere. The public `/docs/credits` page is deliberately not it: that | |
| 56 | + | /// page enumerates what the *software* is built on and ships identically to | |
| 57 | + | /// production, where none of this media exists. | |
| 58 | + | /// | |
| 59 | + | /// No-ops when nothing is curated, so an uncurated box does not publish an empty | |
| 60 | + | /// credits page. | |
| 61 | + | pub async fn seed_media_credits( | |
| 62 | + | pool: &sqlx::PgPool, | |
| 63 | + | projects: &[SeededProject], | |
| 64 | + | assets: &super::manifest::ResolvedAssets, | |
| 65 | + | ) -> Result<(), SeedError> { | |
| 66 | + | let list = assets.attribution_markdown(); | |
| 67 | + | if list.is_empty() { | |
| 68 | + | tracing::info!("example seed: no curated media, skipping the credits post"); | |
| 69 | + | return Ok(()); | |
| 70 | + | } | |
| 71 | + | let Some(project) = projects | |
| 72 | + | .iter() | |
| 73 | + | .find(|p| p.spec.slug == CREDITS_PROJECT_SLUG) | |
| 74 | + | else { | |
| 75 | + | tracing::warn!( | |
| 76 | + | slug = CREDITS_PROJECT_SLUG, | |
| 77 | + | "example seed: credits project missing from the roster; credits not published" | |
| 78 | + | ); | |
| 79 | + | return Ok(()); | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | let body = format!( | |
| 83 | + | "Every file on this demo is public domain or CC0. This is where each one \ | |
| 84 | + | came from.\n\n{list}\n\nNothing here is a real listing. The catalog is \ | |
| 85 | + | fabricated for demonstration; only the media is real, and only because \ | |
| 86 | + | its licence allows it." | |
| 87 | + | ); | |
| 88 | + | let slug = slugify(CREDITS_TITLE); | |
| 89 | + | let body_html = render_creator_markdown(&body, project.user_id, ""); | |
| 90 | + | db::blog_posts::create_blog_post( | |
| 91 | + | pool, | |
| 92 | + | project.project.id, | |
| 93 | + | project.user_id, | |
| 94 | + | CREDITS_TITLE, | |
| 95 | + | &slug, | |
| 96 | + | &body, | |
| 97 | + | &body_html, | |
| 98 | + | true, // publish | |
| 99 | + | false, // web_only | |
| 100 | + | false, // show_on_landing | |
| 101 | + | ) | |
| 102 | + | .await?; | |
| 103 | + | tracing::info!( | |
| 104 | + | assets = assets.len(), | |
| 105 | + | slug = %slug, | |
| 106 | + | "example seed: published media credits" | |
| 107 | + | ); | |
| 108 | + | Ok(()) | |
| 109 | + | } |
| @@ -65,6 +65,13 @@ | |||
| 65 | 65 | pub tags: &'static [&'static str], | |
| 66 | 66 | /// Markdown body for Text items; `None` for everything else. | |
| 67 | 67 | pub body: Option<&'static str>, | |
| 68 | + | /// Manifest id of the item's primary file (`media-manifest.toml`). `None` | |
| 69 | + | /// for item types that serve a generated blob rather than a real file, and | |
| 70 | + | /// for Image items, whose cover *is* the work. An id that is declared but | |
| 71 | + | /// uncurated falls back to the generated placeholder. | |
| 72 | + | pub media: Option<&'static str>, | |
| 73 | + | /// Manifest id of the item's cover art. `None` keeps the grey placeholder. | |
| 74 | + | pub cover: Option<&'static str>, | |
| 68 | 75 | } | |
| 69 | 76 | ||
| 70 | 77 | /// The single project a seeded creator owns, plus its content. | |
| @@ -99,6 +106,9 @@ | |||
| 99 | 106 | pub items: &'static [ItemSpec], | |
| 100 | 107 | /// Blog posts to publish on this project (Phase 4). | |
| 101 | 108 | pub blog: &'static [BlogSpec], | |
| 109 | + | /// Manifest id of the project's cover art (`media-manifest.toml`). `None` | |
| 110 | + | /// keeps the grey placeholder. | |
| 111 | + | pub cover: Option<&'static str>, | |
| 102 | 112 | } | |
| 103 | 113 | ||
| 104 | 114 | /// One fabricated creator plus the project they own. | |
| @@ -144,6 +154,7 @@ | |||
| 144 | 154 | Every release is free to share; pay what it's worth if it moves you.", | |
| 145 | 155 | project: ProjectSpec { | |
| 146 | 156 | slug: "restored-reels-vol-1", | |
| 157 | + | cover: Some("openreels-project-cover"), | |
| 147 | 158 | blog: &[ | |
| 148 | 159 | BlogSpec { | |
| 149 | 160 | title: "Vol. 1 is out", | |
| @@ -174,6 +185,8 @@ | |||
| 174 | 185 | pricing: ItemPricing::Pwyw { min: 100 }, | |
| 175 | 186 | tags: &["audio", "audio.format.music"], | |
| 176 | 187 | body: None, | |
| 188 | + | media: Some("restoration-1-audio"), | |
| 189 | + | cover: Some("restoration-1-cover"), | |
| 177 | 190 | }, | |
| 178 | 191 | ItemSpec { | |
| 179 | 192 | title: "Stem Pack: Strings", | |
| @@ -183,6 +196,8 @@ | |||
| 183 | 196 | pricing: ItemPricing::Pwyw { min: 200 }, | |
| 184 | 197 | tags: &["audio.format.samples", "audio.technique.sampling"], | |
| 185 | 198 | body: None, | |
| 199 | + | media: Some("stem-pack-strings-audio"), | |
| 200 | + | cover: Some("stem-pack-strings-cover"), | |
| 186 | 201 | }, | |
| 187 | 202 | ItemSpec { | |
| 188 | 203 | title: "Session Take (Video)", | |
| @@ -192,6 +207,8 @@ | |||
| 192 | 207 | pricing: ItemPricing::Pwyw { min: 100 }, | |
| 193 | 208 | tags: &["video", "video.genre.music-video"], | |
| 194 | 209 | body: None, | |
| 210 | + | media: Some("session-take-video"), | |
| 211 | + | cover: Some("session-take-cover"), | |
| 195 | 212 | }, | |
| 196 | 213 | ], | |
| 197 | 214 | }, | |
| @@ -204,6 +221,7 @@ | |||
| 204 | 221 | lock-in.", | |
| 205 | 222 | project: ProjectSpec { | |
| 206 | 223 | slug: "deskriver-suite", | |
| 224 | + | cover: Some("deskriver-project-cover"), | |
| 207 | 225 | blog: &[ | |
| 208 | 226 | BlogSpec { | |
| 209 | 227 | title: "One download, buy it once", | |
| @@ -234,6 +252,8 @@ | |||
| 234 | 252 | pricing: ItemPricing::BuyOnce(1200), | |
| 235 | 253 | tags: &["software.format.plugin", "software.format.vst3"], | |
| 236 | 254 | body: None, | |
| 255 | + | media: None, | |
| 256 | + | cover: Some("deskriver-focus-cover"), | |
| 237 | 257 | }, | |
| 238 | 258 | ItemSpec { | |
| 239 | 259 | title: "Minimal Preset Pack", | |
| @@ -243,6 +263,8 @@ | |||
| 243 | 263 | pricing: ItemPricing::BuyOnce(500), | |
| 244 | 264 | tags: &["software", "software.platform.macos"], | |
| 245 | 265 | body: None, | |
| 266 | + | media: None, | |
| 267 | + | cover: Some("deskriver-presets-cover"), | |
| 246 | 268 | }, | |
| 247 | 269 | ItemSpec { | |
| 248 | 270 | title: "Weekly-Review Template", | |
| @@ -252,6 +274,8 @@ | |||
| 252 | 274 | pricing: ItemPricing::BuyOnce(300), | |
| 253 | 275 | tags: &["writing.topic.productivity", "software"], | |
| 254 | 276 | body: None, | |
| 277 | + | media: None, | |
| 278 | + | cover: Some("deskriver-template-cover"), | |
| 255 | 279 | }, | |
| 256 | 280 | ItemSpec { | |
| 257 | 281 | title: "Deskriver Utility (Download)", | |
| @@ -265,6 +289,8 @@ | |||
| 265 | 289 | "software.format.desktop", | |
| 266 | 290 | ], | |
| 267 | 291 | body: None, | |
| 292 | + | media: None, | |
| 293 | + | cover: Some("deskriver-utility-cover"), | |
| 268 | 294 | }, | |
| 269 | 295 | ], | |
| 270 | 296 | }, | |
| @@ -276,6 +302,7 @@ | |||
| 276 | 302 | domain. Download, print, remix, no permission needed.", | |
| 277 | 303 | project: ProjectSpec { | |
| 278 | 304 | slug: "cc0-field-library", | |
| 305 | + | cover: Some("stillfield-project-cover"), | |
| 279 | 306 | blog: &[ | |
| 280 | 307 | BlogSpec { | |
| 281 | 308 | title: "Opening the field library", | |
| @@ -302,6 +329,8 @@ | |||
| 302 | 329 | pricing: ItemPricing::Free, | |
| 303 | 330 | tags: &["visual.medium.photography", "visual"], | |
| 304 | 331 | body: None, | |
| 332 | + | media: None, | |
| 333 | + | cover: Some("field-study-01-cover"), | |
| 305 | 334 | }], | |
| 306 | 335 | }, | |
| 307 | 336 | }, | |
| @@ -312,6 +341,7 @@ | |||
| 312 | 341 | readable editions. New volumes for subscribers each month.", | |
| 313 | 342 | project: ProjectSpec { | |
| 314 | 343 | slug: "the-marginalia-reader", | |
| 344 | + | cover: Some("marginalia-project-cover"), | |
| 315 | 345 | blog: &[ | |
| 316 | 346 | BlogSpec { | |
| 317 | 347 | title: "The first monthly volume", | |
| @@ -356,6 +386,8 @@ | |||
| 356 | 386 | pricing: ItemPricing::Free, | |
| 357 | 387 | tags: &["writing.format.essay", "writing.topic.creativity"], | |
| 358 | 388 | body: Some(SLOW_READING_BODY), | |
| 389 | + | media: None, | |
| 390 | + | cover: Some("on-slow-reading-cover"), | |
| 359 | 391 | }, | |
| 360 | 392 | ItemSpec { | |
| 361 | 393 | title: "Typesetting the Commons", | |
| @@ -365,6 +397,8 @@ | |||
| 365 | 397 | pricing: ItemPricing::Free, | |
| 366 | 398 | tags: &["education.format.course", "education.topic.writing"], | |
| 367 | 399 | body: None, | |
| 400 | + | media: None, | |
| 401 | + | cover: Some("typesetting-commons-cover"), | |
| 368 | 402 | }, | |
| 369 | 403 | ], | |
| 370 | 404 | }, | |
| @@ -377,6 +411,7 @@ | |||
| 377 | 411 | commons, not to us.", | |
| 378 | 412 | project: ProjectSpec { | |
| 379 | 413 | slug: "commons-sampler", | |
| 414 | + | cover: Some("commonshare-project-cover"), | |
| 380 | 415 | blog: &[ | |
| 381 | 416 | BlogSpec { | |
| 382 | 417 | title: "What the account funds", | |
| @@ -403,6 +438,8 @@ | |||
| 403 | 438 | pricing: ItemPricing::Free, | |
| 404 | 439 | tags: &["audio", "visual"], | |
| 405 | 440 | body: None, | |
| 441 | + | media: None, | |
| 442 | + | cover: Some("community-bundle-cover"), | |
| 406 | 443 | }], | |
| 407 | 444 | }, | |
| 408 | 445 | }, | |
| @@ -443,3 +480,75 @@ | |||
| 443 | 480 | } | |
| 444 | 481 | Ok(seeded) | |
| 445 | 482 | } | |
| 483 | + | ||
| 484 | + | #[cfg(test)] | |
| 485 | + | mod tests { | |
| 486 | + | use super::*; | |
| 487 | + | use std::collections::HashSet; | |
| 488 | + | ||
| 489 | + | /// Every manifest id the roster names. | |
| 490 | + | fn referenced_ids() -> Vec<&'static str> { | |
| 491 | + | let mut ids = Vec::new(); | |
| 492 | + | for creator in ROSTER { | |
| 493 | + | ids.extend(creator.project.cover); | |
| 494 | + | for item in creator.project.items { | |
| 495 | + | ids.extend(item.media); | |
| 496 | + | ids.extend(item.cover); | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | ids | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | /// The roster and the manifest have to agree, in both directions. A typo in | |
| 503 | + | /// either one is otherwise silent: the slot keeps its grey placeholder and | |
| 504 | + | /// the box looks merely uncurated rather than broken. | |
| 505 | + | #[test] | |
| 506 | + | fn roster_and_manifest_agree() { | |
| 507 | + | let manifest = | |
| 508 | + | super::super::manifest::Manifest::load().expect("the manifest must be loadable"); | |
| 509 | + | let declared: HashSet<&str> = manifest.ids().collect(); | |
| 510 | + | let referenced: HashSet<&str> = referenced_ids().into_iter().collect(); | |
| 511 | + | ||
| 512 | + | let undeclared: Vec<&str> = referenced.difference(&declared).copied().collect(); | |
| 513 | + | assert!( | |
| 514 | + | undeclared.is_empty(), | |
| 515 | + | "roster references ids the manifest does not declare: {undeclared:?}" | |
| 516 | + | ); | |
| 517 | + | ||
| 518 | + | let unused: Vec<&str> = declared.difference(&referenced).copied().collect(); | |
| 519 | + | assert!( | |
| 520 | + | unused.is_empty(), | |
| 521 | + | "manifest declares ids nothing references; curating them would upload \ | |
| 522 | + | files no page shows: {unused:?}" | |
| 523 | + | ); | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | /// Two slots may share an asset, but not an id by accident: a duplicate here | |
| 527 | + | /// usually means a copy-paste, not a deliberate reuse. | |
| 528 | + | #[test] | |
| 529 | + | fn each_id_is_referenced_once() { | |
| 530 | + | let ids = referenced_ids(); | |
| 531 | + | let mut seen = HashSet::with_capacity(ids.len()); | |
| 532 | + | for id in ids { | |
| 533 | + | assert!(seen.insert(id), "asset id {id:?} is referenced twice"); | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | /// Image items serve their cover as the work, so a `media` id on one would | |
| 538 | + | /// upload a file nothing links to. | |
| 539 | + | #[test] | |
| 540 | + | fn image_items_carry_no_separate_media() { | |
| 541 | + | for creator in ROSTER { | |
| 542 | + | for item in creator.project.items { | |
| 543 | + | if matches!(item.item_type, ItemType::Image | ItemType::Text) { | |
| 544 | + | assert!( | |
| 545 | + | item.media.is_none(), | |
| 546 | + | "{:?} item {:?} declares media, which nothing uploads", | |
| 547 | + | item.item_type, | |
| 548 | + | item.title | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | } |
| @@ -4,13 +4,19 @@ | |||
| 4 | 4 | //! file per item through the storage layer and promotes the item to `'clean'`, so | |
| 5 | 5 | //! previews/downloads resolve and the catalog surfaces in discover. | |
| 6 | 6 | //! | |
| 7 | - | //! This pass uploads **generated placeholders** (a silent WAV, a tiny PNG cover, a | |
| 8 | - | //! short byte blob), not real assets, enough to make the pipeline functional and | |
| 9 | - | //! reproducible with no external URLs. Real public-domain / CC0 media swaps in | |
| 10 | - | //! later via a `media-manifest.toml` + fetch step. When storage is unconfigured | |
| 11 | - | //! (testnot's stubbed default until MinIO is stood up), the whole phase is skipped | |
| 12 | - | //! and items stay hidden. | |
| 7 | + | //! Every slot here has two sources. The real one is a curated public-domain / CC0 | |
| 8 | + | //! asset named by `media-manifest.toml` and fetched by [`super::manifest`]. The | |
| 9 | + | //! fallback is a generated placeholder (a silent WAV, a 16x16 grey PNG, a short | |
| 10 | + | //! byte blob), which keeps the pipeline functional and reproducible with no | |
| 11 | + | //! external URLs. Curation is per asset, so the two mix freely: an item with real | |
| 12 | + | //! cover art and a placeholder download is a normal intermediate state. | |
| 13 | + | //! | |
| 14 | + | //! When storage is unconfigured the whole phase is skipped and items stay hidden. | |
| 13 | 15 | ||
| 16 | + | use std::collections::HashMap; | |
| 17 | + | ||
| 18 | + | use super::creators::ItemSpec; | |
| 19 | + | use super::manifest::ResolvedAssets; | |
| 14 | 20 | use super::projects::SeededProject; | |
| 15 | 21 | use super::{SeedError, SeedMedia}; | |
| 16 | 22 | use crate::db::scan_jobs::ScanTargetKind; | |
| @@ -20,7 +26,7 @@ | |||
| 20 | 26 | /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover. | |
| 21 | 27 | static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png"); | |
| 22 | 28 | ||
| 23 | - | /// Attach placeholder media to every seeded item and promote it to visible. | |
| 29 | + | /// Attach media to every seeded item and promote it to visible. | |
| 24 | 30 | /// | |
| 25 | 31 | /// No-ops (leaving items hidden) when the main storage bucket is unconfigured. | |
| 26 | 32 | pub async fn seed_media( | |
| @@ -37,10 +43,16 @@ | |||
| 37 | 43 | // Project cover (best-effort; needs the public/CDN bucket). | |
| 38 | 44 | attach_project_cover(pool, media, project).await?; | |
| 39 | 45 | ||
| 46 | + | // Items come back from the database; their specs carry the manifest ids. | |
| 47 | + | // Titles are unique within a project, which is what makes this join safe. | |
| 48 | + | let specs: HashMap<&str, &ItemSpec> = | |
| 49 | + | project.spec.items.iter().map(|s| (s.title, s)).collect(); | |
| 50 | + | ||
| 40 | 51 | let items = db::items::get_items_by_project(pool, project.project.id).await?; | |
| 41 | 52 | for item in &items { | |
| 42 | - | attach_item_media(pool, s3, project, item).await?; | |
| 43 | - | attach_item_cover(pool, media, project, item).await?; | |
| 53 | + | let spec = specs.get(item.title.as_str()).copied(); | |
| 54 | + | attach_item_media(pool, s3, &media.assets, project, item, spec).await?; | |
| 55 | + | attach_item_cover(pool, media, project, item, spec).await?; | |
| 44 | 56 | } | |
| 45 | 57 | } | |
| 46 | 58 | Ok(()) | |
| @@ -50,15 +62,24 @@ | |||
| 50 | 62 | async fn attach_item_media( | |
| 51 | 63 | pool: &sqlx::PgPool, | |
| 52 | 64 | s3: &dyn StorageBackend, | |
| 65 | + | assets: &ResolvedAssets, | |
| 53 | 66 | project: &SeededProject, | |
| 54 | 67 | item: &db::DbItem, | |
| 68 | + | spec: Option<&ItemSpec>, | |
| 55 | 69 | ) -> Result<(), SeedError> { | |
| 56 | 70 | let user = project.user_id; | |
| 71 | + | // The curated file for this item, when the manifest declares one and it | |
| 72 | + | // resolved. Everything below falls back to a generated placeholder. | |
| 73 | + | let curated = assets.lookup(spec.and_then(|s| s.media)); | |
| 74 | + | ||
| 57 | 75 | match item.item_type { | |
| 58 | 76 | ItemType::Audio => { | |
| 59 | - | let key = S3Client::generate_key(user, item.id, FileType::Audio, "placeholder.wav"); | |
| 60 | - | s3.upload_object(&key, "audio/wav", silent_wav(), None) | |
| 61 | - | .await?; | |
| 77 | + | let (filename, content_type, bytes) = curated.map_or_else( | |
| 78 | + | || ("placeholder.wav", "audio/wav", silent_wav()), | |
| 79 | + | |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), | |
| 80 | + | ); | |
| 81 | + | let key = S3Client::generate_key(user, item.id, FileType::Audio, filename); | |
| 82 | + | s3.upload_object(&key, content_type, bytes, None).await?; | |
| 62 | 83 | db::scanning::promote_gated( | |
| 63 | 84 | pool, | |
| 64 | 85 | ScanTargetKind::Item, | |
| @@ -69,10 +90,13 @@ | |||
| 69 | 90 | .await?; | |
| 70 | 91 | } | |
| 71 | 92 | ItemType::Video => { | |
| 72 | - | let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4"); | |
| 73 | - | // Placeholder bytes, not a playable video; real media swaps in later. | |
| 74 | - | s3.upload_object(&key, "video/mp4", placeholder_blob("video"), None) | |
| 75 | - | .await?; | |
| 93 | + | let (filename, content_type, bytes) = curated.map_or_else( | |
| 94 | + | // Placeholder bytes, not a playable video. | |
| 95 | + | || ("placeholder.mp4", "video/mp4", placeholder_blob("video")), | |
| 96 | + | |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), | |
| 97 | + | ); | |
| 98 | + | let key = S3Client::generate_key(user, item.id, FileType::Video, filename); | |
| 99 | + | s3.upload_object(&key, content_type, bytes, None).await?; | |
| 76 | 100 | db::scanning::promote_gated( | |
| 77 | 101 | pool, | |
| 78 | 102 | ScanTargetKind::Item, | |
| @@ -98,22 +122,38 @@ | |||
| 98 | 122 | | ItemType::Template | |
| 99 | 123 | | ItemType::Digital | |
| 100 | 124 | | ItemType::Bundle => { | |
| 101 | - | let blob = placeholder_blob(&item.title); | |
| 125 | + | let (filename, content_type, blob, notes) = curated.map_or_else( | |
| 126 | + | || { | |
| 127 | + | ( | |
| 128 | + | "placeholder.txt", | |
| 129 | + | "application/octet-stream", | |
| 130 | + | placeholder_blob(&item.title), | |
| 131 | + | "Initial placeholder release.", | |
| 132 | + | ) | |
| 133 | + | }, | |
| 134 | + | |(a, b)| { | |
| 135 | + | ( | |
| 136 | + | a.filename.as_str(), | |
| 137 | + | a.media_type.as_str(), | |
| 138 | + | b.to_vec(), | |
| 139 | + | "Initial release.", | |
| 140 | + | ) | |
| 141 | + | }, | |
| 142 | + | ); | |
| 102 | 143 | let size = blob.len() as i64; | |
| 103 | 144 | let version = db::versions::create_version( | |
| 104 | 145 | pool, | |
| 105 | 146 | item.id, | |
| 106 | 147 | "1.0.0", | |
| 107 | - | Some("Initial placeholder release."), | |
| 148 | + | Some(notes), | |
| 108 | 149 | None, | |
| 109 | 150 | Some(size), | |
| 110 | - | Some("placeholder.txt"), | |
| 151 | + | Some(filename), | |
| 111 | 152 | None, | |
| 112 | 153 | ) | |
| 113 | 154 | .await?; | |
| 114 | - | let key = S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt"); | |
| 115 | - | s3.upload_object(&key, "application/octet-stream", blob, None) | |
| 116 | - | .await?; | |
| 155 | + | let key = S3Client::generate_version_key(user, item.id, version.id, filename); | |
| 156 | + | s3.upload_object(&key, content_type, blob, None).await?; | |
| 117 | 157 | db::scanning::promote_gated( | |
| 118 | 158 | pool, | |
| 119 | 159 | ScanTargetKind::Version, | |
| @@ -127,25 +167,32 @@ | |||
| 127 | 167 | db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; | |
| 128 | 168 | } | |
| 129 | 169 | } | |
| 130 | - | tracing::info!(title = %item.title, item_type = ?item.item_type, "example seed: attached media"); | |
| 170 | + | tracing::info!( | |
| 171 | + | title = %item.title, | |
| 172 | + | item_type = ?item.item_type, | |
| 173 | + | curated = curated.is_some(), | |
| 174 | + | "example seed: attached media" | |
| 175 | + | ); | |
| 131 | 176 | Ok(()) | |
| 132 | 177 | } | |
| 133 | 178 | ||
| 134 | - | /// Attach a placeholder cover to an item. Best-effort: requires the public/CDN | |
| 135 | - | /// bucket + render base, else skipped. | |
| 179 | + | /// Attach a cover to an item, curated when the manifest has one. Best-effort: | |
| 180 | + | /// requires the public/CDN bucket + render base, else skipped. | |
| 136 | 181 | async fn attach_item_cover( | |
| 137 | 182 | pool: &sqlx::PgPool, | |
| 138 | 183 | media: &SeedMedia, | |
| 139 | 184 | project: &SeededProject, | |
| 140 | 185 | item: &db::DbItem, | |
| 186 | + | spec: Option<&ItemSpec>, | |
| 141 | 187 | ) -> Result<(), SeedError> { | |
| 142 | 188 | let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) | |
| 143 | 189 | else { | |
| 144 | 190 | return Ok(()); | |
| 145 | 191 | }; | |
| 146 | - | let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png"); | |
| 192 | + | let (filename, content_type, bytes) = cover_source(&media.assets, spec.and_then(|s| s.cover)); | |
| 193 | + | let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, filename); | |
| 147 | 194 | public | |
| 148 | - | .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None) | |
| 195 | + | .upload_object(&key, content_type, bytes, None) | |
| 149 | 196 | .await?; | |
| 150 | 197 | let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); | |
| 151 | 198 | sqlx::query( | |
| @@ -160,7 +207,7 @@ | |||
| 160 | 207 | Ok(()) | |
| 161 | 208 | } | |
| 162 | 209 | ||
| 163 | - | /// Attach a placeholder cover to a project. Best-effort (see [`attach_item_cover`]). | |
| 210 | + | /// Attach a cover to a project. Best-effort (see [`attach_item_cover`]). | |
| 164 | 211 | async fn attach_project_cover( | |
| 165 | 212 | pool: &sqlx::PgPool, | |
| 166 | 213 | media: &SeedMedia, | |
| @@ -170,9 +217,10 @@ | |||
| 170 | 217 | else { | |
| 171 | 218 | return Ok(()); | |
| 172 | 219 | }; | |
| 173 | - | let key = S3Client::generate_project_image_key(project.project.id, "cover.png"); | |
| 220 | + | let (filename, content_type, bytes) = cover_source(&media.assets, project.spec.cover); | |
| 221 | + | let key = S3Client::generate_project_image_key(project.project.id, filename); | |
| 174 | 222 | public | |
| 175 | - | .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None) | |
| 223 | + | .upload_object(&key, content_type, bytes, None) | |
| 176 | 224 | .await?; | |
| 177 | 225 | let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); | |
| 178 | 226 | sqlx::query( | |
| @@ -187,6 +235,18 @@ | |||
| 187 | 235 | Ok(()) | |
| 188 | 236 | } | |
| 189 | 237 | ||
| 238 | + | /// The bytes to upload for a cover slot: the curated asset when the id resolved, | |
| 239 | + | /// the generated grey PNG otherwise. | |
| 240 | + | fn cover_source<'a>( | |
| 241 | + | assets: &'a ResolvedAssets, | |
| 242 | + | id: Option<&'a str>, | |
| 243 | + | ) -> (&'a str, &'a str, Vec<u8>) { | |
| 244 | + | assets.lookup(id).map_or_else( | |
| 245 | + | || ("cover.png", "image/png", PLACEHOLDER_PNG.to_vec()), | |
| 246 | + | |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()), | |
| 247 | + | ) | |
| 248 | + | } | |
| 249 | + | ||
| 190 | 250 | /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s), a valid, | |
| 191 | 251 | /// tiny audio file for the placeholder audio player. | |
| 192 | 252 | fn silent_wav() -> Vec<u8> { |
| @@ -31,9 +31,11 @@ | |||
| 31 | 31 | //! that. | |
| 32 | 32 | ||
| 33 | 33 | pub mod blog; | |
| 34 | + | pub mod buyer; | |
| 34 | 35 | pub mod creators; | |
| 35 | 36 | pub mod harness; | |
| 36 | 37 | pub mod items; | |
| 38 | + | pub mod manifest; | |
| 37 | 39 | pub mod media; | |
| 38 | 40 | pub mod projects; | |
| 39 | 41 | pub mod social; | |
| @@ -55,6 +57,11 @@ | |||
| 55 | 57 | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 56 | 58 | /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`). | |
| 57 | 59 | pub cdn_base_url: Option<String>, | |
| 60 | + | /// Curated public-domain / CC0 files, already fetched and verified. Empty | |
| 61 | + | /// means every slot falls back to its generated placeholder, which is the | |
| 62 | + | /// state the box was in before any curation. Populate with | |
| 63 | + | /// [`Self::with_manifest`]. | |
| 64 | + | pub assets: manifest::ResolvedAssets, | |
| 58 | 65 | } | |
| 59 | 66 | ||
| 60 | 67 | impl SeedMedia { | |
| @@ -65,8 +72,20 @@ | |||
| 65 | 72 | s3: None, | |
| 66 | 73 | public_s3: None, | |
| 67 | 74 | cdn_base_url: None, | |
| 75 | + | assets: manifest::ResolvedAssets::default(), | |
| 68 | 76 | } | |
| 69 | 77 | } | |
| 78 | + | ||
| 79 | + | /// Load `media-manifest.toml` and fetch everything curated in it. | |
| 80 | + | /// | |
| 81 | + | /// Fails before the seed writes anything when a curated asset will not fetch | |
| 82 | + | /// or does not match its pinned digest, so a broken manifest leaves the | |
| 83 | + | /// existing catalog standing rather than half-replacing it. A manifest with | |
| 84 | + | /// nothing curated yet succeeds and resolves to nothing. | |
| 85 | + | pub async fn with_manifest(mut self) -> Result<Self, manifest::ManifestError> { | |
| 86 | + | self.assets = manifest::Manifest::load()?.resolve().await?; | |
| 87 | + | Ok(self) | |
| 88 | + | } | |
| 70 | 89 | } | |
| 71 | 90 | ||
| 72 | 91 | /// Reserved email domain for every seeded example account. It is the marker the | |
| @@ -121,6 +140,11 @@ | |||
| 121 | 140 | /// other seed test asserts, and a stray env var in a dev shell should not be | |
| 122 | 141 | /// able to fail them. | |
| 123 | 142 | pub harness: Option<harness::HarnessOptions>, | |
| 143 | + | /// Credential for the demo buyer, when the box carries it. `None` skips that | |
| 144 | + | /// phase, and `/library` stays empty of anything to photograph. Carried | |
| 145 | + | /// explicitly for the same reason as `harness`: the buyer adds an account | |
| 146 | + | /// and nine transactions, which every count-asserting test would feel. | |
| 147 | + | pub buyer: Option<buyer::BuyerOptions>, | |
| 124 | 148 | } | |
| 125 | 149 | ||
| 126 | 150 | impl SeedOptions { | |
| @@ -131,6 +155,7 @@ | |||
| 131 | 155 | allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"), | |
| 132 | 156 | host_url: host_url.to_string(), | |
| 133 | 157 | harness: harness::HarnessOptions::from_env(), | |
| 158 | + | buyer: buyer::BuyerOptions::from_env(), | |
| 134 | 159 | } | |
| 135 | 160 | } | |
| 136 | 161 | } | |
| @@ -175,6 +200,7 @@ | |||
| 175 | 200 | ||
| 176 | 201 | // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts). | |
| 177 | 202 | blog::seed_blog(pool, &projects).await?; | |
| 203 | + | blog::seed_media_credits(pool, &projects, &media.assets).await?; | |
| 178 | 204 | social::seed_social(pool, &projects).await?; | |
| 179 | 205 | tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6"); | |
| 180 | 206 | ||
| @@ -191,6 +217,18 @@ | |||
| 191 | 217 | ), | |
| 192 | 218 | } | |
| 193 | 219 | ||
| 220 | + | // Demo buyer: one login-capable account with a purchase history, so the | |
| 221 | + | // landing carousel's third frame (`/library`) has something to photograph. | |
| 222 | + | // Opt-in per box for the same reason as the harness, and after the catalog | |
| 223 | + | // phases because every purchase references an item they created. | |
| 224 | + | match opts.buyer.as_ref() { | |
| 225 | + | Some(buyer_opts) => buyer::seed_buyer(pool, buyer_opts, &projects).await?, | |
| 226 | + | None => tracing::info!( | |
| 227 | + | "example seed: demo-buyer phase skipped ({} must be set)", | |
| 228 | + | buyer::PASSWORD_ENV, | |
| 229 | + | ), | |
| 230 | + | } | |
| 231 | + | ||
| 194 | 232 | Ok(()) | |
| 195 | 233 | } | |
| 196 | 234 |
| @@ -20,6 +20,7 @@ | |||
| 20 | 20 | ||
| 21 | 21 | use crate::harness::db::TestDb; | |
| 22 | 22 | use crate::harness::storage::InMemoryStorage; | |
| 23 | + | use makenotwork::seed::buyer::{self, BUYER_ACCOUNT_ID}; | |
| 23 | 24 | use makenotwork::seed::{self, SeedMedia, SeedOptions}; | |
| 24 | 25 | use makenotwork::storage::StorageBackend; | |
| 25 | 26 | ||
| @@ -34,6 +35,7 @@ | |||
| 34 | 35 | allow_example_seed: true, | |
| 35 | 36 | host_url: "https://testnot.work".to_string(), | |
| 36 | 37 | harness: None, | |
| 38 | + | buyer: None, | |
| 37 | 39 | } | |
| 38 | 40 | } | |
| 39 | 41 | ||
| @@ -291,6 +293,7 @@ | |||
| 291 | 293 | allow_example_seed: true, | |
| 292 | 294 | host_url: "https://makenot.work".to_string(), | |
| 293 | 295 | harness: None, | |
| 296 | + | buyer: None, | |
| 294 | 297 | }, | |
| 295 | 298 | &SeedMedia::none(), | |
| 296 | 299 | ) | |
| @@ -381,6 +384,10 @@ | |||
| 381 | 384 | s3: Some(s3), | |
| 382 | 385 | public_s3: Some(public), | |
| 383 | 386 | cdn_base_url: Some("https://cdn.example.test".to_string()), | |
| 387 | + | // No curated assets: every slot takes its generated placeholder, and the | |
| 388 | + | // test never reaches the network. Resolving the real manifest is a | |
| 389 | + | // separate, network-touching concern (`seed::manifest`). | |
| 390 | + | assets: makenotwork::seed::manifest::ResolvedAssets::default(), | |
| 384 | 391 | } | |
| 385 | 392 | } | |
| 386 | 393 | ||
| @@ -630,3 +637,219 @@ | |||
| 630 | 637 | assert_eq!(harness_accounts, 0, "harness phase must be opt-in"); | |
| 631 | 638 | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 632 | 639 | } | |
| 640 | + | ||
| 641 | + | // ── Demo buyer (GoingsOn 839a8e5a, option B) ──────────────────────────────── | |
| 642 | + | // | |
| 643 | + | // One login-capable account with a purchase history, so the landing carousel's | |
| 644 | + | // third frame has a library to photograph. Opt-in per box, like the harness. | |
| 645 | + | ||
| 646 | + | /// The demo buyer's password for a test run. Not a secret here; on testnot it | |
| 647 | + | /// comes from the box's EnvironmentFile. | |
| 648 | + | const BUYER_PASSWORD: &str = "demo-buyer-test-password"; | |
| 649 | + | ||
| 650 | + | /// Purchases seeded for the demo buyer (see `seed::buyer::PURCHASES`). | |
| 651 | + | const BUYER_PURCHASES: i64 = 9; | |
| 652 | + | ||
| 653 | + | fn buyer_opts() -> buyer::BuyerOptions { | |
| 654 | + | buyer::BuyerOptions { | |
| 655 | + | password: BUYER_PASSWORD.to_string(), | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | fn testnot_opts_with_buyer() -> SeedOptions { | |
| 660 | + | SeedOptions { | |
| 661 | + | buyer: Some(buyer_opts()), | |
| 662 | + | ..testnot_opts() | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | async fn buyer_purchase_count(pool: &sqlx::PgPool) -> i64 { | |
| 667 | + | sqlx::query_scalar( | |
| 668 | + | "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'completed'", | |
| 669 | + | ) | |
| 670 | + | .bind(BUYER_ACCOUNT_ID) | |
| 671 | + | .fetch_one(pool) | |
| 672 | + | .await | |
| 673 | + | .expect("count buyer purchases") | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | #[tokio::test] | |
| 677 | + | async fn demo_buyer_can_log_in_and_owns_a_library() { | |
| 678 | + | let db = TestDb::new().await; | |
| 679 | + | seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx()) | |
| 680 | + | .await | |
| 681 | + | .expect("seed with buyer phase"); | |
| 682 | + | ||
| 683 | + | // The login the capture run performs. | |
| 684 | + | let hash = password_hash_of(&db.pool, BUYER_ACCOUNT_ID).await; | |
| 685 | + | assert!( | |
| 686 | + | makenotwork::auth::verify_password_async(BUYER_PASSWORD.to_string(), hash) | |
| 687 | + | .await | |
| 688 | + | .expect("verify"), | |
| 689 | + | "the demo buyer's password should verify" | |
| 690 | + | ); | |
| 691 | + | ||
| 692 | + | // Not a sandbox account: `SessionUser::check_not_sandbox` would refuse the | |
| 693 | + | // session, and not a creator, because the point is a buyer. | |
| 694 | + | let (is_sandbox, can_create): (bool, bool) = | |
| 695 | + | sqlx::query_as("SELECT is_sandbox, can_create_projects FROM users WHERE id = $1") | |
| 696 | + | .bind(BUYER_ACCOUNT_ID) | |
| 697 | + | .fetch_one(&db.pool) | |
| 698 | + | .await | |
| 699 | + | .expect("buyer account"); | |
| 700 | + | assert!(!is_sandbox, "a sandbox account cannot hold a session"); | |
| 701 | + | assert!(!can_create, "the demo buyer is a buyer"); | |
| 702 | + | ||
| 703 | + | // Every purchase landed, and the library reads them through the `purchases` | |
| 704 | + | // view, which filters on status = 'completed'. | |
| 705 | + | assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES); | |
| 706 | + | let in_view: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM purchases WHERE buyer_id = $1") | |
| 707 | + | .bind(BUYER_ACCOUNT_ID) | |
| 708 | + | .fetch_one(&db.pool) | |
| 709 | + | .await | |
| 710 | + | .expect("purchases view"); | |
| 711 | + | assert_eq!(in_view, BUYER_PURCHASES, "every purchase should be visible"); | |
| 712 | + | ||
| 713 | + | // But not the whole catalog: a library holding all eleven reads as a | |
| 714 | + | // fixture rather than as somebody's shelf. (The constant-only half of this | |
| 715 | + | // is `seed::buyer`'s own unit test; here it is checked against the items | |
| 716 | + | // actually in the database.) | |
| 717 | + | let unbought = count_example_items(&db.pool).await - BUYER_PURCHASES; | |
| 718 | + | assert!( | |
| 719 | + | unbought > 0, | |
| 720 | + | "the buyer should leave some of the catalog unbought" | |
| 721 | + | ); | |
| 722 | + | ||
| 723 | + | // Platform fee is zero on every row. MNW charges 0%, so a demo receipt | |
| 724 | + | // showing anything else would misrepresent the product. | |
| 725 | + | let nonzero_fees: i64 = sqlx::query_scalar( | |
| 726 | + | "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND platform_fee_cents <> 0", | |
| 727 | + | ) | |
| 728 | + | .bind(BUYER_ACCOUNT_ID) | |
| 729 | + | .fetch_one(&db.pool) | |
| 730 | + | .await | |
| 731 | + | .expect("fee check"); | |
| 732 | + | assert_eq!(nonzero_fees, 0); | |
| 733 | + | ||
| 734 | + | // Paid rows recorded what was paid. `get_user_purchases` derives its Free | |
| 735 | + | // badge from `amount_cents = 0`, so a paid item at zero would badge wrong. | |
| 736 | + | let paid: i64 = sqlx::query_scalar( | |
| 737 | + | "SELECT COUNT(*) FROM transactions t JOIN items i ON i.id = t.item_id \ | |
| 738 | + | WHERE t.buyer_id = $1 AND t.amount_cents > 0", | |
| 739 | + | ) | |
| 740 | + | .bind(BUYER_ACCOUNT_ID) | |
| 741 | + | .fetch_one(&db.pool) | |
| 742 | + | .await | |
| 743 | + | .expect("paid count"); | |
| 744 | + | assert!( | |
| 745 | + | paid >= 4, | |
| 746 | + | "the history should include real payments, got {paid}" | |
| 747 | + | ); | |
| 748 | + | ||
| 749 | + | // An active subscription, so the library's subscription block is not empty. | |
| 750 | + | let (tier, status): (String, String) = sqlx::query_as( | |
| 751 | + | "SELECT t.name, s.status FROM subscriptions s \ | |
| 752 | + | JOIN subscription_tiers t ON t.id = s.tier_id WHERE s.subscriber_id = $1", | |
| 753 | + | ) | |
| 754 | + | .bind(BUYER_ACCOUNT_ID) | |
| 755 | + | .fetch_one(&db.pool) | |
| 756 | + | .await | |
| 757 | + | .expect("buyer subscription"); | |
| 758 | + | assert_eq!((tier.as_str(), status.as_str()), ("Patron", "active")); | |
| 759 | + | ||
| 760 | + | // License keys for the project that sells them: one of the things the | |
| 761 | + | // library page shows, and one of the things MNW sells. | |
| 762 | + | let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE owner_id = $1") | |
| 763 | + | .bind(BUYER_ACCOUNT_ID) | |
| 764 | + | .fetch_one(&db.pool) | |
| 765 | + | .await | |
| 766 | + | .expect("license keys"); | |
| 767 | + | assert!(keys >= 1, "a license-keyed purchase should carry a key"); | |
| 768 | + | ||
| 769 | + | // Most rows are marked downloaded, so the "new version" badge is a signal | |
| 770 | + | // rather than the default state of every row. | |
| 771 | + | let downloaded_items: i64 = | |
| 772 | + | sqlx::query_scalar("SELECT COUNT(DISTINCT item_id) FROM user_downloads WHERE user_id = $1") | |
| 773 | + | .bind(BUYER_ACCOUNT_ID) | |
| 774 | + | .fetch_one(&db.pool) | |
| 775 | + | .await | |
| 776 | + | .expect("downloads"); | |
| 777 | + | assert!( | |
| 778 | + | downloaded_items >= 1, | |
| 779 | + | "the buyer should have downloaded something" | |
| 780 | + | ); | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | #[tokio::test] | |
| 784 | + | async fn demo_buyer_history_does_not_duplicate_across_reseeds() { | |
| 785 | + | let db = TestDb::new().await; | |
| 786 | + | seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx()) | |
| 787 | + | .await | |
| 788 | + | .expect("first seed"); | |
| 789 | + | seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx()) | |
| 790 | + | .await | |
| 791 | + | .expect("reseed"); | |
| 792 | + | ||
| 793 | + | // The catalog phases get idempotency from the example-data wipe, but this | |
| 794 | + | // account is keyed by a fixed id and is not in that set, so its history has | |
| 795 | + | // to be cleared explicitly. Without that, every reseed doubles the library. | |
| 796 | + | assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES); | |
| 797 | + | ||
| 798 | + | let subs: i64 = | |
| 799 | + | sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1") | |
| 800 | + | .bind(BUYER_ACCOUNT_ID) | |
| 801 | + | .fetch_one(&db.pool) | |
| 802 | + | .await | |
| 803 | + | .expect("subscription count"); | |
| 804 | + | assert_eq!(subs, 1, "a reseed must not stack subscriptions"); | |
| 805 | + | ||
| 806 | + | // And the id is stable, which is what lets a stored session cookie or a | |
| 807 | + | // scripted login survive a reset. | |
| 808 | + | let id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1") | |
| 809 | + | .bind("demo_collector") | |
| 810 | + | .fetch_one(&db.pool) | |
| 811 | + | .await | |
| 812 | + | .expect("buyer id"); | |
| 813 | + | assert_eq!(id, BUYER_ACCOUNT_ID); | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | #[tokio::test] | |
| 817 | + | async fn without_buyer_options_the_phase_does_not_run() { | |
| 818 | + | let db = TestDb::new().await; | |
| 819 | + | seed::run(&db.pool, &testnot_opts(), &media_ctx()) | |
| 820 | + | .await | |
| 821 | + | .expect("seed without buyer"); | |
| 822 | + | ||
| 823 | + | let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = $1") | |
| 824 | + | .bind(BUYER_ACCOUNT_ID) | |
| 825 | + | .fetch_one(&db.pool) | |
| 826 | + | .await | |
| 827 | + | .expect("count"); | |
| 828 | + | assert_eq!(accounts, 0, "the demo-buyer phase must be opt-in"); | |
| 829 | + | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | #[tokio::test] | |
| 833 | + | async fn the_demo_buyer_changes_nothing_a_visitor_can_see() { | |
| 834 | + | let db = TestDb::new().await; | |
| 835 | + | seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx()) | |
| 836 | + | .await | |
| 837 | + | .expect("seed with buyer phase"); | |
| 838 | + | ||
| 839 | + | // Option B's boundary, asserted rather than merely documented: the buyer is | |
| 840 | + | // a capture credential, not a demo surface. It owns no project, so it never | |
| 841 | + | // appears on /discover or /creators, and it publishes nothing. | |
| 842 | + | let owned_projects: i64 = | |
| 843 | + | sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE user_id = $1") | |
| 844 | + | .bind(BUYER_ACCOUNT_ID) | |
| 845 | + | .fetch_one(&db.pool) | |
| 846 | + | .await | |
| 847 | + | .expect("owned projects"); | |
| 848 | + | assert_eq!(owned_projects, 0); | |
| 849 | + | ||
| 850 | + | // The catalog a visitor sees is exactly what it was without the phase. | |
| 851 | + | assert_eq!( | |
| 852 | + | discover_visible_example_slugs(&db.pool).await.len() as i64, | |
| 853 | + | SEEDED_CREATORS | |
| 854 | + | ); | |
| 855 | + | } |
| @@ -1,0 +1,429 @@ | |||
| 1 | + | #!/usr/bin/env node | |
| 2 | + | // | |
| 3 | + | // Capture the three landing-carousel frames from one run. | |
| 4 | + | // | |
| 5 | + | // The frames have to agree on viewport, scale, theme, crop and aspect ratio. | |
| 6 | + | // Shooting them by hand, or one at a time, is what makes a carousel look wrong, | |
| 7 | + | // so this produces all three from a single browser session and a single set of | |
| 8 | + | // constants. A re-capture after a UI change is one command. | |
| 9 | + | // | |
| 10 | + | // node scripts/capture-landing-carousel.mjs | |
| 11 | + | // BASE=https://testnot.work node scripts/capture-landing-carousel.mjs | |
| 12 | + | // | |
| 13 | + | // Output lands in static/images/shots/. Nothing is swapped into landing.rs | |
| 14 | + | // automatically: the captures are reviewed before they ship (GoingsOn 7f3da540). | |
| 15 | + | // | |
| 16 | + | // SOURCE IS ALWAYS testnot.work. It carries a seeded catalog and no production | |
| 17 | + | // data, so nothing real leaks into a public marketing asset and the shots stay | |
| 18 | + | // reshootable. Never point this at prod or at a dev box. | |
| 19 | + | // | |
| 20 | + | // WHY CDP AND NOT --screenshot. Plain `chrome --screenshot` is viewport-only, so | |
| 21 | + | // a storefront grid taller than the window falls below the fold. Driving the | |
| 22 | + | // DevTools protocol lets each frame be clipped to the same element at the same | |
| 23 | + | // aspect ratio, which is how the three end up agreeing by construction rather | |
| 24 | + | // than by luck. Node 22+ ships a global WebSocket, so this needs no packages. | |
| 25 | + | // | |
| 26 | + | // THE LIBRARY FRAME NEEDS A SESSION. /library is 401 to anonymous, so the run | |
| 27 | + | // logs in as the demo buyer the seed creates (src/seed/buyer.rs, GoingsOn | |
| 28 | + | // 839a8e5a option B). Pass its password, the same value testnot's | |
| 29 | + | // TESTNOT_BUYER_PASSWORD carries: | |
| 30 | + | // | |
| 31 | + | // CAPTURE_BUYER_PASSWORD=... node scripts/capture-landing-carousel.mjs | |
| 32 | + | // | |
| 33 | + | // It logs in through the real form, so the session is a real session and CSRF | |
| 34 | + | // is satisfied by reading the token off the page rather than by faking one. | |
| 35 | + | // CAPTURE_SESSION_COOKIE still works as an escape hatch when you already hold a | |
| 36 | + | // cookie and would rather not put a password in the environment. | |
| 37 | + | // | |
| 38 | + | // With neither, the script captures the first two frames, says plainly that the | |
| 39 | + | // third is missing, and exits non-zero. Two frames out of three is not a | |
| 40 | + | // carousel, and a green exit would say otherwise. | |
| 41 | + | ||
| 42 | + | import { spawn } from 'node:child_process'; | |
| 43 | + | import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; | |
| 44 | + | import { tmpdir } from 'node:os'; | |
| 45 | + | import path from 'node:path'; | |
| 46 | + | import process from 'node:process'; | |
| 47 | + | ||
| 48 | + | const BASE = (process.env.BASE ?? 'https://testnot.work').replace(/\/+$/, ''); | |
| 49 | + | const CHROME = process.env.CHROME ?? `${process.env.HOME}/.local/bin/chrome-for-testing`; | |
| 50 | + | const OUT_DIR = process.env.OUT_DIR ?? path.join(import.meta.dirname, '..', 'static', 'images', 'shots'); | |
| 51 | + | const SESSION_COOKIE = process.env.CAPTURE_SESSION_COOKIE ?? ''; | |
| 52 | + | const SESSION_COOKIE_NAME = process.env.CAPTURE_SESSION_COOKIE_NAME ?? 'id'; | |
| 53 | + | const BUYER_PASSWORD = process.env.CAPTURE_BUYER_PASSWORD ?? ''; | |
| 54 | + | // Matches HANDLE in src/seed/buyer.rs. Override only if that changes. | |
| 55 | + | const BUYER_LOGIN = process.env.CAPTURE_BUYER_LOGIN ?? 'demo_collector'; | |
| 56 | + | const CAN_AUTH = Boolean(SESSION_COOKIE || BUYER_PASSWORD); | |
| 57 | + | ||
| 58 | + | // Shared frame geometry. Every frame is clipped to these, which is the whole | |
| 59 | + | // point of one script: change a number here and all three move together. | |
| 60 | + | const VIEWPORT = { width: 1440, height: 1600 }; | |
| 61 | + | const SCALE = 2; // 2x for a crisp asset on retina displays | |
| 62 | + | const ASPECT = Number(process.env.CAPTURE_ASPECT ?? 16 / 10); // the carousel's frame ratio | |
| 63 | + | // Every frame is cropped to exactly this many CSS pixels wide, then to ASPECT. | |
| 64 | + | // Deriving the width from each page's own container instead produced a 2400px | |
| 65 | + | // storefront next to a 1600px item page: same ratio, different effective zoom, | |
| 66 | + | // so text and controls changed size between slides. A fixed width is what makes | |
| 67 | + | // the three interchangeable. | |
| 68 | + | const CROP_WIDTH = Number(process.env.CAPTURE_WIDTH ?? 1280); | |
| 69 | + | const SETTLE_MS = 1200; // after load + fonts, for lazy images and islands | |
| 70 | + | ||
| 71 | + | // The element each frame is cropped to, unless the frame names its own. | |
| 72 | + | // `.container` is the outer content wrapper on all three pages, so the three | |
| 73 | + | // crops share their left/right margins. | |
| 74 | + | // | |
| 75 | + | // FRAMING IS NOT SETTLED. A trial run on 2026-08-07 showed the storefront's item | |
| 76 | + | // grid falling below a 16:10 crop anchored at the page top, but the page it was | |
| 77 | + | // shot against had placeholder covers and three items in a five-wide grid. Both | |
| 78 | + | // change when the media (GoingsOn ed68814e) and the wider catalog (32f1b8d1) | |
| 79 | + | // land, and re-tuning against the current content would be fitting to a state | |
| 80 | + | // that is about to move. So the knobs are env vars rather than edits: set | |
| 81 | + | // CAPTURE_ASPECT, or a frame's `selector`, once there is real content to judge. | |
| 82 | + | const DEFAULT_SELECTOR = '.container'; | |
| 83 | + | ||
| 84 | + | /** | |
| 85 | + | * The three frames, in carousel order. Paths are resolved against BASE. | |
| 86 | + | * `alt` mirrors what landing.rs claims the frame shows; keep them in step. | |
| 87 | + | */ | |
| 88 | + | const FRAMES = [ | |
| 89 | + | { | |
| 90 | + | name: 'storefront', | |
| 91 | + | path: '/p/restored-reels-vol-1', | |
| 92 | + | alt: "A creator's storefront showing their listed items with prices and cover art", | |
| 93 | + | needsAuth: false, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: 'item', | |
| 97 | + | // Item URLs carry a uuid that changes every reseed, so the item frame | |
| 98 | + | // resolves one from the storefront at capture time rather than pinning an | |
| 99 | + | // id that dies on the next seed. | |
| 100 | + | // | |
| 101 | + | // Two shapes are possible and which one appears depends on the visitor. | |
| 102 | + | // `/i/<id>` is the item page proper; `/purchase/<id>` is what a storefront | |
| 103 | + | // links to for an item the visitor cannot access, which on a no-login demo | |
| 104 | + | // is every paid item. Prefer the item page, take the purchase page when | |
| 105 | + | // that is all there is: it carries the price and the buy button the frame's | |
| 106 | + | // alt text promises. Override with CAPTURE_ITEM_PATH. | |
| 107 | + | resolvePath: async (send) => { | |
| 108 | + | if (process.env.CAPTURE_ITEM_PATH) return process.env.CAPTURE_ITEM_PATH; | |
| 109 | + | const { result } = await send('Runtime.evaluate', { | |
| 110 | + | expression: `(() => { | |
| 111 | + | const pick = (sel) => { | |
| 112 | + | const a = document.querySelector(sel); | |
| 113 | + | return a ? new URL(a.href).pathname : ''; | |
| 114 | + | }; | |
| 115 | + | return pick('a[href^="/i/"]') || pick('a[href^="/purchase/"]'); | |
| 116 | + | })()`, | |
| 117 | + | returnByValue: true, | |
| 118 | + | }); | |
| 119 | + | return result.value; | |
| 120 | + | }, | |
| 121 | + | from: '/p/restored-reels-vol-1', | |
| 122 | + | alt: 'An item page with its price, buy button, and download details', | |
| 123 | + | needsAuth: false, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: 'library', | |
| 127 | + | path: '/library', | |
| 128 | + | alt: 'A buyer library listing the files they have purchased, ready to download', | |
| 129 | + | needsAuth: true, | |
| 130 | + | }, | |
| 131 | + | ]; | |
| 132 | + | ||
| 133 | + | /** Wait, without pulling in a timers import at every call site. */ | |
| 134 | + | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 135 | + | ||
| 136 | + | /** Launch headless Chrome and return its DevTools browser WebSocket URL. */ | |
| 137 | + | async function launchChrome(userDataDir) { | |
| 138 | + | const child = spawn( | |
| 139 | + | CHROME, | |
| 140 | + | [ | |
| 141 | + | '--headless', | |
| 142 | + | '--disable-gpu', | |
| 143 | + | '--hide-scrollbars', | |
| 144 | + | '--no-sandbox', | |
| 145 | + | '--no-first-run', | |
| 146 | + | '--remote-debugging-port=0', | |
| 147 | + | `--user-data-dir=${userDataDir}`, | |
| 148 | + | '--force-color-profile=srgb', | |
| 149 | + | 'about:blank', | |
| 150 | + | ], | |
| 151 | + | { stdio: ['ignore', 'ignore', 'pipe'] }, | |
| 152 | + | ); | |
| 153 | + | ||
| 154 | + | const wsUrl = await new Promise((resolve, reject) => { | |
| 155 | + | let buffered = ''; | |
| 156 | + | const timer = setTimeout( | |
| 157 | + | () => reject(new Error(`Chrome did not report a DevTools endpoint.\n${buffered}`)), | |
| 158 | + | 20_000, | |
| 159 | + | ); | |
| 160 | + | child.stderr.on('data', (chunk) => { | |
| 161 | + | buffered += chunk; | |
| 162 | + | const match = buffered.match(/DevTools listening on (ws:\/\/\S+)/); | |
| 163 | + | if (match) { | |
| 164 | + | clearTimeout(timer); | |
| 165 | + | resolve(match[1]); | |
| 166 | + | } | |
| 167 | + | }); | |
| 168 | + | child.on('exit', (code) => { | |
| 169 | + | clearTimeout(timer); | |
| 170 | + | reject(new Error(`Chrome exited (${code}) before listening.\n${buffered}`)); | |
| 171 | + | }); | |
| 172 | + | }); | |
| 173 | + | ||
| 174 | + | return { child, wsUrl }; | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /** | |
| 178 | + | * A minimal CDP client: id-tagged requests over one socket, flat-mode sessions. | |
| 179 | + | */ | |
| 180 | + | async function connect(wsUrl) { | |
| 181 | + | const socket = new WebSocket(wsUrl); | |
| 182 | + | await new Promise((resolve, reject) => { | |
| 183 | + | socket.addEventListener('open', resolve, { once: true }); | |
| 184 | + | socket.addEventListener('error', () => reject(new Error(`cannot connect to ${wsUrl}`)), { | |
| 185 | + | once: true, | |
| 186 | + | }); | |
| 187 | + | }); | |
| 188 | + | ||
| 189 | + | let nextId = 1; | |
| 190 | + | const pending = new Map(); | |
| 191 | + | const listeners = new Set(); | |
| 192 | + | ||
| 193 | + | socket.addEventListener('message', (event) => { | |
| 194 | + | const message = JSON.parse(event.data); | |
| 195 | + | if (message.id && pending.has(message.id)) { | |
| 196 | + | const { resolve, reject } = pending.get(message.id); | |
| 197 | + | pending.delete(message.id); | |
| 198 | + | if (message.error) reject(new Error(`${message.error.message} (${message.error.code})`)); | |
| 199 | + | else resolve(message.result); | |
| 200 | + | return; | |
| 201 | + | } | |
| 202 | + | for (const listener of listeners) listener(message); | |
| 203 | + | }); | |
| 204 | + | ||
| 205 | + | const send = (method, params = {}, sessionId) => | |
| 206 | + | new Promise((resolve, reject) => { | |
| 207 | + | const id = nextId++; | |
| 208 | + | pending.set(id, { resolve, reject }); | |
| 209 | + | socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); | |
| 210 | + | }); | |
| 211 | + | ||
| 212 | + | const once = (method, sessionId, timeoutMs = 30_000) => | |
| 213 | + | new Promise((resolve, reject) => { | |
| 214 | + | const timer = setTimeout(() => { | |
| 215 | + | listeners.delete(listener); | |
| 216 | + | reject(new Error(`timed out waiting for ${method}`)); | |
| 217 | + | }, timeoutMs); | |
| 218 | + | const listener = (message) => { | |
| 219 | + | if (message.method === method && (!sessionId || message.sessionId === sessionId)) { | |
| 220 | + | clearTimeout(timer); | |
| 221 | + | listeners.delete(listener); | |
| 222 | + | resolve(message.params); | |
| 223 | + | } | |
| 224 | + | }; | |
| 225 | + | listeners.add(listener); | |
| 226 | + | }); | |
| 227 | + | ||
| 228 | + | return { socket, send, once }; | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /** | |
| 232 | + | * Sign in as the demo buyer through the real login form. | |
| 233 | + | * | |
| 234 | + | * Submitting the page's own form would go through htmx, which swaps the | |
| 235 | + | * response into an error div instead of navigating. So this reads the CSRF | |
| 236 | + | * token and field names off that form and posts a plain detached copy, which | |
| 237 | + | * the handler treats as a full-page POST and answers with a redirect. Reading | |
| 238 | + | * the token rather than minting one is the point: the session that results is | |
| 239 | + | * the same session a person would get. | |
| 240 | + | * | |
| 241 | + | * Returns null on success, or a string saying what went wrong. | |
| 242 | + | */ | |
| 243 | + | async function logIn(call, awaitLoad) { | |
| 244 | + | await call('Page.navigate', { url: `${BASE}/login` }); | |
| 245 | + | await awaitLoad(); | |
| 246 | + | ||
| 247 | + | const submitted = await call('Runtime.evaluate', { | |
| 248 | + | expression: `(() => { | |
| 249 | + | const src = document.querySelector('form.login-form'); | |
| 250 | + | if (!src) return 'no login form at /login'; | |
| 251 | + | const form = document.createElement('form'); | |
| 252 | + | form.method = 'post'; | |
| 253 | + | form.action = '/login'; | |
| 254 | + | const put = (name, value) => { | |
| 255 | + | const input = document.createElement('input'); | |
| 256 | + | input.type = 'hidden'; | |
| 257 | + | input.name = name; | |
| 258 | + | input.value = value; | |
| 259 | + | form.appendChild(input); | |
| 260 | + | }; | |
| 261 | + | // Carry every hidden field the real form has (the CSRF token above all), | |
| 262 | + | // then set the credentials. | |
| 263 | + | for (const [name, value] of new FormData(src).entries()) { | |
| 264 | + | if (name !== 'login' && name !== 'password') put(name, value); | |
| 265 | + | } | |
| 266 | + | put('login', ${JSON.stringify(BUYER_LOGIN)}); | |
| 267 | + | put('password', ${JSON.stringify(BUYER_PASSWORD)}); | |
| 268 | + | document.body.appendChild(form); | |
| 269 | + | form.submit(); | |
| 270 | + | return ''; | |
| 271 | + | })()`, | |
| 272 | + | returnByValue: true, | |
| 273 | + | }); | |
| 274 | + | if (submitted.result.value) return submitted.result.value; | |
| 275 | + | await awaitLoad(); | |
| 276 | + | ||
| 277 | + | // Confirm rather than assume: a wrong password re-renders the login form with | |
| 278 | + | // a 200, so status alone proves nothing. | |
| 279 | + | const check = await call('Runtime.evaluate', { | |
| 280 | + | expression: `document.querySelector('form.login-form') ? 'rejected' : ''`, | |
| 281 | + | returnByValue: true, | |
| 282 | + | }); | |
| 283 | + | return check.result.value ? 'credentials rejected' : null; | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /** | |
| 287 | + | * Clip rectangle for a frame: CROP_WIDTH x (CROP_WIDTH / ASPECT), centred | |
| 288 | + | * horizontally on the element and anchored at its top, because the top of these | |
| 289 | + | * pages is the part worth showing. Every frame gets the same rectangle, which is | |
| 290 | + | * what lets the carousel cross-fade without anything appearing to resize. | |
| 291 | + | */ | |
| 292 | + | function clipFor(box) { | |
| 293 | + | const width = CROP_WIDTH; | |
| 294 | + | const height = width / ASPECT; | |
| 295 | + | return { x: Math.max(0, box.x + (box.width - width) / 2), y: box.y, width, height }; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | async function main() { | |
| 299 | + | const userDataDir = await mkdtemp(path.join(tmpdir(), 'mnw-capture-')); | |
| 300 | + | await mkdir(OUT_DIR, { recursive: true }); | |
| 301 | + | const { child, wsUrl } = await launchChrome(userDataDir); | |
| 302 | + | const { socket, send, once } = await connect(wsUrl); | |
| 303 | + | ||
| 304 | + | const missing = []; | |
| 305 | + | try { | |
| 306 | + | const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); | |
| 307 | + | const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); | |
| 308 | + | const call = (method, params) => send(method, params, sessionId); | |
| 309 | + | ||
| 310 | + | await call('Page.enable'); | |
| 311 | + | await call('Network.enable'); | |
| 312 | + | await call('Emulation.setDeviceMetricsOverride', { | |
| 313 | + | width: VIEWPORT.width, | |
| 314 | + | height: VIEWPORT.height, | |
| 315 | + | deviceScaleFactor: SCALE, | |
| 316 | + | mobile: false, | |
| 317 | + | }); | |
| 318 | + | ||
| 319 | + | if (SESSION_COOKIE) { | |
| 320 | + | const { host } = new URL(BASE); | |
| 321 | + | await call('Network.setCookie', { | |
| 322 | + | name: SESSION_COOKIE_NAME, | |
| 323 | + | value: SESSION_COOKIE, | |
| 324 | + | domain: host, | |
| 325 | + | path: '/', | |
| 326 | + | httpOnly: true, | |
| 327 | + | secure: BASE.startsWith('https'), | |
| 328 | + | }); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | if (BUYER_PASSWORD) { | |
| 332 | + | const failure = await logIn(call, () => once('Page.loadEventFired', sessionId)); | |
| 333 | + | if (failure) { | |
| 334 | + | console.error(` login: ${failure}`); | |
| 335 | + | console.error(' the library frame will be skipped'); | |
| 336 | + | } else { | |
| 337 | + | console.error(` login: signed in as ${BUYER_LOGIN}`); | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | for (const frame of FRAMES) { | |
| 342 | + | if (frame.needsAuth && !CAN_AUTH) { | |
| 343 | + | console.error( | |
| 344 | + | ` ${frame.name}: skipped, needs CAPTURE_BUYER_PASSWORD (or CAPTURE_SESSION_COOKIE)`, | |
| 345 | + | ); | |
| 346 | + | missing.push(frame.name); | |
| 347 | + | continue; | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // Resolve the URL, navigating to a source page first when the frame's | |
| 351 | + | // target is only discoverable from one. | |
| 352 | + | let target = frame.path; | |
| 353 | + | if (frame.resolvePath) { | |
| 354 | + | await call('Page.navigate', { url: BASE + frame.from }); | |
| 355 | + | await once('Page.loadEventFired', sessionId); | |
| 356 | + | target = await frame.resolvePath(call); | |
| 357 | + | if (!target) { | |
| 358 | + | console.error(` ${frame.name}: skipped, no item link on ${frame.from}`); | |
| 359 | + | missing.push(frame.name); | |
| 360 | + | continue; | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | const url = BASE + target; | |
| 365 | + | await call('Page.navigate', { url }); | |
| 366 | + | await once('Page.loadEventFired', sessionId); | |
| 367 | + | await call('Runtime.evaluate', { | |
| 368 | + | expression: 'document.fonts.ready.then(() => true)', | |
| 369 | + | awaitPromise: true, | |
| 370 | + | }); | |
| 371 | + | await sleep(SETTLE_MS); | |
| 372 | + | ||
| 373 | + | const selector = frame.selector ?? DEFAULT_SELECTOR; | |
| 374 | + | const { result } = await call('Runtime.evaluate', { | |
| 375 | + | expression: `(() => { | |
| 376 | + | const el = document.querySelector(${JSON.stringify(selector)}); | |
| 377 | + | if (!el) return null; | |
| 378 | + | const r = el.getBoundingClientRect(); | |
| 379 | + | return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; | |
| 380 | + | })()`, | |
| 381 | + | returnByValue: true, | |
| 382 | + | }); | |
| 383 | + | if (!result.value) { | |
| 384 | + | console.error(` ${frame.name}: skipped, ${selector} not present at ${url}`); | |
| 385 | + | missing.push(frame.name); | |
| 386 | + | continue; | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | const clip = { ...clipFor(result.value), scale: SCALE }; | |
| 390 | + | const shot = await call('Page.captureScreenshot', { | |
| 391 | + | format: 'png', | |
| 392 | + | clip, | |
| 393 | + | captureBeyondViewport: true, | |
| 394 | + | fromSurface: true, | |
| 395 | + | }); | |
| 396 | + | const file = path.join(OUT_DIR, `${frame.name}.png`); | |
| 397 | + | const bytes = Buffer.from(shot.data, 'base64'); | |
| 398 | + | await writeFile(file, bytes); | |
| 399 | + | console.error( | |
| 400 | + | ` ${frame.name}: ${Math.round(clip.width * SCALE)}x${Math.round(clip.height * SCALE)}, ` + | |
| 401 | + | `${Math.round(bytes.length / 1024)}KB -> ${path.relative(process.cwd(), file)}`, | |
| 402 | + | ); | |
| 403 | + | } | |
| 404 | + | } finally { | |
| 405 | + | socket.close(); | |
| 406 | + | // Wait for Chrome to actually exit before removing its profile: kill() only | |
| 407 | + | // sends the signal, and a still-flushing process repopulates the directory | |
| 408 | + | // under rm(). A leftover temp profile is not worth failing a good capture | |
| 409 | + | // over, so cleanup errors are swallowed. | |
| 410 | + | const exited = new Promise((resolve) => child.once('exit', resolve)); | |
| 411 | + | child.kill(); | |
| 412 | + | await Promise.race([exited, sleep(5_000)]); | |
| 413 | + | await rm(userDataDir, { recursive: true, force: true }).catch(() => {}); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | if (missing.length > 0) { | |
| 417 | + | console.error( | |
| 418 | + | `\n${missing.length} of ${FRAMES.length} frames missing (${missing.join(', ')}). ` + | |
| 419 | + | 'The carousel needs all three; nothing has been swapped into landing.rs.', | |
| 420 | + | ); | |
| 421 | + | process.exit(1); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | console.error(`\nAll ${FRAMES.length} frames captured from ${BASE}.`); | |
| 425 | + | console.error('Next: review them (GoingsOn 7f3da540), then point landing.rs at the .png files'); | |
| 426 | + | console.error('and delete static/images/shots/placeholder-*.svg.'); | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | await main(); |
| @@ -1,0 +1,545 @@ | |||
| 1 | + | //! The demo buyer: one login-capable account with a purchase history, so | |
| 2 | + | //! `/library` can be photographed. | |
| 3 | + | //! | |
| 4 | + | //! `/library` is 401 to anonymous and every other seeded account is a creator | |
| 5 | + | //! with nothing bought, so a third of the pitch (buyers keep what they bought, | |
| 6 | + | //! one-click export) could not be shown to anyone. This account exists to close | |
| 7 | + | //! that, and nothing more. | |
| 8 | + | //! | |
| 9 | + | //! # Scope, decided 2026-08-05 (GoingsOn 839a8e5a) | |
| 10 | + | //! | |
| 11 | + | //! Two ways in were on the table. **A**: a visitor-facing temp-account path on | |
| 12 | + | //! testnot, which also retires the prod `/sandbox` funnel (Phase 8-9 of | |
| 13 | + | //! `_private/docs/mnw/testnot-example-seed.md`). **B**: a capture-only credential | |
| 14 | + | //! used by the screenshot run and nobody else. B was chosen to unblock the | |
| 15 | + | //! landing carousel, with A to follow on its own schedule. | |
| 16 | + | //! | |
| 17 | + | //! So this is B, and the boundary matters: **nothing on the public demo | |
| 18 | + | //! changes**. No login CTA, no temp-account endpoint, no `ALLOW_TEMP_ACCOUNTS`, | |
| 19 | + | //! and the account is linked from nowhere. An anonymous visitor to testnot still | |
| 20 | + | //! cannot reach `/library`. That is a known, deliberate gap and A is what closes | |
| 21 | + | //! it. | |
| 22 | + | //! | |
| 23 | + | //! # Why it is opt-in, and why it lives in the seed | |
| 24 | + | //! | |
| 25 | + | //! Same two reasons as [`super::harness`]. The password is a credential and does | |
| 26 | + | //! not belong in a public repo, so the phase runs only when [`PASSWORD_ENV`] is | |
| 27 | + | //! set and skips with a warning otherwise. And `mnw-testnot-seed.sh` drops every | |
| 28 | + | //! schema before it reseeds, so an account created by hand is gone on the next | |
| 29 | + | //! reset with nothing in the diff to explain why the capture run started failing | |
| 30 | + | //! at login. | |
| 31 | + | //! | |
| 32 | + | //! The prod guards in [`super::run`] run first: this is unreachable without | |
| 33 | + | //! `ALLOW_EXAMPLE_SEED=1` on an approved host with no real accounts present. | |
| 34 | + | ||
| 35 | + | use chrono::{DateTime, Duration, Utc}; | |
| 36 | + | use uuid::Uuid; | |
| 37 | + | ||
| 38 | + | use super::projects::SeededProject; | |
| 39 | + | use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; | |
| 40 | + | use crate::auth; | |
| 41 | + | use crate::db::{self, ItemId}; | |
| 42 | + | ||
| 43 | + | /// Env var holding the demo buyer's password. Set in testnot's `EnvironmentFile` | |
| 44 | + | /// alongside the other box secrets, never in this repo. | |
| 45 | + | pub const PASSWORD_ENV: &str = "TESTNOT_BUYER_PASSWORD"; | |
| 46 | + | ||
| 47 | + | /// Fixed id, for the same reason the harness accounts have one: a reseed has to | |
| 48 | + | /// reproduce the same account rather than a new one each time. | |
| 49 | + | pub const BUYER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_b001); | |
| 50 | + | ||
| 51 | + | /// Login handle, and the local part of `{handle}@example.test`. | |
| 52 | + | const HANDLE: &str = "demo_collector"; | |
| 53 | + | ||
| 54 | + | /// Shown on the profile and in the header while the capture runs. | |
| 55 | + | const DISPLAY_NAME: &str = "Demo Collector"; | |
| 56 | + | ||
| 57 | + | /// One purchase in the demo buyer's history. | |
| 58 | + | struct PurchaseSpec { | |
| 59 | + | /// Item title, matched against the seeded catalog. Titles are unique within | |
| 60 | + | /// a project and, across this roster, unique overall. | |
| 61 | + | title: &'static str, | |
| 62 | + | /// Days before the seed run to date the purchase. Spread on purpose: a | |
| 63 | + | /// library where every row says the same timestamp reads as a fixture, and | |
| 64 | + | /// the list is ordered by date, so the spread is what gives it a shape. | |
| 65 | + | days_ago: i64, | |
| 66 | + | /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price | |
| 67 | + | /// and free items. A buyer who always pays exactly the floor is a buyer | |
| 68 | + | /// nobody recognises. | |
| 69 | + | tip_cents: i32, | |
| 70 | + | /// Whether the buyer has already downloaded the current version. `false` | |
| 71 | + | /// leaves the "new version" badge lit, which is worth showing on one or two | |
| 72 | + | /// rows and noise on all of them. | |
| 73 | + | downloaded: bool, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Nine of the eleven seeded items, spanning every purchasable type. | |
| 77 | + | /// | |
| 78 | + | /// Not all eleven: a library holding the entire catalog reads as seeded data | |
| 79 | + | /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting | |
| 80 | + | /// the Commons" are deliberately left unbought. | |
| 81 | + | const PURCHASES: &[PurchaseSpec] = &[ | |
| 82 | + | PurchaseSpec { | |
| 83 | + | title: "Restoration No. 1 (Full Mix)", | |
| 84 | + | days_ago: 2, | |
| 85 | + | tip_cents: 400, | |
| 86 | + | downloaded: false, | |
| 87 | + | }, | |
| 88 | + | PurchaseSpec { | |
| 89 | + | title: "Field Study 01 (Print)", | |
| 90 | + | days_ago: 5, | |
| 91 | + | tip_cents: 0, | |
| 92 | + | downloaded: true, | |
| 93 | + | }, | |
| 94 | + | PurchaseSpec { | |
| 95 | + | title: "Deskriver Focus (Plugin)", | |
| 96 | + | days_ago: 11, | |
| 97 | + | tip_cents: 0, | |
| 98 | + | downloaded: false, | |
| 99 | + | }, | |
| 100 | + | PurchaseSpec { | |
| 101 | + | title: "Stem Pack: Strings", | |
| 102 | + | days_ago: 19, | |
| 103 | + | tip_cents: 300, | |
| 104 | + | downloaded: true, | |
| 105 | + | }, | |
| 106 | + | PurchaseSpec { | |
| 107 | + | title: "On Slow Reading", | |
| 108 | + | days_ago: 24, | |
| 109 | + | tip_cents: 0, | |
| 110 | + | downloaded: true, | |
| 111 | + | }, | |
| 112 | + | PurchaseSpec { | |
| 113 | + | title: "Deskriver Utility (Download)", | |
| 114 | + | days_ago: 38, | |
| 115 | + | tip_cents: 0, | |
| 116 | + | downloaded: true, | |
| 117 | + | }, | |
| 118 | + | PurchaseSpec { | |
| 119 | + | title: "Session Take (Video)", | |
| 120 | + | days_ago: 52, | |
| 121 | + | tip_cents: 150, | |
| 122 | + | downloaded: true, | |
| 123 | + | }, | |
| 124 | + | PurchaseSpec { | |
| 125 | + | title: "Community Bundle Vol. 1", | |
| 126 | + | days_ago: 66, | |
| 127 | + | tip_cents: 0, | |
| 128 | + | downloaded: true, | |
| 129 | + | }, | |
| 130 | + | PurchaseSpec { | |
| 131 | + | title: "Minimal Preset Pack", | |
| 132 | + | days_ago: 91, | |
| 133 | + | tip_cents: 0, | |
| 134 | + | downloaded: true, | |
| 135 | + | }, | |
| 136 | + | ]; | |
| 137 | + | ||
| 138 | + | /// The subscription the buyer holds. Marginalia is the roster's one subscription | |
| 139 | + | /// project, and the library renders subscriptions beside purchases, so without | |
| 140 | + | /// this the frame shows half of what the page does. | |
| 141 | + | const SUBSCRIBED_PROJECT_SLUG: &str = "the-marginalia-reader"; | |
| 142 | + | ||
| 143 | + | /// Which tier. The middle one: the cheapest reads as a trial and the dearest as | |
| 144 | + | /// a plant. | |
| 145 | + | const SUBSCRIBED_TIER_NAME: &str = "Patron"; | |
| 146 | + | ||
| 147 | + | /// The one value the phase needs from the environment. | |
| 148 | + | #[derive(Clone)] | |
| 149 | + | pub struct BuyerOptions { | |
| 150 | + | /// The demo buyer's password, used by the capture run to log in. | |
| 151 | + | pub password: String, | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Hand-written so the password cannot reach a log through | |
| 155 | + | /// [`super::SeedOptions`]'s derived `Debug`. | |
| 156 | + | impl std::fmt::Debug for BuyerOptions { | |
| 157 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 158 | + | f.debug_struct("BuyerOptions") | |
| 159 | + | .field("password", &"<redacted>") | |
| 160 | + | .finish() | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | impl BuyerOptions { | |
| 165 | + | /// Read the password from the environment; `None` when unset or empty, which | |
| 166 | + | /// is the signal to skip the phase. | |
| 167 | + | pub fn from_env() -> Option<Self> { | |
| 168 | + | let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?; | |
| 169 | + | Some(Self { password }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Seed the demo buyer, their purchase history, and their subscription. | |
| 174 | + | /// | |
| 175 | + | /// Called from [`super::run`] after the catalog phases, because every purchase | |
| 176 | + | /// references an item those phases created. Re-runnable: the account is an | |
| 177 | + | /// upsert on its fixed id, and the rows hanging off it are rebuilt from scratch | |
| 178 | + | /// each run so a reseed cannot accumulate duplicates. | |
| 179 | + | pub async fn seed_buyer( | |
| 180 | + | pool: &sqlx::PgPool, | |
| 181 | + | opts: &BuyerOptions, | |
| 182 | + | projects: &[SeededProject], | |
| 183 | + | ) -> Result<(), SeedError> { | |
| 184 | + | let password_hash = auth::hash_password_async(opts.password.clone()).await?; | |
| 185 | + | seed_account(pool, &password_hash).await?; | |
| 186 | + | clear_prior_history(pool).await?; | |
| 187 | + | ||
| 188 | + | let mut bought = 0; | |
| 189 | + | for project in projects { | |
| 190 | + | let items = db::items::get_items_by_project(pool, project.project.id).await?; | |
| 191 | + | for item in &items { | |
| 192 | + | let Some(spec) = PURCHASES.iter().find(|p| p.title == item.title) else { | |
| 193 | + | continue; | |
| 194 | + | }; | |
| 195 | + | let purchased_at = Utc::now() - Duration::days(spec.days_ago); | |
| 196 | + | let amount_cents = amount_for(item, spec); | |
| 197 | + | let transaction_id = | |
| 198 | + | record_purchase(pool, project, item, amount_cents, purchased_at).await?; | |
| 199 | + | if project.spec.features.contains(&"license_keys") { | |
| 200 | + | issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?; | |
| 201 | + | } | |
| 202 | + | if spec.downloaded { | |
| 203 | + | record_download(pool, item.id, purchased_at).await?; | |
| 204 | + | } | |
| 205 | + | bought += 1; | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if bought != PURCHASES.len() { | |
| 210 | + | // A renamed item silently drops a row from the library, and the frame | |
| 211 | + | // just looks thin. Say so instead. | |
| 212 | + | tracing::warn!( | |
| 213 | + | matched = bought, | |
| 214 | + | expected = PURCHASES.len(), | |
| 215 | + | "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs" | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | seed_subscription(pool, projects).await?; | |
| 220 | + | ||
| 221 | + | tracing::warn!( | |
| 222 | + | user_id = %BUYER_ACCOUNT_ID, | |
| 223 | + | handle = HANDLE, | |
| 224 | + | purchases = bought, | |
| 225 | + | "example seed: demo buyer seeded (login-capable, for the capture run only)" | |
| 226 | + | ); | |
| 227 | + | Ok(()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the | |
| 231 | + | /// spec's tip, or nothing for a free item. | |
| 232 | + | /// | |
| 233 | + | /// Reading it off the item rather than hardcoding it keeps the library's badges | |
| 234 | + | /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`, | |
| 235 | + | /// so a paid item recorded at zero would badge wrong. | |
| 236 | + | fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 { | |
| 237 | + | if item.pwyw_enabled { | |
| 238 | + | return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents; | |
| 239 | + | } | |
| 240 | + | item.price_cents | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// Insert (or reset) the buyer account at its fixed id. | |
| 244 | + | /// | |
| 245 | + | /// `is_sandbox` stays FALSE: a sandbox account is refused by | |
| 246 | + | /// `SessionUser::check_not_sandbox`, and this one has to hold a real session. | |
| 247 | + | /// `can_create_projects` stays FALSE, because the whole point is a buyer. | |
| 248 | + | async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> { | |
| 249 | + | let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-")); | |
| 250 | + | sqlx::query( | |
| 251 | + | r" | |
| 252 | + | INSERT INTO users ( | |
| 253 | + | id, username, email, password_hash, display_name, | |
| 254 | + | can_create_projects, email_verified | |
| 255 | + | ) | |
| 256 | + | VALUES ($1, $2, $3, $4, $5, FALSE, TRUE) | |
| 257 | + | ON CONFLICT (id) DO UPDATE SET | |
| 258 | + | username = EXCLUDED.username, | |
| 259 | + | email = EXCLUDED.email, | |
| 260 | + | password_hash = EXCLUDED.password_hash, | |
| 261 | + | display_name = EXCLUDED.display_name, | |
| 262 | + | -- A capture run that tripped the lockout must not survive the | |
| 263 | + | -- reseed: the point of the reset is a known state. | |
| 264 | + | failed_login_attempts = 0, | |
| 265 | + | locked_until = NULL, | |
| 266 | + | suspended_at = NULL, | |
| 267 | + | deactivated_at = NULL | |
| 268 | + | ", | |
| 269 | + | ) | |
| 270 | + | .bind(BUYER_ACCOUNT_ID) | |
| 271 | + | .bind(HANDLE) | |
| 272 | + | .bind(&email) | |
| 273 | + | .bind(password_hash) | |
| 274 | + | .bind(DISPLAY_NAME) | |
| 275 | + | .execute(pool) | |
| 276 | + | .await?; | |
| 277 | + | Ok(()) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Drop everything hanging off the buyer from a previous run. | |
| 281 | + | /// | |
| 282 | + | /// The catalog phases get idempotency from the example-data wipe, which deletes | |
| 283 | + | /// the creators and cascades their items. This account is not in that set (it is | |
| 284 | + | /// keyed by id, not created per run), so its purchases would otherwise survive a | |
| 285 | + | /// reseed pointing at items that no longer exist. | |
| 286 | + | async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> { | |
| 287 | + | // license_keys and user_downloads cascade from transactions and items | |
| 288 | + | // respectively, but the buyer's own rows are keyed by owner/user, so clear | |
| 289 | + | // them explicitly rather than relying on which side of the join went first. | |
| 290 | + | for statement in [ | |
| 291 | + | "DELETE FROM user_downloads WHERE user_id = $1", | |
| 292 | + | "DELETE FROM license_keys WHERE owner_id = $1", | |
| 293 | + | "DELETE FROM subscriptions WHERE subscriber_id = $1", | |
| 294 | + | "DELETE FROM transactions WHERE buyer_id = $1", | |
| 295 | + | ] { | |
| 296 | + | sqlx::query(statement) | |
| 297 | + | .bind(BUYER_ACCOUNT_ID) | |
| 298 | + | .execute(pool) | |
| 299 | + | .await?; | |
| 300 | + | } | |
| 301 | + | Ok(()) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /// Record one completed transaction, which is what the `purchases` view reads. | |
| 305 | + | /// | |
| 306 | + | /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform | |
| 307 | + | /// fee is 0%, so a demo receipt showing anything else would misrepresent the | |
| 308 | + | /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot | |
| 309 | + | /// talks to live Stripe, and the prefix makes a stray row obvious. | |
| 310 | + | /// | |
| 311 | + | /// The currency comes off the seller rather than being hardcoded. `transactions` | |
| 312 | + | /// constrains it to a lowercase supported code, and the real payment path | |
| 313 | + | /// settles in the seller's currency, so reading it keeps a demo receipt true to | |
| 314 | + | /// what a live one would say if a seeded creator is ever given a non-USD | |
| 315 | + | /// settlement currency. | |
| 316 | + | async fn record_purchase( | |
| 317 | + | pool: &sqlx::PgPool, | |
| 318 | + | project: &SeededProject, | |
| 319 | + | item: &db::DbItem, | |
| 320 | + | amount_cents: i32, | |
| 321 | + | purchased_at: DateTime<Utc>, | |
| 322 | + | ) -> Result<Uuid, SeedError> { | |
| 323 | + | let (seller_username, currency): (String, String) = sqlx::query_as( | |
| 324 | + | "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1", | |
| 325 | + | ) | |
| 326 | + | .bind(project.user_id) | |
| 327 | + | .fetch_one(pool) | |
| 328 | + | .await?; | |
| 329 | + | ||
| 330 | + | let transaction_id: Uuid = sqlx::query_scalar( | |
| 331 | + | r" | |
| 332 | + | INSERT INTO transactions ( | |
| 333 | + | buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, | |
| 334 | + | currency, status, stripe_payment_intent_id, | |
| 335 | + | created_at, completed_at, item_title, seller_username | |
| 336 | + | ) | |
| 337 | + | VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9) | |
| 338 | + | RETURNING id | |
| 339 | + | ", | |
| 340 | + | ) | |
| 341 | + | .bind(BUYER_ACCOUNT_ID) | |
| 342 | + | .bind(project.user_id) | |
| 343 | + | .bind(item.id) | |
| 344 | + | .bind(amount_cents) | |
| 345 | + | .bind(¤cy) | |
| 346 | + | .bind(format!("pi_demo_{}", item.id)) | |
| 347 | + | .bind(purchased_at) | |
| 348 | + | .bind(&item.title) | |
| 349 | + | .bind(&seller_username) | |
| 350 | + | .fetch_one(pool) | |
| 351 | + | .await?; | |
| 352 | + | ||
| 353 | + | Ok(transaction_id) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | /// Issue a license key for a purchase from a project that sells them. | |
| 357 | + | /// | |
| 358 | + | /// License keys are one of the things the library page shows and one of the | |
| 359 | + | /// things MNW sells, so the frame is worth more with one visible. The code shape | |
| 360 | + | /// mirrors the real generator's grouping without reusing it: this is display | |
| 361 | + | /// data on a demo box, not a key anything validates. | |
| 362 | + | async fn issue_license_key( | |
| 363 | + | pool: &sqlx::PgPool, | |
| 364 | + | item_id: ItemId, | |
| 365 | + | transaction_id: Uuid, | |
| 366 | + | days_ago: i64, | |
| 367 | + | ) -> Result<(), SeedError> { | |
| 368 | + | // Derived from the item id so a reseed of the same catalog produces the same | |
| 369 | + | // key, and no two items collide on the UNIQUE constraint. | |
| 370 | + | let raw = item_id.as_uuid().simple().to_string().to_uppercase(); | |
| 371 | + | let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]); | |
| 372 | + | sqlx::query( | |
| 373 | + | r" | |
| 374 | + | INSERT INTO license_keys ( | |
| 375 | + | item_id, owner_id, transaction_id, key_code, max_activations, created_at | |
| 376 | + | ) | |
| 377 | + | VALUES ($1, $2, $3, $4, 3, $5) | |
| 378 | + | ON CONFLICT (key_code) DO NOTHING | |
| 379 | + | ", | |
| 380 | + | ) | |
| 381 | + | .bind(item_id) | |
| 382 | + | .bind(BUYER_ACCOUNT_ID) | |
| 383 | + | .bind(transaction_id) | |
| 384 | + | .bind(&key_code) | |
| 385 | + | .bind(Utc::now() - Duration::days(days_ago)) | |
| 386 | + | .execute(pool) | |
| 387 | + | .await?; | |
| 388 | + | Ok(()) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /// Mark every current version of an item as already downloaded. | |
| 392 | + | /// | |
| 393 | + | /// `get_user_purchases` lights its "new version" badge when the item has more | |
| 394 | + | /// versions than the buyer has downloads, so this is what turns the badge off. | |
| 395 | + | /// Leaving it on for a row or two is the point; leaving it on for all nine would | |
| 396 | + | /// read as a broken library rather than a used one. | |
| 397 | + | async fn record_download( | |
| 398 | + | pool: &sqlx::PgPool, | |
| 399 | + | item_id: ItemId, | |
| 400 | + | downloaded_at: DateTime<Utc>, | |
| 401 | + | ) -> Result<(), SeedError> { | |
| 402 | + | sqlx::query( | |
| 403 | + | r" | |
| 404 | + | INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at) | |
| 405 | + | SELECT $1, $2, v.id, $3 | |
| 406 | + | FROM versions v | |
| 407 | + | WHERE v.item_id = $2 AND v.s3_key IS NOT NULL | |
| 408 | + | ON CONFLICT DO NOTHING | |
| 409 | + | ", | |
| 410 | + | ) | |
| 411 | + | .bind(BUYER_ACCOUNT_ID) | |
| 412 | + | .bind(item_id) | |
| 413 | + | .bind(downloaded_at) | |
| 414 | + | .execute(pool) | |
| 415 | + | .await?; | |
| 416 | + | Ok(()) | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | /// Give the buyer an active subscription to the roster's subscription project. | |
| 420 | + | /// | |
| 421 | + | /// `get_user_subscriptions_with_details` joins the tier and the project and | |
| 422 | + | /// filters on nothing but the subscriber, so an `active` row with a future | |
| 423 | + | /// `current_period_end` is the whole requirement. No Stripe call: the ids are | |
| 424 | + | /// fabricated and prefixed, as with the purchases. | |
| 425 | + | async fn seed_subscription( | |
| 426 | + | pool: &sqlx::PgPool, | |
| 427 | + | projects: &[SeededProject], | |
| 428 | + | ) -> Result<(), SeedError> { | |
| 429 | + | let Some(project) = projects | |
| 430 | + | .iter() | |
| 431 | + | .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG) | |
| 432 | + | else { | |
| 433 | + | tracing::warn!( | |
| 434 | + | slug = SUBSCRIBED_PROJECT_SLUG, | |
| 435 | + | "example seed: subscription project missing; demo buyer has no subscription" | |
| 436 | + | ); | |
| 437 | + | return Ok(()); | |
| 438 | + | }; | |
| 439 | + | ||
| 440 | + | let tier_id: Option<Uuid> = | |
| 441 | + | sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2") | |
| 442 | + | .bind(project.project.id) | |
| 443 | + | .bind(SUBSCRIBED_TIER_NAME) | |
| 444 | + | .fetch_optional(pool) | |
| 445 | + | .await?; | |
| 446 | + | let Some(tier_id) = tier_id else { | |
| 447 | + | tracing::warn!( | |
| 448 | + | tier = SUBSCRIBED_TIER_NAME, | |
| 449 | + | "example seed: subscription tier missing; demo buyer has no subscription" | |
| 450 | + | ); | |
| 451 | + | return Ok(()); | |
| 452 | + | }; | |
| 453 | + | ||
| 454 | + | // Started three months back, renewing in a fortnight: a subscription that is | |
| 455 | + | // established rather than brand new, and visibly current. | |
| 456 | + | let started = Utc::now() - Duration::days(92); | |
| 457 | + | let period_start = Utc::now() - Duration::days(16); | |
| 458 | + | let period_end = Utc::now() + Duration::days(14); | |
| 459 | + | sqlx::query( | |
| 460 | + | r" | |
| 461 | + | INSERT INTO subscriptions ( | |
| 462 | + | subscriber_id, tier_id, project_id, stripe_subscription_id, | |
| 463 | + | stripe_customer_id, status, current_period_start, current_period_end, | |
| 464 | + | created_at | |
| 465 | + | ) | |
| 466 | + | VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8) | |
| 467 | + | ", | |
| 468 | + | ) | |
| 469 | + | .bind(BUYER_ACCOUNT_ID) | |
| 470 | + | .bind(tier_id) | |
| 471 | + | .bind(project.project.id) | |
| 472 | + | .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}")) | |
| 473 | + | .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}")) | |
| 474 | + | .bind(period_start) | |
| 475 | + | .bind(period_end) | |
| 476 | + | .bind(started) | |
| 477 | + | .execute(pool) | |
| 478 | + | .await?; | |
| 479 | + | Ok(()) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[cfg(test)] | |
| 483 | + | mod tests { | |
| 484 | + | use super::*; | |
| 485 | + | ||
| 486 | + | #[test] | |
| 487 | + | fn buyer_email_stays_inside_the_reserved_domain() { | |
| 488 | + | // The seed's reset only deletes @example.test accounts, and its third | |
| 489 | + | // guard refuses to run at all when a non-example account exists. A | |
| 490 | + | // handle producing an address outside the domain would both survive | |
| 491 | + | // resets and block the next seed. | |
| 492 | + | let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-")); | |
| 493 | + | assert!(email.ends_with("@example.test"), "{email}"); | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | #[test] | |
| 497 | + | fn the_buyer_does_not_own_the_whole_catalog() { | |
| 498 | + | // Eleven items are seeded. A library holding all of them reads as a | |
| 499 | + | // fixture; this is the assertion that keeps someone from "fixing" the | |
| 500 | + | // gap by adding the last two. |
Lines truncated
| @@ -1,0 +1,448 @@ | |||
| 1 | + | //! Real public-domain / CC0 media for the example seed: the manifest that names | |
| 2 | + | //! each asset, and the fetch step that turns it into bytes. | |
| 3 | + | //! | |
| 4 | + | //! The seed's other phases are pure data in `creators.rs`. Media cannot be, | |
| 5 | + | //! because a real asset is a file somebody has to choose, license-check, and | |
| 6 | + | //! host. So the choosing lives in `media-manifest.toml` (curated by hand, checked | |
| 7 | + | //! into the repo, embedded in the binary) and everything downstream of it is | |
| 8 | + | //! mechanical: fetch by URL, verify the digest, upload through the storage layer, | |
| 9 | + | //! record the attribution. | |
| 10 | + | //! | |
| 11 | + | //! # Curation state is per asset, not per manifest | |
| 12 | + | //! | |
| 13 | + | //! An entry with no `url` is *declared but not yet curated*. It resolves to | |
| 14 | + | //! nothing and [`super::media`] falls back to the generated placeholder for that | |
| 15 | + | //! one slot. So the manifest doubles as the curation checklist: every id the seed | |
| 16 | + | //! can use is listed, and the ones still carrying grey boxes are exactly the ones | |
| 17 | + | //! with an empty `url`. Partial curation is a first-class state, and the box | |
| 18 | + | //! stays seedable throughout. | |
| 19 | + | //! | |
| 20 | + | //! # Failure is loud, and it happens before any write | |
| 21 | + | //! | |
| 22 | + | //! A curated asset that will not fetch, or that fetches to the wrong bytes, fails | |
| 23 | + | //! the whole seed. It does not silently degrade to the placeholder: that is the | |
| 24 | + | //! failure mode `sando/deploy/mnw-testnot-smoke.sh` exists to catch, and a demo | |
| 25 | + | //! box that quietly reverts to grey squares is worse than one that refuses to | |
| 26 | + | //! reseed. Resolution runs to completion before the seed touches the database, so | |
| 27 | + | //! a failure leaves the previous catalog standing. | |
| 28 | + | ||
| 29 | + | use std::collections::HashMap; | |
| 30 | + | use std::path::PathBuf; | |
| 31 | + | use std::time::Duration; | |
| 32 | + | ||
| 33 | + | use serde::Deserialize; | |
| 34 | + | ||
| 35 | + | /// The curated manifest, embedded so a deployed binary carries it. Override with | |
| 36 | + | /// [`MANIFEST_PATH_ENV`] when iterating locally. | |
| 37 | + | const EMBEDDED_MANIFEST: &str = include_str!("media-manifest.toml"); | |
| 38 | + | ||
| 39 | + | /// Path to a manifest file to read instead of the embedded copy. | |
| 40 | + | pub const MANIFEST_PATH_ENV: &str = "SEED_MEDIA_MANIFEST"; | |
| 41 | + | ||
| 42 | + | /// Directory holding fetched assets between runs. Defaults to | |
| 43 | + | /// `{temp_dir}/mnw-seed-media`. | |
| 44 | + | pub const CACHE_DIR_ENV: &str = "SEED_MEDIA_CACHE"; | |
| 45 | + | ||
| 46 | + | /// How long a single asset fetch may take. | |
| 47 | + | const FETCH_TIMEOUT: Duration = Duration::from_mins(1); | |
| 48 | + | ||
| 49 | + | /// Refuse an asset larger than this. Demo media, not a distribution channel; a | |
| 50 | + | /// multi-gigabyte URL in the manifest is a mistake, not a big file. | |
| 51 | + | const MAX_ASSET_BYTES: u64 = 64 * 1024 * 1024; | |
| 52 | + | ||
| 53 | + | /// One curated file: where it comes from, what it is, and who to credit. | |
| 54 | + | #[derive(Debug, Clone, Deserialize)] | |
| 55 | + | pub struct Asset { | |
| 56 | + | /// Stable id referenced from `creators.rs` (`ItemSpec::media`, | |
| 57 | + | /// `ItemSpec::cover`, `ProjectSpec::cover`). | |
| 58 | + | pub id: String, | |
| 59 | + | /// Direct download URL. Absent = declared but not yet curated; the slot keeps | |
| 60 | + | /// its generated placeholder. | |
| 61 | + | #[serde(default)] | |
| 62 | + | pub url: Option<String>, | |
| 63 | + | /// Lowercase hex SHA-256 of the fetched bytes. Absent on a curated asset is | |
| 64 | + | /// allowed but warned about: the run logs the digest it saw so it can be | |
| 65 | + | /// pinned. Present and mismatched is a hard failure. | |
| 66 | + | #[serde(default)] | |
| 67 | + | pub sha256: Option<String>, | |
| 68 | + | /// Content type to upload under (`audio/wav`, `image/jpeg`, ...). | |
| 69 | + | pub media_type: String, | |
| 70 | + | /// Filename to store the object as, and to show on the download. | |
| 71 | + | pub filename: String, | |
| 72 | + | /// SPDX-ish licence string. Public domain / CC0 only; see the manifest header. | |
| 73 | + | pub license: String, | |
| 74 | + | /// Human title of the work, for the credits page. | |
| 75 | + | pub title: String, | |
| 76 | + | /// Creator to credit. `None` for anonymous or corporate-anonymous works. | |
| 77 | + | #[serde(default)] | |
| 78 | + | pub author: Option<String>, | |
| 79 | + | /// The page a visitor can reach to verify the licence claim. Not the direct | |
| 80 | + | /// download URL, the landing page. | |
| 81 | + | pub source: String, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | impl Asset { | |
| 85 | + | /// Whether this asset has been curated (has somewhere to fetch from). | |
| 86 | + | fn is_curated(&self) -> bool { | |
| 87 | + | self.url.as_deref().is_some_and(|u| !u.trim().is_empty()) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Shape of the TOML file: a flat array of assets. | |
| 92 | + | #[derive(Debug, Deserialize)] | |
| 93 | + | struct ManifestFile { | |
| 94 | + | #[serde(default)] | |
| 95 | + | asset: Vec<Asset>, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Why the manifest could not be loaded, or an asset could not be resolved. | |
| 99 | + | #[derive(Debug, thiserror::Error)] | |
| 100 | + | pub enum ManifestError { | |
| 101 | + | /// The override path was set but unreadable. | |
| 102 | + | #[error("cannot read media manifest at {path}: {source}")] | |
| 103 | + | Read { | |
| 104 | + | path: PathBuf, | |
| 105 | + | #[source] | |
| 106 | + | source: std::io::Error, | |
| 107 | + | }, | |
| 108 | + | /// The manifest is not valid TOML, or does not match the schema. | |
| 109 | + | #[error("media manifest is not valid: {0}")] | |
| 110 | + | Parse(#[from] toml::de::Error), | |
| 111 | + | /// Two entries claim the same id, so a reference is ambiguous. | |
| 112 | + | #[error("media manifest declares id {0:?} more than once")] | |
| 113 | + | DuplicateId(String), | |
| 114 | + | /// One or more curated assets failed to fetch or verify. Collected rather | |
| 115 | + | /// than returned one at a time: a curation pass wants the whole list. | |
| 116 | + | #[error("{} media asset(s) failed to resolve:\n{}", .0.len(), .0.join("\n"))] | |
| 117 | + | Unresolved(Vec<String>), | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// The parsed manifest, indexed by asset id. | |
| 121 | + | #[derive(Debug, Default)] | |
| 122 | + | pub struct Manifest { | |
| 123 | + | assets: Vec<Asset>, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | impl Manifest { | |
| 127 | + | /// Load the manifest: the file named by [`MANIFEST_PATH_ENV`] if set, | |
| 128 | + | /// otherwise the copy embedded at build time. | |
| 129 | + | pub fn load() -> Result<Self, ManifestError> { | |
| 130 | + | match std::env::var(MANIFEST_PATH_ENV) { | |
| 131 | + | Ok(path) if !path.trim().is_empty() => { | |
| 132 | + | let path = PathBuf::from(path); | |
| 133 | + | let text = | |
| 134 | + | std::fs::read_to_string(&path).map_err(|source| ManifestError::Read { | |
| 135 | + | path: path.clone(), | |
| 136 | + | source, | |
| 137 | + | })?; | |
| 138 | + | tracing::info!(path = %path.display(), "example seed: using media manifest override"); | |
| 139 | + | Self::parse(&text) | |
| 140 | + | } | |
| 141 | + | _ => Self::parse(EMBEDDED_MANIFEST), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Parse and validate manifest text. Separated from [`Self::load`] so the | |
| 146 | + | /// embedded manifest can be checked by a unit test with no filesystem. | |
| 147 | + | pub fn parse(text: &str) -> Result<Self, ManifestError> { | |
| 148 | + | let file: ManifestFile = toml::from_str(text)?; | |
| 149 | + | let mut seen = std::collections::HashSet::with_capacity(file.asset.len()); | |
| 150 | + | for asset in &file.asset { | |
| 151 | + | if !seen.insert(asset.id.as_str()) { | |
| 152 | + | return Err(ManifestError::DuplicateId(asset.id.clone())); | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | Ok(Self { assets: file.asset }) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Every declared id, curated or not. Used by the roster-coverage test. | |
| 159 | + | pub fn ids(&self) -> impl Iterator<Item = &str> { | |
| 160 | + | self.assets.iter().map(|a| a.id.as_str()) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Fetch every curated asset, verify its digest, and return the resolved set. | |
| 164 | + | /// | |
| 165 | + | /// Uncurated entries are skipped (their slots keep the placeholder). Any | |
| 166 | + | /// curated asset that fails is collected; the call returns | |
| 167 | + | /// [`ManifestError::Unresolved`] listing all of them rather than stopping at | |
| 168 | + | /// the first. | |
| 169 | + | pub async fn resolve(&self) -> Result<ResolvedAssets, ManifestError> { | |
| 170 | + | let curated: Vec<&Asset> = self.assets.iter().filter(|a| a.is_curated()).collect(); | |
| 171 | + | let total = self.assets.len(); | |
| 172 | + | if curated.is_empty() { | |
| 173 | + | tracing::warn!( | |
| 174 | + | declared = total, | |
| 175 | + | "example seed: no media curated yet; every slot keeps its generated placeholder" | |
| 176 | + | ); | |
| 177 | + | return Ok(ResolvedAssets::default()); | |
| 178 | + | } | |
| 179 | + | tracing::info!( | |
| 180 | + | curated = curated.len(), | |
| 181 | + | declared = total, | |
| 182 | + | "example seed: resolving curated media" | |
| 183 | + | ); | |
| 184 | + | ||
| 185 | + | let cache = cache_dir(); | |
| 186 | + | if let Err(e) = std::fs::create_dir_all(&cache) { | |
| 187 | + | tracing::warn!(dir = %cache.display(), error = ?e, "example seed: media cache unusable; fetching every asset fresh"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | let client = reqwest::Client::builder() | |
| 191 | + | .timeout(FETCH_TIMEOUT) | |
| 192 | + | .user_agent("makenotwork-example-seed") | |
| 193 | + | .build() | |
| 194 | + | .map_err(|e| ManifestError::Unresolved(vec![format!("http client: {e}")]))?; | |
| 195 | + | ||
| 196 | + | let mut resolved = HashMap::with_capacity(curated.len()); | |
| 197 | + | let mut failures = Vec::new(); | |
| 198 | + | for asset in curated { | |
| 199 | + | match fetch_asset(&client, &cache, asset).await { | |
| 200 | + | Ok(bytes) => { | |
| 201 | + | resolved.insert(asset.id.clone(), (asset.clone(), bytes)); | |
| 202 | + | } | |
| 203 | + | Err(reason) => failures.push(format!(" {}: {reason}", asset.id)), | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | if !failures.is_empty() { | |
| 208 | + | return Err(ManifestError::Unresolved(failures)); | |
| 209 | + | } | |
| 210 | + | Ok(ResolvedAssets { assets: resolved }) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Curated media, fetched and verified, ready to upload. | |
| 215 | + | #[derive(Debug, Default)] | |
| 216 | + | pub struct ResolvedAssets { | |
| 217 | + | assets: HashMap<String, (Asset, Vec<u8>)>, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | impl ResolvedAssets { | |
| 221 | + | /// The asset behind an id, if it was curated and resolved. | |
| 222 | + | pub fn get(&self, id: &str) -> Option<(&Asset, &[u8])> { | |
| 223 | + | self.assets.get(id).map(|(a, b)| (a, b.as_slice())) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// The asset behind an optional reference, so call sites can pass | |
| 227 | + | /// `spec.cover` straight through. | |
| 228 | + | pub fn lookup(&self, id: Option<&str>) -> Option<(&Asset, &[u8])> { | |
| 229 | + | self.get(id?) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// How many assets resolved. | |
| 233 | + | pub fn len(&self) -> usize { | |
| 234 | + | self.assets.len() | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Whether nothing resolved (every slot is on its placeholder). | |
| 238 | + | pub fn is_empty(&self) -> bool { | |
| 239 | + | self.assets.is_empty() | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// A markdown credits list: one line per resolved asset, title linked to the | |
| 243 | + | /// source page, with author and licence. Sorted by title so a reseed with the | |
| 244 | + | /// same manifest produces the same page. | |
| 245 | + | /// | |
| 246 | + | /// Empty string when nothing resolved, so the caller can skip publishing. | |
| 247 | + | pub fn attribution_markdown(&self) -> String { | |
| 248 | + | if self.assets.is_empty() { | |
| 249 | + | return String::new(); | |
| 250 | + | } | |
| 251 | + | let mut lines: Vec<String> = self | |
| 252 | + | .assets | |
| 253 | + | .values() | |
| 254 | + | .map(|(a, _)| { | |
| 255 | + | let author = a | |
| 256 | + | .author | |
| 257 | + | .as_deref() | |
| 258 | + | .map_or_else(String::new, |author| format!(" by {author}")); | |
| 259 | + | format!("- [{}]({}){} — {}", a.title, a.source, author, a.license) | |
| 260 | + | }) | |
| 261 | + | .collect(); | |
| 262 | + | lines.sort(); | |
| 263 | + | lines.dedup(); | |
| 264 | + | lines.join("\n") | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Where fetched assets are cached between runs. | |
| 269 | + | fn cache_dir() -> PathBuf { | |
| 270 | + | match std::env::var(CACHE_DIR_ENV) { | |
| 271 | + | Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir), | |
| 272 | + | _ => std::env::temp_dir().join("mnw-seed-media"), | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// Fetch one asset, preferring the cache, and verify its digest. | |
| 277 | + | /// | |
| 278 | + | /// The cache is keyed by id, and a cached file is only trusted when the manifest | |
| 279 | + | /// pins a digest and the file matches it. An unpinned asset is re-fetched every | |
| 280 | + | /// run: without a digest there is nothing to tell a stale cache entry from a | |
| 281 | + | /// current one. | |
| 282 | + | async fn fetch_asset( | |
| 283 | + | client: &reqwest::Client, | |
| 284 | + | cache: &std::path::Path, | |
| 285 | + | asset: &Asset, | |
| 286 | + | ) -> Result<Vec<u8>, String> { | |
| 287 | + | let cached = cache.join(&asset.id); | |
| 288 | + | if let (Some(want), Ok(bytes)) = (asset.sha256.as_deref(), std::fs::read(&cached)) | |
| 289 | + | && digest_hex(&bytes).eq_ignore_ascii_case(want.trim()) | |
| 290 | + | { | |
| 291 | + | tracing::debug!(id = %asset.id, "example seed: media cache hit"); | |
| 292 | + | return Ok(bytes); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | let url = asset.url.as_deref().unwrap_or_default().trim(); | |
| 296 | + | let response = client | |
| 297 | + | .get(url) | |
| 298 | + | .send() | |
| 299 | + | .await | |
| 300 | + | .map_err(|e| format!("fetching {url}: {e}"))?; | |
| 301 | + | if !response.status().is_success() { | |
| 302 | + | return Err(format!("fetching {url}: HTTP {}", response.status())); | |
| 303 | + | } | |
| 304 | + | // Refuse an oversized body before buffering it, when the server declares one. | |
| 305 | + | if let Some(len) = response.content_length() | |
| 306 | + | && len > MAX_ASSET_BYTES | |
| 307 | + | { | |
| 308 | + | return Err(format!( | |
| 309 | + | "fetching {url}: {len} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling" | |
| 310 | + | )); | |
| 311 | + | } | |
| 312 | + | let bytes = response | |
| 313 | + | .bytes() | |
| 314 | + | .await | |
| 315 | + | .map_err(|e| format!("reading {url}: {e}"))? | |
| 316 | + | .to_vec(); | |
| 317 | + | if bytes.len() as u64 > MAX_ASSET_BYTES { | |
| 318 | + | return Err(format!( | |
| 319 | + | "fetching {url}: {} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling", | |
| 320 | + | bytes.len() | |
| 321 | + | )); | |
| 322 | + | } | |
| 323 | + | if bytes.is_empty() { | |
| 324 | + | return Err(format!("fetching {url}: empty body")); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | let got = digest_hex(&bytes); | |
| 328 | + | match asset.sha256.as_deref().map(str::trim) { | |
| 329 | + | Some(want) if !want.is_empty() => { | |
| 330 | + | if !got.eq_ignore_ascii_case(want) { | |
| 331 | + | return Err(format!( | |
| 332 | + | "digest mismatch for {url}: manifest pins {want}, fetched {got}. \ | |
| 333 | + | The asset changed at the source; re-check the licence before repinning." | |
| 334 | + | )); | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | _ => tracing::warn!( | |
| 338 | + | id = %asset.id, | |
| 339 | + | sha256 = %got, | |
| 340 | + | "example seed: asset is unpinned; add sha256 = \"{got}\" to media-manifest.toml" | |
| 341 | + | ), | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | if let Err(e) = std::fs::write(&cached, &bytes) { | |
| 345 | + | tracing::debug!(id = %asset.id, error = ?e, "example seed: could not cache asset"); | |
| 346 | + | } | |
| 347 | + | tracing::info!(id = %asset.id, bytes = bytes.len(), "example seed: fetched media asset"); | |
| 348 | + | Ok(bytes) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | /// Lowercase hex SHA-256. | |
| 352 | + | fn digest_hex(bytes: &[u8]) -> String { | |
| 353 | + | use sha2::{Digest, Sha256}; | |
| 354 | + | hex::encode(Sha256::digest(bytes)) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[cfg(test)] | |
| 358 | + | mod tests { | |
| 359 | + | use super::*; | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn embedded_manifest_parses() { | |
| 363 | + | Manifest::parse(EMBEDDED_MANIFEST).expect("the embedded manifest must always be valid"); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn duplicate_ids_are_refused() { | |
| 368 | + | let text = r#" | |
| 369 | + | [[asset]] | |
| 370 | + | id = "a" | |
| 371 | + | media_type = "image/png" | |
| 372 | + | filename = "a.png" | |
| 373 | + | license = "CC0-1.0" | |
| 374 | + | title = "A" | |
| 375 | + | source = "https://example.test/a" | |
| 376 | + | ||
| 377 | + | [[asset]] | |
| 378 | + | id = "a" | |
| 379 | + | media_type = "image/png" | |
| 380 | + | filename = "b.png" | |
| 381 | + | license = "CC0-1.0" | |
| 382 | + | title = "B" | |
| 383 | + | source = "https://example.test/b" | |
| 384 | + | "#; | |
| 385 | + | assert!(matches!( | |
| 386 | + | Manifest::parse(text).unwrap_err(), | |
| 387 | + | ManifestError::DuplicateId(id) if id == "a" | |
| 388 | + | )); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | #[test] | |
| 392 | + | fn an_asset_without_a_url_is_uncurated() { | |
| 393 | + | let text = r#" | |
| 394 | + | [[asset]] | |
| 395 | + | id = "a" | |
| 396 | + | url = " " | |
| 397 | + | media_type = "image/png" | |
| 398 | + | filename = "a.png" | |
| 399 | + | license = "CC0-1.0" | |
| 400 | + | title = "A" | |
| 401 | + | source = "https://example.test/a" | |
| 402 | + | "#; | |
| 403 | + | let manifest = Manifest::parse(text).unwrap(); | |
| 404 | + | assert!(!manifest.assets[0].is_curated()); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | #[tokio::test] | |
| 408 | + | async fn an_uncurated_manifest_resolves_to_nothing_without_network() { | |
| 409 | + | let manifest = Manifest::parse(EMBEDDED_MANIFEST).unwrap(); | |
| 410 | + | // True until the first asset is curated; the point is that a fully | |
| 411 | + | // uncurated manifest never reaches for the network. | |
| 412 | + | if manifest.assets.iter().all(|a| !a.is_curated()) { | |
| 413 | + | assert!(manifest.resolve().await.unwrap().is_empty()); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | #[test] | |
| 418 | + | fn attribution_lists_resolved_assets_only() { | |
| 419 | + | let asset = Asset { | |
| 420 | + | id: "x".into(), | |
| 421 | + | url: Some("https://example.test/x.jpg".into()), | |
| 422 | + | sha256: None, | |
| 423 | + | media_type: "image/jpeg".into(), | |
| 424 | + | filename: "x.jpg".into(), | |
| 425 | + | license: "CC0-1.0".into(), | |
| 426 | + | title: "A Study".into(), | |
| 427 | + | author: Some("A. Person".into()), | |
| 428 | + | source: "https://example.test/x".into(), | |
| 429 | + | }; | |
| 430 | + | let mut assets = HashMap::new(); | |
| 431 | + | assets.insert("x".to_string(), (asset, vec![1, 2, 3])); | |
| 432 | + | let resolved = ResolvedAssets { assets }; | |
| 433 | + | assert_eq!( | |
| 434 | + | resolved.attribution_markdown(), | |
| 435 | + | "- [A Study](https://example.test/x) by A. Person — CC0-1.0" | |
| 436 | + | ); | |
| 437 | + | assert!(ResolvedAssets::default().attribution_markdown().is_empty()); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[test] | |
| 441 | + | fn digest_is_lowercase_hex_sha256() { | |
| 442 | + | // Known vector: SHA-256 of the empty string. | |
| 443 | + | assert_eq!( | |
| 444 | + | digest_hex(b""), | |
| 445 | + | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" | |
| 446 | + | ); | |
| 447 | + | } | |
| 448 | + | } |
| @@ -1,0 +1,257 @@ | |||
| 1 | + | # Curated public-domain / CC0 media for the testnot.work example seed. | |
| 2 | + | # | |
| 3 | + | # This file is the curation checklist. Every id the seed can attach is declared | |
| 4 | + | # here; an entry with an empty `url` is not curated yet and its slot keeps the | |
| 5 | + | # generated placeholder (a 16x16 grey PNG, or a silent WAV). Filling in a `url` | |
| 6 | + | # is the whole act of curation. Nothing else has to change. | |
| 7 | + | # | |
| 8 | + | # Loaded by src/seed/manifest.rs, embedded into the binary at build time. Set | |
| 9 | + | # SEED_MEDIA_MANIFEST to point at a different file while iterating. | |
| 10 | + | # | |
| 11 | + | # RULES, not preferences: | |
| 12 | + | # | |
| 13 | + | # 1. Public domain or CC0 only. No CC-BY, no NC, no "free for personal use". | |
| 14 | + | # testnot.work is a public box and these files sit in object storage under a | |
| 15 | + | # licence claim this file makes on the project's behalf. | |
| 16 | + | # 2. `source` is the landing page a person can open to check that claim, not the | |
| 17 | + | # direct download. If there is no such page, the asset is not usable here. | |
| 18 | + | # 3. `url` is a direct, stable download. Prefer the hosting institution over a | |
| 19 | + | # mirror. | |
| 20 | + | # 4. Pin `sha256` once the asset is chosen. A run with no pin logs the digest it | |
| 21 | + | # saw; paste it back. Pinned and mismatched fails the seed, which is the | |
| 22 | + | # point: it means the file at that URL changed under us. | |
| 23 | + | # 5. Reusing one `url` across several ids is allowed (the same photographer's | |
| 24 | + | # series across a storefront reads fine). Reusing it across *every* id does | |
| 25 | + | # not: a grid of one repeated image is the problem this replaces. | |
| 26 | + | # | |
| 27 | + | # SOURCES implied by the locked naming convention (src/seed/creators.rs): | |
| 28 | + | # audio Musopen (https://musopen.org) — PD recordings of PD scores | |
| 29 | + | # texts Project Gutenberg (https://gutenberg.org) | |
| 30 | + | # photography Wikimedia Commons CC0 (https://commons.wikimedia.org) | |
| 31 | + | # | |
| 32 | + | # COVERS ARE THE LOAD-BEARING HALF. Sixteen of the nineteen entries below are | |
| 33 | + | # cover art, because a storefront of grey squares reads as unfinished however | |
| 34 | + | # many items are in it. The three media files matter less: only Audio, Video and | |
| 35 | + | # Image items serve a real file to a visitor. Every other item type downloads a | |
| 36 | + | # short text blob, and a demo download that says "example file" is honest rather | |
| 37 | + | # than broken, so those are deliberately not declared here. | |
| 38 | + | ||
| 39 | + | # ── Project covers ────────────────────────────────────────────────────────── | |
| 40 | + | # One per storefront (5). These are the largest images on the site: they head | |
| 41 | + | # /p/<slug> and appear on /discover. | |
| 42 | + | ||
| 43 | + | [[asset]] | |
| 44 | + | id = "openreels-project-cover" | |
| 45 | + | url = "" | |
| 46 | + | sha256 = "" | |
| 47 | + | media_type = "image/jpeg" | |
| 48 | + | filename = "openreels-cover.jpg" | |
| 49 | + | license = "" | |
| 50 | + | title = "" | |
| 51 | + | source = "" | |
| 52 | + | # Restored Reels, Vol. 1 — a reissue label. Archival studio or tape imagery. | |
| 53 | + | ||
| 54 | + | [[asset]] | |
| 55 | + | id = "deskriver-project-cover" | |
| 56 | + | url = "" | |
| 57 | + | sha256 = "" | |
| 58 | + | media_type = "image/jpeg" | |
| 59 | + | filename = "deskriver-cover.jpg" | |
| 60 | + | license = "" | |
| 61 | + | title = "" | |
| 62 | + | source = "" | |
| 63 | + | # Deskriver Suite — offline desktop tools. Something spare and geometric; avoid | |
| 64 | + | # stock-photo laptops. | |
| 65 | + | ||
| 66 | + | [[asset]] | |
| 67 | + | id = "stillfield-project-cover" | |
| 68 | + | url = "" | |
| 69 | + | sha256 = "" | |
| 70 | + | media_type = "image/jpeg" | |
| 71 | + | filename = "stillfield-cover.jpg" | |
| 72 | + | license = "" | |
| 73 | + | title = "" | |
| 74 | + | source = "" | |
| 75 | + | # CC0 Field Library — landscape and still-life photography. This one should be | |
| 76 | + | # the strongest image on the box; it is the storefront most likely to be the | |
| 77 | + | # carousel's frame 1. | |
| 78 | + | ||
| 79 | + | [[asset]] | |
| 80 | + | id = "marginalia-project-cover" | |
| 81 | + | url = "" | |
| 82 | + | sha256 = "" | |
| 83 | + | media_type = "image/jpeg" | |
| 84 | + | filename = "marginalia-cover.jpg" | |
| 85 | + | license = "" | |
| 86 | + | title = "" | |
| 87 | + | source = "" | |
| 88 | + | # The Marginalia Reader — a one-person press. Type specimens, letterpress, | |
| 89 | + | # bookbinding. | |
| 90 | + | ||
| 91 | + | [[asset]] | |
| 92 | + | id = "commonshare-project-cover" | |
| 93 | + | url = "" | |
| 94 | + | sha256 = "" | |
| 95 | + | media_type = "image/jpeg" | |
| 96 | + | filename = "commonshare-cover.jpg" | |
| 97 | + | license = "" | |
| 98 | + | title = "" | |
| 99 | + | source = "" | |
| 100 | + | # Commons Sampler — the benefit account. Anything communal and unbranded. | |
| 101 | + | ||
| 102 | + | # ── Item covers ───────────────────────────────────────────────────────────── | |
| 103 | + | # One per item (11), in roster order. | |
| 104 | + | ||
| 105 | + | [[asset]] | |
| 106 | + | id = "restoration-1-cover" | |
| 107 | + | url = "" | |
| 108 | + | sha256 = "" | |
| 109 | + | media_type = "image/jpeg" | |
| 110 | + | filename = "restoration-1-cover.jpg" | |
| 111 | + | license = "" | |
| 112 | + | title = "" | |
| 113 | + | source = "" | |
| 114 | + | ||
| 115 | + | [[asset]] | |
| 116 | + | id = "stem-pack-strings-cover" | |
| 117 | + | url = "" | |
| 118 | + | sha256 = "" | |
| 119 | + | media_type = "image/jpeg" | |
| 120 | + | filename = "stem-pack-strings-cover.jpg" | |
| 121 | + | license = "" | |
| 122 | + | title = "" | |
| 123 | + | source = "" | |
| 124 | + | ||
| 125 | + | [[asset]] | |
| 126 | + | id = "session-take-cover" | |
| 127 | + | url = "" | |
| 128 | + | sha256 = "" | |
| 129 | + | media_type = "image/jpeg" | |
| 130 | + | filename = "session-take-cover.jpg" | |
| 131 | + | license = "" | |
| 132 | + | title = "" | |
| 133 | + | source = "" | |
| 134 | + | ||
| 135 | + | [[asset]] | |
| 136 | + | id = "deskriver-focus-cover" | |
| 137 | + | url = "" | |
| 138 | + | sha256 = "" | |
| 139 | + | media_type = "image/jpeg" | |
| 140 | + | filename = "deskriver-focus-cover.jpg" | |
| 141 | + | license = "" | |
| 142 | + | title = "" | |
| 143 | + | source = "" | |
| 144 | + | ||
| 145 | + | [[asset]] | |
| 146 | + | id = "deskriver-presets-cover" | |
| 147 | + | url = "" | |
| 148 | + | sha256 = "" | |
| 149 | + | media_type = "image/jpeg" | |
| 150 | + | filename = "deskriver-presets-cover.jpg" | |
| 151 | + | license = "" | |
| 152 | + | title = "" | |
| 153 | + | source = "" | |
| 154 | + | ||
| 155 | + | [[asset]] | |
| 156 | + | id = "deskriver-template-cover" | |
| 157 | + | url = "" | |
| 158 | + | sha256 = "" | |
| 159 | + | media_type = "image/jpeg" | |
| 160 | + | filename = "deskriver-template-cover.jpg" | |
| 161 | + | license = "" | |
| 162 | + | title = "" | |
| 163 | + | source = "" | |
| 164 | + | ||
| 165 | + | [[asset]] | |
| 166 | + | id = "deskriver-utility-cover" | |
| 167 | + | url = "" | |
| 168 | + | sha256 = "" | |
| 169 | + | media_type = "image/jpeg" | |
| 170 | + | filename = "deskriver-utility-cover.jpg" | |
| 171 | + | license = "" | |
| 172 | + | title = "" | |
| 173 | + | source = "" | |
| 174 | + | ||
| 175 | + | [[asset]] | |
| 176 | + | id = "field-study-01-cover" | |
| 177 | + | url = "" | |
| 178 | + | sha256 = "" | |
| 179 | + | media_type = "image/jpeg" | |
| 180 | + | filename = "field-study-01.jpg" | |
| 181 | + | license = "" | |
| 182 | + | title = "" | |
| 183 | + | source = "" | |
| 184 | + | # This one is both the cover and the work: "Field Study 01 (Print)" is an Image | |
| 185 | + | # item, so the cover is what a buyer downloads. Use a full-resolution file, not | |
| 186 | + | # a thumbnail. | |
| 187 | + | ||
| 188 | + | [[asset]] | |
| 189 | + | id = "on-slow-reading-cover" | |
| 190 | + | url = "" | |
| 191 | + | sha256 = "" | |
| 192 | + | media_type = "image/jpeg" | |
| 193 | + | filename = "on-slow-reading-cover.jpg" | |
| 194 | + | license = "" | |
| 195 | + | title = "" | |
| 196 | + | source = "" | |
| 197 | + | ||
| 198 | + | [[asset]] | |
| 199 | + | id = "typesetting-commons-cover" | |
| 200 | + | url = "" | |
| 201 | + | sha256 = "" | |
| 202 | + | media_type = "image/jpeg" | |
| 203 | + | filename = "typesetting-commons-cover.jpg" | |
| 204 | + | license = "" | |
| 205 | + | title = "" | |
| 206 | + | source = "" | |
| 207 | + | ||
| 208 | + | [[asset]] | |
| 209 | + | id = "community-bundle-cover" | |
| 210 | + | url = "" | |
| 211 | + | sha256 = "" | |
| 212 | + | media_type = "image/jpeg" | |
| 213 | + | filename = "community-bundle-cover.jpg" | |
| 214 | + | license = "" | |
| 215 | + | title = "" | |
| 216 | + | source = "" | |
| 217 | + | ||
| 218 | + | # ── Media files ───────────────────────────────────────────────────────────── | |
| 219 | + | # The three items that serve a real file to a visitor. Audio has to be actually | |
| 220 | + | # playable: the player is a surface visitors touch, and a silent WAV in it reads | |
| 221 | + | # as a broken site rather than an unfinished one. | |
| 222 | + | ||
| 223 | + | [[asset]] | |
| 224 | + | id = "restoration-1-audio" | |
| 225 | + | url = "" | |
| 226 | + | sha256 = "" | |
| 227 | + | media_type = "audio/mpeg" | |
| 228 | + | filename = "restoration-1.mp3" | |
| 229 | + | license = "" | |
| 230 | + | title = "" | |
| 231 | + | source = "" | |
| 232 | + | # "Restoration No. 1 (Full Mix)". A PD recording of a PD score, Musopen. Keep it | |
| 233 | + | # a few minutes at most; it is fetched on every uncached reseed. | |
| 234 | + | ||
| 235 | + | [[asset]] | |
| 236 | + | id = "stem-pack-strings-audio" | |
| 237 | + | url = "" | |
| 238 | + | sha256 = "" | |
| 239 | + | media_type = "audio/wav" | |
| 240 | + | filename = "stem-pack-strings.wav" | |
| 241 | + | license = "" | |
| 242 | + | title = "" | |
| 243 | + | source = "" | |
| 244 | + | # "Stem Pack: Strings", a Sample item, so this downloads rather than streams. | |
| 245 | + | # A strings-only excerpt of the same recording is the coherent choice. | |
| 246 | + | ||
| 247 | + | [[asset]] | |
| 248 | + | id = "session-take-video" | |
| 249 | + | url = "" | |
| 250 | + | sha256 = "" | |
| 251 | + | media_type = "video/mp4" | |
| 252 | + | filename = "session-take.mp4" | |
| 253 | + | license = "" | |
| 254 | + | title = "" | |
| 255 | + | source = "" | |
| 256 | + | # "Session Take (Video)". Short, PD archival footage. Watch the size ceiling in | |
| 257 | + | # manifest.rs (64 MB); a long clip belongs nowhere near a reseed. |