Skip to main content

max / makenotwork

Landing page rewrite, doc polish, version bump to 0.2.1 Strengthened landing page: feature grid, explore links, sharper tier descriptions, "How it works" section, dashboard docs link. Documentation cross-references verified clean (62 links). Added security and audience tools to how-we-work.md feature list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-12 02:03 UTC
Commit: 120fd7be2d4defeb4c44e02283f4765a09e78051
Parent: 3fb6820
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 // =============================================================================