max / makenotwork
84 files changed,
+2875 insertions,
-1322 deletions
| @@ -85,6 +85,8 @@ Video and streaming cost more than audio. Audio costs more than text. The prices | |||
| 85 | 85 | | Video content, courses | Big Files ($30) | | |
| 86 | 86 | | Live streams | Streaming ($40) | | |
| 87 | 87 | ||
| 88 | + | *Big Files and Streaming tier features (video upload, live streaming) are on the roadmap but not yet available. Creators on those tiers today get all current platform features plus priority access when video and streaming launch.* | |
| 89 | + | ||
| 88 | 90 | ### What Every Tier Includes | |
| 89 | 91 | ||
| 90 | 92 | - Unlimited uploads within your content type | |
| @@ -98,6 +100,8 @@ Video and streaming cost more than audio. Audio costs more than text. The prices | |||
| 98 | 100 | - License keys, discount codes, download codes | |
| 99 | 101 | - Pay-what-you-want pricing option | |
| 100 | 102 | - Subscription tiers with Stripe billing | |
| 103 | + | - Follows, broadcast emails, email notifications (sales, followers, releases, logins) | |
| 104 | + | - 2FA/TOTP, passkeys/WebAuthn, session management, account lockout | |
| 101 | 105 | ||
| 102 | 106 | ### Earn-Back Credit Program | |
| 103 | 107 |
| @@ -16,6 +16,10 @@ Everything listed here is live and working. | |||
| 16 | 16 | - **Projects**: Group items under a project with its own page, slug, description, and category | |
| 17 | 17 | - **Blog**: Per-project blog with markdown posts, draft/publish workflow, included in data exports | |
| 18 | 18 | - **RSS feeds**: Auto-generated feeds per project (items + blog posts), personal feed across followed creators | |
| 19 | + | - **Scheduled publishing**: Set a future publish date, items auto-publish on schedule | |
| 20 | + | - **Item duplication**: Clone any item's metadata to a new draft | |
| 21 | + | - **Bulk operations**: Publish, unpublish, or delete multiple items at once | |
| 22 | + | - **Content insertions**: Reusable audio clip library with pre-roll, mid-roll, and post-roll placement per item | |
| 19 | 23 | ||
| 20 | 24 | ### Selling & Monetization | |
| 21 | 25 | ||
| @@ -49,6 +53,8 @@ Everything listed here is live and working. | |||
| 49 | 53 | - **Transactions**: Full purchase and sales history, filterable | |
| 50 | 54 | - **Contacts**: View fans who shared their email at purchase, with purchase count and total spent | |
| 51 | 55 | - **Broadcasts**: Send plain-text email updates to all your followers (rate-limited to one per 24 hours) | |
| 56 | + | - **Revenue charting**: Time-series revenue with selectable periods (7d/30d/90d/all), period-over-period comparison, per-project breakdown | |
| 57 | + | - **Getting-started email drip**: 3-step onboarding sequence (welcome, profile tips, Stripe setup guide) | |
| 52 | 58 | - **Data export**: All projects, items, blog posts, sales (CSV), and purchases (CSV) downloadable anytime | |
| 53 | 59 | - **Custom links**: Add external links to your profile | |
| 54 | 60 | ||
| @@ -75,13 +81,15 @@ Everything listed here is live and working. | |||
| 75 | 81 | - **Source-available codebase**: PolyForm Noncommercial 1.0.0 | |
| 76 | 82 | - **Creator waitlist**: Invite-only launch with lottery waves and hand-picked approvals | |
| 77 | 83 | - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, stats, user suspension/unsuspension, appeal processing, revenue reports, transaction history, CSV data export, and S3 storage audits -- connects directly to the database, no web UI needed | |
| 84 | + | - **JSON-LD structured data**: Product, MusicRecording, BlogPosting, Article, ProfilePage, and CollectionPage schemas | |
| 78 | 85 | - **Documentation**: Server-rendered from markdown, auto-linked cross-references | |
| 79 | 86 | - **Transactional email**: Password reset, email verification, purchase receipts, subscription lifecycle, sale and follower notifications via Postmark with bounce/complaint suppression | |
| 80 | 87 | - **Git source browser**: Browse server-hosted bare repositories with syntax highlighting | |
| 88 | + | - **SSH git access**: Clone and push to hosted repositories with SSH key authentication | |
| 81 | 89 | - **Health monitoring**: Real uptime tracking, database status, service connectivity checks | |
| 82 | 90 | - **Malware scanning**: ClamAV + YARA rules + MalwareBazaar hash lookup on file uploads | |
| 83 | 91 | - **Creator guide**: 12-page documentation covering the full UX surface area | |
| 84 | - | - **621 automated tests**: Unit, integration, workflow, and health tests | |
| 92 | + | - **824 automated tests**: Unit, integration, workflow, and health tests | |
| 85 | 93 | ||
| 86 | 94 | ### Developer Infrastructure (SyncKit) | |
| 87 | 95 | ||
| @@ -100,7 +108,6 @@ Cloud sync and OTA update infrastructure for indie app developers, hosted on Mak | |||
| 100 | 108 | Near-term work. No timelines because we ship when it's ready. | |
| 101 | 109 | ||
| 102 | 110 | - **Beta launch**: Final testing pass, onboard first creators | |
| 103 | - | - **Notification preferences UI**: Dashboard settings page for managing email notification toggles | |
| 104 | 111 | - **Admin CLI expansion**: Broadcast sending to all users for platform announcements | |
| 105 | 112 | ||
| 106 | 113 | --- | |
| @@ -125,18 +132,10 @@ Embed audio players and buy buttons on your own website. Single-track and album | |||
| 125 | 132 | ||
| 126 | 133 | Creators upload pre-converted files (FLAC, MP3 320k, etc.). Fans choose their preferred format. No server-side transcoding. | |
| 127 | 134 | ||
| 128 | - | ### Structured data and SEO | |
| 129 | - | ||
| 130 | - | JSON-LD (Product, MusicRecording, Article). OG and Twitter Card meta tags are already live. | |
| 131 | - | ||
| 132 | 135 | ### Open source creator tools | |
| 133 | 136 | ||
| 134 | 137 | Built-in git hosting with source browser, plus GitHub integration for software creators. Sponsor tiers, license display, release hosting, build status badges. | |
| 135 | 138 | ||
| 136 | - | ### Analytics | |
| 137 | - | ||
| 138 | - | Replace the placeholder analytics tab with real data: revenue over time, per-item metrics, download/play counts, follower trends. | |
| 139 | - | ||
| 140 | 139 | ### Custom domains | |
| 141 | 140 | ||
| 142 | 141 | Point your own domain to a project page. DNS verification, automatic TLS via Caddy. |
| @@ -34,7 +34,11 @@ Respect revocations. Don't export contact lists and continue emailing fans who r | |||
| 34 | 34 | ||
| 35 | 35 | ## Broadcast Email | |
| 36 | 36 | ||
| 37 | - | Send updates to fans who opted in to contact sharing. Planned but not yet live — see the [Roadmap](../about/roadmap.md). | |
| 37 | + | Send a plain-text email update to all your followers. | |
| 38 | + | ||
| 39 | + | - One broadcast per 24 hours (rate-limited to prevent spam) | |
| 40 | + | - Every broadcast includes a signed unsubscribe URL so fans can opt out | |
| 41 | + | - Compose and send from your creator dashboard | |
| 38 | 42 | ||
| 39 | 43 | ## RSS | |
| 40 | 44 | ||
| @@ -44,7 +48,12 @@ Fans also get a personal feed across all creators and projects they follow. | |||
| 44 | 48 | ||
| 45 | 49 | ## Notifications | |
| 46 | 50 | ||
| 47 | - | Sale and follower notifications via email are planned. Currently, check your Dashboard for transaction history. | |
| 51 | + | Email notifications for key account events, each individually toggleable in your account settings: | |
| 52 | + | ||
| 53 | + | - **Sale alerts** — When someone purchases your content | |
| 54 | + | - **Follower alerts** — When someone follows you or your projects | |
| 55 | + | - **New release announcements** — When creators you follow publish new items | |
| 56 | + | - **New device login warnings** — When your account is accessed from a new device | |
| 48 | 57 | ||
| 49 | 58 | --- | |
| 50 | 59 |
| @@ -12,7 +12,7 @@ Your creator dashboard shows: | |||
| 12 | 12 | - Recent transactions | |
| 13 | 13 | - Follower counts | |
| 14 | 14 | ||
| 15 | - | Full analytics with charts and trends are planned. The current dashboard gives you the numbers that matter. | |
| 15 | + | The dashboard includes revenue charting with selectable time ranges (7 days, 30 days, 90 days, all time), period-over-period comparison showing percentage changes, and per-project revenue breakdown. | |
| 16 | 16 | ||
| 17 | 17 | ## Transaction History | |
| 18 | 18 |
| @@ -3453,7 +3453,7 @@ dependencies = [ | |||
| 3453 | 3453 | ||
| 3454 | 3454 | [[package]] | |
| 3455 | 3455 | name = "makenotwork" | |
| 3456 | - | version = "0.1.9" | |
| 3456 | + | version = "0.2.0" | |
| 3457 | 3457 | dependencies = [ | |
| 3458 | 3458 | "ammonia", | |
| 3459 | 3459 | "anyhow", | |
| @@ -3471,6 +3471,7 @@ dependencies = [ | |||
| 3471 | 3471 | "dotenvy", | |
| 3472 | 3472 | "git2", | |
| 3473 | 3473 | "goblin", | |
| 3474 | + | "governor", | |
| 3474 | 3475 | "hex", | |
| 3475 | 3476 | "hmac", | |
| 3476 | 3477 | "http-body-util", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.1.9" | |
| 3 | + | version = "0.2.1" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 | ||
| @@ -37,6 +37,7 @@ tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] } | |||
| 37 | 37 | ||
| 38 | 38 | # Rate Limiting | |
| 39 | 39 | tower_governor = "0.6.0" | |
| 40 | + | governor = "0.8.1" | |
| 40 | 41 | ||
| 41 | 42 | # JWT (SyncKit) | |
| 42 | 43 | jsonwebtoken = "9.3.1" |
| @@ -60,6 +60,12 @@ upload_binary() { | |||
| 60 | 60 | ssh $SERVER "systemctl stop makenotwork || true" | |
| 61 | 61 | scp target/$TARGET/release/$BINARY_NAME $SERVER:$REMOTE_DIR/$BINARY_NAME | |
| 62 | 62 | ssh $SERVER "chmod +x $REMOTE_DIR/$BINARY_NAME" | |
| 63 | + | # Also upload mnw-admin binary (used for SSH key management) | |
| 64 | + | if [ -f "target/$TARGET/release/mnw-admin" ]; then | |
| 65 | + | scp target/$TARGET/release/mnw-admin $SERVER:$REMOTE_DIR/mnw-admin | |
| 66 | + | ssh $SERVER "chmod +x $REMOTE_DIR/mnw-admin" | |
| 67 | + | echo "[upload] mnw-admin binary uploaded" | |
| 68 | + | fi | |
| 63 | 69 | echo "[upload] Done" | |
| 64 | 70 | } | |
| 65 | 71 |
| @@ -0,0 +1,45 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # Set up SSH key infrastructure for git push access. | |
| 3 | + | # Run once on the production server after initial deploy. | |
| 4 | + | # | |
| 5 | + | # Prerequisites: git system user exists (from setup-git-ssh.sh) | |
| 6 | + | ||
| 7 | + | set -e | |
| 8 | + | ||
| 9 | + | echo "[setup] Configuring SSH key infrastructure..." | |
| 10 | + | ||
| 11 | + | # Ensure git user's .ssh directory exists with correct permissions | |
| 12 | + | mkdir -p /opt/git/.ssh | |
| 13 | + | chown git:git /opt/git/.ssh | |
| 14 | + | chmod 700 /opt/git/.ssh | |
| 15 | + | ||
| 16 | + | # Create empty authorized_keys if it doesn't exist | |
| 17 | + | touch /opt/git/.ssh/authorized_keys | |
| 18 | + | chown git:git /opt/git/.ssh/authorized_keys | |
| 19 | + | chmod 600 /opt/git/.ssh/authorized_keys | |
| 20 | + | ||
| 21 | + | # Ensure mnw-admin binary exists at the expected path | |
| 22 | + | if [ ! -f /opt/makenotwork/mnw-admin ]; then | |
| 23 | + | echo "[setup] WARNING: /opt/makenotwork/mnw-admin not found." | |
| 24 | + | echo " Deploy the binary first, then re-run this script." | |
| 25 | + | fi | |
| 26 | + | ||
| 27 | + | # Add sudoers rule: allow makenotwork user to run rebuild-keys as git | |
| 28 | + | SUDOERS_FILE="/etc/sudoers.d/mnw-git-ssh" | |
| 29 | + | if [ ! -f "$SUDOERS_FILE" ]; then | |
| 30 | + | echo "makenotwork ALL=(git) NOPASSWD: /opt/makenotwork/mnw-admin rebuild-keys" > "$SUDOERS_FILE" | |
| 31 | + | chmod 440 "$SUDOERS_FILE" | |
| 32 | + | echo "[setup] Added sudoers rule: $SUDOERS_FILE" | |
| 33 | + | else | |
| 34 | + | echo "[setup] Sudoers rule already exists: $SUDOERS_FILE" | |
| 35 | + | fi | |
| 36 | + | ||
| 37 | + | # Verify sudoers syntax | |
| 38 | + | visudo -cf "$SUDOERS_FILE" | |
| 39 | + | ||
| 40 | + | echo "[setup] SSH key infrastructure configured." | |
| 41 | + | echo "" | |
| 42 | + | echo "Next steps:" | |
| 43 | + | echo " 1. Users add SSH keys via the dashboard" | |
| 44 | + | echo " 2. The web app triggers: sudo -u git /opt/makenotwork/mnw-admin rebuild-keys" | |
| 45 | + | echo " 3. SSH clone: git clone git@makenot.work:{username}/{repo}.git" |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | CREATE TABLE ssh_keys ( | |
| 2 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 3 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 4 | + | public_key TEXT NOT NULL, | |
| 5 | + | fingerprint VARCHAR(128) NOT NULL, | |
| 6 | + | label VARCHAR(128) NOT NULL DEFAULT '', | |
| 7 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 8 | + | UNIQUE (user_id, fingerprint) | |
| 9 | + | ); | |
| 10 | + | ||
| 11 | + | CREATE INDEX idx_ssh_keys_user_id ON ssh_keys(user_id); |
| @@ -16,6 +16,8 @@ | |||
| 16 | 16 | //! mnw-admin transactions <user> Recent sales for a user | |
| 17 | 17 | //! mnw-admin export <user> CSV export of a user's sales | |
| 18 | 18 | //! mnw-admin storage <user> S3 storage audit for a user | |
| 19 | + | //! mnw-admin rebuild-keys Rebuild authorized_keys from DB | |
| 20 | + | //! mnw-admin git-auth <key_id> Authenticate SSH git operations | |
| 19 | 21 | ||
| 20 | 22 | use clap::{Parser, Subcommand}; | |
| 21 | 23 | use sqlx::PgPool; | |
| @@ -90,6 +92,13 @@ enum Command { | |||
| 90 | 92 | /// Username to audit | |
| 91 | 93 | username: String, | |
| 92 | 94 | }, | |
| 95 | + | /// Rebuild /opt/git/.ssh/authorized_keys from the database | |
| 96 | + | RebuildKeys, | |
| 97 | + | /// Authenticate an SSH git operation (called by sshd command= prefix) | |
| 98 | + | GitAuth { | |
| 99 | + | /// SSH key ID from the authorized_keys command= prefix | |
| 100 | + | key_id: String, | |
| 101 | + | }, | |
| 93 | 102 | } | |
| 94 | 103 | ||
| 95 | 104 | #[tokio::main] | |
| @@ -118,6 +127,8 @@ async fn main() -> anyhow::Result<()> { | |||
| 118 | 127 | Command::Transactions { username } => cmd_transactions(&pool, &username).await?, | |
| 119 | 128 | Command::Export { username } => cmd_export(&pool, &username).await?, | |
| 120 | 129 | Command::Storage { username } => cmd_storage(&pool, &username).await?, | |
| 130 | + | Command::RebuildKeys => cmd_rebuild_keys(&pool).await?, | |
| 131 | + | Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?, | |
| 121 | 132 | } | |
| 122 | 133 | ||
| 123 | 134 | Ok(()) | |
| @@ -581,3 +592,157 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |||
| 581 | 592 | ); | |
| 582 | 593 | Ok(()) | |
| 583 | 594 | } | |
| 595 | + | ||
| 596 | + | // ── SSH key commands ── | |
| 597 | + | ||
| 598 | + | const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys"; | |
| 599 | + | const MNW_ADMIN_PATH: &str = "/opt/makenotwork/mnw-admin"; | |
| 600 | + | ||
| 601 | + | async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> { | |
| 602 | + | let keys = db::ssh_keys::get_all_keys_with_username(pool).await?; | |
| 603 | + | ||
| 604 | + | let mut content = String::new(); | |
| 605 | + | content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n"); | |
| 606 | + | ||
| 607 | + | for key in &keys { | |
| 608 | + | // Each line: command="...",restrictions {public_key} | |
| 609 | + | content.push_str(&format!( | |
| 610 | + | "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n", | |
| 611 | + | MNW_ADMIN_PATH, key.id, key.public_key, | |
| 612 | + | )); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // Atomic write: write to temp file, then rename | |
| 616 | + | let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH); | |
| 617 | + | std::fs::write(&tmp_path, &content)?; | |
| 618 | + | std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?; | |
| 619 | + | ||
| 620 | + | // Set permissions to 600 | |
| 621 | + | #[cfg(unix)] | |
| 622 | + | { | |
| 623 | + | use std::os::unix::fs::PermissionsExt; | |
| 624 | + | std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?; | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | println!("Rebuilt authorized_keys with {} key(s).", keys.len()); | |
| 628 | + | Ok(()) | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { | |
| 632 | + | // Parse the SSH_ORIGINAL_COMMAND | |
| 633 | + | let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND") | |
| 634 | + | .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?; | |
| 635 | + | ||
| 636 | + | // Parse: "git-upload-pack 'owner/repo.git'" or "git-receive-pack 'owner/repo.git'" | |
| 637 | + | let (operation, repo_path) = parse_ssh_command(&original_cmd)?; | |
| 638 | + | ||
| 639 | + | // Parse repo path: "owner/repo.git" → (owner, repo_name) | |
| 640 | + | let (owner, repo_name) = parse_repo_path(&repo_path)?; | |
| 641 | + | ||
| 642 | + | // Look up the SSH key → user | |
| 643 | + | let key_id: db::SshKeyId = key_id_str | |
| 644 | + | .parse() | |
| 645 | + | .map_err(|_| anyhow::anyhow!("invalid key ID"))?; | |
| 646 | + | ||
| 647 | + | let (_, user_id, _ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id) | |
| 648 | + | .await? | |
| 649 | + | .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?; | |
| 650 | + | ||
| 651 | + | // Look up the repo owner | |
| 652 | + | let owner_user = db::users::get_user_by_username(pool, &Username::from_trusted(owner.to_string())) | |
| 653 | + | .await? | |
| 654 | + | .ok_or_else(|| anyhow::anyhow!("repository not found"))?; | |
| 655 | + | ||
| 656 | + | let repo = db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name) | |
| 657 | + | .await? | |
| 658 | + | .ok_or_else(|| anyhow::anyhow!("repository not found"))?; | |
| 659 | + | ||
| 660 | + | // Permission check | |
| 661 | + | match operation { | |
| 662 | + | GitOperation::ReceivePack => { | |
| 663 | + | // Push: must be repo owner | |
| 664 | + | if user_id != owner_user.id { | |
| 665 | + | anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name); | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | GitOperation::UploadPack | GitOperation::Archive => { | |
| 669 | + | // Clone/fetch: check visibility | |
| 670 | + | match repo.visibility.as_str() { | |
| 671 | + | "private" => { | |
| 672 | + | if user_id != owner_user.id { | |
| 673 | + | anyhow::bail!("repository not found"); | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | // "public" and "unlisted" allow anyone with SSH access | |
| 677 | + | _ => {} | |
| 678 | + | } | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // Authorized — exec git-shell | |
| 683 | + | let err = exec_git_shell(&original_cmd); | |
| 684 | + | // exec_git_shell only returns on error | |
| 685 | + | anyhow::bail!("failed to exec git-shell: {}", err); | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | #[derive(Debug)] | |
| 689 | + | enum GitOperation { | |
| 690 | + | UploadPack, | |
| 691 | + | ReceivePack, | |
| 692 | + | Archive, | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> { | |
| 696 | + | let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); | |
| 697 | + | if parts.len() != 2 { | |
| 698 | + | anyhow::bail!("invalid git command"); | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | let operation = match parts[0] { | |
| 702 | + | "git-upload-pack" => GitOperation::UploadPack, | |
| 703 | + | "git-receive-pack" => GitOperation::ReceivePack, | |
| 704 | + | "git-upload-archive" => GitOperation::Archive, | |
| 705 | + | _ => anyhow::bail!("unsupported git command: {}", parts[0]), | |
| 706 | + | }; | |
| 707 | + | ||
| 708 | + | // Strip surrounding quotes from the repo path | |
| 709 | + | let repo_path = parts[1].trim_matches('\'').trim_matches('"'); | |
| 710 | + | Ok((operation, repo_path.to_string())) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> { | |
| 714 | + | // Strip leading / if present | |
| 715 | + | let path = path.trim_start_matches('/'); | |
| 716 | + | ||
| 717 | + | let parts: Vec<&str> = path.splitn(2, '/').collect(); | |
| 718 | + | if parts.len() != 2 { | |
| 719 | + | anyhow::bail!("invalid repository path"); | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | let owner = parts[0]; | |
| 723 | + | let mut repo_name = parts[1].to_string(); | |
| 724 | + | ||
| 725 | + | // Reject path traversal | |
| 726 | + | if owner.contains("..") || repo_name.contains("..") { | |
| 727 | + | anyhow::bail!("invalid repository path"); | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | // Strip .git suffix if present | |
| 731 | + | if repo_name.ends_with(".git") { | |
| 732 | + | repo_name.truncate(repo_name.len() - 4); | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | if owner.is_empty() || repo_name.is_empty() { | |
| 736 | + | anyhow::bail!("invalid repository path"); | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | Ok((owner, repo_name)) | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | /// Replace the current process with git-shell. | |
| 743 | + | fn exec_git_shell(original_cmd: &str) -> std::io::Error { | |
| 744 | + | use std::os::unix::process::CommandExt; | |
| 745 | + | std::process::Command::new("git-shell") | |
| 746 | + | .args(["-c", original_cmd]) | |
| 747 | + | .exec() | |
| 748 | + | } |
| @@ -162,6 +162,7 @@ define_pg_uuid_id!( | |||
| 162 | 162 | ContentInsertionPlacementId, | |
| 163 | 163 | InviteCodeId, | |
| 164 | 164 | GitRepoId, | |
| 165 | + | SshKeyId, | |
| 165 | 166 | ); | |
| 166 | 167 | ||
| 167 | 168 | #[cfg(test)] |
| @@ -37,7 +37,8 @@ pub(crate) mod content_insertions; | |||
| 37 | 37 | pub(crate) mod invites; | |
| 38 | 38 | pub(crate) mod analytics; | |
| 39 | 39 | pub(crate) mod email_suppressions; | |
| 40 | - | pub(crate) mod git_repos; | |
| 40 | + | pub mod git_repos; | |
| 41 | + | pub mod ssh_keys; | |
| 41 | 42 | ||
| 42 | 43 | pub use id_types::*; | |
| 43 | 44 | pub use validated_types::*; |
| @@ -1190,6 +1190,34 @@ pub struct DbOAuthCode { | |||
| 1190 | 1190 | pub created_at: DateTime<Utc>, | |
| 1191 | 1191 | } | |
| 1192 | 1192 | ||
| 1193 | + | /// An SSH public key registered by a user for git push access. | |
| 1194 | + | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 1195 | + | pub struct DbSshKey { | |
| 1196 | + | /// Database primary key. | |
| 1197 | + | pub id: SshKeyId, | |
| 1198 | + | /// User who owns this key. | |
| 1199 | + | pub user_id: UserId, | |
| 1200 | + | /// The SSH public key data (e.g., "ssh-ed25519 AAAA..."). | |
| 1201 | + | pub public_key: String, | |
| 1202 | + | /// SHA-256 fingerprint (e.g., "SHA256:abc..."). | |
| 1203 | + | pub fingerprint: String, | |
| 1204 | + | /// Human-readable label for the key (e.g., "laptop"). | |
| 1205 | + | pub label: String, | |
| 1206 | + | /// When the key was registered. | |
| 1207 | + | pub created_at: DateTime<Utc>, | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | /// An SSH key joined with its owner's username, for authorized_keys rebuild. | |
| 1211 | + | #[derive(Debug, Clone, FromRow)] | |
| 1212 | + | pub struct SshKeyWithUsername { | |
| 1213 | + | /// SSH key primary key. | |
| 1214 | + | pub id: SshKeyId, | |
| 1215 | + | /// The SSH public key data. | |
| 1216 | + | pub public_key: String, | |
| 1217 | + | /// Owner's username. | |
| 1218 | + | pub username: String, | |
| 1219 | + | } | |
| 1220 | + | ||
| 1193 | 1221 | /// A tracked login session for remote revocation. | |
| 1194 | 1222 | #[derive(Debug, Clone, FromRow)] | |
| 1195 | 1223 | pub struct DbUserSession { |
| @@ -0,0 +1,92 @@ | |||
| 1 | + | //! SSH key CRUD and lookup queries. | |
| 2 | + | ||
| 3 | + | use sqlx::PgPool; | |
| 4 | + | ||
| 5 | + | use super::models::{DbSshKey, SshKeyWithUsername}; | |
| 6 | + | use super::{SshKeyId, UserId}; | |
| 7 | + | use crate::error::Result; | |
| 8 | + | ||
| 9 | + | /// Add an SSH public key for a user. | |
| 10 | + | pub async fn add_key( | |
| 11 | + | pool: &PgPool, | |
| 12 | + | user_id: UserId, | |
| 13 | + | public_key: &str, | |
| 14 | + | fingerprint: &str, | |
| 15 | + | label: &str, | |
| 16 | + | ) -> Result<DbSshKey> { | |
| 17 | + | let key = sqlx::query_as::<_, DbSshKey>( | |
| 18 | + | r#" | |
| 19 | + | INSERT INTO ssh_keys (user_id, public_key, fingerprint, label) | |
| 20 | + | VALUES ($1, $2, $3, $4) | |
| 21 | + | RETURNING * | |
| 22 | + | "#, | |
| 23 | + | ) | |
| 24 | + | .bind(user_id) | |
| 25 | + | .bind(public_key) | |
| 26 | + | .bind(fingerprint) | |
| 27 | + | .bind(label) | |
| 28 | + | .fetch_one(pool) | |
| 29 | + | .await?; | |
| 30 | + | ||
| 31 | + | Ok(key) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// List all SSH keys for a user, newest first. | |
| 35 | + | pub async fn list_keys_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbSshKey>> { | |
| 36 | + | let keys = sqlx::query_as::<_, DbSshKey>( | |
| 37 | + | "SELECT * FROM ssh_keys WHERE user_id = $1 ORDER BY created_at DESC", | |
| 38 | + | ) | |
| 39 | + | .bind(user_id) | |
| 40 | + | .fetch_all(pool) | |
| 41 | + | .await?; | |
| 42 | + | ||
| 43 | + | Ok(keys) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Delete an SSH key. Returns false if not found or not owned by the user. | |
| 47 | + | pub async fn delete_key(pool: &PgPool, key_id: SshKeyId, user_id: UserId) -> Result<bool> { | |
| 48 | + | let result = | |
| 49 | + | sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2") | |
| 50 | + | .bind(key_id) | |
| 51 | + | .bind(user_id) | |
| 52 | + | .execute(pool) | |
| 53 | + | .await?; | |
| 54 | + | ||
| 55 | + | Ok(result.rows_affected() > 0) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Get all SSH keys with their owner's username, for authorized_keys rebuild. | |
| 59 | + | pub async fn get_all_keys_with_username(pool: &PgPool) -> Result<Vec<SshKeyWithUsername>> { | |
| 60 | + | let rows = sqlx::query_as::<_, SshKeyWithUsername>( | |
| 61 | + | r#" | |
| 62 | + | SELECT sk.id, sk.public_key, u.username::TEXT as username | |
| 63 | + | FROM ssh_keys sk | |
| 64 | + | JOIN users u ON u.id = sk.user_id | |
| 65 | + | ORDER BY sk.created_at | |
| 66 | + | "#, | |
| 67 | + | ) | |
| 68 | + | .fetch_all(pool) | |
| 69 | + | .await?; | |
| 70 | + | ||
| 71 | + | Ok(rows) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Look up an SSH key by ID, returning the key and its owner. For git-auth. | |
| 75 | + | pub async fn get_key_with_user( | |
| 76 | + | pool: &PgPool, | |
| 77 | + | key_id: SshKeyId, | |
| 78 | + | ) -> Result<Option<(SshKeyId, UserId, String)>> { | |
| 79 | + | let row = sqlx::query_as::<_, (SshKeyId, UserId, String)>( | |
| 80 | + | r#" | |
| 81 | + | SELECT sk.id, sk.user_id, u.username::TEXT as username | |
| 82 | + | FROM ssh_keys sk | |
| 83 | + | JOIN users u ON u.id = sk.user_id | |
| 84 | + | WHERE sk.id = $1 | |
| 85 | + | "#, | |
| 86 | + | ) | |
| 87 | + | .bind(key_id) | |
| 88 | + | .fetch_optional(pool) | |
| 89 | + | .await?; | |
| 90 | + | ||
| 91 | + | Ok(row) | |
| 92 | + | } |
| @@ -1,1297 +0,0 @@ | |||
| 1 | - | //! Email service for sending transactional emails | |
| 2 | - | //! | |
| 3 | - | //! Currently supports logging emails in development. | |
| 4 | - | //! Can be extended to use Postmark or other providers. | |
| 5 | - | ||
| 6 | - | use crate::constants; | |
| 7 | - | use crate::db::UserId; | |
| 8 | - | use crate::error::{AppError, Result}; | |
| 9 | - | ||
| 10 | - | /// Email service configuration | |
| 11 | - | #[derive(Clone)] | |
| 12 | - | pub struct EmailConfig { | |
| 13 | - | /// Postmark API token (optional, logs if not set) | |
| 14 | - | pub postmark_token: Option<String>, | |
| 15 | - | /// Default from address | |
| 16 | - | pub from_address: String, | |
| 17 | - | /// Default from name | |
| 18 | - | pub from_name: String, | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | impl std::fmt::Debug for EmailConfig { | |
| 22 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 23 | - | f.debug_struct("EmailConfig") | |
| 24 | - | .field("postmark_token", &self.postmark_token.as_ref().map(|_| "[REDACTED]")) | |
| 25 | - | .field("from_address", &self.from_address) | |
| 26 | - | .field("from_name", &self.from_name) | |
| 27 | - | .finish() | |
| 28 | - | } | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | impl EmailConfig { | |
| 32 | - | /// Load email configuration from environment | |
| 33 | - | pub fn from_env() -> Self { | |
| 34 | - | EmailConfig { | |
| 35 | - | postmark_token: std::env::var("POSTMARK_TOKEN").ok(), | |
| 36 | - | from_address: std::env::var("EMAIL_FROM_ADDRESS") | |
| 37 | - | .unwrap_or_else(|_| "noreply@makenot.work".to_string()), | |
| 38 | - | from_name: std::env::var("EMAIL_FROM_NAME") | |
| 39 | - | .unwrap_or_else(|_| "Makenotwork".to_string()), | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | /// Email client for sending emails | |
| 45 | - | #[derive(Clone)] | |
| 46 | - | pub struct EmailClient { | |
| 47 | - | config: EmailConfig, | |
| 48 | - | http_client: reqwest::Client, | |
| 49 | - | pool: Option<sqlx::PgPool>, | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | impl EmailClient { | |
| 53 | - | /// Create a new email client | |
| 54 | - | pub fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self { | |
| 55 | - | let http_client = reqwest::Client::builder() | |
| 56 | - | .timeout(std::time::Duration::from_secs(10)) | |
| 57 | - | .build() | |
| 58 | - | .expect("Failed to build email HTTP client"); | |
| 59 | - | ||
| 60 | - | EmailClient { | |
| 61 | - | config, | |
| 62 | - | http_client, | |
| 63 | - | pool, | |
| 64 | - | } | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | /// Send a password reset email | |
| 68 | - | pub async fn send_password_reset( | |
| 69 | - | &self, | |
| 70 | - | to_email: &str, | |
| 71 | - | to_name: Option<&str>, | |
| 72 | - | reset_url: &str, | |
| 73 | - | ) -> Result<()> { | |
| 74 | - | let subject = "Reset your password"; | |
| 75 | - | let body = format!( | |
| 76 | - | r#"Hi{name}, | |
| 77 | - | ||
| 78 | - | You requested to reset your password. Click the link below to set a new password: | |
| 79 | - | ||
| 80 | - | {url} | |
| 81 | - | ||
| 82 | - | This link expires in 15 minutes. If you didn't request this, you can ignore this email. | |
| 83 | - | ||
| 84 | - | - Makenotwork"#, | |
| 85 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 86 | - | url = reset_url | |
| 87 | - | ); | |
| 88 | - | ||
| 89 | - | self.send_email(to_email, subject, &body).await | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | /// Send an email verification email | |
| 93 | - | pub async fn send_verification( | |
| 94 | - | &self, | |
| 95 | - | to_email: &str, | |
| 96 | - | to_name: Option<&str>, | |
| 97 | - | verify_url: &str, | |
| 98 | - | ) -> Result<()> { | |
| 99 | - | let subject = "Verify your email"; | |
| 100 | - | let body = format!( | |
| 101 | - | r#"Hi{name}, | |
| 102 | - | ||
| 103 | - | Please verify your email address by clicking the link below: | |
| 104 | - | ||
| 105 | - | {url} | |
| 106 | - | ||
| 107 | - | This link expires in 24 hours. | |
| 108 | - | ||
| 109 | - | - Makenotwork"#, | |
| 110 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 111 | - | url = verify_url | |
| 112 | - | ); | |
| 113 | - | ||
| 114 | - | self.send_email(to_email, subject, &body).await | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | /// Send an account lockout notification | |
| 118 | - | pub async fn send_lockout_notification( | |
| 119 | - | &self, | |
| 120 | - | to_email: &str, | |
| 121 | - | to_name: Option<&str>, | |
| 122 | - | login_link_url: Option<&str>, | |
| 123 | - | ) -> Result<()> { | |
| 124 | - | let subject = "Security alert: Account locked"; | |
| 125 | - | let body = format!( | |
| 126 | - | r#"Hi{name}, | |
| 127 | - | ||
| 128 | - | Your account has been temporarily locked due to multiple failed login attempts. | |
| 129 | - | ||
| 130 | - | {login_link} | |
| 131 | - | ||
| 132 | - | If this wasn't you, please contact support immediately. | |
| 133 | - | ||
| 134 | - | - Makenotwork"#, | |
| 135 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 136 | - | login_link = login_link_url | |
| 137 | - | .map(|url| format!("Use this link to log in securely:\n\n{}\n\nThis link expires in 15 minutes.", url)) | |
| 138 | - | .unwrap_or_else(|| "Your account will unlock automatically in 15 minutes.".to_string()) | |
| 139 | - | ); | |
| 140 | - | ||
| 141 | - | self.send_email(to_email, subject, &body).await | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | /// Send a one-time login link | |
| 145 | - | pub async fn send_login_link( | |
| 146 | - | &self, | |
| 147 | - | to_email: &str, | |
| 148 | - | to_name: Option<&str>, | |
| 149 | - | login_url: &str, | |
| 150 | - | ) -> Result<()> { | |
| 151 | - | let subject = "Your login link"; | |
| 152 | - | let body = format!( | |
| 153 | - | r#"Hi{name}, | |
| 154 | - | ||
| 155 | - | Click the link below to log in to your account: | |
| 156 | - | ||
| 157 | - | {url} | |
| 158 | - | ||
| 159 | - | This link expires in 15 minutes and can only be used once. | |
| 160 | - | ||
| 161 | - | If you didn't request this, you can ignore this email. | |
| 162 | - | ||
| 163 | - | - Makenotwork"#, | |
| 164 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 165 | - | url = login_url | |
| 166 | - | ); | |
| 167 | - | ||
| 168 | - | self.send_email(to_email, subject, &body).await | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | /// Send account deletion confirmation email | |
| 172 | - | pub async fn send_deletion_confirmation( | |
| 173 | - | &self, | |
| 174 | - | to_email: &str, | |
| 175 | - | to_name: Option<&str>, | |
| 176 | - | delete_url: &str, | |
| 177 | - | ) -> Result<()> { | |
| 178 | - | let subject = "Confirm account deletion"; | |
| 179 | - | let body = format!( | |
| 180 | - | r#"Hi{name}, | |
| 181 | - | ||
| 182 | - | You requested to delete your Makenotwork account. | |
| 183 | - | ||
| 184 | - | IMPORTANT: This action is permanent and cannot be undone. | |
| 185 | - | ||
| 186 | - | Clicking the link below will immediately and permanently delete: | |
| 187 | - | - All your projects and items | |
| 188 | - | - All uploaded content (audio, images) | |
| 189 | - | - Your profile and account settings | |
| 190 | - | - Your custom links | |
| 191 | - | ||
| 192 | - | Purchases made by your fans will remain accessible to them. | |
| 193 | - | ||
| 194 | - | To confirm deletion, click this link: | |
| 195 | - | ||
| 196 | - | {url} | |
| 197 | - | ||
| 198 | - | This link expires in 1 hour. | |
| 199 | - | ||
| 200 | - | If you did not request this, do NOT click the link. Your account is safe. | |
| 201 | - | ||
| 202 | - | - Makenotwork"#, | |
| 203 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 204 | - | url = delete_url | |
| 205 | - | ); | |
| 206 | - | ||
| 207 | - | self.send_email(to_email, subject, &body).await | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | /// Send a purchase confirmation email | |
| 211 | - | pub async fn send_purchase_confirmation( | |
| 212 | - | &self, | |
| 213 | - | to_email: &str, | |
| 214 | - | to_name: Option<&str>, | |
| 215 | - | item_title: &str, | |
| 216 | - | price: &str, | |
| 217 | - | ) -> Result<()> { | |
| 218 | - | let subject = "Your purchase is confirmed"; | |
| 219 | - | let body = format!( | |
| 220 | - | r#"Hi{name}, | |
| 221 | - | ||
| 222 | - | Your purchase of {item} ({price}) is confirmed. | |
| 223 | - | ||
| 224 | - | You can access your purchase from your library at any time. | |
| 225 | - | ||
| 226 | - | - Makenotwork"#, | |
| 227 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 228 | - | item = item_title, | |
| 229 | - | price = price | |
| 230 | - | ); | |
| 231 | - | ||
| 232 | - | self.send_email(to_email, subject, &body).await | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | /// Send a subscription started email | |
| 236 | - | pub async fn send_subscription_started( | |
| 237 | - | &self, | |
| 238 | - | to_email: &str, | |
| 239 | - | to_name: Option<&str>, | |
| 240 | - | tier_name: &str, | |
| 241 | - | project_title: &str, | |
| 242 | - | price: &str, | |
| 243 | - | ) -> Result<()> { | |
| 244 | - | let subject = &format!("You're subscribed to {}", project_title); | |
| 245 | - | let body = format!( | |
| 246 | - | r#"Hi{name}, | |
| 247 | - | ||
| 248 | - | You're now subscribed to {project} ({tier} - {price}/mo). | |
| 249 | - | ||
| 250 | - | You have access to all content included in this tier. Your subscription will renew automatically each month. | |
| 251 | - | ||
| 252 | - | - Makenotwork"#, | |
| 253 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 254 | - | project = project_title, | |
| 255 | - | tier = tier_name, | |
| 256 | - | price = price | |
| 257 | - | ); | |
| 258 | - | ||
| 259 | - | self.send_email(to_email, subject, &body).await | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | /// Send a subscription cancelled email | |
| 263 | - | pub async fn send_subscription_cancelled( | |
| 264 | - | &self, | |
| 265 | - | to_email: &str, | |
| 266 | - | to_name: Option<&str>, | |
| 267 | - | tier_name: &str, | |
| 268 | - | project_title: &str, | |
| 269 | - | ) -> Result<()> { | |
| 270 | - | let subject = "Your subscription has been cancelled"; | |
| 271 | - | let body = format!( | |
| 272 | - | r#"Hi{name}, | |
| 273 | - | ||
| 274 | - | Your subscription to {project} ({tier}) has been cancelled. | |
| 275 | - | ||
| 276 | - | You will retain access until the end of your current billing period. | |
| 277 | - | ||
| 278 | - | - Makenotwork"#, | |
| 279 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 280 | - | project = project_title, | |
| 281 | - | tier = tier_name | |
| 282 | - | ); | |
| 283 | - | ||
| 284 | - | self.send_email(to_email, subject, &body).await | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | /// Send a subscription renewed email | |
| 288 | - | pub async fn send_subscription_renewed( | |
| 289 | - | &self, | |
| 290 | - | to_email: &str, | |
| 291 | - | to_name: Option<&str>, | |
| 292 | - | tier_name: &str, | |
| 293 | - | price: &str, | |
| 294 | - | ) -> Result<()> { | |
| 295 | - | let subject = "Your subscription has been renewed"; | |
| 296 | - | let body = format!( | |
| 297 | - | r#"Hi{name}, | |
| 298 | - | ||
| 299 | - | Your subscription ({tier} - {price}/mo) has been renewed for another month. | |
| 300 | - | ||
| 301 | - | - Makenotwork"#, | |
| 302 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 303 | - | tier = tier_name, | |
| 304 | - | price = price | |
| 305 | - | ); | |
| 306 | - | ||
| 307 | - | self.send_email(to_email, subject, &body).await | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | /// Send a new-device login notification | |
| 311 | - | pub async fn send_new_login_notification( | |
| 312 | - | &self, | |
| 313 | - | to_email: &str, | |
| 314 | - | to_name: Option<&str>, | |
| 315 | - | device: Option<&str>, | |
| 316 | - | ip: Option<&str>, | |
| 317 | - | unsub_url: Option<&str>, | |
| 318 | - | ) -> Result<()> { | |
| 319 | - | let subject = "New sign-in to your account"; | |
| 320 | - | let device_line = device.unwrap_or("Unknown device"); | |
| 321 | - | let ip_line = ip.unwrap_or("Unknown"); | |
| 322 | - | let body = format!( | |
| 323 | - | r#"Hi{name}, | |
| 324 | - | ||
| 325 | - | Your account was just signed in to from a new device. | |
| 326 | - | ||
| 327 | - | Device: {device} | |
| 328 | - | IP address: {ip} | |
| 329 | - | ||
| 330 | - | If this was you, no action is needed. If you don't recognize this sign-in, go to your dashboard and revoke the session under Settings > Sessions. | |
| 331 | - | ||
| 332 | - | - Makenotwork"#, | |
| 333 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 334 | - | device = device_line, | |
| 335 | - | ip = ip_line, | |
| 336 | - | ); | |
| 337 | - | ||
| 338 | - | self.send_email_with_unsub(to_email, subject, &body, unsub_url).await | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | /// Send a suspension notification to a user | |
| 342 | - | pub async fn send_suspension_notification( | |
| 343 | - | &self, | |
| 344 | - | to_email: &str, | |
| 345 | - | to_name: Option<&str>, | |
| 346 | - | reason: &str, | |
| 347 | - | ) -> Result<()> { | |
| 348 | - | let subject = "Your account has been suspended"; | |
| 349 | - | let body = format!( | |
| 350 | - | r#"Hi{name}, | |
| 351 | - | ||
| 352 | - | Your Makenotwork account has been suspended. | |
| 353 | - | ||
| 354 | - | Reason: {reason} | |
| 355 | - | ||
| 356 | - | You can appeal this decision from your dashboard. You can also export your data at any time. | |
| 357 | - | ||
| 358 | - | Log in to your dashboard to submit an appeal or export your data. | |
| 359 | - | ||
| 360 | - | - Makenotwork"#, | |
| 361 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 362 | - | reason = reason | |
| 363 | - | ); | |
| 364 | - | ||
| 365 | - | self.send_email(to_email, subject, &body).await | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | /// Send an appeal decision notification to a user | |
| 369 | - | pub async fn send_appeal_decision( | |
| 370 | - | &self, | |
| 371 | - | to_email: &str, | |
| 372 | - | to_name: Option<&str>, | |
| 373 | - | decision: &str, | |
| 374 | - | response: &str, | |
| 375 | - | ) -> Result<()> { | |
| 376 | - | let outcome = if decision == "approved" { | |
| 377 | - | "Your account has been reinstated" | |
| 378 | - | } else { | |
| 379 | - | "Your appeal has been denied" | |
| 380 | - | }; | |
| 381 | - | ||
| 382 | - | let subject = "Your appeal has been reviewed"; | |
| 383 | - | let body = format!( | |
| 384 | - | r#"Hi{name}, | |
| 385 | - | ||
| 386 | - | {outcome}. | |
| 387 | - | ||
| 388 | - | Response from the review team: | |
| 389 | - | ||
| 390 | - | {response} | |
| 391 | - | ||
| 392 | - | Log in to your dashboard for more details. | |
| 393 | - | ||
| 394 | - | - Makenotwork"#, | |
| 395 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 396 | - | outcome = outcome, | |
| 397 | - | response = response | |
| 398 | - | ); | |
| 399 | - | ||
| 400 | - | self.send_email(to_email, subject, &body).await | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | /// Send a platform shutdown notice to a user | |
| 404 | - | pub async fn send_shutdown_notice( | |
| 405 | - | &self, | |
| 406 | - | to_email: &str, | |
| 407 | - | to_name: Option<&str>, | |
| 408 | - | shutdown_date: &str, | |
| 409 | - | ) -> Result<()> { | |
| 410 | - | let subject = "Important: Makenot.work is shutting down"; | |
| 411 | - | let body = format!( | |
| 412 | - | r#"Hi{name}, | |
| 413 | - | ||
| 414 | - | We are writing to let you know that Makenot.work will be shutting down on {shutdown_date}. | |
| 415 | - | ||
| 416 | - | You have at least 90 days from today to export all of your data. Your projects, content, sales history, and follower data can all be exported from your dashboard. | |
| 417 | - | ||
| 418 | - | To export your data, log in and visit your dashboard export page. | |
| 419 | - | ||
| 420 | - | We built Makenotwork on the principle of no lock-in, and we intend to honor that through the end. Thank you for being part of this. | |
| 421 | - | ||
| 422 | - | - Makenotwork"#, | |
| 423 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 424 | - | shutdown_date = shutdown_date | |
| 425 | - | ); | |
| 426 | - | ||
| 427 | - | self.send_email(to_email, subject, &body).await | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | /// Notify a creator that their invite code was redeemed by a new user. | |
| 431 | - | pub async fn send_invite_redeemed( | |
| 432 | - | &self, | |
| 433 | - | to_email: &str, | |
| 434 | - | to_name: Option<&str>, | |
| 435 | - | invitee_username: &str, | |
| 436 | - | ) -> Result<()> { | |
| 437 | - | let subject = "Your invite was used"; | |
| 438 | - | let body = format!( | |
| 439 | - | r#"Hi{name}, | |
| 440 | - | ||
| 441 | - | {invitee} just signed up using one of your invite codes. Their account is pending admin approval. | |
| 442 | - | ||
| 443 | - | - Makenotwork"#, | |
| 444 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 445 | - | invitee = invitee_username | |
| 446 | - | ); | |
| 447 | - | ||
| 448 | - | self.send_email(to_email, subject, &body).await | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | /// Notify a creator that someone bought their content. | |
| 452 | - | pub async fn send_sale_notification( | |
| 453 | - | &self, | |
| 454 | - | to_email: &str, | |
| 455 | - | to_name: Option<&str>, | |
| 456 | - | buyer_username: &str, | |
| 457 | - | item_title: &str, | |
| 458 | - | price: &str, | |
| 459 | - | unsub_url: Option<&str>, | |
| 460 | - | ) -> Result<()> { | |
| 461 | - | let subject = format!("New sale: {}", item_title); | |
| 462 | - | let body = format!( | |
| 463 | - | r#"Hi{name}, | |
| 464 | - | ||
| 465 | - | {buyer} just purchased {item} for {price}. | |
| 466 | - | ||
| 467 | - | View your sales from your dashboard. | |
| 468 | - | ||
| 469 | - | - Makenotwork"#, | |
| 470 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 471 | - | buyer = buyer_username, | |
| 472 | - | item = item_title, | |
| 473 | - | price = price, | |
| 474 | - | ); | |
| 475 | - | ||
| 476 | - | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | /// Notify a creator that someone followed them or their project. | |
| 480 | - | pub async fn send_follower_notification( | |
| 481 | - | &self, | |
| 482 | - | to_email: &str, | |
| 483 | - | to_name: Option<&str>, | |
| 484 | - | follower_username: &str, | |
| 485 | - | context: &str, | |
| 486 | - | unsub_url: Option<&str>, | |
| 487 | - | ) -> Result<()> { | |
| 488 | - | let subject = "New follower"; | |
| 489 | - | let body = format!( | |
| 490 | - | r#"Hi{name}, | |
| 491 | - | ||
| 492 | - | {follower} is now following {context}. | |
| 493 | - | ||
| 494 | - | - Makenotwork"#, | |
| 495 | - | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 496 | - | follower = follower_username, | |
| 497 | - | context = context, | |
| 498 | - | ); | |
| 499 | - | ||
| 500 | - | self.send_email_with_unsub(to_email, subject, &body, unsub_url).await |
Lines truncated
| @@ -0,0 +1,802 @@ | |||
| 1 | + | //! Email service for sending transactional emails | |
| 2 | + | //! | |
| 3 | + | //! Currently supports logging emails in development. | |
| 4 | + | //! Can be extended to use Postmark or other providers. | |
| 5 | + | ||
| 6 | + | mod tokens; | |
| 7 | + | pub use tokens::*; | |
| 8 | + | ||
| 9 | + | use crate::error::{AppError, Result}; | |
| 10 | + | ||
| 11 | + | /// Email service configuration | |
| 12 | + | #[derive(Clone)] | |
| 13 | + | pub struct EmailConfig { | |
| 14 | + | /// Postmark API token (optional, logs if not set) | |
| 15 | + | pub postmark_token: Option<String>, | |
| 16 | + | /// Default from address | |
| 17 | + | pub from_address: String, | |
| 18 | + | /// Default from name | |
| 19 | + | pub from_name: String, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl std::fmt::Debug for EmailConfig { | |
| 23 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 24 | + | f.debug_struct("EmailConfig") | |
| 25 | + | .field("postmark_token", &self.postmark_token.as_ref().map(|_| "[REDACTED]")) | |
| 26 | + | .field("from_address", &self.from_address) | |
| 27 | + | .field("from_name", &self.from_name) | |
| 28 | + | .finish() | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl EmailConfig { | |
| 33 | + | /// Load email configuration from environment | |
| 34 | + | pub fn from_env() -> Self { | |
| 35 | + | EmailConfig { | |
| 36 | + | postmark_token: std::env::var("POSTMARK_TOKEN").ok(), | |
| 37 | + | from_address: std::env::var("EMAIL_FROM_ADDRESS") | |
| 38 | + | .unwrap_or_else(|_| "noreply@makenot.work".to_string()), | |
| 39 | + | from_name: std::env::var("EMAIL_FROM_NAME") | |
| 40 | + | .unwrap_or_else(|_| "Makenotwork".to_string()), | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Email client for sending emails | |
| 46 | + | #[derive(Clone)] | |
| 47 | + | pub struct EmailClient { | |
| 48 | + | config: EmailConfig, | |
| 49 | + | http_client: reqwest::Client, | |
| 50 | + | pool: Option<sqlx::PgPool>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl EmailClient { | |
| 54 | + | /// Create a new email client | |
| 55 | + | pub fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self { | |
| 56 | + | let http_client = reqwest::Client::builder() | |
| 57 | + | .timeout(std::time::Duration::from_secs(10)) | |
| 58 | + | .build() | |
| 59 | + | .expect("Failed to build email HTTP client"); | |
| 60 | + | ||
| 61 | + | EmailClient { | |
| 62 | + | config, | |
| 63 | + | http_client, | |
| 64 | + | pool, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Send a password reset email | |
| 69 | + | pub async fn send_password_reset( | |
| 70 | + | &self, | |
| 71 | + | to_email: &str, | |
| 72 | + | to_name: Option<&str>, | |
| 73 | + | reset_url: &str, | |
| 74 | + | ) -> Result<()> { | |
| 75 | + | let subject = "Reset your password"; | |
| 76 | + | let body = format!( | |
| 77 | + | r#"Hi{name}, | |
| 78 | + | ||
| 79 | + | You requested to reset your password. Click the link below to set a new password: | |
| 80 | + | ||
| 81 | + | {url} | |
| 82 | + | ||
| 83 | + | This link expires in 15 minutes. If you didn't request this, you can ignore this email. | |
| 84 | + | ||
| 85 | + | - Makenotwork"#, | |
| 86 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 87 | + | url = reset_url | |
| 88 | + | ); | |
| 89 | + | ||
| 90 | + | self.send_email(to_email, subject, &body).await | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Send an email verification email | |
| 94 | + | pub async fn send_verification( | |
| 95 | + | &self, | |
| 96 | + | to_email: &str, | |
| 97 | + | to_name: Option<&str>, | |
| 98 | + | verify_url: &str, | |
| 99 | + | ) -> Result<()> { | |
| 100 | + | let subject = "Verify your email"; | |
| 101 | + | let body = format!( | |
| 102 | + | r#"Hi{name}, | |
| 103 | + | ||
| 104 | + | Please verify your email address by clicking the link below: | |
| 105 | + | ||
| 106 | + | {url} | |
| 107 | + | ||
| 108 | + | This link expires in 24 hours. | |
| 109 | + | ||
| 110 | + | - Makenotwork"#, | |
| 111 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 112 | + | url = verify_url | |
| 113 | + | ); | |
| 114 | + | ||
| 115 | + | self.send_email(to_email, subject, &body).await | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Send an account lockout notification | |
| 119 | + | pub async fn send_lockout_notification( | |
| 120 | + | &self, | |
| 121 | + | to_email: &str, | |
| 122 | + | to_name: Option<&str>, | |
| 123 | + | login_link_url: Option<&str>, | |
| 124 | + | ) -> Result<()> { | |
| 125 | + | let subject = "Security alert: Account locked"; | |
| 126 | + | let body = format!( | |
| 127 | + | r#"Hi{name}, | |
| 128 | + | ||
| 129 | + | Your account has been temporarily locked due to multiple failed login attempts. | |
| 130 | + | ||
| 131 | + | {login_link} | |
| 132 | + | ||
| 133 | + | If this wasn't you, please contact support immediately. | |
| 134 | + | ||
| 135 | + | - Makenotwork"#, | |
| 136 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 137 | + | login_link = login_link_url | |
| 138 | + | .map(|url| format!("Use this link to log in securely:\n\n{}\n\nThis link expires in 15 minutes.", url)) | |
| 139 | + | .unwrap_or_else(|| "Your account will unlock automatically in 15 minutes.".to_string()) | |
| 140 | + | ); | |
| 141 | + | ||
| 142 | + | self.send_email(to_email, subject, &body).await | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Send a one-time login link | |
| 146 | + | pub async fn send_login_link( | |
| 147 | + | &self, | |
| 148 | + | to_email: &str, | |
| 149 | + | to_name: Option<&str>, | |
| 150 | + | login_url: &str, | |
| 151 | + | ) -> Result<()> { | |
| 152 | + | let subject = "Your login link"; | |
| 153 | + | let body = format!( | |
| 154 | + | r#"Hi{name}, | |
| 155 | + | ||
| 156 | + | Click the link below to log in to your account: | |
| 157 | + | ||
| 158 | + | {url} | |
| 159 | + | ||
| 160 | + | This link expires in 15 minutes and can only be used once. | |
| 161 | + | ||
| 162 | + | If you didn't request this, you can ignore this email. | |
| 163 | + | ||
| 164 | + | - Makenotwork"#, | |
| 165 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 166 | + | url = login_url | |
| 167 | + | ); | |
| 168 | + | ||
| 169 | + | self.send_email(to_email, subject, &body).await | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Send account deletion confirmation email | |
| 173 | + | pub async fn send_deletion_confirmation( | |
| 174 | + | &self, | |
| 175 | + | to_email: &str, | |
| 176 | + | to_name: Option<&str>, | |
| 177 | + | delete_url: &str, | |
| 178 | + | ) -> Result<()> { | |
| 179 | + | let subject = "Confirm account deletion"; | |
| 180 | + | let body = format!( | |
| 181 | + | r#"Hi{name}, | |
| 182 | + | ||
| 183 | + | You requested to delete your Makenotwork account. | |
| 184 | + | ||
| 185 | + | IMPORTANT: This action is permanent and cannot be undone. | |
| 186 | + | ||
| 187 | + | Clicking the link below will immediately and permanently delete: | |
| 188 | + | - All your projects and items | |
| 189 | + | - All uploaded content (audio, images) | |
| 190 | + | - Your profile and account settings | |
| 191 | + | - Your custom links | |
| 192 | + | ||
| 193 | + | Purchases made by your fans will remain accessible to them. | |
| 194 | + | ||
| 195 | + | To confirm deletion, click this link: | |
| 196 | + | ||
| 197 | + | {url} | |
| 198 | + | ||
| 199 | + | This link expires in 1 hour. | |
| 200 | + | ||
| 201 | + | If you did not request this, do NOT click the link. Your account is safe. | |
| 202 | + | ||
| 203 | + | - Makenotwork"#, | |
| 204 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 205 | + | url = delete_url | |
| 206 | + | ); | |
| 207 | + | ||
| 208 | + | self.send_email(to_email, subject, &body).await | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Send a purchase confirmation email | |
| 212 | + | pub async fn send_purchase_confirmation( | |
| 213 | + | &self, | |
| 214 | + | to_email: &str, | |
| 215 | + | to_name: Option<&str>, | |
| 216 | + | item_title: &str, | |
| 217 | + | price: &str, | |
| 218 | + | ) -> Result<()> { | |
| 219 | + | let subject = "Your purchase is confirmed"; | |
| 220 | + | let body = format!( | |
| 221 | + | r#"Hi{name}, | |
| 222 | + | ||
| 223 | + | Your purchase of {item} ({price}) is confirmed. | |
| 224 | + | ||
| 225 | + | You can access your purchase from your library at any time. | |
| 226 | + | ||
| 227 | + | - Makenotwork"#, | |
| 228 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 229 | + | item = item_title, | |
| 230 | + | price = price | |
| 231 | + | ); | |
| 232 | + | ||
| 233 | + | self.send_email(to_email, subject, &body).await | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// Send a subscription started email | |
| 237 | + | pub async fn send_subscription_started( | |
| 238 | + | &self, | |
| 239 | + | to_email: &str, | |
| 240 | + | to_name: Option<&str>, | |
| 241 | + | tier_name: &str, | |
| 242 | + | project_title: &str, | |
| 243 | + | price: &str, | |
| 244 | + | ) -> Result<()> { | |
| 245 | + | let subject = &format!("You're subscribed to {}", project_title); | |
| 246 | + | let body = format!( | |
| 247 | + | r#"Hi{name}, | |
| 248 | + | ||
| 249 | + | You're now subscribed to {project} ({tier} - {price}/mo). | |
| 250 | + | ||
| 251 | + | You have access to all content included in this tier. Your subscription will renew automatically each month. | |
| 252 | + | ||
| 253 | + | - Makenotwork"#, | |
| 254 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 255 | + | project = project_title, | |
| 256 | + | tier = tier_name, | |
| 257 | + | price = price | |
| 258 | + | ); | |
| 259 | + | ||
| 260 | + | self.send_email(to_email, subject, &body).await | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Send a subscription cancelled email | |
| 264 | + | pub async fn send_subscription_cancelled( | |
| 265 | + | &self, | |
| 266 | + | to_email: &str, | |
| 267 | + | to_name: Option<&str>, | |
| 268 | + | tier_name: &str, | |
| 269 | + | project_title: &str, | |
| 270 | + | ) -> Result<()> { | |
| 271 | + | let subject = "Your subscription has been cancelled"; | |
| 272 | + | let body = format!( | |
| 273 | + | r#"Hi{name}, | |
| 274 | + | ||
| 275 | + | Your subscription to {project} ({tier}) has been cancelled. | |
| 276 | + | ||
| 277 | + | You will retain access until the end of your current billing period. | |
| 278 | + | ||
| 279 | + | - Makenotwork"#, | |
| 280 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 281 | + | project = project_title, | |
| 282 | + | tier = tier_name | |
| 283 | + | ); | |
| 284 | + | ||
| 285 | + | self.send_email(to_email, subject, &body).await | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// Send a subscription renewed email | |
| 289 | + | pub async fn send_subscription_renewed( | |
| 290 | + | &self, | |
| 291 | + | to_email: &str, | |
| 292 | + | to_name: Option<&str>, | |
| 293 | + | tier_name: &str, | |
| 294 | + | price: &str, | |
| 295 | + | ) -> Result<()> { | |
| 296 | + | let subject = "Your subscription has been renewed"; | |
| 297 | + | let body = format!( | |
| 298 | + | r#"Hi{name}, | |
| 299 | + | ||
| 300 | + | Your subscription ({tier} - {price}/mo) has been renewed for another month. | |
| 301 | + | ||
| 302 | + | - Makenotwork"#, | |
| 303 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 304 | + | tier = tier_name, | |
| 305 | + | price = price | |
| 306 | + | ); | |
| 307 | + | ||
| 308 | + | self.send_email(to_email, subject, &body).await | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// Send a new-device login notification | |
| 312 | + | pub async fn send_new_login_notification( | |
| 313 | + | &self, | |
| 314 | + | to_email: &str, | |
| 315 | + | to_name: Option<&str>, | |
| 316 | + | device: Option<&str>, | |
| 317 | + | ip: Option<&str>, | |
| 318 | + | unsub_url: Option<&str>, | |
| 319 | + | ) -> Result<()> { | |
| 320 | + | let subject = "New sign-in to your account"; | |
| 321 | + | let device_line = device.unwrap_or("Unknown device"); | |
| 322 | + | let ip_line = ip.unwrap_or("Unknown"); | |
| 323 | + | let body = format!( | |
| 324 | + | r#"Hi{name}, | |
| 325 | + | ||
| 326 | + | Your account was just signed in to from a new device. | |
| 327 | + | ||
| 328 | + | Device: {device} | |
| 329 | + | IP address: {ip} | |
| 330 | + | ||
| 331 | + | If this was you, no action is needed. If you don't recognize this sign-in, go to your dashboard and revoke the session under Settings > Sessions. | |
| 332 | + | ||
| 333 | + | - Makenotwork"#, | |
| 334 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 335 | + | device = device_line, | |
| 336 | + | ip = ip_line, | |
| 337 | + | ); | |
| 338 | + | ||
| 339 | + | self.send_email_with_unsub(to_email, subject, &body, unsub_url).await | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | /// Send a suspension notification to a user | |
| 343 | + | pub async fn send_suspension_notification( | |
| 344 | + | &self, | |
| 345 | + | to_email: &str, | |
| 346 | + | to_name: Option<&str>, | |
| 347 | + | reason: &str, | |
| 348 | + | ) -> Result<()> { | |
| 349 | + | let subject = "Your account has been suspended"; | |
| 350 | + | let body = format!( | |
| 351 | + | r#"Hi{name}, | |
| 352 | + | ||
| 353 | + | Your Makenotwork account has been suspended. | |
| 354 | + | ||
| 355 | + | Reason: {reason} | |
| 356 | + | ||
| 357 | + | You can appeal this decision from your dashboard. You can also export your data at any time. | |
| 358 | + | ||
| 359 | + | Log in to your dashboard to submit an appeal or export your data. | |
| 360 | + | ||
| 361 | + | - Makenotwork"#, | |
| 362 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 363 | + | reason = reason | |
| 364 | + | ); | |
| 365 | + | ||
| 366 | + | self.send_email(to_email, subject, &body).await | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | /// Send an appeal decision notification to a user | |
| 370 | + | pub async fn send_appeal_decision( | |
| 371 | + | &self, | |
| 372 | + | to_email: &str, | |
| 373 | + | to_name: Option<&str>, | |
| 374 | + | decision: &str, | |
| 375 | + | response: &str, | |
| 376 | + | ) -> Result<()> { | |
| 377 | + | let outcome = if decision == "approved" { | |
| 378 | + | "Your account has been reinstated" | |
| 379 | + | } else { | |
| 380 | + | "Your appeal has been denied" | |
| 381 | + | }; | |
| 382 | + | ||
| 383 | + | let subject = "Your appeal has been reviewed"; | |
| 384 | + | let body = format!( | |
| 385 | + | r#"Hi{name}, | |
| 386 | + | ||
| 387 | + | {outcome}. | |
| 388 | + | ||
| 389 | + | Response from the review team: | |
| 390 | + | ||
| 391 | + | {response} | |
| 392 | + | ||
| 393 | + | Log in to your dashboard for more details. | |
| 394 | + | ||
| 395 | + | - Makenotwork"#, | |
| 396 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 397 | + | outcome = outcome, | |
| 398 | + | response = response | |
| 399 | + | ); | |
| 400 | + | ||
| 401 | + | self.send_email(to_email, subject, &body).await | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | /// Send a platform shutdown notice to a user | |
| 405 | + | pub async fn send_shutdown_notice( | |
| 406 | + | &self, | |
| 407 | + | to_email: &str, | |
| 408 | + | to_name: Option<&str>, | |
| 409 | + | shutdown_date: &str, | |
| 410 | + | ) -> Result<()> { | |
| 411 | + | let subject = "Important: Makenot.work is shutting down"; | |
| 412 | + | let body = format!( | |
| 413 | + | r#"Hi{name}, | |
| 414 | + | ||
| 415 | + | We are writing to let you know that Makenot.work will be shutting down on {shutdown_date}. | |
| 416 | + | ||
| 417 | + | You have at least 90 days from today to export all of your data. Your projects, content, sales history, and follower data can all be exported from your dashboard. | |
| 418 | + | ||
| 419 | + | To export your data, log in and visit your dashboard export page. | |
| 420 | + | ||
| 421 | + | We built Makenotwork on the principle of no lock-in, and we intend to honor that through the end. Thank you for being part of this. | |
| 422 | + | ||
| 423 | + | - Makenotwork"#, | |
| 424 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 425 | + | shutdown_date = shutdown_date | |
| 426 | + | ); | |
| 427 | + | ||
| 428 | + | self.send_email(to_email, subject, &body).await | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | /// Notify a creator that their invite code was redeemed by a new user. | |
| 432 | + | pub async fn send_invite_redeemed( | |
| 433 | + | &self, | |
| 434 | + | to_email: &str, | |
| 435 | + | to_name: Option<&str>, | |
| 436 | + | invitee_username: &str, | |
| 437 | + | ) -> Result<()> { | |
| 438 | + | let subject = "Your invite was used"; | |
| 439 | + | let body = format!( | |
| 440 | + | r#"Hi{name}, | |
| 441 | + | ||
| 442 | + | {invitee} just signed up using one of your invite codes. Their account is pending admin approval. | |
| 443 | + | ||
| 444 | + | - Makenotwork"#, | |
| 445 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 446 | + | invitee = invitee_username | |
| 447 | + | ); | |
| 448 | + | ||
| 449 | + | self.send_email(to_email, subject, &body).await | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// Notify a creator that someone bought their content. | |
| 453 | + | pub async fn send_sale_notification( | |
| 454 | + | &self, | |
| 455 | + | to_email: &str, | |
| 456 | + | to_name: Option<&str>, | |
| 457 | + | buyer_username: &str, | |
| 458 | + | item_title: &str, | |
| 459 | + | price: &str, | |
| 460 | + | unsub_url: Option<&str>, | |
| 461 | + | ) -> Result<()> { | |
| 462 | + | let subject = format!("New sale: {}", item_title); | |
| 463 | + | let body = format!( | |
| 464 | + | r#"Hi{name}, | |
| 465 | + | ||
| 466 | + | {buyer} just purchased {item} for {price}. | |
| 467 | + | ||
| 468 | + | View your sales from your dashboard. | |
| 469 | + | ||
| 470 | + | - Makenotwork"#, | |
| 471 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 472 | + | buyer = buyer_username, | |
| 473 | + | item = item_title, | |
| 474 | + | price = price, | |
| 475 | + | ); | |
| 476 | + | ||
| 477 | + | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | /// Notify a creator that someone followed them or their project. | |
| 481 | + | pub async fn send_follower_notification( | |
| 482 | + | &self, | |
| 483 | + | to_email: &str, | |
| 484 | + | to_name: Option<&str>, | |
| 485 | + | follower_username: &str, | |
| 486 | + | context: &str, | |
| 487 | + | unsub_url: Option<&str>, | |
| 488 | + | ) -> Result<()> { | |
| 489 | + | let subject = "New follower"; | |
| 490 | + | let body = format!( | |
| 491 | + | r#"Hi{name}, | |
| 492 | + | ||
| 493 | + | {follower} is now following {context}. | |
| 494 | + | ||
| 495 | + | - Makenotwork"#, | |
| 496 | + | name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(), | |
| 497 | + | follower = follower_username, | |
| 498 | + | context = context, | |
| 499 | + | ); | |
| 500 | + |
Lines truncated
| @@ -0,0 +1,500 @@ | |||
| 1 | + | //! HMAC-signed URL generation and verification for email actions. | |
| 2 | + | ||
| 3 | + | use crate::constants; | |
| 4 | + | use crate::db::UserId; | |
| 5 | + | ||
| 6 | + | /// Generate HMAC-signed password reset URL | |
| 7 | + | pub fn generate_password_reset_url( | |
| 8 | + | host_url: &str, | |
| 9 | + | user_id: UserId, | |
| 10 | + | password_hash: &str, | |
| 11 | + | secret: &str, | |
| 12 | + | ) -> String { | |
| 13 | + | use hmac::{Hmac, Mac}; | |
| 14 | + | use sha2::Sha256; | |
| 15 | + | ||
| 16 | + | let expires = chrono::Utc::now().timestamp() + constants::PASSWORD_RESET_EXPIRY_SECS; | |
| 17 | + | // Use full password hash to bind token to current password | |
| 18 | + | // This invalidates the link if password changes | |
| 19 | + | let message = format!("reset:{}:{}:{}", user_id, expires, password_hash); | |
| 20 | + | ||
| 21 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 22 | + | // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here | |
| 23 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 24 | + | mac.update(message.as_bytes()); | |
| 25 | + | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 26 | + | ||
| 27 | + | format!( | |
| 28 | + | "{}/reset-password?user={}&expires={}&sig={}", | |
| 29 | + | host_url, user_id, expires, signature | |
| 30 | + | ) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Verify HMAC-signed password reset URL | |
| 34 | + | pub fn verify_password_reset_signature( | |
| 35 | + | user_id: UserId, | |
| 36 | + | expires: i64, | |
| 37 | + | password_hash: &str, | |
| 38 | + | signature: &str, | |
| 39 | + | secret: &str, | |
| 40 | + | ) -> bool { | |
| 41 | + | use hmac::{Hmac, Mac}; | |
| 42 | + | use sha2::Sha256; | |
| 43 | + | ||
| 44 | + | // Check expiration | |
| 45 | + | if expires < chrono::Utc::now().timestamp() { | |
| 46 | + | return false; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Use full password hash to match generation | |
| 50 | + | let message = format!("reset:{}:{}:{}", user_id, expires, password_hash); | |
| 51 | + | ||
| 52 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 53 | + | // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here | |
| 54 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 55 | + | mac.update(message.as_bytes()); | |
| 56 | + | ||
| 57 | + | // Constant-time comparison | |
| 58 | + | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 59 | + | if expected.len() != signature.len() { | |
| 60 | + | return false; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | let mut result = 0u8; | |
| 64 | + | for (a, b) in expected.bytes().zip(signature.bytes()) { | |
| 65 | + | result |= a ^ b; | |
| 66 | + | } | |
| 67 | + | result == 0 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Generate email verification URL | |
| 71 | + | pub fn generate_verification_url( | |
| 72 | + | host_url: &str, | |
| 73 | + | user_id: UserId, | |
| 74 | + | email: &str, | |
| 75 | + | secret: &str, | |
| 76 | + | ) -> String { | |
| 77 | + | use hmac::{Hmac, Mac}; | |
| 78 | + | use sha2::Sha256; | |
| 79 | + | ||
| 80 | + | let expires = chrono::Utc::now().timestamp() + constants::EMAIL_VERIFICATION_EXPIRY_SECS; | |
| 81 | + | let message = format!("{}:{}:{}", user_id, expires, email); | |
| 82 | + | ||
| 83 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 84 | + | // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here | |
| 85 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 86 | + | mac.update(message.as_bytes()); | |
| 87 | + | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 88 | + | ||
| 89 | + | format!( | |
| 90 | + | "{}/verify-email?user={}&expires={}&sig={}", | |
| 91 | + | host_url, user_id, expires, signature | |
| 92 | + | ) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Generate a one-time login token | |
| 96 | + | /// Returns (token, token_hash) where token is sent to user and token_hash is stored in DB | |
| 97 | + | pub fn generate_login_token() -> (String, String) { | |
| 98 | + | use sha2::{Sha256, Digest}; | |
| 99 | + | ||
| 100 | + | // Generate random token | |
| 101 | + | let mut token_bytes = [0u8; 32]; | |
| 102 | + | rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut token_bytes); | |
| 103 | + | let token = hex::encode(token_bytes); | |
| 104 | + | ||
| 105 | + | // Hash the token for storage | |
| 106 | + | let mut hasher = Sha256::new(); | |
| 107 | + | hasher.update(token.as_bytes()); | |
| 108 | + | let token_hash = hex::encode(hasher.finalize()); | |
| 109 | + | ||
| 110 | + | (token, token_hash) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Generate login link URL | |
| 114 | + | pub fn generate_login_link_url(host_url: &str, token: &str) -> String { | |
| 115 | + | format!("{}/login-link?token={}", host_url, token) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Verify a login token against the stored hash | |
| 119 | + | pub fn verify_login_token(token: &str, stored_hash: &str) -> bool { | |
| 120 | + | use sha2::{Sha256, Digest}; | |
| 121 | + | ||
| 122 | + | let mut hasher = Sha256::new(); | |
| 123 | + | hasher.update(token.as_bytes()); | |
| 124 | + | let computed_hash = hex::encode(hasher.finalize()); | |
| 125 | + | ||
| 126 | + | // Constant-time comparison | |
| 127 | + | if computed_hash.len() != stored_hash.len() { | |
| 128 | + | return false; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | let mut result = 0u8; | |
| 132 | + | for (a, b) in computed_hash.bytes().zip(stored_hash.bytes()) { | |
| 133 | + | result |= a ^ b; | |
| 134 | + | } | |
| 135 | + | result == 0 | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Verify email verification signature | |
| 139 | + | pub fn verify_email_signature( | |
| 140 | + | user_id: UserId, | |
| 141 | + | expires: i64, | |
| 142 | + | email: &str, | |
| 143 | + | signature: &str, | |
| 144 | + | secret: &str, | |
| 145 | + | ) -> bool { | |
| 146 | + | use hmac::{Hmac, Mac}; | |
| 147 | + | use sha2::Sha256; | |
| 148 | + | ||
| 149 | + | if expires < chrono::Utc::now().timestamp() { | |
| 150 | + | return false; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | let message = format!("{}:{}:{}", user_id, expires, email); | |
| 154 | + | ||
| 155 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 156 | + | // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here | |
| 157 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 158 | + | mac.update(message.as_bytes()); | |
| 159 | + | ||
| 160 | + | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 161 | + | if expected.len() != signature.len() { | |
| 162 | + | return false; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | let mut result = 0u8; | |
| 166 | + | for (a, b) in expected.bytes().zip(signature.bytes()) { | |
| 167 | + | result |= a ^ b; | |
| 168 | + | } | |
| 169 | + | result == 0 | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Generate an HMAC-signed unsubscribe URL. | |
| 173 | + | /// | |
| 174 | + | /// The URL is permanent (no expiry) — it changes a user preference or removes | |
| 175 | + | /// a follow relationship, both of which are easily reversible. | |
| 176 | + | /// | |
| 177 | + | /// * `action` — one of: `broadcast`, `release`, `sale`, `follower`, `login` | |
| 178 | + | /// * `target` — for `broadcast`: the creator's user ID to unfollow; | |
| 179 | + | /// for preferences: same as `user_id` | |
| 180 | + | pub fn generate_unsubscribe_url( | |
| 181 | + | host_url: &str, | |
| 182 | + | user_id: UserId, | |
| 183 | + | action: &str, | |
| 184 | + | target: &str, | |
| 185 | + | secret: &str, | |
| 186 | + | ) -> String { | |
| 187 | + | use hmac::{Hmac, Mac}; | |
| 188 | + | use sha2::Sha256; | |
| 189 | + | ||
| 190 | + | let message = format!("unsub:{}:{}:{}", user_id, action, target); | |
| 191 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 192 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 193 | + | mac.update(message.as_bytes()); | |
| 194 | + | let signature = hex::encode(mac.finalize().into_bytes()); | |
| 195 | + | ||
| 196 | + | format!( | |
| 197 | + | "{}/unsubscribe?user={}&action={}&target={}&sig={}", | |
| 198 | + | host_url, user_id, action, target, signature | |
| 199 | + | ) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Verify an unsubscribe URL signature. | |
| 203 | + | pub fn verify_unsubscribe_signature( | |
| 204 | + | user_id: UserId, | |
| 205 | + | action: &str, | |
| 206 | + | target: &str, | |
| 207 | + | signature: &str, | |
| 208 | + | secret: &str, | |
| 209 | + | ) -> bool { | |
| 210 | + | use hmac::{Hmac, Mac}; | |
| 211 | + | use sha2::Sha256; | |
| 212 | + | ||
| 213 | + | let message = format!("unsub:{}:{}:{}", user_id, action, target); | |
| 214 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 215 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 216 | + | mac.update(message.as_bytes()); | |
| 217 | + | ||
| 218 | + | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 219 | + | ||
| 220 | + | // Constant-time comparison | |
| 221 | + | if expected.len() != signature.len() { | |
| 222 | + | return false; | |
| 223 | + | } | |
| 224 | + | let mut result = 0u8; | |
| 225 | + | for (a, b) in expected.bytes().zip(signature.bytes()) { | |
| 226 | + | result |= a ^ b; | |
| 227 | + | } | |
| 228 | + | result == 0 | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// Generate account deletion URL | |
| 232 | + | pub fn generate_deletion_url( | |
| 233 | + | host_url: &str, | |
| 234 | + | user_id: UserId, | |
| 235 | + | email: &str, | |
| 236 | + | secret: &str, | |
| 237 | + | ) -> String { | |
| 238 | + | let expires = chrono::Utc::now().timestamp() + constants::ACCOUNT_DELETION_EXPIRY_SECS; | |
| 239 | + | let sig = generate_deletion_signature(secret, user_id, expires, email); | |
| 240 | + | ||
| 241 | + | format!( | |
| 242 | + | "{}/confirm-delete?user={}&expires={}&sig={}", | |
| 243 | + | host_url, user_id, expires, sig | |
| 244 | + | ) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// Generate HMAC signature for account deletion | |
| 248 | + | pub fn generate_deletion_signature( | |
| 249 | + | secret: &str, | |
| 250 | + | user_id: UserId, | |
| 251 | + | expires: i64, | |
| 252 | + | email: &str, | |
| 253 | + | ) -> String { | |
| 254 | + | use hmac::{Hmac, Mac}; | |
| 255 | + | use sha2::Sha256; | |
| 256 | + | ||
| 257 | + | let message = format!("delete:{}:{}:{}", user_id, expires, email); | |
| 258 | + | ||
| 259 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 260 | + | // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here | |
| 261 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 262 | + | mac.update(message.as_bytes()); | |
| 263 | + | hex::encode(mac.finalize().into_bytes()) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[cfg(test)] | |
| 267 | + | mod tests { | |
| 268 | + | use super::*; | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn test_password_reset_url_generation_and_verification() { | |
| 272 | + | let host = "https://makenot.work"; | |
| 273 | + | let user_id = UserId::new(); | |
| 274 | + | let password_hash = "argon2$abc123def456789012345678901234567890"; | |
| 275 | + | let secret = "test-secret-key"; | |
| 276 | + | ||
| 277 | + | let url = generate_password_reset_url(host, user_id, password_hash, secret); | |
| 278 | + | assert!(url.contains("/reset-password")); | |
| 279 | + | assert!(url.contains(&user_id.to_string())); | |
| 280 | + | ||
| 281 | + | // Extract params | |
| 282 | + | let url_parsed: url::Url = url.parse().unwrap(); | |
| 283 | + | let expires: i64 = url_parsed | |
| 284 | + | .query_pairs() | |
| 285 | + | .find(|(k, _)| k == "expires") | |
| 286 | + | .unwrap() | |
| 287 | + | .1 | |
| 288 | + | .parse() | |
| 289 | + | .unwrap(); | |
| 290 | + | let sig = url_parsed | |
| 291 | + | .query_pairs() | |
| 292 | + | .find(|(k, _)| k == "sig") | |
| 293 | + | .unwrap() | |
| 294 | + | .1 | |
| 295 | + | .to_string(); | |
| 296 | + | ||
| 297 | + | assert!(verify_password_reset_signature( | |
| 298 | + | user_id, | |
| 299 | + | expires, | |
| 300 | + | password_hash, | |
| 301 | + | &sig, | |
| 302 | + | secret | |
| 303 | + | )); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn password_reset_rejects_wrong_secret() { | |
| 308 | + | let user_id = UserId::new(); | |
| 309 | + | let hash = "argon2$abc123"; | |
| 310 | + | let secret = "real-secret"; | |
| 311 | + | ||
| 312 | + | let url = generate_password_reset_url("https://example.com", user_id, hash, secret); | |
| 313 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 314 | + | let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); | |
| 315 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 316 | + | ||
| 317 | + | assert!(!verify_password_reset_signature(user_id, expires, hash, &sig, "wrong-secret")); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn password_reset_rejects_expired_token() { | |
| 322 | + | let user_id = UserId::new(); | |
| 323 | + | let hash = "argon2$abc123"; | |
| 324 | + | let secret = "test-secret"; | |
| 325 | + | ||
| 326 | + | // Use an already-expired timestamp | |
| 327 | + | let expired = chrono::Utc::now().timestamp() - 1; | |
| 328 | + | assert!(!verify_password_reset_signature(user_id, expired, hash, "deadbeef", secret)); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn password_reset_rejects_wrong_password_hash() { | |
| 333 | + | let user_id = UserId::new(); | |
| 334 | + | let secret = "test-secret"; | |
| 335 | + | ||
| 336 | + | let url = generate_password_reset_url("https://example.com", user_id, "hash-v1", secret); | |
| 337 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 338 | + | let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); | |
| 339 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 340 | + | ||
| 341 | + | // Signature was bound to "hash-v1", should fail with "hash-v2" (password changed) | |
| 342 | + | assert!(!verify_password_reset_signature(user_id, expires, "hash-v2", &sig, secret)); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn verification_url_round_trip() { | |
| 347 | + | let host = "https://makenot.work"; | |
| 348 | + | let user_id = UserId::new(); | |
| 349 | + | let email = "user@example.com"; | |
| 350 | + | let secret = "verify-secret"; | |
| 351 | + | ||
| 352 | + | let url = generate_verification_url(host, user_id, email, secret); | |
| 353 | + | assert!(url.contains("/verify-email")); | |
| 354 | + | assert!(url.contains(&user_id.to_string())); | |
| 355 | + | ||
| 356 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 357 | + | let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); | |
| 358 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 359 | + | ||
| 360 | + | assert!(verify_email_signature(user_id, expires, email, &sig, secret)); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[test] | |
| 364 | + | fn verification_rejects_wrong_email() { | |
| 365 | + | let user_id = UserId::new(); | |
| 366 | + | let secret = "verify-secret"; | |
| 367 | + | ||
| 368 | + | let url = generate_verification_url("https://example.com", user_id, "real@example.com", secret); | |
| 369 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 370 | + | let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); | |
| 371 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 372 | + | ||
| 373 | + | assert!(!verify_email_signature(user_id, expires, "attacker@evil.com", &sig, secret)); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | #[test] | |
| 377 | + | fn verification_rejects_expired() { | |
| 378 | + | let user_id = UserId::new(); | |
| 379 | + | let expired = chrono::Utc::now().timestamp() - 1; | |
| 380 | + | assert!(!verify_email_signature(user_id, expired, "a@b.com", "deadbeef", "secret")); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn login_token_round_trip() { | |
| 385 | + | let (token, token_hash) = generate_login_token(); | |
| 386 | + | ||
| 387 | + | // Token and hash should be different | |
| 388 | + | assert_ne!(token, token_hash); | |
| 389 | + | // Both should be hex-encoded 32-byte values (64 hex chars) | |
| 390 | + | assert_eq!(token.len(), 64); | |
| 391 | + | assert_eq!(token_hash.len(), 64); | |
| 392 | + | ||
| 393 | + | assert!(verify_login_token(&token, &token_hash)); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | #[test] | |
| 397 | + | fn login_token_rejects_wrong_token() { | |
| 398 | + | let (_token, token_hash) = generate_login_token(); | |
| 399 | + | assert!(!verify_login_token("0000000000000000000000000000000000000000000000000000000000000000", &token_hash)); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn login_token_unique_each_call() { | |
| 404 | + | let (token1, _) = generate_login_token(); | |
| 405 | + | let (token2, _) = generate_login_token(); | |
| 406 | + | assert_ne!(token1, token2); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn login_link_url_format() { | |
| 411 | + | let url = generate_login_link_url("https://makenot.work", "abc123"); | |
| 412 | + | assert_eq!(url, "https://makenot.work/login-link?token=abc123"); | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | #[test] | |
| 416 | + | fn deletion_url_round_trip() { | |
| 417 | + | let user_id = UserId::new(); | |
| 418 | + | let email = "user@example.com"; | |
| 419 | + | let secret = "delete-secret"; | |
| 420 | + | ||
| 421 | + | let url = generate_deletion_url("https://makenot.work", user_id, email, secret); | |
| 422 | + | assert!(url.contains("/confirm-delete")); | |
| 423 | + | assert!(url.contains(&user_id.to_string())); | |
| 424 | + | ||
| 425 | + | // Extract and verify the signature | |
| 426 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 427 | + | let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap(); | |
| 428 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 429 | + | ||
| 430 | + | let expected_sig = generate_deletion_signature(secret, user_id, expires, email); | |
| 431 | + | assert_eq!(sig, expected_sig); | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | #[test] | |
| 435 | + | fn deletion_signature_rejects_wrong_secret() { | |
| 436 | + | let user_id = UserId::new(); | |
| 437 | + | let expires = chrono::Utc::now().timestamp() + 3600; | |
| 438 | + | let sig = generate_deletion_signature("real-secret", user_id, expires, "a@b.com"); | |
| 439 | + | let wrong = generate_deletion_signature("wrong-secret", user_id, expires, "a@b.com"); | |
| 440 | + | assert_ne!(sig, wrong); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn unsubscribe_url_round_trip() { | |
| 445 | + | let user_id = UserId::new(); | |
| 446 | + | let url = generate_unsubscribe_url("https://makenot.work", user_id, "release", &user_id.to_string(), "secret"); | |
| 447 | + | assert!(url.contains("/unsubscribe")); | |
| 448 | + | assert!(url.contains("action=release")); | |
| 449 | + | ||
| 450 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 451 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 452 | + | assert!(verify_unsubscribe_signature(user_id, "release", &user_id.to_string(), &sig, "secret")); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[test] | |
| 456 | + | fn unsubscribe_rejects_wrong_action() { | |
| 457 | + | let user_id = UserId::new(); | |
| 458 | + | let url = generate_unsubscribe_url("https://makenot.work", user_id, "sale", &user_id.to_string(), "secret"); | |
| 459 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 460 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 461 | + | // Verify with different action should fail | |
| 462 | + | assert!(!verify_unsubscribe_signature(user_id, "follower", &user_id.to_string(), &sig, "secret")); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | #[test] | |
| 466 | + | fn unsubscribe_rejects_wrong_secret() { | |
| 467 | + | let user_id = UserId::new(); | |
| 468 | + | let url = generate_unsubscribe_url("https://makenot.work", user_id, "login", &user_id.to_string(), "real-secret"); | |
| 469 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 470 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 471 | + | assert!(!verify_unsubscribe_signature(user_id, "login", &user_id.to_string(), &sig, "wrong-secret")); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | #[test] | |
| 475 | + | fn unsubscribe_broadcast_with_target() { | |
| 476 | + | let user_id = UserId::new(); | |
| 477 | + | let creator_id = UserId::new(); | |
| 478 | + | let url = generate_unsubscribe_url("https://makenot.work", user_id, "broadcast", &creator_id.to_string(), "secret"); | |
| 479 | + | let parsed: url::Url = url.parse().unwrap(); | |
| 480 | + | let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); | |
| 481 | + | assert!(verify_unsubscribe_signature(user_id, "broadcast", &creator_id.to_string(), &sig, "secret")); | |
| 482 | + | // Wrong target should fail | |
| 483 | + | assert!(!verify_unsubscribe_signature(user_id, "broadcast", &user_id.to_string(), &sig, "secret")); | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | #[test] | |
| 487 | + | fn constant_time_compare_equal() { | |
| 488 | + | use crate::helpers::constant_time_compare; | |
| 489 | + | assert!(constant_time_compare("hello", "hello")); | |
| 490 | + | assert!(constant_time_compare("", "")); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | #[test] | |
| 494 | + | fn constant_time_compare_not_equal() { | |
| 495 | + | use crate::helpers::constant_time_compare; | |
| 496 | + | assert!(!constant_time_compare("hello", "world")); | |
| 497 | + | assert!(!constant_time_compare("hello", "hell")); | |
| 498 | + | assert!(!constant_time_compare("short", "longer")); | |
| 499 | + | } | |
| 500 | + | } |
| @@ -98,6 +98,56 @@ pub fn format_file_size(bytes: i64) -> String { | |||
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | /// Build an HX-Trigger header value that fires a showToast event. | |
| 101 | + | /// Build a rate limiter config from a per-millisecond interval and burst size. | |
| 102 | + | pub fn rate_limiter_ms( | |
| 103 | + | ms: u64, | |
| 104 | + | burst: u32, | |
| 105 | + | ) -> std::sync::Arc< | |
| 106 | + | tower_governor::governor::GovernorConfig< | |
| 107 | + | tower_governor::key_extractor::SmartIpKeyExtractor, | |
| 108 | + | ::governor::middleware::NoOpMiddleware, | |
| 109 | + | >, | |
| 110 | + | > { | |
| 111 | + | std::sync::Arc::new( | |
| 112 | + | tower_governor::governor::GovernorConfigBuilder::default() | |
| 113 | + | .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) | |
| 114 | + | .per_millisecond(ms) | |
| 115 | + | .burst_size(burst) | |
| 116 | + | .finish() | |
| 117 | + | .expect("rate limiter config"), | |
| 118 | + | ) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Build a rate limiter config from a per-second rate and burst size. | |
| 122 | + | pub fn rate_limiter_per_sec( | |
| 123 | + | per_sec: u64, | |
| 124 | + | burst: u32, | |
| 125 | + | ) -> std::sync::Arc< | |
| 126 | + | tower_governor::governor::GovernorConfig< | |
| 127 | + | tower_governor::key_extractor::SmartIpKeyExtractor, | |
| 128 | + | ::governor::middleware::NoOpMiddleware, | |
| 129 | + | >, | |
| 130 | + | > { | |
| 131 | + | std::sync::Arc::new( | |
| 132 | + | tower_governor::governor::GovernorConfigBuilder::default() | |
| 133 | + | .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) | |
| 134 | + | .per_second(per_sec) | |
| 135 | + | .burst_size(burst) | |
| 136 | + | .finish() | |
| 137 | + | .expect("rate limiter config"), | |
| 138 | + | ) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Build an HTMX response that shows a toast notification with an empty body. | |
| 142 | + | /// | |
| 143 | + | /// Use for delete/action endpoints that only need to signal success via toast. | |
| 144 | + | pub fn htmx_toast_response( | |
| 145 | + | message: &str, | |
| 146 | + | toast_type: &str, | |
| 147 | + | ) -> ([(&'static str, HeaderValue); 1], axum::response::Html<String>) { | |
| 148 | + | ([("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new())) | |
| 149 | + | } | |
| 150 | + | ||
| 101 | 151 | pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue { | |
| 102 | 152 | let json = serde_json::json!({ | |
| 103 | 153 | "showToast": { |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, State}, | |
| 5 | - | response::{Html, IntoResponse}, | |
| 5 | + | response::IntoResponse, | |
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| @@ -11,7 +11,7 @@ use crate::{ | |||
| 11 | 11 | auth::AuthUser, | |
| 12 | 12 | db::{self, BlogPostId, ProjectId, Slug}, | |
| 13 | 13 | error::{AppError, Result}, | |
| 14 | - | helpers::{hx_toast, slugify}, | |
| 14 | + | helpers::{htmx_toast_response, slugify}, | |
| 15 | 15 | types::ListResponse, | |
| 16 | 16 | validation, | |
| 17 | 17 | AppState, | |
| @@ -242,10 +242,7 @@ pub(super) async fn delete_blog_post( | |||
| 242 | 242 | verify_blog_post_ownership(&state, id, user.id).await?; | |
| 243 | 243 | ||
| 244 | 244 | db::blog_posts::delete_blog_post(&state.db, id).await?; | |
| 245 | - | Ok(( | |
| 246 | - | [("HX-Trigger", hx_toast("Blog post deleted", "success"))], | |
| 247 | - | Html(String::new()), | |
| 248 | - | )) | |
| 245 | + | Ok(htmx_toast_response("Blog post deleted", "success")) | |
| 249 | 246 | } | |
| 250 | 247 | ||
| 251 | 248 | /// List all blog posts for a project (owner sees all, others see published only). |
| @@ -12,7 +12,7 @@ use crate::{ | |||
| 12 | 12 | auth::AuthUser, | |
| 13 | 13 | db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId}, | |
| 14 | 14 | error::{AppError, Result}, | |
| 15 | - | helpers::hx_toast, | |
| 15 | + | helpers::htmx_toast_response, | |
| 16 | 16 | storage::{FileType, S3Client}, | |
| 17 | 17 | templates::InsertionListTemplate, | |
| 18 | 18 | AppState, | |
| @@ -213,10 +213,7 @@ pub(super) async fn rename_insertion( | |||
| 213 | 213 | return Err(AppError::NotFound); | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | - | Ok(( | |
| 217 | - | [("HX-Trigger", hx_toast("Insertion renamed", "success"))], | |
| 218 | - | Html(String::new()), | |
| 219 | - | )) | |
| 216 | + | Ok(htmx_toast_response("Insertion renamed", "success")) | |
| 220 | 217 | } | |
| 221 | 218 | ||
| 222 | 219 | /// Delete an insertion clip (cascades placements). | |
| @@ -242,10 +239,7 @@ pub(super) async fn delete_insertion( | |||
| 242 | 239 | return Err(AppError::NotFound); | |
| 243 | 240 | } | |
| 244 | 241 | ||
| 245 | - | Ok(( | |
| 246 | - | [("HX-Trigger", hx_toast("Insertion deleted", "success"))], | |
| 247 | - | Html(String::new()), | |
| 248 | - | )) | |
| 242 | + | Ok(htmx_toast_response("Insertion deleted", "success")) | |
| 249 | 243 | } | |
| 250 | 244 | ||
| 251 | 245 | // ============================================================================= | |
| @@ -334,10 +328,7 @@ pub(super) async fn create_placement( | |||
| 334 | 328 | ) | |
| 335 | 329 | .await?; | |
| 336 | 330 | ||
| 337 | - | Ok(( | |
| 338 | - | [("HX-Trigger", hx_toast("Insertion placed", "success"))], | |
| 339 | - | Html(String::new()), | |
| 340 | - | )) | |
| 331 | + | Ok(htmx_toast_response("Insertion placed", "success")) | |
| 341 | 332 | } | |
| 342 | 333 | ||
| 343 | 334 | /// Remove a placement. | |
| @@ -359,10 +350,7 @@ pub(super) async fn delete_placement( | |||
| 359 | 350 | ||
| 360 | 351 | db::content_insertions::delete_placement(&state.db, placement_id).await?; | |
| 361 | 352 | ||
| 362 | - | Ok(( | |
| 363 | - | [("HX-Trigger", hx_toast("Placement removed", "success"))], | |
| 364 | - | Html(String::new()), | |
| 365 | - | )) | |
| 353 | + | Ok(htmx_toast_response("Placement removed", "success")) | |
| 366 | 354 | } | |
| 367 | 355 | ||
| 368 | 356 | // ============================================================================= |