Skip to main content

max / makenotwork

21.6 KB · 696 lines History Blame Raw
1 //! Internal API endpoints for CLI feature access (tags, broadcast, tiers,
2 //! collections, custom domains).
3
4 use axum::extract::State;
5 use axum::response::IntoResponse;
6 use axum::Json;
7 use serde::{Deserialize, Serialize};
8
9 use crate::auth::ServiceAuth;
10 use crate::constants;
11 use crate::db::{self, CollectionId, ItemId, ProjectId, Slug, UserId};
12 use crate::error::{AppError, Result, ResultExt};
13 use crate::AppState;
14
15 /// User ID query parameter shared by all internal endpoints.
16 #[derive(Deserialize)]
17 pub(super) struct UserIdParam {
18 pub user_id: UserId,
19 }
20
21 // ── Tags ──
22
23 #[derive(Deserialize)]
24 pub(super) struct TagItemRequest {
25 user_id: UserId,
26 item_id: ItemId,
27 tag_id: String,
28 }
29
30 #[derive(Serialize)]
31 struct TagView {
32 id: String,
33 name: String,
34 slug: String,
35 is_primary: bool,
36 }
37
38 /// GET /api/internal/creator/items/{id}/tags?user_id=...
39 pub(super) async fn list_item_tags(
40 State(state): State<AppState>,
41 _auth: ServiceAuth,
42 axum::extract::Path(item_id): axum::extract::Path<ItemId>,
43 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
44 ) -> Result<impl IntoResponse> {
45 let item = db::items::get_item_by_id(&state.db, item_id)
46 .await?
47 .ok_or(AppError::NotFound)?;
48 let project = db::projects::get_project_by_id(&state.db, item.project_id)
49 .await?
50 .ok_or(AppError::NotFound)?;
51 if project.user_id != q.user_id {
52 return Err(AppError::Forbidden);
53 }
54
55 let tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
56 let views: Vec<TagView> = tags
57 .iter()
58 .map(|t| TagView {
59 id: t.tag_id.to_string(),
60 name: t.tag_name.clone(),
61 slug: t.tag_slug.to_string(),
62 is_primary: t.is_primary,
63 })
64 .collect();
65
66 Ok(Json(views))
67 }
68
69 /// POST /api/internal/creator/items/tags
70 pub(super) async fn add_item_tag(
71 State(state): State<AppState>,
72 _auth: ServiceAuth,
73 Json(req): Json<TagItemRequest>,
74 ) -> Result<impl IntoResponse> {
75 let item = db::items::get_item_by_id(&state.db, req.item_id)
76 .await?
77 .ok_or(AppError::NotFound)?;
78 let project = db::projects::get_project_by_id(&state.db, item.project_id)
79 .await?
80 .ok_or(AppError::NotFound)?;
81 if project.user_id != req.user_id {
82 return Err(AppError::Forbidden);
83 }
84
85 let tag_id: db::TagId = req.tag_id.parse::<uuid::Uuid>()
86 .map(db::TagId::from)
87 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
88
89 let _tag = db::tags::get_tag_by_id(&state.db, tag_id)
90 .await?
91 .ok_or_else(|| AppError::validation("Tag not found".to_string()))?;
92
93 db::tags::add_tag_to_item(&state.db, req.item_id, tag_id, false).await?;
94
95 Ok(Json(serde_json::json!({"success": true})))
96 }
97
98 /// POST /api/internal/creator/items/tags/remove
99 pub(super) async fn remove_item_tag(
100 State(state): State<AppState>,
101 _auth: ServiceAuth,
102 Json(req): Json<TagItemRequest>,
103 ) -> Result<impl IntoResponse> {
104 let item = db::items::get_item_by_id(&state.db, req.item_id)
105 .await?
106 .ok_or(AppError::NotFound)?;
107 let project = db::projects::get_project_by_id(&state.db, item.project_id)
108 .await?
109 .ok_or(AppError::NotFound)?;
110 if project.user_id != req.user_id {
111 return Err(AppError::Forbidden);
112 }
113
114 let tag_id: db::TagId = req.tag_id.parse::<uuid::Uuid>()
115 .map(db::TagId::from)
116 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
117
118 db::tags::remove_tag_from_item(&state.db, req.item_id, tag_id).await?;
119
120 Ok(Json(serde_json::json!({"success": true})))
121 }
122
123 #[derive(Deserialize)]
124 pub(super) struct TagSearchQuery {
125 q: String,
126 }
127
128 /// GET /api/internal/tags/search?q=...
129 pub(super) async fn search_tags(
130 State(state): State<AppState>,
131 _auth: ServiceAuth,
132 axum::extract::Query(q): axum::extract::Query<TagSearchQuery>,
133 ) -> Result<impl IntoResponse> {
134 let tags = db::tags::search_tags(&state.db, &q.q, 20).await?;
135 let views: Vec<TagView> = tags
136 .iter()
137 .map(|t| TagView {
138 id: t.id.to_string(),
139 name: t.name.clone(),
140 slug: t.slug.to_string(),
141 is_primary: false,
142 })
143 .collect();
144 Ok(Json(views))
145 }
146
147 // ── Broadcast ──
148
149 #[derive(Deserialize)]
150 pub(super) struct BroadcastRequest {
151 user_id: UserId,
152 subject: String,
153 body: String,
154 }
155
156 /// POST /api/internal/creator/broadcast
157 pub(super) async fn send_broadcast(
158 State(state): State<AppState>,
159 _auth: ServiceAuth,
160 Json(req): Json<BroadcastRequest>,
161 ) -> Result<impl IntoResponse> {
162 if req.subject.is_empty() || req.subject.len() > 200 {
163 return Err(AppError::validation("Subject must be 1-200 characters".to_string()));
164 }
165 if req.body.is_empty() || req.body.len() > 5000 {
166 return Err(AppError::validation("Body must be 1-5000 characters".to_string()));
167 }
168
169 let db_user = db::users::get_user_by_id(&state.db, req.user_id)
170 .await?
171 .ok_or(AppError::NotFound)?;
172
173 if !db_user.can_create_projects {
174 return Err(AppError::Forbidden);
175 }
176
177 let set = db::users::try_set_broadcast_at(&state.db, req.user_id).await?;
178 if !set {
179 return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string()));
180 }
181
182 let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?;
183 let count = followers.len();
184
185 if count > 0 {
186 let creator_name = db_user.display_name.as_deref()
187 .unwrap_or(&db_user.username)
188 .to_string();
189 let host_url = state.config.host_url.clone();
190 let signing_secret = state.config.signing_secret.clone();
191 let creator_id = req.user_id;
192 let subject = req.subject.clone();
193 let body = req.body.clone();
194 let email_client = state.email.clone();
195
196 tokio::spawn(async move {
197 let mut set = tokio::task::JoinSet::new();
198 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
199
200 for follower in followers {
201 if set.len() >= constants::BROADCAST_PARALLELISM {
202 let _ = set.join_next().await;
203 }
204
205 let email_client = email_client.clone();
206 let host_url = host_url.clone();
207 let signing_secret = signing_secret.clone();
208 let creator_name = creator_name.clone();
209 let subject = subject.clone();
210 let body = body.clone();
211 let creator_id_str = creator_id.to_string();
212
213 set.spawn(async move {
214 let unsub_url = crate::email::generate_unsubscribe_url(
215 &host_url, follower.id,
216 crate::email::UnsubscribeAction::Broadcast,
217 &creator_id_str, &signing_secret,
218 );
219 if let Err(e) = email_client.send_broadcast(
220 &follower.email,
221 follower.display_name.as_deref(),
222 &creator_name,
223 &subject,
224 &body,
225 Some(&unsub_url),
226 ).await {
227 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
228 }
229 });
230
231 tokio::time::sleep(chunk_delay).await;
232 }
233
234 while set.join_next().await.is_some() {}
235 });
236 }
237
238 Ok(Json(serde_json::json!({"success": true, "recipient_count": count})))
239 }
240
241 // ── Tiers ──
242
243 #[derive(Serialize)]
244 struct TierView {
245 id: String,
246 name: String,
247 description: String,
248 price_cents: i32,
249 is_active: bool,
250 }
251
252 /// GET /api/internal/creator/projects/{id}/tiers?user_id=...
253 pub(super) async fn list_tiers(
254 State(state): State<AppState>,
255 _auth: ServiceAuth,
256 axum::extract::Path(project_id): axum::extract::Path<ProjectId>,
257 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
258 ) -> Result<impl IntoResponse> {
259 let project = db::projects::get_project_by_id(&state.db, project_id)
260 .await?
261 .ok_or(AppError::NotFound)?;
262 if project.user_id != q.user_id {
263 return Err(AppError::Forbidden);
264 }
265
266 let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project_id).await?;
267 let views: Vec<TierView> = tiers
268 .iter()
269 .map(|t| TierView {
270 id: t.id.to_string(),
271 name: t.name.clone(),
272 description: t.description.clone().unwrap_or_default(),
273 price_cents: t.price_cents,
274 is_active: t.is_active,
275 })
276 .collect();
277
278 Ok(Json(views))
279 }
280
281 // ── Collections ──
282
283 #[derive(Deserialize)]
284 pub(super) struct CreateCollectionRequest {
285 user_id: UserId,
286 slug: String,
287 title: String,
288 description: Option<String>,
289 is_public: Option<bool>,
290 }
291
292 #[derive(Serialize)]
293 struct CollectionView {
294 id: String,
295 slug: String,
296 title: String,
297 description: String,
298 is_public: bool,
299 item_count: i64,
300 }
301
302 /// GET /api/internal/creator/collections?user_id=...
303 pub(super) async fn list_collections(
304 State(state): State<AppState>,
305 _auth: ServiceAuth,
306 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
307 ) -> Result<impl IntoResponse> {
308 let collections = db::collections::get_collections_by_user(&state.db, q.user_id).await?;
309 let views: Vec<CollectionView> = collections
310 .iter()
311 .map(|c| CollectionView {
312 id: c.id.to_string(),
313 slug: c.slug.to_string(),
314 title: c.title.clone(),
315 description: c.description.clone().unwrap_or_default(),
316 is_public: c.is_public,
317 item_count: c.item_count,
318 })
319 .collect();
320
321 Ok(Json(views))
322 }
323
324 /// POST /api/internal/creator/collections
325 pub(super) async fn create_collection(
326 State(state): State<AppState>,
327 _auth: ServiceAuth,
328 Json(req): Json<CreateCollectionRequest>,
329 ) -> Result<impl IntoResponse> {
330 let slug = Slug::new(&req.slug)
331 .map_err(|e| AppError::validation(e.to_string()))?;
332
333 let collection = db::collections::create_collection(
334 &state.db,
335 req.user_id,
336 &slug,
337 &req.title,
338 req.description.as_deref(),
339 req.is_public.unwrap_or(true),
340 ).await?;
341
342 Ok(Json(serde_json::json!({
343 "id": collection.id.to_string(),
344 "slug": collection.slug.to_string(),
345 "title": collection.title,
346 })))
347 }
348
349 /// DELETE /api/internal/creator/collections/{id}?user_id=...
350 pub(super) async fn delete_collection(
351 State(state): State<AppState>,
352 _auth: ServiceAuth,
353 axum::extract::Path(collection_id): axum::extract::Path<CollectionId>,
354 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
355 ) -> Result<impl IntoResponse> {
356 let collection = db::collections::get_collection_by_id(&state.db, collection_id)
357 .await?
358 .ok_or(AppError::NotFound)?;
359 if collection.user_id != q.user_id {
360 return Err(AppError::Forbidden);
361 }
362
363 db::collections::delete_collection(&state.db, collection_id).await?;
364
365 Ok(axum::http::StatusCode::NO_CONTENT)
366 }
367
368 // ── Custom Domains ──
369
370 #[derive(Deserialize)]
371 pub(super) struct AddDomainRequest {
372 user_id: UserId,
373 domain: String,
374 }
375
376 /// GET /api/internal/creator/domain?user_id=...
377 pub(super) async fn get_domain(
378 State(state): State<AppState>,
379 _auth: ServiceAuth,
380 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
381 ) -> Result<impl IntoResponse> {
382 let domain = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id).await?;
383 match domain {
384 Some(d) => Ok(Json(serde_json::json!({
385 "id": d.id.to_string(),
386 "domain": d.domain,
387 "verified": d.verified,
388 "verification_token": d.verification_token,
389 }))),
390 None => Ok(Json(serde_json::json!(null))),
391 }
392 }
393
394 /// POST /api/internal/creator/domain
395 pub(super) async fn add_domain(
396 State(state): State<AppState>,
397 _auth: ServiceAuth,
398 Json(req): Json<AddDomainRequest>,
399 ) -> Result<impl IntoResponse> {
400 let domain = req.domain.to_lowercase().trim().to_string();
401 if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
402 return Err(AppError::validation("Invalid domain".to_string()));
403 }
404 if domain.contains("makenot.work") || domain.contains("makenotwork") {
405 return Err(AppError::validation("Cannot use MNW domains".to_string()));
406 }
407
408 let token = generate_verification_token();
409 let record = db::custom_domains::create_custom_domain(&state.db, req.user_id, &domain, &token).await?;
410
411 Ok(Json(serde_json::json!({
412 "id": record.id.to_string(),
413 "domain": record.domain,
414 "verified": record.verified,
415 "verification_token": record.verification_token,
416 "instructions": format!("Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.", record.domain, record.verification_token),
417 })))
418 }
419
420 /// POST /api/internal/creator/domain/verify?user_id=...
421 pub(super) async fn verify_domain(
422 State(state): State<AppState>,
423 _auth: ServiceAuth,
424 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
425 ) -> Result<impl IntoResponse> {
426 let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id)
427 .await?
428 .ok_or(AppError::NotFound)?;
429
430 if record.verified {
431 return Ok(Json(serde_json::json!({"verified": true, "message": "Already verified"})));
432 }
433
434 // DNS lookup via Cloudflare DoH
435 let lookup_name = format!("_mnw-verify.{}", record.domain);
436 let url = format!(
437 "https://cloudflare-dns.com/dns-query?name={}&type=TXT",
438 lookup_name
439 );
440 let resp = reqwest::Client::new()
441 .get(&url)
442 .header("accept", "application/dns-json")
443 .timeout(std::time::Duration::from_secs(5))
444 .send()
445 .await
446 .context("dns lookup")?;
447
448 let json: serde_json::Value = resp.json().await
449 .context("parse dns response")?;
450
451 let verified = json["Answer"]
452 .as_array()
453 .map(|answers| {
454 answers.iter().any(|a| {
455 a["data"]
456 .as_str()
457 .map(|d| d.trim_matches('"') == record.verification_token)
458 .unwrap_or(false)
459 })
460 })
461 .unwrap_or(false);
462
463 if verified {
464 db::custom_domains::mark_domain_verified(&state.db, record.id).await?;
465 state.domain_cache.insert(record.domain.clone(), q.user_id);
466 Ok(Json(serde_json::json!({"verified": true, "message": "Domain verified"})))
467 } else {
468 Ok(Json(serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)})))
469 }
470 }
471
472 /// DELETE /api/internal/creator/domain?user_id=...
473 pub(super) async fn remove_domain(
474 State(state): State<AppState>,
475 _auth: ServiceAuth,
476 axum::extract::Query(q): axum::extract::Query<UserIdParam>,
477 ) -> Result<impl IntoResponse> {
478 let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id)
479 .await?
480 .ok_or(AppError::NotFound)?;
481
482 db::custom_domains::delete_custom_domain(&state.db, record.id, q.user_id).await?;
483 state.domain_cache.remove(&record.domain);
484
485 Ok(axum::http::StatusCode::NO_CONTENT)
486 }
487
488 fn generate_verification_token() -> String {
489 let mut bytes = [0u8; 16];
490 rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes);
491 format!("mnw-verify-{}", hex::encode(bytes))
492 }
493
494 /// Map a project type string to its feature flags. Unknown types default to
495 /// `["downloads"]` (the safest superset for an unrecognised request).
496 fn features_for_project_type(project_type: &str) -> Vec<String> {
497 match project_type {
498 "audio" => vec!["audio".to_string()],
499 "digital" => vec!["downloads".to_string()],
500 "video" => vec!["video".to_string()],
501 "mixed" => vec!["audio".to_string(), "downloads".to_string()],
502 "subscription" => vec!["subscriptions".to_string()],
503 _ => vec!["downloads".to_string()],
504 }
505 }
506
507 /// Derive a URL-safe slug from a title: lowercase, alphanumeric + space, then
508 /// collapse runs of whitespace to single hyphens. Returns `"project"` when the
509 /// input contains no alphanumerics.
510 fn slug_from_title(title: &str) -> String {
511 let s: String = title
512 .to_lowercase()
513 .chars()
514 .map(|c| if c.is_alphanumeric() || c == ' ' { c } else { ' ' })
515 .collect::<String>()
516 .split_whitespace()
517 .collect::<Vec<_>>()
518 .join("-");
519 if s.is_empty() { "project".to_string() } else { s }
520 }
521
522 // ── Project creation ──
523
524 #[derive(Deserialize)]
525 pub(super) struct CreateProjectRequest {
526 user_id: UserId,
527 title: String,
528 project_type: String,
529 description: Option<String>,
530 }
531
532 #[derive(Serialize)]
533 struct CreateProjectResponse {
534 id: String,
535 slug: String,
536 title: String,
537 project_type: String,
538 }
539
540 /// POST /api/internal/creator/projects
541 pub(super) async fn create_project(
542 State(state): State<AppState>,
543 _auth: ServiceAuth,
544 Json(req): Json<CreateProjectRequest>,
545 ) -> Result<impl IntoResponse> {
546 // Verify user can create projects
547 let user = db::users::get_user_by_id(&state.db, req.user_id)
548 .await?
549 .ok_or(AppError::NotFound)?;
550
551 if !user.can_create_projects {
552 return Err(AppError::Forbidden);
553 }
554
555 if req.title.is_empty() || req.title.len() > 100 {
556 return Err(AppError::BadRequest("Title must be 1-100 characters".to_string()));
557 }
558
559 let features = features_for_project_type(&req.project_type);
560 let slug = Slug::from_trusted(slug_from_title(&req.title));
561
562 let project = db::projects::create_project(
563 &state.db,
564 req.user_id,
565 &slug,
566 &req.title,
567 req.description.as_deref(),
568 &features,
569 )
570 .await?;
571
572 Ok(Json(CreateProjectResponse {
573 id: project.id.to_string(),
574 slug: project.slug.to_string(),
575 title: project.title,
576 project_type: project.project_type.to_string(),
577 }))
578 }
579
580 #[cfg(test)]
581 mod tests {
582 use super::*;
583
584 // ── generate_verification_token ──
585
586 #[test]
587 fn verification_token_has_expected_prefix_and_length() {
588 let t = generate_verification_token();
589 // "mnw-verify-" (11) + 32 hex chars (16 bytes × 2) = 43.
590 assert!(t.starts_with("mnw-verify-"), "token prefix wrong: {t}");
591 assert_eq!(t.len(), 11 + 32, "token length wrong: {t}");
592 let hex_part = &t[11..];
593 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()),
594 "non-hex suffix: {hex_part}");
595 }
596
597 #[test]
598 fn verification_tokens_are_unique() {
599 let a = generate_verification_token();
600 let b = generate_verification_token();
601 assert_ne!(a, b, "two tokens collided");
602 }
603
604 // ── features_for_project_type — each match arm ──
605
606 #[test]
607 fn features_audio() {
608 assert_eq!(features_for_project_type("audio"), vec!["audio".to_string()]);
609 }
610
611 #[test]
612 fn features_digital() {
613 assert_eq!(features_for_project_type("digital"), vec!["downloads".to_string()]);
614 }
615
616 #[test]
617 fn features_video() {
618 assert_eq!(features_for_project_type("video"), vec!["video".to_string()]);
619 }
620
621 #[test]
622 fn features_mixed_combines_audio_and_downloads_in_order() {
623 // Pins ordering — `vec!["audio", "downloads"]` not the reverse.
624 assert_eq!(
625 features_for_project_type("mixed"),
626 vec!["audio".to_string(), "downloads".to_string()],
627 );
628 }
629
630 #[test]
631 fn features_subscription() {
632 assert_eq!(features_for_project_type("subscription"), vec!["subscriptions".to_string()]);
633 }
634
635 #[test]
636 fn features_unknown_defaults_to_downloads() {
637 // Pins the `_ => vec!["downloads"]` fallback.
638 assert_eq!(features_for_project_type("unknown"), vec!["downloads".to_string()]);
639 assert_eq!(features_for_project_type(""), vec!["downloads".to_string()]);
640 // Case-sensitive: "Audio" is not "audio".
641 assert_eq!(features_for_project_type("Audio"), vec!["downloads".to_string()]);
642 }
643
644 // ── slug_from_title ──
645
646 #[test]
647 fn slug_lowercases_and_hyphenates_words() {
648 assert_eq!(slug_from_title("Hello World"), "hello-world");
649 }
650
651 #[test]
652 fn slug_strips_non_alphanumeric() {
653 // Pins `is_alphanumeric() || c == ' '` — punctuation becomes a space
654 // which then collapses with adjacent whitespace.
655 assert_eq!(slug_from_title("Project: A & B!"), "project-a-b");
656 }
657
658 #[test]
659 fn slug_collapses_runs_of_whitespace() {
660 assert_eq!(slug_from_title("a b\tc"), "a-b-c");
661 }
662
663 #[test]
664 fn slug_keeps_digits() {
665 assert_eq!(slug_from_title("V2 Beats"), "v2-beats");
666 }
667
668 #[test]
669 fn slug_unicode_alphanumeric_passes_through() {
670 // `is_alphanumeric()` is Unicode-aware. Lowercased Greek letters survive.
671 let s = slug_from_title("Λ Test");
672 // Don't pin the exact case-folded form; just that the alphanumerics are preserved.
673 assert!(s.contains("test"));
674 assert!(!s.is_empty());
675 }
676
677 #[test]
678 fn slug_empty_input_defaults_to_project() {
679 // Pins the `if s.is_empty() { "project" }` fallback.
680 assert_eq!(slug_from_title(""), "project");
681 assert_eq!(slug_from_title(" "), "project");
682 assert_eq!(slug_from_title("!!! ???"), "project");
683 }
684
685 #[test]
686 fn slug_single_word_no_hyphen() {
687 assert_eq!(slug_from_title("Solo"), "solo");
688 }
689
690 #[test]
691 fn slug_leading_trailing_whitespace_ignored() {
692 // split_whitespace handles edges.
693 assert_eq!(slug_from_title(" hello world "), "hello-world");
694 }
695 }
696