Skip to main content

max / makenotwork

11.2 KB · 306 lines History Blame Raw
1 //! Example-marketplace seed for the `testnot.work` staging box.
2 //!
3 //! Replaces testnot's nightly prod-data restore with a self-contained catalog of
4 //! fabricated creators and public-domain items, so staging never holds real user
5 //! data. Invoked deliberately via the `--seed-examples` flag on the server binary
6 //! (see `main.rs`), never at boot. Design + sprint plan:
7 //! `_private/docs/mnw/testnot-example-seed.md`.
8 //!
9 //! # Prod safety
10 //!
11 //! The seed writes fabricated rows; it must be impossible to run against
12 //! production. [`run`] enforces three layered guards, all required:
13 //!
14 //! 1. `ALLOW_EXAMPLE_SEED=1` must be set (opt-in switch).
15 //! 2. `HOST_URL` must resolve to an approved example host, `testnot.work` (or a
16 //! subdomain) or localhost. The prod apex `makenot.work` is not on the
17 //! allowlist, so it is refused.
18 //! 3. The `users` table must contain no *real* accounts. "Real" means any email
19 //! outside the reserved [`EXAMPLE_EMAIL_DOMAIN`]; a database with only example
20 //! accounts (or none) passes.
21 //!
22 //! # Reset semantics
23 //!
24 //! Decision (Phase 0): **wipe-example-data-first**. Before seeding, [`run`]
25 //! deletes every `@example.test` account, which cascades to their projects,
26 //! items, follows, and transactions (all `ON DELETE CASCADE` on `user_id`). This
27 //! makes `--seed-examples` idempotent and re-runnable to reset testnot without a
28 //! full schema drop. It is safe precisely because guard 3 guarantees only example
29 //! data is ever present when the seed runs. The Phase 6 `mnw-testnot-seed.sh` may
30 //! still reset the schema for a fully clean slate, but the seed does not depend on
31 //! that.
32
33 pub mod blog;
34 pub mod creators;
35 pub mod items;
36 pub mod media;
37 pub mod projects;
38 pub mod social;
39
40 use std::sync::Arc;
41
42 use sqlx::PgPool;
43
44 use crate::storage::StorageBackend;
45
46 /// Storage handles the media phase needs, resolved from config at the seed call
47 /// site. When `s3` is `None` (testnot's stubbed default until MinIO is stood up),
48 /// the media phase is skipped and items stay hidden. Covers additionally require
49 /// `public_s3` + `cdn_base_url` (the CDN render base).
50 pub struct SeedMedia {
51 /// Main (gated) bucket for audio/video/download files.
52 pub s3: Option<Arc<dyn StorageBackend>>,
53 /// Public/CDN bucket for cover images.
54 pub public_s3: Option<Arc<dyn StorageBackend>>,
55 /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`).
56 pub cdn_base_url: Option<String>,
57 }
58
59 impl SeedMedia {
60 /// No storage, the media phase is skipped and items remain hidden. Used on a
61 /// stubbed-S3 box and in tests that only exercise the DB-only phases.
62 pub fn none() -> Self {
63 Self {
64 s3: None,
65 public_s3: None,
66 cdn_base_url: None,
67 }
68 }
69 }
70
71 /// Reserved email domain for every seeded example account. It is the marker the
72 /// prod-safety guard uses to tell fabricated seed data from real users: the seed
73 /// refuses to run when any account with a different domain exists, and the reset
74 /// step only ever deletes accounts in this domain. (The `003_seed_demo`
75 /// migration's `elena@example.com` is not a concern, migration
76 /// `080_remove_demo_data` deletes it, so a freshly-migrated DB holds no
77 /// non-example accounts.)
78 pub const EXAMPLE_EMAIL_DOMAIN: &str = "example.test";
79
80 /// Why the example seed refused to run, or how it failed partway through.
81 #[derive(Debug, thiserror::Error)]
82 pub enum SeedError {
83 /// `ALLOW_EXAMPLE_SEED` was not set to `1`.
84 #[error("refusing to seed: ALLOW_EXAMPLE_SEED is not set to 1")]
85 NotAllowed,
86 /// `HOST_URL` did not resolve to an approved example host.
87 #[error(
88 "refusing to seed: HOST_URL {0:?} is not an approved example host \
89 (testnot.work or localhost only)"
90 )]
91 ProdHost(String),
92 /// The database already holds non-example accounts.
93 #[error(
94 "refusing to seed: database holds {0} non-example account(s); the example \
95 seed only runs on a database that contains example data or nothing"
96 )]
97 RealUsersPresent(i64),
98 /// A database error while checking guards, resetting, or seeding.
99 #[error(transparent)]
100 Db(#[from] sqlx::Error),
101 /// An application error from a `db::*` helper or the password hasher while
102 /// seeding content (the `db` layer returns [`crate::error::AppError`], not a
103 /// raw `sqlx::Error`).
104 #[error(transparent)]
105 App(#[from] crate::error::AppError),
106 }
107
108 /// Inputs for a seed run, resolved from the process environment and config.
109 #[derive(Debug, Clone)]
110 pub struct SeedOptions {
111 /// `ALLOW_EXAMPLE_SEED=1` was present.
112 pub allow_example_seed: bool,
113 /// The configured `HOST_URL` (from [`crate::config::Config`]).
114 pub host_url: String,
115 }
116
117 impl SeedOptions {
118 /// Read the opt-in switch from the environment; take `host_url` from config.
119 pub fn from_env(host_url: &str) -> Self {
120 Self {
121 allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"),
122 host_url: host_url.to_string(),
123 }
124 }
125 }
126
127 /// Run the example seed: guards, reset, then content.
128 ///
129 /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the
130 /// database holds a fresh fabricated catalog. Callers should treat this as a
131 /// one-shot: run it, then exit the process.
132 pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result<(), SeedError> {
133 // Guards 1 + 2: opt-in switch and approved host. Pure, no DB.
134 check_static_guards(opts.allow_example_seed, &opts.host_url)?;
135
136 // Guard 3: no real accounts present.
137 let real = count_real_users(pool).await?;
138 if real > 0 {
139 return Err(SeedError::RealUsersPresent(real));
140 }
141
142 tracing::warn!(
143 host = %opts.host_url,
144 "example seed: all guards passed; wiping prior example data, then seeding"
145 );
146
147 // Reset semantics: wipe-example-data-first (see module docs).
148 let wiped = reset_example_data(pool).await?;
149 tracing::info!(
150 wiped_users = wiped,
151 "example seed: cleared prior example accounts"
152 );
153
154 // Phases 1-2: durable creators, one project each, then items + pricing + tiers
155 // + tags. Items are seeded hidden (`scan_status='pending'`); Phase 3 attaches
156 // media and makes them visible. Phase 4 layers blog/social on top.
157 let creators = creators::seed_creators(pool).await?;
158 let projects = projects::seed_projects(pool, &creators).await?;
159 items::seed_items(pool, &projects).await?;
160
161 // Phase 3: attach placeholder media and flip items visible. Skipped (items
162 // stay hidden) when storage is unconfigured.
163 media::seed_media(pool, media, &projects).await?;
164
165 // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts).
166 blog::seed_blog(pool, &projects).await?;
167 social::seed_social(pool, &projects).await?;
168 tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6");
169
170 Ok(())
171 }
172
173 /// The env + host guards, computed from explicit inputs so they can be unit-tested
174 /// without a process environment or a database.
175 pub fn check_static_guards(allow_example_seed: bool, host_url: &str) -> Result<(), SeedError> {
176 if !allow_example_seed {
177 return Err(SeedError::NotAllowed);
178 }
179 if !is_approved_example_host(host_url) {
180 return Err(SeedError::ProdHost(host_url.to_string()));
181 }
182 Ok(())
183 }
184
185 /// Whether `host_url`'s host is on the example-seed allowlist. Allowlist, not
186 /// denylist: only `testnot.work` (and subdomains) and localhost pass, so the prod
187 /// apex and any unknown host are refused by default.
188 fn is_approved_example_host(host_url: &str) -> bool {
189 let host = url::Url::parse(host_url)
190 .ok()
191 .and_then(|u| u.host_str().map(str::to_ascii_lowercase));
192 match host {
193 Some(h) => {
194 h == "testnot.work"
195 || h.ends_with(".testnot.work")
196 || h == "localhost"
197 || h == "127.0.0.1"
198 || h == "::1"
199 }
200 // Not a parseable URL with a host, refuse rather than guess.
201 None => false,
202 }
203 }
204
205 /// Count accounts that are NOT example accounts (email domain outside
206 /// [`EXAMPLE_EMAIL_DOMAIN`]). A non-zero result means real data is present and the
207 /// seed must refuse.
208 async fn count_real_users(pool: &PgPool) -> Result<i64, sqlx::Error> {
209 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE $1")
210 .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
211 .fetch_one(pool)
212 .await
213 }
214
215 /// Delete every example account and its content. Returns the number of accounts
216 /// removed. Safe only after the guards have confirmed no real data is present.
217 ///
218 /// Projects are deleted first, on purpose: that cascades their items, blog posts,
219 /// versions, tiers, and tags (all `ON DELETE CASCADE` on `project_id`). It clears
220 /// `blog_posts.author_id` in particular, whose FK to `users` is *not*
221 /// `ON DELETE CASCADE` and would otherwise block the subsequent `DELETE FROM users`
222 /// (the user-scoped `follows` rows do cascade on `follower_id`).
223 async fn reset_example_data(pool: &PgPool) -> Result<u64, sqlx::Error> {
224 let like = format!("%@{EXAMPLE_EMAIL_DOMAIN}");
225 sqlx::query(
226 "DELETE FROM projects WHERE user_id IN \
227 (SELECT id FROM users WHERE lower(email) LIKE $1)",
228 )
229 .bind(&like)
230 .execute(pool)
231 .await?;
232
233 let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1")
234 .bind(&like)
235 .execute(pool)
236 .await?;
237 Ok(res.rows_affected())
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 #[test]
245 fn refuses_without_allow_flag() {
246 let err = check_static_guards(false, "https://testnot.work").unwrap_err();
247 assert!(matches!(err, SeedError::NotAllowed));
248 }
249
250 #[test]
251 fn refuses_prod_apex_even_with_flag() {
252 // The exact prod-shaped config: allowed switch on, prod host.
253 let err = check_static_guards(true, "https://makenot.work").unwrap_err();
254 assert!(matches!(err, SeedError::ProdHost(_)));
255 }
256
257 #[test]
258 fn refuses_prod_subdomain() {
259 for host in [
260 "https://www.makenot.work",
261 "https://app.makenot.work",
262 "https://makenot.work/creators",
263 ] {
264 let err = check_static_guards(true, host).unwrap_err();
265 assert!(
266 matches!(err, SeedError::ProdHost(_)),
267 "should refuse {host}"
268 );
269 }
270 }
271
272 #[test]
273 fn allows_testnot_and_localhost() {
274 for host in [
275 "https://testnot.work",
276 "https://testnot.work/",
277 "https://sub.testnot.work",
278 "http://localhost:3000",
279 "http://127.0.0.1:8080",
280 ] {
281 assert!(
282 check_static_guards(true, host).is_ok(),
283 "should allow {host}"
284 );
285 }
286 }
287
288 #[test]
289 fn refuses_lookalike_hosts() {
290 // Substring tricks must not slip past the allowlist.
291 for host in [
292 "https://testnot.work.evil.com",
293 "https://nottestnot.work",
294 "https://testnot-work.com",
295 "not-a-url",
296 "",
297 ] {
298 let err = check_static_guards(true, host).unwrap_err();
299 assert!(
300 matches!(err, SeedError::ProdHost(_)),
301 "should refuse {host}"
302 );
303 }
304 }
305 }
306