//! Internal API endpoints for CLI feature access (tags, broadcast, tiers, //! collections, custom domains). use crate::auth::InternalActor; use axum::Json; use axum::extract::State; use axum::response::IntoResponse; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::AppCaches; use crate::auth::ServiceAuth; use crate::config::Config; use crate::constants; use crate::db::{self, CollectionId, ItemId, ProjectId, Slug}; use crate::email::EmailClient; use crate::error::{AppError, Result, ResultExt}; /// User ID query parameter shared by all internal endpoints. #[derive(Deserialize)] pub(super) struct UserIdParam {} // --- Tags --- #[derive(Deserialize)] pub(super) struct TagItemRequest { item_id: ItemId, tag_id: String, } #[derive(Serialize)] struct TagView { id: String, name: String, slug: String, is_primary: bool, } /// GET /api/internal/creator/items/{id}/tags?user_id=... #[tracing::instrument(skip_all, name = "internal::list_item_tags")] pub(super) async fn list_item_tags( State(db): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Path(item_id): axum::extract::Path, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let tags = db::tags::get_tags_for_item(&db, item_id).await?; let views: Vec = tags .iter() .map(|t| TagView { id: t.tag_id.to_string(), name: t.tag_name.clone(), slug: t.tag_slug.clone(), is_primary: t.is_primary, }) .collect(); Ok(Json(views)) } /// POST /api/internal/creator/items/tags #[tracing::instrument(skip_all, name = "internal::add_item_tag")] pub(super) async fn add_item_tag( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&db, req.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let tag_id: db::TagId = req .tag_id .parse::() .map(db::TagId::from) .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?; let _tag = db::tags::get_tag_by_id(&db, tag_id) .await? .ok_or_else(|| AppError::validation("Tag not found".to_string()))?; db::tags::add_tag_to_item(&db, req.item_id, tag_id, false).await?; Ok(Json(serde_json::json!({"success": true}))) } /// POST /api/internal/creator/items/tags/remove #[tracing::instrument(skip_all, name = "internal::remove_item_tag")] pub(super) async fn remove_item_tag( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&db, req.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let tag_id: db::TagId = req .tag_id .parse::() .map(db::TagId::from) .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?; db::tags::remove_tag_from_item(&db, req.item_id, tag_id).await?; Ok(Json(serde_json::json!({"success": true}))) } #[derive(Deserialize)] pub(super) struct TagSearchQuery { q: String, } /// GET /api/internal/tags/search?q=... #[tracing::instrument(skip_all, name = "internal::search_tags")] pub(super) async fn search_tags( State(db): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let tags = db::tags::search_tags(&db, &q.q, 20).await?; let views: Vec = tags .iter() .map(|t| TagView { id: t.id.to_string(), name: t.name.clone(), slug: t.slug.clone(), is_primary: false, }) .collect(); Ok(Json(views)) } // --- Broadcast --- #[derive(Deserialize)] pub(super) struct BroadcastRequest { subject: String, body: String, } /// POST /api/internal/creator/broadcast #[tracing::instrument(skip_all, name = "internal::send_broadcast")] pub(super) async fn send_broadcast( State(db): State, State(config): State, State(email): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { if req.subject.is_empty() || req.subject.len() > 200 { return Err(AppError::validation( "Subject must be 1-200 characters".to_string(), )); } if req.body.is_empty() || req.body.len() > 5000 { return Err(AppError::validation( "Body must be 1-5000 characters".to_string(), )); } let db_user = db::users::get_user_by_id(&db, actor.user_id()) .await? .ok_or(AppError::NotFound)?; if !db_user.can_create_projects { return Err(AppError::Forbidden); } let set = db::users::try_set_broadcast_at(&db, actor.user_id()).await?; if !set { return Err(AppError::validation( "You can only send one broadcast per 24 hours".to_string(), )); } // Enforce the broadcast recipient cap at the type level, the same // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs) // uses, so the two handlers can no longer drift on the cap check. let followers = db::follows::get_follower_emails(&db, actor.user_id()).await?; let recipients = match crate::email::BoundedRecipients::new(followers) { Ok(r) => r, Err(count) => { // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted. let _ = db::users::clear_broadcast_at(&db, actor.user_id()).await; return Err(AppError::validation(format!( "Broadcast would reach {count} followers, above the per-send limit of 10,000. \ Email info@makenot.work to lift the cap for your account." ))); } }; let count = recipients.len(); // The monthly allowance, the same gate the public twin applies. Bounds the // count over the billing month, which neither the per-send cap above nor the // 24h slot does (`db::mail_caps`). let verdict = db::mail_caps::reserve( &db, actor.user_id(), i64::try_from(count).unwrap_or(i64::MAX), ) .await?; if let Some(message) = verdict.refusal_message() { // Roll back the 24h slot: the broadcast never left. let _ = db::users::clear_broadcast_at(&db, actor.user_id()).await; return Err(AppError::validation(message)); } if count > 0 { // The same attribution the public twin records (`93f23f00`). No list: // a broadcast goes to a creator's followers, which is not one. let send = db::mail_attribution::record_send( &db, actor.user_id(), None, db::mail_attribution::SendKind::Broadcast, i64::try_from(count).unwrap_or(i64::MAX), ) .await .inspect_err(|error| { tracing::warn!(error = ?error, user_id = %actor.user_id(), "could not record the send; this broadcast will be unattributed"); }) .ok(); let followers = recipients.into_inner(); let creator_name = db_user .display_name .as_deref() .unwrap_or(&db_user.username) .to_string(); let host_url = config.host_url.clone(); let signing_secret = config.signing_secret.clone(); let creator_id = actor.user_id(); let subject = req.subject.clone(); let body = req.body.clone(); let email_client = email.clone(); tokio::spawn(async move { let mut set = tokio::task::JoinSet::new(); let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS); for follower in followers { if set.len() >= constants::BROADCAST_PARALLELISM { let _ = set.join_next().await; } let email_client = email_client.clone(); let host_url = host_url.clone(); let signing_secret = signing_secret.clone(); let creator_name = creator_name.clone(); let subject = subject.clone(); let body = body.clone(); let creator_id_str = creator_id.to_string(); set.spawn(async move { let unsub_url = crate::email::generate_unsubscribe_url( &host_url, follower.id, crate::email::UnsubscribeAction::Broadcast, &creator_id_str, &signing_secret, ); if let Err(e) = email_client .send_broadcast( &follower.email, follower.display_name.as_deref(), &creator_name, &subject, &body, crate::email::Fanout { unsub_url: Some(&unsub_url), send, }, ) .await { tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed"); } }); tokio::time::sleep(chunk_delay).await; } while set.join_next().await.is_some() {} }); } Ok(Json( serde_json::json!({"success": true, "recipient_count": count}), )) } // --- Tiers --- #[derive(Serialize)] struct TierView { id: String, name: String, description: String, price_cents: i32, is_active: bool, } /// GET /api/internal/creator/projects/{id}/tiers?user_id=... #[tracing::instrument(skip_all, name = "internal::list_tiers")] pub(super) async fn list_tiers( State(db): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Path(project_id): axum::extract::Path, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let project = db::projects::get_project_by_id(&db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != actor.user_id() { return Err(AppError::Forbidden); } let tiers = db::subscriptions::get_all_tiers_by_project(&db, project_id).await?; let views: Vec = tiers .iter() .map(|t| TierView { id: t.id.to_string(), name: t.name.clone(), description: t.description.clone().unwrap_or_default(), price_cents: t.price_cents, is_active: t.is_active, }) .collect(); Ok(Json(views)) } // --- Collections --- #[derive(Deserialize)] pub(super) struct CreateCollectionRequest { slug: String, title: String, description: Option, is_public: Option, } #[derive(Serialize)] struct CollectionView { id: String, slug: String, title: String, description: String, is_public: bool, item_count: i64, } /// GET /api/internal/creator/collections?user_id=... #[tracing::instrument(skip_all, name = "internal::list_collections")] pub(super) async fn list_collections( State(db): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let collections = db::collections::get_collections_by_user(&db, actor.user_id()).await?; let views: Vec = collections .iter() .map(|c| CollectionView { id: c.id.to_string(), slug: c.slug.to_string(), title: c.title.clone(), description: c.description.clone().unwrap_or_default(), is_public: c.is_public, item_count: c.item_count, }) .collect(); Ok(Json(views)) } /// POST /api/internal/creator/collections #[tracing::instrument(skip_all, name = "internal::create_collection")] pub(super) async fn create_collection( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let slug = Slug::new(&req.slug).map_err(|e| AppError::validation(e.to_string()))?; let collection = db::collections::create_collection( &db, actor.user_id(), &slug, &req.title, req.description.as_deref(), req.is_public.unwrap_or(true), ) .await?; Ok(Json(serde_json::json!({ "id": collection.id.to_string(), "slug": collection.slug.to_string(), "title": collection.title, }))) } /// DELETE /api/internal/creator/collections/{id}?user_id=... #[tracing::instrument(skip_all, name = "internal::delete_collection")] pub(super) async fn delete_collection( State(db): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Path(collection_id): axum::extract::Path, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let collection = db::collections::get_collection_by_id(&db, collection_id) .await? .ok_or(AppError::NotFound)?; if collection.user_id != actor.user_id() { return Err(AppError::Forbidden); } db::collections::delete_collection(&db, collection_id, actor.user_id()).await?; Ok(axum::http::StatusCode::NO_CONTENT) } // --- Custom Domains --- #[derive(Deserialize)] pub(super) struct AddDomainRequest { domain: String, } /// GET /api/internal/creator/domain?user_id=... #[tracing::instrument(skip_all, name = "internal::get_domain")] pub(super) async fn get_domain( State(db): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let domain = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()).await?; match domain { Some(d) => Ok(Json(serde_json::json!({ "id": d.id.to_string(), "domain": d.domain, "verified": d.verified, "verification_token": d.verification_token, }))), None => Ok(Json(serde_json::json!(null))), } } /// POST /api/internal/creator/domain #[tracing::instrument(skip_all, name = "internal::add_domain")] pub(super) async fn add_domain( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { let domain = req.domain.to_lowercase().trim().to_string(); // Reuse the web path's validator (per-label length + charset + hyphen-edge // checks) instead of the weaker `contains('.')` gate, so the CLI and web // domain paths enforce one contract (audit Run 13 API-consistency). crate::routes::api::domains::validate_domain(&domain)?; let token = generate_verification_token(); // Map the global `UNIQUE(domain)` violation to a clean 409, matching the web // path, a domain another user already holds must not surface as a raw 500. let record = db::custom_domains::create_custom_domain(&db, actor.user_id(), &domain, &token) .await .map_err(|e| { crate::helpers::map_unique_violation(e, "That domain is already registered") })?; Ok(Json(serde_json::json!({ "id": record.id.to_string(), "domain": record.domain, "verified": record.verified, "verification_token": record.verification_token, "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), }))) } /// POST /api/internal/creator/domain/verify?user_id=... #[tracing::instrument(skip_all, name = "internal::verify_domain")] pub(super) async fn verify_domain( State(db): State, State(caches): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()) .await? .ok_or(AppError::NotFound)?; if record.verified { return Ok(Json( serde_json::json!({"verified": true, "message": "Already verified"}), )); } // DNS lookup via Cloudflare DoH let lookup_name = format!("_mnw-verify.{}", record.domain); let url = format!("https://cloudflare-dns.com/dns-query?name={lookup_name}&type=TXT"); let resp = crate::helpers::HTTP_CLIENT .get(&url) .header("accept", "application/dns-json") .timeout(std::time::Duration::from_secs(5)) .send() .await .context("dns lookup")?; let json: serde_json::Value = resp.json().await.context("parse dns response")?; let verified = json["Answer"].as_array().is_some_and(|answers| { answers.iter().any(|a| { a["data"] .as_str() .is_some_and(|d| d.trim_matches('"') == record.verification_token) }) }); if verified { db::custom_domains::mark_domain_verified(&db, record.id).await?; caches .domain_cache .insert(record.domain.clone(), actor.user_id()); Ok(Json( serde_json::json!({"verified": true, "message": "Domain verified"}), )) } else { Ok(Json( serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)}), )) } } /// DELETE /api/internal/creator/domain?user_id=... #[tracing::instrument(skip_all, name = "internal::remove_domain")] pub(super) async fn remove_domain( State(db): State, State(caches): State, actor: InternalActor, _auth: ServiceAuth, axum::extract::Query(_q): axum::extract::Query, ) -> Result { let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()) .await? .ok_or(AppError::NotFound)?; db::custom_domains::delete_custom_domain(&db, record.id, actor.user_id()).await?; caches.domain_cache.remove(&record.domain); Ok(axum::http::StatusCode::NO_CONTENT) } fn generate_verification_token() -> String { let mut bytes = [0u8; 16]; rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes); format!("mnw-verify-{}", hex::encode(bytes)) } /// Map a project type string to its feature flags. Unknown types default to /// `["downloads"]` (the safest superset for an unrecognised request). fn features_for_project_type(project_type: &str) -> Vec { match project_type { "audio" => vec!["audio".to_string()], "digital" => vec!["downloads".to_string()], "video" => vec!["video".to_string()], "mixed" => vec!["audio".to_string(), "downloads".to_string()], "subscription" => vec!["subscriptions".to_string()], _ => vec!["downloads".to_string()], } } /// Derive a URL-safe slug from a title: lowercase, alphanumeric + space, then /// collapse runs of whitespace to single hyphens. Returns `"project"` when the /// input contains no alphanumerics. fn slug_from_title(title: &str) -> String { let s: String = title .to_lowercase() .chars() .map(|c| { if c.is_alphanumeric() || c == ' ' { c } else { ' ' } }) .collect::() .split_whitespace() .collect::>() .join("-"); if s.is_empty() { "project".to_string() } else { s } } // --- Project creation --- #[derive(Deserialize)] pub(super) struct CreateProjectRequest { title: String, project_type: String, description: Option, } #[derive(Serialize)] struct CreateProjectResponse { id: String, slug: String, title: String, project_type: String, } /// POST /api/internal/creator/projects #[tracing::instrument(skip_all, name = "internal::create_project")] pub(super) async fn create_project( State(db): State, actor: InternalActor, _auth: ServiceAuth, Json(req): Json, ) -> Result { // Verify user can create projects let user = db::users::get_user_by_id(&db, actor.user_id()) .await? .ok_or(AppError::NotFound)?; if !user.can_create_projects { return Err(AppError::Forbidden); } if req.title.is_empty() || req.title.len() > 100 { return Err(AppError::BadRequest( "Title must be 1-100 characters".to_string(), )); } let features = features_for_project_type(&req.project_type); let slug = Slug::from_trusted(slug_from_title(&req.title)); let project = db::projects::create_project( &db, actor.user_id(), &slug, &req.title, req.description.as_deref(), &features, ) .await?; Ok(Json(CreateProjectResponse { id: project.id.to_string(), slug: project.slug.to_string(), title: project.title, project_type: project.project_type.to_string(), })) } #[cfg(test)] mod tests { use super::*; // --- generate_verification_token --- #[test] fn verification_token_has_expected_prefix_and_length() { let t = generate_verification_token(); // "mnw-verify-" (11) + 32 hex chars (16 bytes × 2) = 43. assert!(t.starts_with("mnw-verify-"), "token prefix wrong: {t}"); assert_eq!(t.len(), 11 + 32, "token length wrong: {t}"); let hex_part = &t[11..]; assert!( hex_part.chars().all(|c| c.is_ascii_hexdigit()), "non-hex suffix: {hex_part}" ); } #[test] fn verification_tokens_are_unique() { let a = generate_verification_token(); let b = generate_verification_token(); assert_ne!(a, b, "two tokens collided"); } // --- features_for_project_type, each match arm --- #[test] fn features_audio() { assert_eq!( features_for_project_type("audio"), vec!["audio".to_string()] ); } #[test] fn features_digital() { assert_eq!( features_for_project_type("digital"), vec!["downloads".to_string()] ); } #[test] fn features_video() { assert_eq!( features_for_project_type("video"), vec!["video".to_string()] ); } #[test] fn features_mixed_combines_audio_and_downloads_in_order() { // Pins ordering, `vec!["audio", "downloads"]` not the reverse. assert_eq!( features_for_project_type("mixed"), vec!["audio".to_string(), "downloads".to_string()], ); } #[test] fn features_subscription() { assert_eq!( features_for_project_type("subscription"), vec!["subscriptions".to_string()] ); } #[test] fn features_unknown_defaults_to_downloads() { // Pins the `_ => vec!["downloads"]` fallback. assert_eq!( features_for_project_type("unknown"), vec!["downloads".to_string()] ); assert_eq!(features_for_project_type(""), vec!["downloads".to_string()]); // Case-sensitive: "Audio" is not "audio". assert_eq!( features_for_project_type("Audio"), vec!["downloads".to_string()] ); } // --- slug_from_title --- #[test] fn slug_lowercases_and_hyphenates_words() { assert_eq!(slug_from_title("Hello World"), "hello-world"); } #[test] fn slug_strips_non_alphanumeric() { // Pins `is_alphanumeric() || c == ' '`, punctuation becomes a space // which then collapses with adjacent whitespace. assert_eq!(slug_from_title("Project: A & B!"), "project-a-b"); } #[test] fn slug_collapses_runs_of_whitespace() { assert_eq!(slug_from_title("a b\tc"), "a-b-c"); } #[test] fn slug_keeps_digits() { assert_eq!(slug_from_title("V2 Beats"), "v2-beats"); } #[test] fn slug_unicode_alphanumeric_passes_through() { // `is_alphanumeric()` is Unicode-aware. Lowercased Greek letters survive. let s = slug_from_title("Λ Test"); // Don't pin the exact case-folded form; just that the alphanumerics are preserved. assert!(s.contains("test")); assert!(!s.is_empty()); } #[test] fn slug_empty_input_defaults_to_project() { // Pins the `if s.is_empty() { "project" }` fallback. assert_eq!(slug_from_title(""), "project"); assert_eq!(slug_from_title(" "), "project"); assert_eq!(slug_from_title("!!! ???"), "project"); } #[test] fn slug_single_word_no_hyphen() { assert_eq!(slug_from_title("Solo"), "solo"); } #[test] fn slug_leading_trailing_whitespace_ignored() { // split_whitespace handles edges. assert_eq!(slug_from_title(" hello world "), "hello-world"); } }