//! Internal API endpoints for CLI feature access (tags, broadcast, tiers, //! collections, custom domains). use axum::extract::State; use axum::response::IntoResponse; use axum::Json; use serde::{Deserialize, Serialize}; use crate::auth::ServiceAuth; use crate::constants; use crate::db::{self, CollectionId, ItemId, ProjectId, Slug, UserId}; use crate::error::{AppError, Result, ResultExt}; use crate::AppState; /// User ID query parameter shared by all internal endpoints. #[derive(Deserialize)] pub(super) struct UserIdParam { pub user_id: UserId, } // ── Tags ── #[derive(Deserialize)] pub(super) struct TagItemRequest { user_id: UserId, 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=... pub(super) async fn list_item_tags( State(state): State, _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(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != q.user_id { return Err(AppError::Forbidden); } let tags = db::tags::get_tags_for_item(&state.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.to_string(), is_primary: t.is_primary, }) .collect(); Ok(Json(views)) } /// POST /api/internal/creator/items/tags pub(super) async fn add_item_tag( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, req.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.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(&state.db, tag_id) .await? .ok_or_else(|| AppError::validation("Tag not found".to_string()))?; db::tags::add_tag_to_item(&state.db, req.item_id, tag_id, false).await?; Ok(Json(serde_json::json!({"success": true}))) } /// POST /api/internal/creator/items/tags/remove pub(super) async fn remove_item_tag( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let item = db::items::get_item_by_id(&state.db, req.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != req.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(&state.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=... pub(super) async fn search_tags( State(state): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let tags = db::tags::search_tags(&state.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.to_string(), is_primary: false, }) .collect(); Ok(Json(views)) } // ── Broadcast ── #[derive(Deserialize)] pub(super) struct BroadcastRequest { user_id: UserId, subject: String, body: String, } /// POST /api/internal/creator/broadcast pub(super) async fn send_broadcast( State(state): State, _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(&state.db, req.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(&state.db, req.user_id).await?; if !set { return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string())); } let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?; let count = followers.len(); if count > 0 { let creator_name = db_user.display_name.as_deref() .unwrap_or(&db_user.username) .to_string(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let creator_id = req.user_id; let subject = req.subject.clone(); let body = req.body.clone(); let email_client = state.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, Some(&unsub_url), ).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=... pub(super) async fn list_tiers( State(state): State, _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(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if project.user_id != q.user_id { return Err(AppError::Forbidden); } let tiers = db::subscriptions::get_all_tiers_by_project(&state.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 { user_id: UserId, 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=... pub(super) async fn list_collections( State(state): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let collections = db::collections::get_collections_by_user(&state.db, q.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 pub(super) async fn create_collection( State(state): State, _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( &state.db, req.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=... pub(super) async fn delete_collection( State(state): State, _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(&state.db, collection_id) .await? .ok_or(AppError::NotFound)?; if collection.user_id != q.user_id { return Err(AppError::Forbidden); } db::collections::delete_collection(&state.db, collection_id).await?; Ok(axum::http::StatusCode::NO_CONTENT) } // ── Custom Domains ── #[derive(Deserialize)] pub(super) struct AddDomainRequest { user_id: UserId, domain: String, } /// GET /api/internal/creator/domain?user_id=... pub(super) async fn get_domain( State(state): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let domain = db::custom_domains::get_custom_domain_by_user(&state.db, q.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 pub(super) async fn add_domain( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { let domain = req.domain.to_lowercase().trim().to_string(); if domain.is_empty() || domain.len() > 253 || !domain.contains('.') { return Err(AppError::validation("Invalid domain".to_string())); } if domain.contains("makenot.work") || domain.contains("makenotwork") { return Err(AppError::validation("Cannot use MNW domains".to_string())); } let token = generate_verification_token(); let record = db::custom_domains::create_custom_domain(&state.db, req.user_id, &domain, &token).await?; 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=... pub(super) async fn verify_domain( State(state): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.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={}&type=TXT", lookup_name ); let resp = reqwest::Client::new() .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() .map(|answers| { answers.iter().any(|a| { a["data"] .as_str() .map(|d| d.trim_matches('"') == record.verification_token) .unwrap_or(false) }) }) .unwrap_or(false); if verified { db::custom_domains::mark_domain_verified(&state.db, record.id).await?; state.domain_cache.insert(record.domain.clone(), q.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=... pub(super) async fn remove_domain( State(state): State, _auth: ServiceAuth, axum::extract::Query(q): axum::extract::Query, ) -> Result { let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id) .await? .ok_or(AppError::NotFound)?; db::custom_domains::delete_custom_domain(&state.db, record.id, q.user_id).await?; state.domain_cache.remove(&record.domain); Ok(axum::http::StatusCode::NO_CONTENT) } fn generate_verification_token() -> String { let mut bytes = [0u8; 16]; rand::RngCore::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 { user_id: UserId, 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 pub(super) async fn create_project( State(state): State, _auth: ServiceAuth, Json(req): Json, ) -> Result { // Verify user can create projects let user = db::users::get_user_by_id(&state.db, req.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( &state.db, req.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"); } }