//! Project API: create, update, delete. use axum::{ extract::{Path, State}, http::header::HeaderMap, response::{IntoResponse, Response}, Form, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility}, error::{AppError, Result, ResultExt}, helpers::{htmx_toast_response, is_htmx_request}, types::ListResponse, validation, AppState, }; use super::verify_project_ownership; // ============================================================================= // Project API // ============================================================================= /// Form input for creating a new project. #[derive(Debug, Deserialize)] pub struct CreateProjectRequest { pub slug: Slug, pub title: String, pub description: Option, #[serde(default)] pub features: Vec, pub category: Option, } /// JSON response representing a project. #[derive(Debug, Serialize)] pub struct ProjectResponse { pub id: ProjectId, pub slug: String, pub title: String, pub description: Option, pub project_type: ProjectType, pub features: Vec, pub is_public: bool, } /// Create a new project for the authenticated creator. #[tracing::instrument(skip_all, name = "projects::create_project")] pub(super) async fn create_project( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; // Gate: only creators can create projects if !user.can_create_projects { return Err(AppError::Forbidden); } // Validate input (slug is validated by Slug's Deserialize impl) validation::validate_project_title(&req.title)?; if let Some(ref desc) = req.description { validation::validate_project_description(desc)?; } // Resolve category if provided let category_id = if let Some(ref cat_name) = req.category { let trimmed = cat_name.trim(); if !trimmed.is_empty() { let cat = db::categories::get_or_create_category(&state.db, trimmed).await?; Some(cat.id) } else { None } } else { None }; // Validate feature values for f in &req.features { f.parse::() .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?; } let project = db::projects::create_project( &state.db, user.id, &req.slug, &req.title, req.description.as_deref(), &req.features, ) .await?; // Set category if resolved if let Some(cat_id) = category_id { db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?; } // Create default mailing lists (non-blocking) if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &req.title).await { tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists"); } db::users::bump_cache_generation(&state.db, user.id).await?; db::projects::bump_cache_generation(&state.db, project.id).await?; // Fire-and-forget: provision a paired MT community if let Some(ref mt) = state.mt_client { let mt = mt.clone(); let db = state.db.clone(); let project_id = project.id; let slug = project.slug.to_string(); let title = project.title.clone(); let desc = project.description.clone(); let username = user.username.to_string(); let display_name = user.display_name.clone(); let user_id = user.id; tokio::spawn(async move { match mt .create_community(&crate::mt_client::CreateCommunityRequest { name: title, slug, description: desc, owner_mnw_id: *user_id, owner_username: username, owner_display_name: display_name, }) .await { Ok(resp) => { if let Err(e) = db::projects::set_mt_community_id(&db, project_id, resp.community_id).await { tracing::warn!(error = ?e, "failed to store MT community ID"); } } Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"), } }); } if is_htmx_request(&headers) { // Return HX-Redirect header to redirect to the project dashboard let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/project/{}", project.slug) .parse() .expect("static redirect path is valid"), ); return Ok(response); } Ok(Json(ProjectResponse { id: project.id, slug: project.slug.to_string(), title: project.title, description: project.description, project_type: project.project_type, features: project.features, is_public: project.is_public, }).into_response()) } /// List all projects for the authenticated user. #[tracing::instrument(skip_all, name = "projects::list_projects")] pub(super) async fn list_projects( State(state): State, AuthUser(user): AuthUser, ) -> Result { let projects = db::projects::get_projects_by_user(&state.db, user.id).await?; let data: Vec = projects .into_iter() .map(|p| ProjectResponse { id: p.id, slug: p.slug.to_string(), title: p.title, description: p.description, project_type: p.project_type, features: p.features, is_public: p.is_public, }) .collect(); Ok(Json(ListResponse { data })) } /// JSON input for updating an existing project. #[derive(Debug, Deserialize)] pub struct UpdateProjectRequest { pub title: Option, pub description: Option, pub features: Option>, pub is_public: Option, pub category: Option, /// Pricing model as kebab string: "free" | "buy_once" | "pwyw" | "subscription". pub pricing_model: Option, /// Buy-once price in dollars. Required when pricing_model="buy_once". pub price_dollars: Option, /// PWYW minimum in dollars. Optional when pricing_model="pwyw". pub pwyw_min_dollars: Option, } /// Update an existing project owned by the authenticated user. #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))] pub(super) async fn update_project( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&id)); user.check_not_suspended()?; verify_project_ownership(&state, id, user.id).await?; // Validate input (same rules as create_project, but all fields are optional) if let Some(ref title) = req.title { validation::validate_project_title(title)?; } if let Some(ref desc) = req.description { validation::validate_project_description(desc)?; } // Resolve category if provided if let Some(ref cat_name) = req.category { let trimmed = cat_name.trim(); if trimmed.is_empty() { db::projects::set_project_category(&state.db, id, user.id, None).await?; } else { let cat = db::categories::get_or_create_category(&state.db, trimmed).await?; db::projects::set_project_category(&state.db, id, user.id, Some(cat.id)).await?; } } // Validate feature values if provided if let Some(ref features) = req.features { for f in features { f.parse::() .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?; } } let updated = db::projects::update_project( &state.db, id, user.id, req.title.as_deref(), req.description.as_deref(), req.features.as_deref(), req.is_public, ) .await?; if let Some(ref model_str) = req.pricing_model { let kind: db::PricingKind = model_str .parse() .map_err(|_| AppError::validation(format!("Invalid pricing_model: {model_str}")))?; let price_cents = if kind == db::PricingKind::BuyOnce { let dollars = req.price_dollars.ok_or_else(|| { AppError::validation("price_dollars required for buy_once") })?; // Reject NaN/Inf/negative/overflow before the cast — the raw // `(dollars * 100.0).round() as i32` form silently turns NaN into 0 // and saturates large values to i32::MAX. let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?; if cents < 50 { return Err(AppError::validation( "price_dollars must be at least 0.50", )); } cents } else { 0 }; let pwyw_min_cents = if kind == db::PricingKind::Pwyw { let dollars = req.pwyw_min_dollars.unwrap_or(0.0); Some(crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?) } else { None }; db::projects::update_project_pricing( &state.db, id, user.id, kind, price_cents, pwyw_min_cents, ) .await?; } db::projects::bump_cache_generation(&state.db, id).await?; Ok(Json(ProjectResponse { id: updated.id, slug: updated.slug.to_string(), title: updated.title, description: updated.description, project_type: updated.project_type, features: updated.features, is_public: updated.is_public, })) } /// Delete a project owned by the authenticated user. /// /// Before deleting, enqueues all S3 keys (item files, version files, project /// cover image) for durable deletion and decrements the user's storage counter. #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))] pub(super) async fn delete_project( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&id)); user.check_not_suspended()?; let project = verify_project_ownership(&state, id, user.id).await?; // Collect all S3 keys from items + versions before CASCADE delete destroys them let item_keys = db::items::get_project_item_s3_keys(&state.db, id).await?; let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?; // Include the project cover image if present let mut all_keys: Vec<(String, String)> = item_keys .into_iter() .chain(version_keys) .map(|k| (k, "main".to_string())) .collect(); if let Some(ref url) = project.cover_image_url && let Some(key) = crate::storage::extract_s3_key_from_url( url, state.config.cdn_base_url.as_deref(), state.s3.as_deref().map(|s| s.bucket()), state.config.storage.as_ref().map(|c| c.endpoint.as_str()), ) { all_keys.push((key, "main".to_string())); } // Enqueue for durable S3 deletion (survives crashes) if let Err(e) = db::pending_s3_deletions::enqueue_deletions( &state.db, &all_keys, "project_delete", ) .await { tracing::warn!(error = ?e, "failed to enqueue S3 deletions for project"); } // Decrement storage before deleting rows let storage_bytes = db::items::get_project_storage_bytes(&state.db, id).await?; if storage_bytes > 0 && let Err(e) = db::creator_tiers::decrement_storage_used(&state.db, user.id, storage_bytes).await { tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete"); } db::projects::delete_project(&state.db, id, user.id).await?; db::users::bump_cache_generation(&state.db, user.id).await?; Ok(htmx_toast_response("Project deleted", "success")) } // ============================================================================= // Git Repo Linking // ============================================================================= /// JSON input for linking a repo to a project. #[derive(Debug, Deserialize)] pub struct LinkRepoRequest { pub name: String, } /// Link a git repo to a project. The repo must exist and be owned by the same user. #[tracing::instrument(skip_all, name = "projects::link_repo")] pub(super) async fn link_repo( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&state, id, user.id).await?; let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &req.name) .await? .ok_or(AppError::validation("Repository not found".to_string()))?; db::git_repos::link_repo_to_project(&state.db, repo.id, id).await?; db::projects::bump_cache_generation(&state.db, id).await?; Ok(htmx_toast_response("Repository linked", "success")) } /// Unlink a git repo from a project. The repo must be owned by the same user. #[tracing::instrument(skip_all, name = "projects::unlink_repo")] pub(super) async fn unlink_repo( State(state): State, AuthUser(user): AuthUser, Path((id, repo_name)): Path<(ProjectId, String)>, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&state, id, user.id).await?; let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &repo_name) .await? .ok_or(AppError::validation("Repository not found".to_string()))?; db::git_repos::unlink_repo_from_project(&state.db, repo.id).await?; db::projects::bump_cache_generation(&state.db, id).await?; Ok(htmx_toast_response("Repository unlinked", "success")) } // ============================================================================= // Git Repo Creation + Visibility // ============================================================================= /// JSON input for creating a bare repo on disk. #[derive(Debug, Deserialize)] pub struct CreateRepoRequest { pub name: String, pub visibility: Option, } /// JSON response representing a git repo. #[derive(Debug, Serialize)] pub struct RepoResponse { pub id: GitRepoId, pub name: String, pub visibility: Visibility, } /// Create a bare git repo on disk and register it in the DB. #[tracing::instrument(skip_all, name = "projects::create_repo")] pub(super) async fn create_repo( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules) let name = req.name.trim(); if name.is_empty() || name.len() > 64 { return Err(AppError::validation("Repository name must be 1-64 characters".to_string())); } if name.starts_with('.') || name == ".." { return Err(AppError::validation("Invalid repository name".to_string())); } if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(AppError::validation( "Repository name may only contain letters, numbers, hyphens, underscores, and dots".to_string(), )); } // Validate visibility (enum deserialization handles validation) let visibility = req.visibility.unwrap_or(Visibility::Public); // Need git_repos_path configured let git_root = state .config .git_repos_path .as_deref() .ok_or_else(|| AppError::validation("Git repositories are not configured on this server".to_string()))?; // Check repo doesn't already exist in DB if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() { return Err(AppError::validation("A repository with that name already exists".to_string())); } // Create bare repo on disk: {git_root}/{username}/{name}.git let username = user.username.to_string(); let owner_dir = std::path::Path::new(git_root).join(&username); let repo_dir = owner_dir.join(format!("{name}.git")); if repo_dir.exists() { return Err(AppError::validation("A repository with that name already exists on disk".to_string())); } std::fs::create_dir_all(&owner_dir) .context("create git owner directory")?; git2::Repository::init_bare(&repo_dir) .context("init bare git repo")?; // Install post-receive hook if build triggers are configured if let Some(token) = &state.config.build_trigger_token { let hooks_dir = repo_dir.join("hooks"); let hook_path = hooks_dir.join("post-receive"); let hook_content = crate::build_runner::post_receive_hook(token, &username, name); if let Err(e) = std::fs::write(&hook_path, &hook_content) { tracing::warn!(error = ?e, "failed to install post-receive hook"); } else { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions( &hook_path, std::fs::Permissions::from_mode(0o755), ); } } } // Register in DB let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?; Ok(Json(RepoResponse { id: db_repo.id, name: db_repo.name, visibility: db_repo.visibility, })) } /// JSON input for updating repo visibility. #[derive(Debug, Deserialize)] pub struct UpdateRepoVisibilityRequest { pub visibility: Visibility, } /// Update a repo's visibility. The repo must be owned by the authenticated user. #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")] pub(super) async fn update_repo_visibility( State(state): State, AuthUser(user): AuthUser, Path(repo_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let repo = db::git_repos::get_repos_by_user(&state.db, user.id) .await? .into_iter() .find(|r| r.id == repo_id) .ok_or(AppError::NotFound)?; if repo.user_id != user.id { return Err(AppError::Forbidden); } db::git_repos::update_visibility(&state.db, repo_id, req.visibility).await?; Ok(htmx_toast_response("Visibility updated", "success")) } // ============================================================================= // Project Members API // ============================================================================= /// Form input for adding a project member. #[derive(Debug, Deserialize)] pub struct AddMemberForm { pub username: String, pub split_percent: i16, pub role: Option, } /// POST /api/projects/{id}/members - Add a member to a project #[tracing::instrument(skip_all, name = "api::add_project_member")] pub async fn add_project_member( State(state): State, AuthUser(session_user): AuthUser, Path(project_id): Path, Form(form): Form, ) -> Result { let project_id: ProjectId = project_id.parse::() .map(ProjectId::from) .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?; let _project = verify_project_ownership(&state, project_id, session_user.id).await?; // Validate split percent if form.split_percent < 1 || form.split_percent > 99 { return Err(AppError::validation("Split must be between 1% and 99%".to_string())); } // Look up the member by username (validate untrusted form input) let username = db::Username::new(&form.username)?; let member_user = db::users::get_user_by_username(&state.db, &username) .await? .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; // Can't add yourself if member_user.id == session_user.id { return Err(AppError::validation("You are already the project owner".to_string())); } let role = form.role.unwrap_or(db::ProjectRole::Member); db::project_members::add_project_member( &state.db, project_id, member_user.id, role, form.split_percent, session_user.id, ).await?; // Bump cache generation so the tab refreshes db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response( &format!("Added @{} with {}% split", member_user.username, form.split_percent), "success", ).into_response()) } /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member #[tracing::instrument(skip_all, name = "api::remove_project_member")] pub async fn remove_project_member( State(state): State, AuthUser(session_user): AuthUser, Path((project_id, user_id)): Path<(String, String)>, ) -> Result { let project_id: ProjectId = project_id.parse::() .map(ProjectId::from) .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?; let user_id: db::UserId = user_id.parse::() .map(db::UserId::from) .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?; verify_project_ownership(&state, project_id, session_user.id).await?; let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?; if !removed { return Err(AppError::NotFound); } db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response("Member removed", "success").into_response()) } // ============================================================================= // Repo Collaborators API // ============================================================================= #[derive(Debug, Deserialize)] pub struct AddCollaboratorForm { pub username: String, #[serde(default = "default_true")] pub can_push: bool, } fn default_true() -> bool { true } #[derive(Debug, Serialize)] pub struct CollaboratorResponse { pub user_id: UserId, pub username: String, pub can_push: bool, pub created_at: String, } /// Verify the authenticated user owns this repo and return it. async fn verify_repo_ownership( state: &AppState, repo_id: GitRepoId, user_id: UserId, ) -> Result { let repo = db::git_repos::get_repo_by_id(&state.db, repo_id) .await? .ok_or(AppError::NotFound)?; if repo.user_id != user_id { return Err(AppError::Forbidden); } Ok(repo) } /// POST /api/repos/{id}/collaborators: add a collaborator by username. #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")] pub async fn add_repo_collaborator( State(state): State, AuthUser(user): AuthUser, Path(repo_id): Path, Form(form): Form, ) -> Result { user.check_not_suspended()?; let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; let username = db::Username::new(&form.username)?; let collab_user = db::users::get_user_by_username(&state.db, &username) .await? .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?; if collab_user.id == user.id { return Err(AppError::validation("You are already the repo owner".to_string())); } db::repo_collaborators::add_collaborator(&state.db, repo_id, collab_user.id, form.can_push) .await .map_err(|e| { if let AppError::Database(ref db_err) = e && db_err.to_string().contains("repo_collaborators_repo_id_user_id_key") { return AppError::validation("This user is already a collaborator".to_string()); } e })?; Ok(htmx_toast_response( &format!("Added @{} as collaborator", collab_user.username), "success", ).into_response()) } /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator. #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")] pub async fn remove_repo_collaborator( State(state): State, AuthUser(user): AuthUser, Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>, ) -> Result { user.check_not_suspended()?; let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; let removed = db::repo_collaborators::remove_collaborator(&state.db, repo_id, collab_user_id).await?; if !removed { return Err(AppError::NotFound); } Ok(htmx_toast_response("Collaborator removed", "success").into_response()) } /// GET /api/repos/{id}/collaborators: list collaborators (JSON). #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")] pub async fn list_repo_collaborators( State(state): State, AuthUser(user): AuthUser, Path(repo_id): Path, ) -> Result { let _repo = verify_repo_ownership(&state, repo_id, user.id).await?; let collabs = db::repo_collaborators::list_collaborators(&state.db, repo_id).await?; let data: Vec = collabs .into_iter() .map(|c| CollaboratorResponse { user_id: c.user_id, username: c.username, can_push: c.can_push, created_at: c.created_at.format("%b %d, %Y").to_string(), }) .collect(); Ok(Json(ListResponse { data })) }