max / makenotwork
20 files changed,
+656 insertions,
-89 deletions
| @@ -13,7 +13,7 @@ Public code means public scrutiny. Our practices are auditable. | |||
| 13 | 13 | ## Repository | |
| 14 | 14 | ||
| 15 | 15 | ``` | |
| 16 | - | makenot.work/git/maxmj/ | |
| 16 | + | makenot.work/git/max/ | |
| 17 | 17 | ``` | |
| 18 | 18 | ||
| 19 | 19 | Available for review: |
| @@ -170,7 +170,7 @@ All code is publicly available for review. You can: | |||
| 170 | 170 | - Check for vulnerabilities | |
| 171 | 171 | - Report issues responsibly | |
| 172 | 172 | ||
| 173 | - | Repository: [makenot.work/git/maxmj/](https://makenot.work/git/maxmj/) | |
| 173 | + | Repository: [makenot.work/git/max/](https://makenot.work/git/max/) | |
| 174 | 174 | ||
| 175 | 175 | --- | |
| 176 | 176 |
| @@ -3453,7 +3453,7 @@ dependencies = [ | |||
| 3453 | 3453 | ||
| 3454 | 3454 | [[package]] | |
| 3455 | 3455 | name = "makenotwork" | |
| 3456 | - | version = "0.1.7" | |
| 3456 | + | version = "0.1.9" | |
| 3457 | 3457 | dependencies = [ | |
| 3458 | 3458 | "ammonia", | |
| 3459 | 3459 | "anyhow", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.1.8" | |
| 3 | + | version = "0.1.9" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | # Setup SSH git push access on the production VPS. | |
| 3 | 3 | # | |
| 4 | 4 | # Creates a `git` system user with git-shell, home directory at /opt/git/ | |
| 5 | - | # so that `git@makenot.work:maxmj/makenotwork.git` resolves to | |
| 6 | - | # /opt/git/maxmj/makenotwork.git. | |
| 5 | + | # so that `git@makenot.work:max/makenotwork.git` resolves to | |
| 6 | + | # /opt/git/max/makenotwork.git. | |
| 7 | 7 | # | |
| 8 | 8 | # Run as root on the production VPS. | |
| 9 | 9 | ||
| @@ -62,7 +62,7 @@ echo "" | |||
| 62 | 62 | echo "=== Git SSH setup complete ===" | |
| 63 | 63 | echo "" | |
| 64 | 64 | echo "Test with:" | |
| 65 | - | echo " git clone git@makenot.work:maxmj/makenotwork.git" | |
| 65 | + | echo " git clone git@makenot.work:max/makenotwork.git" | |
| 66 | 66 | echo " cd makenotwork && git push" | |
| 67 | 67 | echo "" | |
| 68 | 68 | echo "To add more SSH keys, edit: $GIT_HOME/.ssh/authorized_keys" |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Onboarding email drip sequence tracking. | |
| 2 | + | -- step 0 = no emails sent, 1 = welcome sent, 2 = profile tips sent, 3 = stripe guide sent (done). | |
| 3 | + | ||
| 4 | + | ALTER TABLE users ADD COLUMN onboarding_email_step SMALLINT NOT NULL DEFAULT 0; | |
| 5 | + | ALTER TABLE users ADD COLUMN onboarding_email_sent_at TIMESTAMPTZ; |
| @@ -0,0 +1 @@ | |||
| 1 | + | ALTER TABLE git_repos ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'public'; |
| @@ -6,7 +6,7 @@ use super::models::DbGitRepo; | |||
| 6 | 6 | use super::{GitRepoId, ProjectId, UserId}; | |
| 7 | 7 | use crate::error::Result; | |
| 8 | 8 | ||
| 9 | - | /// Register a new git repository for a user. | |
| 9 | + | /// Register a new git repository for a user (default visibility: public). | |
| 10 | 10 | pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> { | |
| 11 | 11 | let repo = sqlx::query_as::<_, DbGitRepo>( | |
| 12 | 12 | r#" | |
| @@ -23,6 +23,29 @@ pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<D | |||
| 23 | 23 | Ok(repo) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | /// Register a new git repository with explicit visibility. | |
| 27 | + | pub async fn create_repo_with_visibility( | |
| 28 | + | pool: &PgPool, | |
| 29 | + | user_id: UserId, | |
| 30 | + | name: &str, | |
| 31 | + | visibility: &str, | |
| 32 | + | ) -> Result<DbGitRepo> { | |
| 33 | + | let repo = sqlx::query_as::<_, DbGitRepo>( | |
| 34 | + | r#" | |
| 35 | + | INSERT INTO git_repos (user_id, name, visibility) | |
| 36 | + | VALUES ($1, $2, $3) | |
| 37 | + | RETURNING * | |
| 38 | + | "#, | |
| 39 | + | ) | |
| 40 | + | .bind(user_id) | |
| 41 | + | .bind(name) | |
| 42 | + | .bind(visibility) | |
| 43 | + | .fetch_one(pool) | |
| 44 | + | .await?; | |
| 45 | + | ||
| 46 | + | Ok(repo) | |
| 47 | + | } | |
| 48 | + | ||
| 26 | 49 | /// Look up a repo by its owning user and bare name. Returns `None` if not found. | |
| 27 | 50 | pub async fn get_repo_by_user_and_name( | |
| 28 | 51 | pool: &PgPool, | |
| @@ -91,3 +114,18 @@ pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Resu | |||
| 91 | 114 | ||
| 92 | 115 | Ok(()) | |
| 93 | 116 | } | |
| 117 | + | ||
| 118 | + | /// Update the visibility of a repo. | |
| 119 | + | pub async fn update_visibility( | |
| 120 | + | pool: &PgPool, | |
| 121 | + | repo_id: GitRepoId, | |
| 122 | + | visibility: &str, | |
| 123 | + | ) -> Result<()> { | |
| 124 | + | sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1") | |
| 125 | + | .bind(repo_id) | |
| 126 | + | .bind(visibility) | |
| 127 | + | .execute(pool) | |
| 128 | + | .await?; | |
| 129 | + | ||
| 130 | + | Ok(()) | |
| 131 | + | } |
| @@ -134,6 +134,11 @@ pub struct DbUser { | |||
| 134 | 134 | pub notify_release: bool, | |
| 135 | 135 | /// When the creator last sent a broadcast email (rate limiting). | |
| 136 | 136 | pub last_broadcast_at: Option<DateTime<Utc>>, | |
| 137 | + | // Onboarding email drip | |
| 138 | + | /// Current step in the getting-started email sequence (0 = none sent, 3 = complete). | |
| 139 | + | pub onboarding_email_step: i16, | |
| 140 | + | /// When the last onboarding email was sent. | |
| 141 | + | pub onboarding_email_sent_at: Option<DateTime<Utc>>, | |
| 137 | 142 | } | |
| 138 | 143 | ||
| 139 | 144 | impl DbUser { | |
| @@ -196,6 +201,8 @@ pub struct DbGitRepo { | |||
| 196 | 201 | pub project_id: Option<ProjectId>, | |
| 197 | 202 | /// When the repo was registered. | |
| 198 | 203 | pub created_at: DateTime<Utc>, | |
| 204 | + | /// Visibility: "public", "unlisted", or "private". | |
| 205 | + | pub visibility: String, | |
| 199 | 206 | } | |
| 200 | 207 | ||
| 201 | 208 | /// A purchasable or free item within a project. | |
| @@ -1265,6 +1272,8 @@ mod tests { | |||
| 1265 | 1272 | notify_follower: true, | |
| 1266 | 1273 | notify_release: true, | |
| 1267 | 1274 | last_broadcast_at: None, | |
| 1275 | + | onboarding_email_step: 0, | |
| 1276 | + | onboarding_email_sent_at: None, | |
| 1268 | 1277 | } | |
| 1269 | 1278 | } | |
| 1270 | 1279 |
| @@ -485,6 +485,41 @@ pub async fn set_upload_trusted(pool: &PgPool, user_id: UserId, trusted: bool) - | |||
| 485 | 485 | Ok(()) | |
| 486 | 486 | } | |
| 487 | 487 | ||
| 488 | + | // ── Onboarding email drip ── | |
| 489 | + | ||
| 490 | + | /// Users who need the next onboarding email. Returns users at a given step | |
| 491 | + | /// whose last email was sent more than `min_age` ago (or never). | |
| 492 | + | pub async fn get_onboarding_candidates( | |
| 493 | + | pool: &PgPool, | |
| 494 | + | step: i16, | |
| 495 | + | min_age: chrono::Duration, | |
| 496 | + | ) -> Result<Vec<DbUser>> { | |
| 497 | + | let cutoff = chrono::Utc::now() - min_age; | |
| 498 | + | let users = sqlx::query_as::<_, DbUser>( | |
| 499 | + | "SELECT * FROM users | |
| 500 | + | WHERE onboarding_email_step = $1 | |
| 501 | + | AND (onboarding_email_sent_at IS NULL OR onboarding_email_sent_at < $2) | |
| 502 | + | AND suspended_at IS NULL", | |
| 503 | + | ) | |
| 504 | + | .bind(step) | |
| 505 | + | .bind(cutoff) | |
| 506 | + | .fetch_all(pool) | |
| 507 | + | .await?; | |
| 508 | + | Ok(users) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | /// Advance a user's onboarding email step and record the send time. | |
| 512 | + | pub async fn advance_onboarding_step(pool: &PgPool, user_id: UserId, new_step: i16) -> Result<()> { | |
| 513 | + | sqlx::query( | |
| 514 | + | "UPDATE users SET onboarding_email_step = $2, onboarding_email_sent_at = NOW() WHERE id = $1", | |
| 515 | + | ) | |
| 516 | + | .bind(user_id) | |
| 517 | + | .bind(new_step) | |
| 518 | + | .execute(pool) | |
| 519 | + | .await?; | |
| 520 | + | Ok(()) | |
| 521 | + | } | |
| 522 | + | ||
| 488 | 523 | /// Disconnect a user's Stripe account | |
| 489 | 524 | pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<DbUser> { | |
| 490 | 525 | let user = sqlx::query_as::<_, DbUser>( |
| @@ -561,6 +561,96 @@ Check it out: {url} | |||
| 561 | 561 | self.send_email(to, subject, body).await | |
| 562 | 562 | } | |
| 563 | 563 | ||
| 564 | + | // ── Onboarding email sequence ── | |
| 565 | + | ||
| 566 | + | /// Step 1: Welcome email sent immediately after signup. | |
| 567 | + | pub async fn send_onboarding_welcome( | |
| 568 | + | &self, | |
| 569 | + | to_email: &str, | |
| 570 | + | to_name: Option<&str>, | |
| 571 | + | host_url: &str, | |
| 572 | + | ) -> Result<()> { | |
| 573 | + | let subject = "Welcome to Makenot.work"; | |
| 574 | + | let body = format!( | |
| 575 | + | r#"Hi{name}, | |
| 576 | + | ||
| 577 | + | Welcome to Makenot.work — the fair creator platform. | |
| 578 | + | ||
| 579 | + | Here's what makes us different: | |
| 580 | + | - 0% platform fee. You keep everything (minus Stripe's ~3% processing). | |
| 581 | + | - No lock-in. Export your data, cancel anytime. | |
| 582 | + | - Source-available. You can see how everything works. | |
| 583 | + | ||
| 584 | + | To get started, head to your dashboard: | |
| 585 | + | {host_url}/dashboard | |
| 586 | + | ||
| 587 | + | You'll find a checklist there to help you set up your profile, connect Stripe, create your first project, and publish your first item. | |
| 588 | + | ||
| 589 | + | If you have questions, reply to this email — it goes straight to a human. | |
| 590 | + | ||
| 591 | + | - Makenotwork"#, | |
| 592 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 593 | + | host_url = host_url, | |
| 594 | + | ); | |
| 595 | + | ||
| 596 | + | self.send_email(to_email, subject, &body).await | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | /// Step 2: Profile setup tips (sent ~24h after signup). | |
| 600 | + | pub async fn send_onboarding_profile( | |
| 601 | + | &self, | |
| 602 | + | to_email: &str, | |
| 603 | + | to_name: Option<&str>, | |
| 604 | + | host_url: &str, | |
| 605 | + | ) -> Result<()> { | |
| 606 | + | let subject = "Set up your creator profile"; | |
| 607 | + | let body = format!( | |
| 608 | + | r#"Hi{name}, | |
| 609 | + | ||
| 610 | + | A quick tip: creators with a display name and bio get more attention from fans. | |
| 611 | + | ||
| 612 | + | Head to your account settings to add them: | |
| 613 | + | {host_url}/dashboard (Account tab) | |
| 614 | + | ||
| 615 | + | Your display name appears on all your projects and items. Your bio tells fans who you are and what you create. | |
| 616 | + | ||
| 617 | + | Once your profile is set, create your first project — that's where your items live. | |
| 618 | + | ||
| 619 | + | - Makenotwork"#, | |
| 620 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 621 | + | host_url = host_url, | |
| 622 | + | ); | |
| 623 | + | ||
| 624 | + | self.send_email(to_email, subject, &body).await | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | /// Step 3: Stripe connection guide (sent ~72h after signup). | |
| 628 | + | pub async fn send_onboarding_stripe( | |
| 629 | + | &self, | |
| 630 | + | to_email: &str, | |
| 631 | + | to_name: Option<&str>, | |
| 632 | + | host_url: &str, | |
| 633 | + | ) -> Result<()> { | |
| 634 | + | let subject = "Start receiving payments"; | |
| 635 | + | let body = format!( | |
| 636 | + | r#"Hi{name}, | |
| 637 | + | ||
| 638 | + | To sell on Makenot.work, connect your Stripe account. It takes about 5 minutes. | |
| 639 | + | ||
| 640 | + | {host_url}/dashboard (Payments tab) | |
| 641 | + | ||
| 642 | + | Stripe handles payment processing at ~3% per transaction. That's the only fee — Makenot.work takes 0%. | |
| 643 | + | ||
| 644 | + | Once connected, you can set prices on your items and start selling immediately. | |
| 645 | + | ||
| 646 | + | - Makenotwork"#, | |
| 647 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 648 | + | host_url = host_url, | |
| 649 | + | ); | |
| 650 | + | ||
| 651 | + | self.send_email(to_email, subject, &body).await | |
| 652 | + | } | |
| 653 | + | ||
| 564 | 654 | /// Send an email (implementation) | |
| 565 | 655 | async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { | |
| 566 | 656 | self.send_email_inner(to, subject, body, &[], None).await |
| @@ -171,7 +171,9 @@ pub fn api_routes() -> Router<AppState> { | |||
| 171 | 171 | .route("/api/projects", post(projects::create_project)) | |
| 172 | 172 | .route("/api/projects/{id}", put(projects::update_project)) | |
| 173 | 173 | .route("/api/projects/{id}", delete(projects::delete_project)) | |
| 174 | - | // Git repo linking | |
| 174 | + | // Git repo management | |
| 175 | + | .route("/api/repos", post(projects::create_repo)) | |
| 176 | + | .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility)) | |
| 175 | 177 | .route("/api/projects/{id}/repos", post(projects::link_repo)) | |
| 176 | 178 | .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo)) | |
| 177 | 179 | // Item routes |
| @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; | |||
| 10 | 10 | ||
| 11 | 11 | use crate::{ | |
| 12 | 12 | auth::AuthUser, | |
| 13 | - | db::{self, ProjectId, Slug}, | |
| 13 | + | db::{self, GitRepoId, ProjectId, Slug}, | |
| 14 | 14 | error::{AppError, Result}, | |
| 15 | 15 | helpers::{hx_toast, is_htmx_request}, | |
| 16 | 16 | types::ListResponse, | |
| @@ -270,3 +270,126 @@ pub(super) async fn unlink_repo( | |||
| 270 | 270 | Html(String::new()), | |
| 271 | 271 | )) | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // ============================================================================= | |
| 275 | + | // Git Repo Creation + Visibility | |
| 276 | + | // ============================================================================= | |
| 277 | + | ||
| 278 | + | /// JSON input for creating a bare repo on disk. | |
| 279 | + | #[derive(Debug, Deserialize)] | |
| 280 | + | pub struct CreateRepoRequest { | |
| 281 | + | pub name: String, | |
| 282 | + | pub visibility: Option<String>, | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// JSON response representing a git repo. | |
| 286 | + | #[derive(Debug, Serialize)] | |
| 287 | + | pub struct RepoResponse { | |
| 288 | + | pub id: GitRepoId, | |
| 289 | + | pub name: String, | |
| 290 | + | pub visibility: String, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Create a bare git repo on disk and register it in the DB. | |
| 294 | + | #[tracing::instrument(skip_all, name = "projects::create_repo")] | |
| 295 | + | pub(super) async fn create_repo( | |
| 296 | + | State(state): State<AppState>, | |
| 297 | + | AuthUser(user): AuthUser, | |
| 298 | + | Json(req): Json<CreateRepoRequest>, | |
| 299 | + | ) -> Result<impl IntoResponse> { | |
| 300 | + | user.check_not_suspended()?; | |
| 301 | + | ||
| 302 | + | // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules) | |
| 303 | + | let name = req.name.trim(); | |
| 304 | + | if name.is_empty() || name.len() > 64 { | |
| 305 | + | return Err(AppError::Validation("Repository name must be 1-64 characters".to_string())); | |
| 306 | + | } | |
| 307 | + | if name.starts_with('.') || name == ".." { | |
| 308 | + | return Err(AppError::Validation("Invalid repository name".to_string())); | |
| 309 | + | } | |
| 310 | + | if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { | |
| 311 | + | return Err(AppError::Validation( | |
| 312 | + | "Repository name may only contain letters, numbers, hyphens, underscores, and dots".to_string(), | |
| 313 | + | )); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Validate visibility | |
| 317 | + | let visibility = req.visibility.as_deref().unwrap_or("public"); | |
| 318 | + | if !matches!(visibility, "public" | "unlisted" | "private") { | |
| 319 | + | return Err(AppError::Validation("Visibility must be public, unlisted, or private".to_string())); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Need git_repos_path configured | |
| 323 | + | let git_root = state | |
| 324 | + | .config | |
| 325 | + | .git_repos_path | |
| 326 | + | .as_deref() | |
| 327 | + | .ok_or_else(|| AppError::Validation("Git repositories are not configured on this server".to_string()))?; | |
| 328 | + | ||
| 329 | + | // Check repo doesn't already exist in DB | |
| 330 | + | if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() { | |
| 331 | + | return Err(AppError::Validation("A repository with that name already exists".to_string())); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Create bare repo on disk: {git_root}/{username}/{name}.git | |
| 335 | + | let username = user.username.to_string(); | |
| 336 | + | let owner_dir = std::path::Path::new(git_root).join(&username); | |
| 337 | + | let repo_dir = owner_dir.join(format!("{name}.git")); | |
| 338 | + | ||
| 339 | + | if repo_dir.exists() { | |
| 340 | + | return Err(AppError::Validation("A repository with that name already exists on disk".to_string())); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | std::fs::create_dir_all(&owner_dir) | |
| 344 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create owner directory: {e}")))?; | |
| 345 | + | ||
| 346 | + | git2::Repository::init_bare(&repo_dir) | |
| 347 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to init bare repo: {e}")))?; | |
| 348 | + | ||
| 349 | + | // Register in DB | |
| 350 | + | let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?; | |
| 351 | + | ||
| 352 | + | Ok(Json(RepoResponse { | |
| 353 | + | id: db_repo.id, | |
| 354 | + | name: db_repo.name, | |
| 355 | + | visibility: db_repo.visibility, | |
| 356 | + | })) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | /// JSON input for updating repo visibility. | |
| 360 | + | #[derive(Debug, Deserialize)] | |
| 361 | + | pub struct UpdateRepoVisibilityRequest { | |
| 362 | + | pub visibility: String, | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// Update a repo's visibility. The repo must be owned by the authenticated user. | |
| 366 | + | #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")] | |
| 367 | + | pub(super) async fn update_repo_visibility( | |
| 368 | + | State(state): State<AppState>, | |
| 369 | + | AuthUser(user): AuthUser, | |
| 370 | + | Path(repo_id): Path<GitRepoId>, | |
| 371 | + | Json(req): Json<UpdateRepoVisibilityRequest>, | |
| 372 | + | ) -> Result<impl IntoResponse> { | |
| 373 | + | user.check_not_suspended()?; | |
| 374 | + | ||
| 375 | + | let repo = db::git_repos::get_repos_by_user(&state.db, user.id) | |
| 376 | + | .await? | |
| 377 | + | .into_iter() | |
| 378 | + | .find(|r| r.id == repo_id) | |
| 379 | + | .ok_or(AppError::NotFound)?; | |
| 380 | + | ||
| 381 | + | if repo.user_id != user.id { | |
| 382 | + | return Err(AppError::Forbidden); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | if !matches!(req.visibility.as_str(), "public" | "unlisted" | "private") { | |
| 386 | + | return Err(AppError::Validation("Visibility must be public, unlisted, or private".to_string())); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | db::git_repos::update_visibility(&state.db, repo_id, &req.visibility).await?; | |
| 390 | + | ||
| 391 | + | Ok(( | |
| 392 | + | [("HX-Trigger", hx_toast("Visibility updated", "success"))], | |
| 393 | + | Html(String::new()), | |
| 394 | + | )) | |
| 395 | + | } |
| @@ -396,6 +396,8 @@ async fn join_handler( | |||
| 396 | 396 | ); | |
| 397 | 397 | ||
| 398 | 398 | let email_client = state.email.clone(); | |
| 399 | + | let welcome_host_url = state.config.host_url.clone(); | |
| 400 | + | let welcome_db = state.db.clone(); | |
| 399 | 401 | tokio::spawn(async move { | |
| 400 | 402 | if let Err(e) = email_client | |
| 401 | 403 | .send_verification(&user_email, user_display_name.as_deref(), &verify_url) | |
| @@ -403,6 +405,14 @@ async fn join_handler( | |||
| 403 | 405 | { | |
| 404 | 406 | tracing::error!(error = ?e, "failed to send verification email"); | |
| 405 | 407 | } | |
| 408 | + | // Send welcome email and advance onboarding step | |
| 409 | + | if let Err(e) = email_client | |
| 410 | + | .send_onboarding_welcome(&user_email, user_display_name.as_deref(), &welcome_host_url) | |
| 411 | + | .await | |
| 412 | + | { | |
| 413 | + | tracing::error!(error = ?e, "failed to send welcome email"); | |
| 414 | + | } | |
| 415 | + | let _ = db::users::advance_onboarding_step(&welcome_db, user_id, 1).await; | |
| 406 | 416 | }); | |
| 407 | 417 | ||
| 408 | 418 | // For HTMX requests, return redirect header (JS will intercept and show success) |
| @@ -8,13 +8,14 @@ use axum::{ | |||
| 8 | 8 | routing::{get, post}, | |
| 9 | 9 | Router, | |
| 10 | 10 | }; | |
| 11 | + | use git2::Repository; | |
| 11 | 12 | use serde::Deserialize; | |
| 12 | 13 | use tower_sessions::Session; | |
| 13 | 14 | ||
| 14 | 15 | use crate::{ | |
| 15 | 16 | auth::MaybeUser, | |
| 16 | 17 | constants, | |
| 17 | - | db::{self, Username}, | |
| 18 | + | db::{self, DbGitRepo, DbUser, UserId, Username}, | |
| 18 | 19 | error::{AppError, Result}, | |
| 19 | 20 | git, | |
| 20 | 21 | helpers::get_csrf_token, | |
| @@ -108,27 +109,74 @@ fn parent_of(path: &str) -> String { | |||
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | // ============================================================================ | |
| 111 | - | // Auto-registration | |
| 112 | + | // Repo resolution + visibility | |
| 112 | 113 | // ============================================================================ | |
| 113 | 114 | ||
| 114 | - | /// Ensure a disk-visible repo has a row in `git_repos`. | |
| 115 | - | /// Called on first visit — if the repo exists on disk but not in the DB, insert it. | |
| 116 | - | async fn auto_register_repo(state: &AppState, owner: &str, repo_name: &str) { | |
| 115 | + | /// Result of resolving a git repo from the URL: DB record, opened git2 repo, and owner user. | |
| 116 | + | #[allow(dead_code)] | |
| 117 | + | struct ResolvedRepo { | |
| 118 | + | db_repo: DbGitRepo, | |
| 119 | + | git_repo: Repository, | |
| 120 | + | db_user: DbUser, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Resolve a repo from the URL `{owner}/{repo_name}`: | |
| 124 | + | /// 1. Look up user by username | |
| 125 | + | /// 2. Look up `git_repos` row (auto-register if missing but exists on disk) | |
| 126 | + | /// 3. Check visibility against the session user | |
| 127 | + | /// 4. Open the repo from disk | |
| 128 | + | fn resolve_repo_name(repo_name: &str) -> &str { | |
| 129 | + | repo_name.strip_suffix(".git").unwrap_or(repo_name) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | async fn resolve_repo( | |
| 133 | + | state: &AppState, | |
| 134 | + | owner: &str, | |
| 135 | + | repo_name: &str, | |
| 136 | + | session_user_id: Option<UserId>, | |
| 137 | + | ) -> Result<ResolvedRepo> { | |
| 138 | + | let root = repos_root(state)?; | |
| 139 | + | ||
| 140 | + | // 1. Look up user by URL owner | |
| 117 | 141 | let username = Username::from_trusted(owner.to_string()); | |
| 118 | - | let db_user = match db::users::get_user_by_username(&state.db, &username).await { | |
| 119 | - | Ok(Some(u)) => u, | |
| 120 | - | _ => return, | |
| 142 | + | let db_user = db::users::get_user_by_username(&state.db, &username) | |
| 143 | + | .await? | |
| 144 | + | .ok_or(AppError::NotFound)?; | |
| 145 | + | ||
| 146 | + | // 2. Look up repo in DB | |
| 147 | + | let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await? { | |
| 148 | + | Some(r) => r, | |
| 149 | + | None => { | |
| 150 | + | // Auto-register: repo exists on disk but not in DB | |
| 151 | + | // Try opening from disk first to verify it exists | |
| 152 | + | let disk_dir = owner; | |
| 153 | + | if git::open_repo(&root, disk_dir, repo_name).is_err() { | |
| 154 | + | return Err(AppError::NotFound); | |
| 155 | + | } | |
| 156 | + | match db::git_repos::create_repo(&state.db, db_user.id, repo_name).await { | |
| 157 | + | Ok(r) => r, | |
| 158 | + | Err(e) => { | |
| 159 | + | tracing::debug!(owner, repo_name, error = ?e, "auto-register failed, retrying lookup"); | |
| 160 | + | // Race condition: another request registered it first | |
| 161 | + | db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name) | |
| 162 | + | .await? | |
| 163 | + | .ok_or(AppError::NotFound)? | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 121 | 167 | }; | |
| 122 | 168 | ||
| 123 | - | // Already registered? | |
| 124 | - | if let Ok(Some(_)) = db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await { | |
| 125 | - | return; | |
| 169 | + | // 3. Check visibility | |
| 170 | + | let is_owner = session_user_id == Some(db_user.id); | |
| 171 | + | if db_repo.visibility == "private" && !is_owner { | |
| 172 | + | return Err(AppError::NotFound); | |
| 126 | 173 | } | |
| 174 | + | // "unlisted" and "public" are both accessible via direct URL | |
| 127 | 175 | ||
| 128 | - | // Not registered — insert (no project link) | |
| 129 | - | if let Err(e) = db::git_repos::create_repo(&state.db, db_user.id, repo_name).await { | |
| 130 | - | tracing::debug!(owner, repo_name, error = ?e, "auto-register repo failed (may already exist)"); | |
| 131 | - | } | |
| 176 | + | // 4. Open repo from disk | |
| 177 | + | let git_repo = git::open_repo(&root, owner, repo_name)?; | |
| 178 | + | ||
| 179 | + | Ok(ResolvedRepo { db_repo, git_repo, db_user }) | |
| 132 | 180 | } | |
| 133 | 181 | ||
| 134 | 182 | // ============================================================================ | |
| @@ -138,21 +186,8 @@ async fn auto_register_repo(state: &AppState, owner: &str, repo_name: &str) { | |||
| 138 | 186 | /// Look up the project linked to a git repo and fetch its public items with versions. | |
| 139 | 187 | async fn fetch_linked_releases( | |
| 140 | 188 | state: &AppState, | |
| 141 | - | owner: &str, | |
| 142 | - | repo_name: &str, | |
| 189 | + | db_repo: &DbGitRepo, | |
| 143 | 190 | ) -> (Option<Project>, Vec<ReleaseItem>) { | |
| 144 | - | let username = Username::from_trusted(owner.to_string()); | |
| 145 | - | let db_user = match db::users::get_user_by_username(&state.db, &username).await { | |
| 146 | - | Ok(Some(u)) => u, | |
| 147 | - | _ => return (None, Vec::new()), | |
| 148 | - | }; | |
| 149 | - | ||
| 150 | - | // Look up via git_repos table | |
| 151 | - | let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await { | |
| 152 | - | Ok(Some(r)) => r, | |
| 153 | - | _ => return (None, Vec::new()), | |
| 154 | - | }; | |
| 155 | - | ||
| 156 | 191 | let project_id = match db_repo.project_id { | |
| 157 | 192 | Some(id) => id, | |
| 158 | 193 | None => return (None, Vec::new()), | |
| @@ -199,22 +234,17 @@ async fn repo_overview( | |||
| 199 | 234 | MaybeUser(maybe_user): MaybeUser, | |
| 200 | 235 | Path((owner, repo_name)): Path<(String, String)>, | |
| 201 | 236 | ) -> Result<impl IntoResponse> { | |
| 202 | - | let root = repos_root(&state)?; | |
| 203 | - | let repo = git::open_repo(&root, &owner, &repo_name)?; | |
| 204 | - | let info = git::repo_info(&repo, &repo_name); | |
| 205 | - | let refs = git::list_refs(&repo); | |
| 237 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 238 | + | let info = git::repo_info(&resolved.git_repo, &repo_name); | |
| 239 | + | let refs = git::list_refs(&resolved.git_repo); | |
| 206 | 240 | ||
| 207 | - | let commit_oid = git::resolve_ref(&repo, &info.default_branch)?; | |
| 208 | - | let tree_items = git::list_tree(&repo, commit_oid, "")?; | |
| 209 | - | let readme_html = git::find_readme(&repo, commit_oid); | |
| 241 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &info.default_branch)?; | |
| 242 | + | let tree_items = git::list_tree(&resolved.git_repo, commit_oid, "")?; | |
| 243 | + | let readme_html = git::find_readme(&resolved.git_repo, commit_oid); | |
| 210 | 244 | ||
| 211 | 245 | let csrf_token = get_csrf_token(&session).await; | |
| 212 | 246 | ||
| 213 | - | // Auto-register repo in DB if it exists on disk but isn't tracked yet | |
| 214 | - | auto_register_repo(&state, &owner, &repo_name).await; | |
| 215 | - | ||
| 216 | - | // Look up linked project + releases | |
| 217 | - | let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await; | |
| 247 | + | let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await; | |
| 218 | 248 | ||
| 219 | 249 | Ok(GitRepoTemplate { | |
| 220 | 250 | csrf_token, | |
| @@ -240,17 +270,15 @@ async fn tree_root( | |||
| 240 | 270 | MaybeUser(maybe_user): MaybeUser, | |
| 241 | 271 | Path((owner, repo_name, git_ref)): Path<(String, String, String)>, | |
| 242 | 272 | ) -> Result<impl IntoResponse> { | |
| 243 | - | let root = repos_root(&state)?; | |
| 244 | - | let repo = git::open_repo(&root, &owner, &repo_name)?; | |
| 245 | - | let refs = git::list_refs(&repo); | |
| 246 | - | let commit_oid = git::resolve_ref(&repo, &git_ref)?; | |
| 247 | - | let tree_items = git::list_tree(&repo, commit_oid, "")?; | |
| 248 | - | let readme_html = git::find_readme(&repo, commit_oid); | |
| 273 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 274 | + | let refs = git::list_refs(&resolved.git_repo); | |
| 275 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?; | |
| 276 | + | let tree_items = git::list_tree(&resolved.git_repo, commit_oid, "")?; | |
| 277 | + | let readme_html = git::find_readme(&resolved.git_repo, commit_oid); | |
| 249 | 278 | ||
| 250 | 279 | let csrf_token = get_csrf_token(&session).await; | |
| 251 | 280 | ||
| 252 | - | // Look up linked project + releases | |
| 253 | - | let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await; | |
| 281 | + | let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await; | |
| 254 | 282 | ||
| 255 | 283 | Ok(GitRepoTemplate { | |
| 256 | 284 | csrf_token, | |
| @@ -276,13 +304,12 @@ async fn tree_or_file( | |||
| 276 | 304 | MaybeUser(maybe_user): MaybeUser, | |
| 277 | 305 | Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>, | |
| 278 | 306 | ) -> Result<Response> { | |
| 279 | - | let root = repos_root(&state)?; | |
| 280 | - | let repo = git::open_repo(&root, &owner, &repo_name)?; | |
| 281 | - | let commit_oid = git::resolve_ref(&repo, &git_ref)?; | |
| 307 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 308 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?; | |
| 282 | 309 | ||
| 283 | 310 | // Try as directory first | |
| 284 | - | if let Ok(tree_items) = git::list_tree(&repo, commit_oid, &path) { | |
| 285 | - | let refs = git::list_refs(&repo); | |
| 311 | + | if let Ok(tree_items) = git::list_tree(&resolved.git_repo, commit_oid, &path) { | |
| 312 | + | let refs = git::list_refs(&resolved.git_repo); | |
| 286 | 313 | let breadcrumbs = build_breadcrumbs(&path); | |
| 287 | 314 | let parent_path = parent_of(&path); | |
| 288 | 315 | let csrf_token = get_csrf_token(&session).await; | |
| @@ -305,7 +332,7 @@ async fn tree_or_file( | |||
| 305 | 332 | } | |
| 306 | 333 | ||
| 307 | 334 | // Try as file | |
| 308 | - | let file_content = git::read_file(&repo, commit_oid, &path)?; | |
| 335 | + | let file_content = git::read_file(&resolved.git_repo, commit_oid, &path)?; | |
| 309 | 336 | let filename = path | |
| 310 | 337 | .rsplit('/') | |
| 311 | 338 | .next() | |
| @@ -367,17 +394,16 @@ async fn commit_log( | |||
| 367 | 394 | Path((owner, repo_name, git_ref)): Path<(String, String, String)>, | |
| 368 | 395 | Query(query): Query<CommitQuery>, | |
| 369 | 396 | ) -> Result<impl IntoResponse> { | |
| 370 | - | let root = repos_root(&state)?; | |
| 371 | - | let repo = git::open_repo(&root, &owner, &repo_name)?; | |
| 372 | - | let refs = git::list_refs(&repo); | |
| 373 | - | let commit_oid = git::resolve_ref(&repo, &git_ref)?; | |
| 397 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 398 | + | let refs = git::list_refs(&resolved.git_repo); | |
| 399 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?; | |
| 374 | 400 | ||
| 375 | 401 | let page = query.page.unwrap_or(1).max(1); | |
| 376 | 402 | let limit = constants::GIT_COMMITS_PER_PAGE; | |
| 377 | 403 | let offset = (page - 1) * limit; | |
| 378 | 404 | ||
| 379 | 405 | // Fetch one extra to detect if there are more pages | |
| 380 | - | let commits = git::commit_log(&repo, commit_oid, limit + 1, offset)?; | |
| 406 | + | let commits = git::commit_log(&resolved.git_repo, commit_oid, limit + 1, offset)?; | |
| 381 | 407 | let has_more = commits.len() > limit; | |
| 382 | 408 | let commits: Vec<_> = commits.into_iter().take(limit).collect(); | |
| 383 | 409 | ||
| @@ -400,21 +426,21 @@ async fn commit_log( | |||
| 400 | 426 | #[tracing::instrument(skip_all, name = "git::raw_file")] | |
| 401 | 427 | async fn raw_file( | |
| 402 | 428 | State(state): State<AppState>, | |
| 429 | + | MaybeUser(maybe_user): MaybeUser, | |
| 403 | 430 | Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>, | |
| 404 | 431 | ) -> Result<Response> { | |
| 405 | - | let root = repos_root(&state)?; | |
| 406 | - | let repo = git::open_repo(&root, &owner, &repo_name)?; | |
| 407 | - | let commit_oid = git::resolve_ref(&repo, &git_ref)?; | |
| 432 | + | let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 433 | + | let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?; | |
| 408 | 434 | ||
| 409 | 435 | // Read the raw blob directly | |
| 410 | - | let commit = repo | |
| 436 | + | let commit = resolved.git_repo | |
| 411 | 437 | .find_commit(commit_oid) | |
| 412 | 438 | .map_err(|_| AppError::NotFound)?; | |
| 413 | 439 | let tree = commit.tree().map_err(|_| AppError::NotFound)?; | |
| 414 | 440 | let entry = tree | |
| 415 | 441 | .get_path(std::path::Path::new(&path)) | |
| 416 | 442 | .map_err(|_| AppError::NotFound)?; | |
| 417 | - | let obj = entry.to_object(&repo).map_err(|_| AppError::NotFound)?; | |
| 443 | + | let obj = entry.to_object(&resolved.git_repo).map_err(|_| AppError::NotFound)?; | |
| 418 | 444 | let blob = obj.as_blob().ok_or(AppError::NotFound)?; | |
| 419 | 445 | let content = blob.content().to_vec(); | |
| 420 | 446 | ||
| @@ -460,6 +486,7 @@ struct InfoRefsQuery { | |||
| 460 | 486 | #[tracing::instrument(skip_all, name = "git::smart_http_info_refs")] | |
| 461 | 487 | async fn smart_http_info_refs( | |
| 462 | 488 | State(state): State<AppState>, | |
| 489 | + | MaybeUser(maybe_user): MaybeUser, | |
| 463 | 490 | Path((owner, repo_name)): Path<(String, String)>, | |
| 464 | 491 | Query(query): Query<InfoRefsQuery>, | |
| 465 | 492 | ) -> Result<Response> { | |
| @@ -468,7 +495,10 @@ async fn smart_http_info_refs( | |||
| 468 | 495 | return Err(AppError::Forbidden); | |
| 469 | 496 | } | |
| 470 | 497 | ||
| 471 | - | let repo_name = repo_name.strip_suffix(".git").unwrap_or(&repo_name); | |
| 498 | + | let repo_name = resolve_repo_name(&repo_name); | |
| 499 | + | // Resolve for visibility check (will 404 if private + not owner) | |
| 500 | + | resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 501 | + | ||
| 472 | 502 | let root = repos_root(&state)?; | |
| 473 | 503 | let repo_path = git::repo_disk_path(&root, &owner, repo_name)?; | |
| 474 | 504 | ||
| @@ -512,10 +542,14 @@ async fn smart_http_info_refs( | |||
| 512 | 542 | #[tracing::instrument(skip_all, name = "git::smart_http_upload_pack")] | |
| 513 | 543 | async fn smart_http_upload_pack( | |
| 514 | 544 | State(state): State<AppState>, | |
| 545 | + | MaybeUser(maybe_user): MaybeUser, | |
| 515 | 546 | Path((owner, repo_name)): Path<(String, String)>, | |
| 516 | 547 | body: axum::body::Bytes, | |
| 517 | 548 | ) -> Result<Response> { | |
| 518 | - | let repo_name = repo_name.strip_suffix(".git").unwrap_or(&repo_name); | |
| 549 | + | let repo_name = resolve_repo_name(&repo_name); | |
| 550 | + | // Resolve for visibility check (will 404 if private + not owner) | |
| 551 | + | resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?; | |
| 552 | + | ||
| 519 | 553 | let root = repos_root(&state)?; | |
| 520 | 554 | let repo_path = git::repo_disk_path(&root, &owner, repo_name)?; | |
| 521 | 555 |
| @@ -65,6 +65,59 @@ pub async fn send_release_announcements(state: &AppState, item: &DbItem) { | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | /// Process the getting-started email drip sequence. | |
| 69 | + | /// | |
| 70 | + | /// Step 1 (welcome) is sent at signup in the auth handler. | |
| 71 | + | /// Step 2 (profile tips) fires 24h after welcome, skipped if display_name is set. | |
| 72 | + | /// Step 3 (Stripe guide) fires 72h after welcome, skipped if Stripe is connected. | |
| 73 | + | async fn send_onboarding_emails(state: &AppState) { | |
| 74 | + | let host_url = &state.config.host_url; | |
| 75 | + | ||
| 76 | + | // Step 1→2: profile tips (24h after welcome) | |
| 77 | + | if let Ok(users) = | |
| 78 | + | db::users::get_onboarding_candidates(&state.db, 1, chrono::Duration::hours(24)).await | |
| 79 | + | { | |
| 80 | + | for user in users { | |
| 81 | + | // Skip if they already set a display name | |
| 82 | + | if user.display_name.is_some() { | |
| 83 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await; | |
| 84 | + | continue; | |
| 85 | + | } | |
| 86 | + | if let Err(e) = state | |
| 87 | + | ||
| 88 | + | .send_onboarding_profile(&user.email, user.display_name.as_deref(), host_url) | |
| 89 | + | .await | |
| 90 | + | { | |
| 91 | + | tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding profile email"); | |
| 92 | + | continue; | |
| 93 | + | } | |
| 94 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await; | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Step 2→3: Stripe guide (72h after welcome) | |
| 99 | + | if let Ok(users) = | |
| 100 | + | db::users::get_onboarding_candidates(&state.db, 2, chrono::Duration::hours(48)).await | |
| 101 | + | { | |
| 102 | + | for user in users { | |
| 103 | + | // Skip if they already connected Stripe | |
| 104 | + | if user.stripe_account_id.is_some() { | |
| 105 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await; | |
| 106 | + | continue; | |
| 107 | + | } | |
| 108 | + | if let Err(e) = state | |
| 109 | + | ||
| 110 | + | .send_onboarding_stripe(&user.email, user.display_name.as_deref(), host_url) | |
| 111 | + | .await | |
| 112 | + | { | |
| 113 | + | tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding stripe email"); | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await; | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 68 | 121 | /// Spawn the background scheduler loop. Drop `shutdown_tx` to stop it. | |
| 69 | 122 | pub fn spawn_scheduler( | |
| 70 | 123 | state: AppState, | |
| @@ -104,6 +157,9 @@ pub fn spawn_scheduler( | |||
| 104 | 157 | } | |
| 105 | 158 | } | |
| 106 | 159 | ||
| 160 | + | // Send onboarding drip emails | |
| 161 | + | send_onboarding_emails(&state).await; | |
| 162 | + | ||
| 107 | 163 | // Publish scheduled blog posts | |
| 108 | 164 | match db::blog_posts::publish_scheduled_blog_posts(&state.db).await { | |
| 109 | 165 | Ok(posts) => { |
| @@ -106,4 +106,34 @@ function saveTextContent() { | |||
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | document.getElementById('text-body').addEventListener('input', updateWordCount); | |
| 109 | + | ||
| 110 | + | // Auto-save: debounce text body changes (30s after last keystroke) | |
| 111 | + | var autoSaveTimer = null; | |
| 112 | + | var autoSaveStatus = document.getElementById('text-save-status'); | |
| 113 | + | ||
| 114 | + | document.getElementById('text-body').addEventListener('input', function() { | |
| 115 | + | clearTimeout(autoSaveTimer); | |
| 116 | + | autoSaveTimer = setTimeout(function() { | |
| 117 | + | autoSaveStatus.innerHTML = '<span class="save-status" style="opacity: 0.5;">Saving...</span>'; | |
| 118 | + | var body = document.getElementById('text-body').value; | |
| 119 | + | fetch('/api/items/{{ item.id }}/text', { | |
| 120 | + | method: 'PUT', | |
| 121 | + | headers: { 'Content-Type': 'application/json' }, | |
| 122 | + | body: JSON.stringify({ body: body }) | |
| 123 | + | }) | |
| 124 | + | .then(function(res) { return res.json(); }) | |
| 125 | + | .then(function(data) { | |
| 126 | + | autoSaveStatus.innerHTML = '<span class="save-status success">Auto-saved</span>'; | |
| 127 | + | if (data.word_count !== undefined) { | |
| 128 | + | document.getElementById('word-count').textContent = data.word_count + ' words'; | |
| 129 | + | } | |
| 130 | + | setTimeout(function() { | |
| 131 | + | if (autoSaveStatus.textContent === 'Auto-saved') autoSaveStatus.innerHTML = ''; | |
| 132 | + | }, 3000); | |
| 133 | + | }) | |
| 134 | + | .catch(function() { | |
| 135 | + | autoSaveStatus.innerHTML = '<span class="save-status error">Auto-save failed</span>'; | |
| 136 | + | }); | |
| 137 | + | }, 30000); | |
| 138 | + | }); | |
| 109 | 139 | </script> |
| @@ -159,6 +159,42 @@ | |||
| 159 | 159 | }); | |
| 160 | 160 | } | |
| 161 | 161 | ||
| 162 | + | // Auto-save: debounce blog post changes (30s after last keystroke, edit mode only) | |
| 163 | + | var blogAutoSaveTimer = null; | |
| 164 | + | var postStatus = document.getElementById('post-status'); | |
| 165 | + | ||
| 166 | + | function setupBlogAutoSave() { | |
| 167 | + | ['post-title', 'post-slug', 'post-body'].forEach(function(id) { | |
| 168 | + | document.getElementById(id).addEventListener('input', function() { | |
| 169 | + | if (!editingPostId) return; | |
| 170 | + | clearTimeout(blogAutoSaveTimer); | |
| 171 | + | blogAutoSaveTimer = setTimeout(function() { | |
| 172 | + | var title = document.getElementById('post-title').value.trim(); | |
| 173 | + | var slug = document.getElementById('post-slug').value.trim(); | |
| 174 | + | var body = document.getElementById('post-body').value; | |
| 175 | + | if (!title || !slug) return; | |
| 176 | + | postStatus.innerHTML = '<span style="opacity: 0.5;">Saving...</span>'; | |
| 177 | + | fetch('/api/blog/' + editingPostId, { | |
| 178 | + | method: 'PUT', | |
| 179 | + | headers: { 'Content-Type': 'application/json' }, | |
| 180 | + | body: JSON.stringify({ title: title, slug: slug, body_markdown: body, is_published: false }) | |
| 181 | + | }) | |
| 182 | + | .then(function(res) { | |
| 183 | + | if (!res.ok) throw new Error('Auto-save failed'); | |
| 184 | + | postStatus.innerHTML = '<span style="color: var(--text-muted);">Auto-saved</span>'; | |
| 185 | + | setTimeout(function() { | |
| 186 | + | if (postStatus.textContent === 'Auto-saved') postStatus.innerHTML = ''; | |
| 187 | + | }, 3000); | |
| 188 | + | }) | |
| 189 | + | .catch(function() { | |
| 190 | + | postStatus.innerHTML = '<span style="color: #c0392b;">Auto-save failed</span>'; | |
| 191 | + | }); | |
| 192 | + | }, 30000); | |
| 193 | + | }); | |
| 194 | + | }); | |
| 195 | + | } | |
| 196 | + | setupBlogAutoSave(); | |
| 197 | + | ||
| 162 | 198 | function saveBlogPost(publish) { | |
| 163 | 199 | var title = document.getElementById('post-title').value.trim(); | |
| 164 | 200 | var slug = document.getElementById('post-slug').value.trim(); |
| @@ -84,8 +84,50 @@ | |||
| 84 | 84 | })(); | |
| 85 | 85 | </script> | |
| 86 | 86 | {% else if linked_repos.is_empty() %} | |
| 87 | - | <p style="opacity: 0.7; text-align: left;">No repositories available. Repositories are automatically registered when visited via <code>/git/username/repo</code>.</p> | |
| 87 | + | <p style="opacity: 0.7; text-align: left;">No repositories available. Create one below or push to the server.</p> | |
| 88 | 88 | {% endif %} | |
| 89 | + | ||
| 90 | + | <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);"> | |
| 91 | + | <label for="create-repo-name">Create Repository</label> | |
| 92 | + | <div style="display: flex; gap: 0.5rem; align-items: center;"> | |
| 93 | + | <input type="text" id="create-repo-name" placeholder="repo-name" style="flex: 1;" pattern="[a-zA-Z0-9._-]+" title="Letters, numbers, hyphens, underscores, and dots"> | |
| 94 | + | <button class="secondary" id="create-repo-btn" style="white-space: nowrap;">Create</button> | |
| 95 | + | </div> | |
| 96 | + | <div class="hint">Creates a bare git repository on the server.</div> | |
| 97 | + | <div id="create-repo-status" style="margin-top: 0.25rem;"></div> | |
| 98 | + | </div> | |
| 99 | + | <script> | |
| 100 | + | (function() { | |
| 101 | + | var btn = document.getElementById('create-repo-btn'); | |
| 102 | + | var input = document.getElementById('create-repo-name'); | |
| 103 | + | var status = document.getElementById('create-repo-status'); | |
| 104 | + | if (!btn || !input) return; | |
| 105 | + | btn.addEventListener('click', function() { | |
| 106 | + | var name = input.value.trim(); | |
| 107 | + | if (!name) return; | |
| 108 | + | btn.disabled = true; | |
| 109 | + | fetch('/api/repos', { | |
| 110 | + | method: 'POST', | |
| 111 | + | headers: {'Content-Type': 'application/json'}, | |
| 112 | + | body: JSON.stringify({name: name}) | |
| 113 | + | }).then(function(r) { | |
| 114 | + | if (r.ok) { | |
| 115 | + | htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'}); | |
| 116 | + | } else { | |
| 117 | + | return r.json().then(function(data) { | |
| 118 | + | status.textContent = data.error || 'Failed to create repository'; | |
| 119 | + | status.style.color = 'var(--error-color)'; | |
| 120 | + | }); | |
| 121 | + | } | |
| 122 | + | }).catch(function() { | |
| 123 | + | status.textContent = 'Network error'; | |
| 124 | + | status.style.color = 'var(--error-color)'; | |
| 125 | + | }).finally(function() { | |
| 126 | + | btn.disabled = false; | |
| 127 | + | }); | |
| 128 | + | }); | |
| 129 | + | })(); | |
| 130 | + | </script> | |
| 89 | 131 | </div> | |
| 90 | 132 | {% endif %} | |
| 91 | 133 |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use crate::harness::TestHarness; | |
| 6 | 6 | ||
| 7 | - | /// Create a temp bare repo at `{dir}/owner/testrepo.git` with one commit on "main". | |
| 7 | + | /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with one commit on "main". | |
| 8 | 8 | /// Contains: README.md, src/main.rs | |
| 9 | 9 | fn make_test_repo(dir: &std::path::Path) { | |
| 10 | 10 | let bare_path = dir.join("testowner").join("testrepo.git"); | |
| @@ -44,6 +44,14 @@ fn make_test_repo(dir: &std::path::Path) { | |||
| 44 | 44 | bare_repo.set_head("refs/heads/main").unwrap(); | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | /// Set up a harness with git repos and a user matching the disk owner. | |
| 48 | + | async fn setup_git_harness(tmp: &tempfile::TempDir) -> TestHarness { | |
| 49 | + | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 50 | + | // Create a user whose username matches the disk directory | |
| 51 | + | h.signup("testowner", "testowner@example.com", "password123").await; | |
| 52 | + | h | |
| 53 | + | } | |
| 54 | + | ||
| 47 | 55 | // ── 404 when git not configured ── | |
| 48 | 56 | ||
| 49 | 57 | #[tokio::test] | |
| @@ -60,7 +68,7 @@ async fn git_repo_returns_404_when_not_configured() { | |||
| 60 | 68 | async fn git_repo_overview() { | |
| 61 | 69 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 62 | 70 | make_test_repo(tmp.path()); | |
| 63 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 71 | + | let mut h = setup_git_harness(&tmp).await; | |
| 64 | 72 | ||
| 65 | 73 | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 66 | 74 | assert!( | |
| @@ -79,7 +87,7 @@ async fn git_repo_overview() { | |||
| 79 | 87 | async fn git_nonexistent_repo_returns_404() { | |
| 80 | 88 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 81 | 89 | make_test_repo(tmp.path()); | |
| 82 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 90 | + | let mut h = setup_git_harness(&tmp).await; | |
| 83 | 91 | ||
| 84 | 92 | let resp = h.client.get("/git/testowner/nope").await; | |
| 85 | 93 | assert_eq!(resp.status, 404); | |
| @@ -91,7 +99,7 @@ async fn git_nonexistent_repo_returns_404() { | |||
| 91 | 99 | async fn git_tree_at_ref() { | |
| 92 | 100 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 93 | 101 | make_test_repo(tmp.path()); | |
| 94 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 102 | + | let mut h = setup_git_harness(&tmp).await; | |
| 95 | 103 | ||
| 96 | 104 | let resp = h.client.get("/git/testowner/testrepo/tree/main").await; | |
| 97 | 105 | assert!( | |
| @@ -110,7 +118,7 @@ async fn git_tree_at_ref() { | |||
| 110 | 118 | async fn git_tree_subdirectory() { | |
| 111 | 119 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 112 | 120 | make_test_repo(tmp.path()); | |
| 113 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 121 | + | let mut h = setup_git_harness(&tmp).await; | |
| 114 | 122 | ||
| 115 | 123 | let resp = h | |
| 116 | 124 | .client | |
| @@ -130,7 +138,7 @@ async fn git_tree_subdirectory() { | |||
| 130 | 138 | async fn git_file_view() { | |
| 131 | 139 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 132 | 140 | make_test_repo(tmp.path()); | |
| 133 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 141 | + | let mut h = setup_git_harness(&tmp).await; | |
| 134 | 142 | ||
| 135 | 143 | let resp = h | |
| 136 | 144 | .client | |
| @@ -151,7 +159,7 @@ async fn git_file_view() { | |||
| 151 | 159 | async fn git_file_nonexistent_returns_404() { | |
| 152 | 160 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 153 | 161 | make_test_repo(tmp.path()); | |
| 154 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 162 | + | let mut h = setup_git_harness(&tmp).await; | |
| 155 | 163 | ||
| 156 | 164 | let resp = h | |
| 157 | 165 | .client | |
| @@ -166,7 +174,7 @@ async fn git_file_nonexistent_returns_404() { | |||
| 166 | 174 | async fn git_commit_log() { | |
| 167 | 175 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 168 | 176 | make_test_repo(tmp.path()); | |
| 169 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 177 | + | let mut h = setup_git_harness(&tmp).await; | |
| 170 | 178 | ||
| 171 | 179 | let resp = h | |
| 172 | 180 | .client | |
| @@ -189,7 +197,7 @@ async fn git_commit_log() { | |||
| 189 | 197 | async fn git_raw_file() { | |
| 190 | 198 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 191 | 199 | make_test_repo(tmp.path()); | |
| 192 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 200 | + | let mut h = setup_git_harness(&tmp).await; | |
| 193 | 201 | ||
| 194 | 202 | let resp = h | |
| 195 | 203 | .client | |
| @@ -212,7 +220,7 @@ async fn git_raw_file() { | |||
| 212 | 220 | async fn git_path_traversal_rejected() { | |
| 213 | 221 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 214 | 222 | make_test_repo(tmp.path()); | |
| 215 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 223 | + | let mut h = setup_git_harness(&tmp).await; | |
| 216 | 224 | ||
| 217 | 225 | let resp = h.client.get("/git/../etc/testrepo").await; | |
| 218 | 226 | // Axum may normalize or reject; we just check it doesn't succeed | |
| @@ -229,7 +237,7 @@ async fn git_path_traversal_rejected() { | |||
| 229 | 237 | async fn git_invalid_ref_returns_404() { | |
| 230 | 238 | let tmp = tempfile::TempDir::new().unwrap(); | |
| 231 | 239 | make_test_repo(tmp.path()); | |
| 232 | - | let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; | |
| 240 | + | let mut h = setup_git_harness(&tmp).await; | |
| 233 | 241 | ||
| 234 | 242 | let resp = h | |
| 235 | 243 | .client | |
| @@ -237,3 +245,51 @@ async fn git_invalid_ref_returns_404() { | |||
| 237 | 245 | .await; | |
| 238 | 246 | assert_eq!(resp.status, 404); | |
| 239 | 247 | } | |
| 248 | + | ||
| 249 | + | // ── Visibility: private repo ── | |
| 250 | + | ||
| 251 | + | #[tokio::test] | |
| 252 | + | async fn git_private_repo_hidden_from_anonymous() { | |
| 253 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 254 | + | make_test_repo(tmp.path()); | |
| 255 | + | let mut h = setup_git_harness(&tmp).await; | |
| 256 | + | ||
| 257 | + | // Visit the repo to auto-register it | |
| 258 | + | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 259 | + | assert!(resp.status.is_success()); | |
| 260 | + | ||
| 261 | + | // Set visibility to private via SQL | |
| 262 | + | sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") | |
| 263 | + | .execute(&h.db) | |
| 264 | + | .await | |
| 265 | + | .unwrap(); | |
| 266 | + | ||
| 267 | + | // Log out so we're anonymous | |
| 268 | + | h.client.post_form("/logout", "").await; | |
| 269 | + | ||
| 270 | + | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 271 | + | assert_eq!(resp.status, 404, "Private repo should be 404 for anonymous users"); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[tokio::test] | |
| 275 | + | async fn git_private_repo_visible_to_owner() { | |
| 276 | + | let tmp = tempfile::TempDir::new().unwrap(); | |
| 277 | + | make_test_repo(tmp.path()); | |
| 278 | + | let mut h = setup_git_harness(&tmp).await; | |
| 279 | + | ||
| 280 | + | // Visit the repo to auto-register it | |
| 281 | + | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 282 | + | assert!(resp.status.is_success()); | |
| 283 | + | ||
| 284 | + | // Set visibility to private | |
| 285 | + | sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") | |
| 286 | + | .execute(&h.db) | |
| 287 | + | .await | |
| 288 | + | .unwrap(); | |
| 289 | + | ||
| 290 | + | // Log in as the owner | |
| 291 | + | h.login("testowner", "password123").await; | |
| 292 | + | ||
| 293 | + | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 294 | + | assert!(resp.status.is_success(), "Owner should see private repo: {} {}", resp.status, resp.text); | |
| 295 | + | } |