max / makenotwork
25 files changed,
+1775 insertions,
-2036 deletions
| @@ -3350,7 +3350,7 @@ dependencies = [ | |||
| 3350 | 3350 | ||
| 3351 | 3351 | [[package]] | |
| 3352 | 3352 | name = "makenotwork" | |
| 3353 | - | version = "0.3.9" | |
| 3353 | + | version = "0.3.10" | |
| 3354 | 3354 | dependencies = [ | |
| 3355 | 3355 | "anyhow", | |
| 3356 | 3356 | "argon2", |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | -- Issue inbound email: map email Message-ID headers to issues for threading. | |
| 2 | + | ||
| 3 | + | CREATE TABLE issue_message_ids ( | |
| 4 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 5 | + | message_id TEXT NOT NULL UNIQUE, | |
| 6 | + | issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 7 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 8 | + | ); | |
| 9 | + | ||
| 10 | + | CREATE INDEX idx_issue_message_ids_issue ON issue_message_ids(issue_id); |
| @@ -17,7 +17,16 @@ | |||
| 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 | 19 | //! mnw-admin rebuild-keys Rebuild authorized_keys from DB | |
| 20 | - | //! mnw-admin git-auth <key_id> Authenticate SSH git operations | |
| 20 | + | //! mnw-admin git-auth <key_id> Authenticate SSH git/management operations | |
| 21 | + | //! | |
| 22 | + | //! SSH management commands (via git-auth dispatcher): | |
| 23 | + | //! repo list List your repositories | |
| 24 | + | //! repo info <name> Show repo details + issue counts | |
| 25 | + | //! repo delete <name> --confirm Delete a repo (DB + disk) | |
| 26 | + | //! repo set-visibility <name> <v> Set public/private/unlisted | |
| 27 | + | //! repo set-description <name> "d" Set description (quote for spaces) | |
| 28 | + | //! key list List your SSH keys | |
| 29 | + | //! key rm <fingerprint> Remove an SSH key by fingerprint | |
| 21 | 30 | ||
| 22 | 31 | use clap::{Parser, Subcommand}; | |
| 23 | 32 | use sqlx::PgPool; | |
| @@ -105,6 +114,9 @@ enum Command { | |||
| 105 | 114 | ||
| 106 | 115 | #[tokio::main] | |
| 107 | 116 | async fn main() -> anyhow::Result<()> { | |
| 117 | + | // Try the production .env first (SSH invocations have CWD=/opt/git), | |
| 118 | + | // then fall back to the local directory for dev usage. | |
| 119 | + | dotenvy::from_path("/opt/makenotwork/.env").ok(); | |
| 108 | 120 | dotenvy::dotenv().ok(); | |
| 109 | 121 | ||
| 110 | 122 | let database_url = std::env::var("DATABASE_URL") | |
| @@ -598,6 +610,23 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |||
| 598 | 610 | ||
| 599 | 611 | // ── Build hooks command ── | |
| 600 | 612 | ||
| 613 | + | /// Install a post-receive hook into a single bare repo directory. | |
| 614 | + | fn install_hook_for_repo(repo_path: &std::path::Path, hook_content: &str) -> anyhow::Result<()> { | |
| 615 | + | let hooks_dir = repo_path.join("hooks"); | |
| 616 | + | std::fs::create_dir_all(&hooks_dir)?; | |
| 617 | + | ||
| 618 | + | let hook_path = hooks_dir.join("post-receive"); | |
| 619 | + | std::fs::write(&hook_path, hook_content)?; | |
| 620 | + | ||
| 621 | + | #[cfg(unix)] | |
| 622 | + | { | |
| 623 | + | use std::os::unix::fs::PermissionsExt; | |
| 624 | + | std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?; | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | Ok(()) | |
| 628 | + | } | |
| 629 | + | ||
| 601 | 630 | async fn cmd_install_hooks() -> anyhow::Result<()> { | |
| 602 | 631 | let token = std::env::var("BUILD_TRIGGER_TOKEN") | |
| 603 | 632 | .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?; | |
| @@ -631,21 +660,7 @@ async fn cmd_install_hooks() -> anyhow::Result<()> { | |||
| 631 | 660 | continue; | |
| 632 | 661 | } | |
| 633 | 662 | ||
| 634 | - | let hooks_dir = repo_path.join("hooks"); | |
| 635 | - | std::fs::create_dir_all(&hooks_dir)?; | |
| 636 | - | ||
| 637 | - | let hook_path = hooks_dir.join("post-receive"); | |
| 638 | - | std::fs::write(&hook_path, &hook_content)?; | |
| 639 | - | ||
| 640 | - | #[cfg(unix)] | |
| 641 | - | { | |
| 642 | - | use std::os::unix::fs::PermissionsExt; | |
| 643 | - | std::fs::set_permissions( | |
| 644 | - | &hook_path, | |
| 645 | - | std::fs::Permissions::from_mode(0o755), | |
| 646 | - | )?; | |
| 647 | - | } | |
| 648 | - | ||
| 663 | + | install_hook_for_repo(&repo_path, &hook_content)?; | |
| 649 | 664 | installed += 1; | |
| 650 | 665 | } | |
| 651 | 666 | } | |
| @@ -660,64 +675,50 @@ const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys"; | |||
| 660 | 675 | const MNW_ADMIN_PATH: &str = "/opt/makenotwork/mnw-admin"; | |
| 661 | 676 | ||
| 662 | 677 | async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> { | |
| 663 | - | let keys = db::ssh_keys::get_all_keys_with_username(pool).await?; | |
| 664 | - | ||
| 665 | - | let mut content = String::new(); | |
| 666 | - | content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n"); | |
| 667 | - | ||
| 668 | - | for key in &keys { | |
| 669 | - | // Each line: command="...",restrictions {public_key} | |
| 670 | - | content.push_str(&format!( | |
| 671 | - | "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n", | |
| 672 | - | MNW_ADMIN_PATH, key.id, key.public_key, | |
| 673 | - | )); | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | // Atomic write: write to temp file, then rename | |
| 677 | - | let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH); | |
| 678 | - | std::fs::write(&tmp_path, &content)?; | |
| 679 | - | std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?; | |
| 680 | - | ||
| 681 | - | // Set ownership to git:git and permissions to 600. | |
| 682 | - | // mnw-admin runs as root, but sshd requires authorized_keys owned by the target user. | |
| 683 | - | #[cfg(unix)] | |
| 684 | - | { | |
| 685 | - | use std::os::unix::fs::PermissionsExt; | |
| 686 | - | std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?; | |
| 687 | - | ||
| 688 | - | let status = std::process::Command::new("chown") | |
| 689 | - | .args(["git:git", AUTHORIZED_KEYS_PATH]) | |
| 690 | - | .status()?; | |
| 691 | - | if !status.success() { | |
| 692 | - | anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH); | |
| 693 | - | } | |
| 694 | - | } | |
| 695 | - | ||
| 696 | - | println!("Rebuilt authorized_keys with {} key(s).", keys.len()); | |
| 678 | + | let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len(); | |
| 679 | + | write_authorized_keys(pool, true).await?; | |
| 680 | + | println!("Rebuilt authorized_keys with {} key(s).", key_count); | |
| 697 | 681 | Ok(()) | |
| 698 | 682 | } | |
| 699 | 683 | ||
| 700 | 684 | async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { | |
| 701 | - | // Parse the SSH_ORIGINAL_COMMAND | |
| 702 | 685 | let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND") | |
| 703 | 686 | .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?; | |
| 704 | 687 | ||
| 705 | - | // Parse: "git-upload-pack 'owner/repo.git'" or "git-receive-pack 'owner/repo.git'" | |
| 706 | - | let (operation, repo_path) = parse_ssh_command(&original_cmd)?; | |
| 707 | - | ||
| 708 | - | // Parse repo path: "owner/repo.git" → (owner, repo_name) | |
| 709 | - | let (owner, repo_name) = parse_repo_path(&repo_path)?; | |
| 710 | - | ||
| 711 | 688 | // Look up the SSH key → user | |
| 712 | 689 | let key_id: db::SshKeyId = key_id_str | |
| 713 | 690 | .parse() | |
| 714 | 691 | .map_err(|_| anyhow::anyhow!("invalid key ID"))?; | |
| 715 | 692 | ||
| 716 | - | let (_, user_id, _ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id) | |
| 693 | + | let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id) | |
| 717 | 694 | .await? | |
| 718 | 695 | .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?; | |
| 719 | 696 | ||
| 720 | - | // Look up the repo owner | |
| 697 | + | // Dispatch: git operations start with "git-", everything else is a management command | |
| 698 | + | if original_cmd.starts_with("git-") { | |
| 699 | + | exec_git_operation(pool, user_id, &original_cmd).await | |
| 700 | + | } else { | |
| 701 | + | exec_management_command(pool, user_id, &ssh_username, &original_cmd).await | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // ── Git operations ── | |
| 706 | + | ||
| 707 | + | #[derive(Debug)] | |
| 708 | + | enum GitOperation { | |
| 709 | + | UploadPack, | |
| 710 | + | ReceivePack, | |
| 711 | + | Archive, | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | async fn exec_git_operation( | |
| 715 | + | pool: &PgPool, | |
| 716 | + | user_id: db::UserId, | |
| 717 | + | original_cmd: &str, | |
| 718 | + | ) -> anyhow::Result<()> { | |
| 719 | + | let (operation, repo_path) = parse_ssh_command(original_cmd)?; | |
| 720 | + | let (owner, repo_name) = parse_repo_path(&repo_path)?; | |
| 721 | + | ||
| 721 | 722 | let owner_user = db::users::get_user_by_username(pool, &Username::from_trusted(owner.to_string())) | |
| 722 | 723 | .await? | |
| 723 | 724 | .ok_or_else(|| anyhow::anyhow!("repository not found"))?; | |
| @@ -738,6 +739,12 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { | |||
| 738 | 739 | std::fs::create_dir_all(&owner_dir)?; | |
| 739 | 740 | git2::Repository::init_bare(&repo_dir)?; | |
| 740 | 741 | ||
| 742 | + | // Install post-receive hook if build trigger token is configured | |
| 743 | + | if let Ok(token) = std::env::var("BUILD_TRIGGER_TOKEN") { | |
| 744 | + | let hook_content = makenotwork::build_runner::post_receive_hook(&token); | |
| 745 | + | install_hook_for_repo(&repo_dir, &hook_content)?; | |
| 746 | + | } | |
| 747 | + | ||
| 741 | 748 | // Fix ownership so the git user can write | |
| 742 | 749 | let status = std::process::Command::new("chown") | |
| 743 | 750 | .args(["-R", "git:git"]) | |
| @@ -767,18 +774,10 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> { | |||
| 767 | 774 | } | |
| 768 | 775 | ||
| 769 | 776 | // Authorized — exec git-shell | |
| 770 | - | let err = exec_git_shell(&original_cmd); | |
| 771 | - | // exec_git_shell only returns on error | |
| 777 | + | let err = exec_git_shell(original_cmd); | |
| 772 | 778 | anyhow::bail!("failed to exec git-shell: {}", err); | |
| 773 | 779 | } | |
| 774 | 780 | ||
| 775 | - | #[derive(Debug)] | |
| 776 | - | enum GitOperation { | |
| 777 | - | UploadPack, | |
| 778 | - | ReceivePack, | |
| 779 | - | Archive, | |
| 780 | - | } | |
| 781 | - | ||
| 782 | 781 | fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> { | |
| 783 | 782 | let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); | |
| 784 | 783 | if parts.len() != 2 { | |
| @@ -792,15 +791,12 @@ fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> { | |||
| 792 | 791 | _ => anyhow::bail!("unsupported git command: {}", parts[0]), | |
| 793 | 792 | }; | |
| 794 | 793 | ||
| 795 | - | // Strip surrounding quotes from the repo path | |
| 796 | 794 | let repo_path = parts[1].trim_matches('\'').trim_matches('"'); | |
| 797 | 795 | Ok((operation, repo_path.to_string())) | |
| 798 | 796 | } | |
| 799 | 797 | ||
| 800 | 798 | fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> { | |
| 801 | - | // Strip leading / if present | |
| 802 | 799 | let path = path.trim_start_matches('/'); | |
| 803 | - | ||
| 804 | 800 | let parts: Vec<&str> = path.splitn(2, '/').collect(); | |
| 805 | 801 | if parts.len() != 2 { | |
| 806 | 802 | anyhow::bail!("invalid repository path"); | |
| @@ -809,12 +805,10 @@ fn parse_repo_path(path: &str) -> anyhow::Result<(&str, String)> { | |||
| 809 | 805 | let owner = parts[0]; | |
| 810 | 806 | let mut repo_name = parts[1].to_string(); | |
| 811 | 807 | ||
| 812 | - | // Reject path traversal | |
| 813 | 808 | if owner.contains("..") || repo_name.contains("..") { | |
| 814 | 809 | anyhow::bail!("invalid repository path"); | |
| 815 | 810 | } | |
| 816 | 811 | ||
| 817 | - | // Strip .git suffix if present | |
| 818 | 812 | if repo_name.ends_with(".git") { | |
| 819 | 813 | repo_name.truncate(repo_name.len() - 4); | |
| 820 | 814 | } | |
| @@ -833,3 +827,414 @@ fn exec_git_shell(original_cmd: &str) -> std::io::Error { | |||
| 833 | 827 | .args(["-c", original_cmd]) | |
| 834 | 828 | .exec() | |
| 835 | 829 | } | |
| 830 | + | ||
| 831 | + | // ── SSH management commands ── | |
| 832 | + | ||
| 833 | + | #[derive(Debug, PartialEq)] | |
| 834 | + | enum ManagementCommand { | |
| 835 | + | RepoList, | |
| 836 | + | RepoInfo { name: String }, | |
| 837 | + | RepoDelete { name: String }, | |
| 838 | + | RepoSetVisibility { name: String, visibility: String }, | |
| 839 | + | RepoSetDescription { name: String, description: String }, | |
| 840 | + | KeyList, | |
| 841 | + | KeyRemove { fingerprint: String }, | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | /// Split a command string on whitespace, respecting double-quoted segments. | |
| 845 | + | fn shell_tokenize(input: &str) -> Vec<String> { | |
| 846 | + | let mut tokens = Vec::new(); | |
| 847 | + | let mut current = String::new(); | |
| 848 | + | let mut in_quotes = false; | |
| 849 | + | let mut chars = input.chars(); | |
| 850 | + | ||
| 851 | + | while let Some(ch) = chars.next() { | |
| 852 | + | if in_quotes { | |
| 853 | + | if ch == '"' { | |
| 854 | + | in_quotes = false; | |
| 855 | + | } else { | |
| 856 | + | current.push(ch); | |
| 857 | + | } | |
| 858 | + | } else if ch == '"' { | |
| 859 | + | in_quotes = true; | |
| 860 | + | } else if ch.is_ascii_whitespace() { | |
| 861 | + | if !current.is_empty() { | |
| 862 | + | tokens.push(std::mem::take(&mut current)); | |
| 863 | + | } | |
| 864 | + | } else { | |
| 865 | + | current.push(ch); | |
| 866 | + | } | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | if !current.is_empty() { | |
| 870 | + | tokens.push(current); | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | tokens | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> { | |
| 877 | + | let strs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect(); | |
| 878 | + | ||
| 879 | + | match strs.as_slice() { | |
| 880 | + | ["repo", "list"] => Ok(ManagementCommand::RepoList), | |
| 881 | + | ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { name: name.to_string() }), | |
| 882 | + | ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }), | |
| 883 | + | ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"), | |
| 884 | + | ["repo", "set-visibility", name, vis] => { | |
| 885 | + | match *vis { | |
| 886 | + | "public" | "private" | "unlisted" => {} | |
| 887 | + | _ => anyhow::bail!("visibility must be public, private, or unlisted"), | |
| 888 | + | } | |
| 889 | + | Ok(ManagementCommand::RepoSetVisibility { | |
| 890 | + | name: name.to_string(), | |
| 891 | + | visibility: vis.to_string(), | |
| 892 | + | }) | |
| 893 | + | } | |
| 894 | + | ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription { | |
| 895 | + | name: name.to_string(), | |
| 896 | + | description: desc.to_string(), | |
| 897 | + | }), | |
| 898 | + | ["key", "list"] => Ok(ManagementCommand::KeyList), | |
| 899 | + | ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }), | |
| 900 | + | _ => anyhow::bail!("unknown command. Available: repo list|info|delete|set-visibility|set-description, key list|rm"), | |
| 901 | + | } | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | async fn exec_management_command( | |
| 905 | + | pool: &PgPool, | |
| 906 | + | user_id: db::UserId, | |
| 907 | + | username: &str, | |
| 908 | + | original_cmd: &str, | |
| 909 | + | ) -> anyhow::Result<()> { | |
| 910 | + | let tokens = shell_tokenize(original_cmd); | |
| 911 | + | let cmd = parse_management_command(&tokens)?; | |
| 912 | + | ||
| 913 | + | match cmd { | |
| 914 | + | ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await, | |
| 915 | + | ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await, | |
| 916 | + | ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await, | |
| 917 | + | ManagementCommand::RepoSetVisibility { name, visibility } => { | |
| 918 | + | cmd_ssh_repo_set_visibility(pool, user_id, &name, &visibility).await | |
| 919 | + | } | |
| 920 | + | ManagementCommand::RepoSetDescription { name, description } => { | |
| 921 | + | cmd_ssh_repo_set_description(pool, user_id, &name, &description).await | |
| 922 | + | } | |
| 923 | + | ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await, | |
| 924 | + | ManagementCommand::KeyRemove { fingerprint } => cmd_ssh_key_remove(pool, user_id, &fingerprint).await, | |
| 925 | + | } | |
| 926 | + | } | |
| 927 | + | ||
| 928 | + | async fn cmd_ssh_repo_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> { | |
| 929 | + | let repos = db::git_repos::get_repos_by_user(pool, user_id).await?; | |
| 930 | + | ||
| 931 | + | if repos.is_empty() { | |
| 932 | + | println!("No repositories."); | |
| 933 | + | return Ok(()); | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | println!("{:<30} {:<10} Description", "Name", "Visibility"); | |
| 937 | + | println!("{}", "-".repeat(70)); | |
| 938 | + | ||
| 939 | + | for repo in &repos { | |
| 940 | + | let desc = if repo.description.is_empty() { | |
| 941 | + | "-".to_string() | |
| 942 | + | } else if repo.description.len() > 28 { | |
| 943 | + | format!("{}...", &repo.description[..25]) | |
| 944 | + | } else { | |
| 945 | + | repo.description.clone() | |
| 946 | + | }; | |
| 947 | + | println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc); | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | println!("\n{} repo(s).", repos.len()); | |
| 951 | + | Ok(()) | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | async fn cmd_ssh_repo_info(pool: &PgPool, user_id: db::UserId, name: &str) -> anyhow::Result<()> { | |
| 955 | + | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 956 | + | .await? | |
| 957 | + | .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; | |
| 958 | + | ||
| 959 | + | let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?; | |
| 960 | + | ||
| 961 | + | println!("Name: {}", repo.name); | |
| 962 | + | println!("Visibility: {}", repo.visibility); | |
| 963 | + | println!("Description: {}", if repo.description.is_empty() { "-" } else { &repo.description }); | |
| 964 | + | println!("Created: {}", repo.created_at.format("%Y-%m-%d %H:%M UTC")); | |
| 965 | + | println!("Issues: {} open, {} closed", open_issues, closed_issues); | |
| 966 | + | ||
| 967 | + | Ok(()) | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | async fn cmd_ssh_repo_delete( | |
| 971 | + | pool: &PgPool, | |
| 972 | + | user_id: db::UserId, | |
| 973 | + | username: &str, | |
| 974 | + | name: &str, | |
| 975 | + | ) -> anyhow::Result<()> { | |
| 976 | + | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 977 | + | .await? | |
| 978 | + | .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; | |
| 979 | + | ||
| 980 | + | // Delete from DB (issues cascade) | |
| 981 | + | db::git_repos::delete_repo(pool, repo.id).await?; | |
| 982 | + | ||
| 983 | + | // Delete from disk | |
| 984 | + | let git_root = std::env::var("GIT_REPOS_PATH") | |
| 985 | + | .unwrap_or_else(|_| "/opt/git".to_string()); | |
| 986 | + | let repo_dir = std::path::Path::new(&git_root) | |
| 987 | + | .join(username) | |
| 988 | + | .join(format!("{}.git", name)); | |
| 989 | + | ||
| 990 | + | if repo_dir.exists() { | |
| 991 | + | std::fs::remove_dir_all(&repo_dir)?; | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | println!("Deleted repository '{}'.", name); | |
| 995 | + | Ok(()) | |
| 996 | + | } | |
| 997 | + | ||
| 998 | + | async fn cmd_ssh_repo_set_visibility( | |
| 999 | + | pool: &PgPool, | |
| 1000 | + | user_id: db::UserId, | |
| 1001 | + | name: &str, | |
| 1002 | + | visibility: &str, | |
| 1003 | + | ) -> anyhow::Result<()> { | |
| 1004 | + | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 1005 | + | .await? | |
| 1006 | + | .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; | |
| 1007 | + | ||
| 1008 | + | db::git_repos::update_visibility(pool, repo.id, visibility).await?; | |
| 1009 | + | ||
| 1010 | + | println!("Set visibility of '{}' to '{}'.", name, visibility); | |
| 1011 | + | Ok(()) | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | async fn cmd_ssh_repo_set_description( | |
| 1015 | + | pool: &PgPool, | |
| 1016 | + | user_id: db::UserId, | |
| 1017 | + | name: &str, | |
| 1018 | + | description: &str, | |
| 1019 | + | ) -> anyhow::Result<()> { | |
| 1020 | + | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 1021 | + | .await? | |
| 1022 | + | .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; | |
| 1023 | + | ||
| 1024 | + | db::git_repos::update_repo_settings(pool, repo.id, description, &repo.visibility).await?; | |
| 1025 | + | ||
| 1026 | + | println!("Updated description of '{}'.", name); | |
| 1027 | + | Ok(()) | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | async fn cmd_ssh_key_list(pool: &PgPool, user_id: db::UserId) -> anyhow::Result<()> { | |
| 1031 | + | let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?; | |
| 1032 | + | ||
| 1033 | + | if keys.is_empty() { | |
| 1034 | + | println!("No SSH keys."); | |
| 1035 | + | return Ok(()); | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | println!("{:<50} {:<20} Added", "Fingerprint", "Label"); | |
| 1039 | + | println!("{}", "-".repeat(80)); | |
| 1040 | + | ||
| 1041 | + | for key in &keys { | |
| 1042 | + | let label = if key.label.is_empty() { "-" } else { &key.label }; | |
| 1043 | + | println!( | |
| 1044 | + | "{:<50} {:<20} {}", | |
| 1045 | + | key.fingerprint, | |
| 1046 | + | label, | |
| 1047 | + | key.created_at.format("%Y-%m-%d"), | |
| 1048 | + | ); | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | println!("\n{} key(s).", keys.len()); | |
| 1052 | + | Ok(()) | |
| 1053 | + | } | |
| 1054 | + | ||
| 1055 | + | async fn cmd_ssh_key_remove(pool: &PgPool, user_id: db::UserId, fingerprint: &str) -> anyhow::Result<()> { | |
| 1056 | + | let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?; | |
| 1057 | + | ||
| 1058 | + | if !deleted { | |
| 1059 | + | anyhow::bail!("SSH key with fingerprint '{}' not found", fingerprint); | |
| 1060 | + | } | |
| 1061 | + | ||
| 1062 | + | // Rebuild authorized_keys to remove the deleted key | |
| 1063 | + | write_authorized_keys(pool, true).await?; | |
| 1064 | + | ||
| 1065 | + | println!("Removed SSH key '{}'.", fingerprint); | |
| 1066 | + | Ok(()) | |
| 1067 | + | } | |
| 1068 | + | ||
| 1069 | + | /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership. | |
| 1070 | + | async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> { | |
| 1071 | + | let keys = db::ssh_keys::get_all_keys_with_username(pool).await?; | |
| 1072 | + | ||
| 1073 | + | let mut content = String::new(); | |
| 1074 | + | content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n"); | |
| 1075 | + | ||
| 1076 | + | for key in &keys { | |
| 1077 | + | content.push_str(&format!( | |
| 1078 | + | "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n", | |
| 1079 | + | MNW_ADMIN_PATH, key.id, key.public_key, | |
| 1080 | + | )); | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH); | |
| 1084 | + | std::fs::write(&tmp_path, &content)?; | |
| 1085 | + | std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?; | |
| 1086 | + | ||
| 1087 | + | #[cfg(unix)] | |
| 1088 | + | { | |
| 1089 | + | use std::os::unix::fs::PermissionsExt; | |
| 1090 | + | std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?; | |
| 1091 | + | ||
| 1092 | + | if set_ownership { | |
| 1093 | + | let status = std::process::Command::new("chown") | |
| 1094 | + | .args(["git:git", AUTHORIZED_KEYS_PATH]) | |
| 1095 | + | .status()?; | |
| 1096 | + | if !status.success() { | |
| 1097 | + | anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH); | |
| 1098 | + | } | |
| 1099 | + | } | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | Ok(()) | |
| 1103 | + | } | |
| 1104 | + |
Lines truncated
| @@ -138,7 +138,7 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response { | |||
| 138 | 138 | "/login", "/join", | |
| 139 | 139 | "/api/sync/auth", "/api/sync/push", "/api/sync/pull", "/api/sync/status", | |
| 140 | 140 | "/api/sync/devices", "/api/sync/keys", "/api/sync/blobs", | |
| 141 | - | "/oauth", "/auth/passkey", "/postmark/webhook", | |
| 141 | + | "/oauth", "/auth/passkey", "/postmark/", | |
| 142 | 142 | "/unsubscribe", "/confirm-delete", | |
| 143 | 143 | ]; | |
| 144 | 144 |
| @@ -3,9 +3,9 @@ | |||
| 3 | 3 | use sqlx::PgPool; | |
| 4 | 4 | ||
| 5 | 5 | use super::models::{ | |
| 6 | - | DbIssue, DbIssueComment, DbIssueCommentWithAuthor, DbIssueLabel, DbIssueWithMeta, | |
| 6 | + | DbIssue, DbIssueComment, DbIssueCommentWithAuthor, DbIssueWithMeta, | |
| 7 | 7 | }; | |
| 8 | - | use super::{GitRepoId, IssueCommentId, IssueId, IssueLabelId, IssueStatus, UserId}; | |
| 8 | + | use super::{GitRepoId, IssueId, IssueStatus, UserId}; | |
| 9 | 9 | use crate::error::Result; | |
| 10 | 10 | ||
| 11 | 11 | // ── Issues ── | |
| @@ -56,6 +56,15 @@ async fn try_create_issue( | |||
| 56 | 56 | Ok(issue) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | /// Get a single issue by its primary key. | |
| 60 | + | pub async fn get_issue_by_id(pool: &PgPool, issue_id: IssueId) -> Result<Option<DbIssue>> { | |
| 61 | + | let issue = sqlx::query_as::<_, DbIssue>("SELECT * FROM issues WHERE id = $1") | |
| 62 | + | .bind(issue_id) | |
| 63 | + | .fetch_optional(pool) | |
| 64 | + | .await?; | |
| 65 | + | Ok(issue) | |
| 66 | + | } | |
| 67 | + | ||
| 59 | 68 | /// Get a single issue by its repo-scoped number. | |
| 60 | 69 | pub async fn get_issue_by_number( | |
| 61 | 70 | pool: &PgPool, | |
| @@ -240,141 +249,62 @@ pub async fn list_comments( | |||
| 240 | 249 | Ok(comments) | |
| 241 | 250 | } | |
| 242 | 251 | ||
| 243 | - | /// Delete a comment by ID. | |
| 244 | - | pub async fn delete_comment(pool: &PgPool, comment_id: IssueCommentId) -> Result<()> { | |
| 245 | - | sqlx::query("DELETE FROM issue_comments WHERE id = $1") | |
| 246 | - | .bind(comment_id) | |
| 247 | - | .execute(pool) | |
| 248 | - | .await?; | |
| 249 | - | ||
| 250 | - | Ok(()) | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | /// Get a single comment by ID (for permission checks). | |
| 254 | - | pub async fn get_comment(pool: &PgPool, comment_id: IssueCommentId) -> Result<Option<DbIssueComment>> { | |
| 255 | - | let comment = sqlx::query_as::<_, DbIssueComment>( | |
| 256 | - | "SELECT * FROM issue_comments WHERE id = $1", | |
| 257 | - | ) | |
| 258 | - | .bind(comment_id) | |
| 259 | - | .fetch_optional(pool) | |
| 260 | - | .await?; | |
| 261 | - | ||
| 262 | - | Ok(comment) | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // ── Labels ── | |
| 252 | + | // ── Issue participants (for email notifications) ── | |
| 266 | 253 | ||
| 267 | - | /// Create a label for a repo. | |
| 268 | - | pub async fn create_label( | |
| 269 | - | pool: &PgPool, | |
| 270 | - | repo_id: GitRepoId, | |
| 271 | - | name: &str, | |
| 272 | - | color: &str, | |
| 273 | - | ) -> Result<DbIssueLabel> { | |
| 274 | - | let label = sqlx::query_as::<_, DbIssueLabel>( | |
| 254 | + | /// Get all distinct participant user IDs for an issue (author + all comment authors). | |
| 255 | + | pub async fn get_issue_participants(pool: &PgPool, issue_id: IssueId) -> Result<Vec<UserId>> { | |
| 256 | + | let ids = sqlx::query_scalar::<_, UserId>( | |
| 275 | 257 | r#" | |
| 276 | - | INSERT INTO issue_labels (repo_id, name, color) | |
| 277 | - | VALUES ($1, $2, $3) | |
| 278 | - | RETURNING * | |
| 258 | + | SELECT DISTINCT author_user_id | |
| 259 | + | FROM ( | |
| 260 | + | SELECT author_user_id FROM issues WHERE id = $1 | |
| 261 | + | UNION | |
| 262 | + | SELECT author_user_id FROM issue_comments WHERE issue_id = $1 | |
| 263 | + | ) AS participants | |
| 279 | 264 | "#, | |
| 280 | 265 | ) | |
| 281 | - | .bind(repo_id) | |
| 282 | - | .bind(name) | |
| 283 | - | .bind(color) | |
| 284 | - | .fetch_one(pool) | |
| 285 | - | .await?; | |
| 286 | - | ||
| 287 | - | Ok(label) | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | /// List all labels for a repo. | |
| 291 | - | pub async fn list_labels(pool: &PgPool, repo_id: GitRepoId) -> Result<Vec<DbIssueLabel>> { | |
| 292 | - | let labels = sqlx::query_as::<_, DbIssueLabel>( | |
| 293 | - | "SELECT * FROM issue_labels WHERE repo_id = $1 ORDER BY name ASC", | |
| 294 | - | ) | |
| 295 | - | .bind(repo_id) | |
| 266 | + | .bind(issue_id) | |
| 296 | 267 | .fetch_all(pool) | |
| 297 | 268 | .await?; | |
| 298 | 269 | ||
| 299 | - | Ok(labels) | |
| 270 | + | Ok(ids) | |
| 300 | 271 | } | |
| 301 | 272 | ||
| 302 | - | /// Update a label's name and color. | |
| 303 | - | pub async fn update_label( | |
| 304 | - | pool: &PgPool, | |
| 305 | - | label_id: IssueLabelId, | |
| 306 | - | name: &str, | |
| 307 | - | color: &str, | |
| 308 | - | ) -> Result<()> { | |
| 309 | - | sqlx::query("UPDATE issue_labels SET name = $2, color = $3 WHERE id = $1") | |
| 310 | - | .bind(label_id) | |
| 311 | - | .bind(name) | |
| 312 | - | .bind(color) | |
| 313 | - | .execute(pool) | |
| 314 | - | .await?; | |
| 315 | - | ||
| 316 | - | Ok(()) | |
| 317 | - | } | |
| 273 | + | // ── Issue message ID mapping (for email threading) ── | |
| 318 | 274 | ||
| 319 | - | /// Delete a label (cascades to assignments). | |
| 320 | - | pub async fn delete_label(pool: &PgPool, label_id: IssueLabelId) -> Result<()> { | |
| 321 | - | sqlx::query("DELETE FROM issue_labels WHERE id = $1") | |
| 322 | - | .bind(label_id) | |
| 323 | - | .execute(pool) | |
| 324 | - | .await?; | |
| 325 | - | ||
| 326 | - | Ok(()) | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | /// Add a label to an issue. | |
| 330 | - | pub async fn add_label_to_issue( | |
| 275 | + | /// Store a mapping from an email Message-ID to an issue. | |
| 276 | + | pub async fn insert_issue_message_id( | |
| 331 | 277 | pool: &PgPool, | |
| 278 | + | message_id: &str, | |
| 332 | 279 | issue_id: IssueId, | |
| 333 | - | label_id: IssueLabelId, | |
| 334 | 280 | ) -> Result<()> { | |
| 335 | 281 | sqlx::query( | |
| 336 | - | "INSERT INTO issue_label_assignments (issue_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 282 | + | "INSERT INTO issue_message_ids (message_id, issue_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 337 | 283 | ) | |
| 284 | + | .bind(message_id) | |
| 338 | 285 | .bind(issue_id) | |
| 339 | - | .bind(label_id) | |
| 340 | 286 | .execute(pool) | |
| 341 | 287 | .await?; | |
| 342 | 288 | ||
| 343 | 289 | Ok(()) | |
| 344 | 290 | } | |
| 345 | 291 | ||
| 346 | - | /// Remove a label from an issue. | |
| 347 | - | pub async fn remove_label_from_issue( | |
| 292 | + | /// Look up an issue ID by any of the given email Message-IDs. | |
| 293 | + | pub async fn get_issue_id_by_any_message_id( | |
| 348 | 294 | pool: &PgPool, | |
| 349 | - | issue_id: IssueId, | |
| 350 | - | label_id: IssueLabelId, | |
| 351 | - | ) -> Result<()> { | |
| 352 | - | sqlx::query("DELETE FROM issue_label_assignments WHERE issue_id = $1 AND label_id = $2") | |
| 353 | - | .bind(issue_id) | |
| 354 | - | .bind(label_id) | |
| 355 | - | .execute(pool) | |
| 356 | - | .await?; | |
| 357 | - | ||
| 358 | - | Ok(()) | |
| 359 | - | } | |
| 295 | + | message_ids: &[&str], | |
| 296 | + | ) -> Result<Option<IssueId>> { | |
| 297 | + | if message_ids.is_empty() { | |
| 298 | + | return Ok(None); | |
| 299 | + | } | |
| 360 | 300 | ||
| 361 | - | /// Get all labels attached to an issue. | |
| 362 | - | pub async fn get_labels_for_issue( | |
| 363 | - | pool: &PgPool, | |
| 364 | - | issue_id: IssueId, | |
| 365 | - | ) -> Result<Vec<DbIssueLabel>> { | |
| 366 | - | let labels = sqlx::query_as::<_, DbIssueLabel>( | |
| 367 | - | r#" | |
| 368 | - | SELECT l.* | |
| 369 | - | FROM issue_labels l | |
| 370 | - | JOIN issue_label_assignments a ON a.label_id = l.id | |
| 371 | - | WHERE a.issue_id = $1 | |
| 372 | - | ORDER BY l.name ASC | |
| 373 | - | "#, | |
| 301 | + | let ids: Vec<String> = message_ids.iter().map(|s| s.to_string()).collect(); | |
| 302 | + | let issue_id = sqlx::query_scalar::<_, IssueId>( | |
| 303 | + | "SELECT issue_id FROM issue_message_ids WHERE message_id = ANY($1) LIMIT 1", | |
| 374 | 304 | ) | |
| 375 | - | .bind(issue_id) | |
| 376 | - | .fetch_all(pool) | |
| 305 | + | .bind(&ids) | |
| 306 | + | .fetch_optional(pool) | |
| 377 | 307 | .await?; | |
| 378 | 308 | ||
| 379 | - | Ok(labels) | |
| 309 | + | Ok(issue_id) | |
| 380 | 310 | } |
| @@ -40,7 +40,7 @@ pub(crate) mod analytics; | |||
| 40 | 40 | pub(crate) mod email_suppressions; | |
| 41 | 41 | pub mod git_repos; | |
| 42 | 42 | pub mod ssh_keys; | |
| 43 | - | pub(crate) mod issues; | |
| 43 | + | pub mod issues; | |
| 44 | 44 | pub(crate) mod reports; | |
| 45 | 45 | pub(crate) mod fan_plus; | |
| 46 | 46 | pub(crate) mod collections; |
| @@ -55,6 +55,22 @@ pub async fn delete_key(pool: &PgPool, key_id: SshKeyId, user_id: UserId) -> Res | |||
| 55 | 55 | Ok(result.rows_affected() > 0) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | /// Delete an SSH key by fingerprint. Returns false if not found or not owned by the user. | |
| 59 | + | pub async fn delete_key_by_fingerprint( | |
| 60 | + | pool: &PgPool, | |
| 61 | + | user_id: UserId, | |
| 62 | + | fingerprint: &str, | |
| 63 | + | ) -> Result<bool> { | |
| 64 | + | let result = | |
| 65 | + | sqlx::query("DELETE FROM ssh_keys WHERE user_id = $1 AND fingerprint = $2") | |
| 66 | + | .bind(user_id) | |
| 67 | + | .bind(fingerprint) | |
| 68 | + | .execute(pool) | |
| 69 | + | .await?; | |
| 70 | + | ||
| 71 | + | Ok(result.rows_affected() > 0) | |
| 72 | + | } | |
| 73 | + | ||
| 58 | 74 | /// Get all SSH keys with their owner's username, for authorized_keys rebuild. | |
| 59 | 75 | pub async fn get_all_keys_with_username(pool: &PgPool) -> Result<Vec<SshKeyWithUsername>> { | |
| 60 | 76 | let rows = sqlx::query_as::<_, SshKeyWithUsername>( |
| @@ -91,6 +91,32 @@ impl EmailClient { | |||
| 91 | 91 | self.send_with_unsub_inner(to, subject, body, unsub_url, None).await | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | + | /// Send an email with extra headers and an optional unsubscribe link. | |
| 95 | + | /// | |
| 96 | + | /// Used for issue notifications where we need Reply-To, Message-ID, and In-Reply-To headers. | |
| 97 | + | pub(crate) async fn send_email_with_headers_and_unsub( | |
| 98 | + | &self, | |
| 99 | + | to: &str, | |
| 100 | + | subject: &str, | |
| 101 | + | body: &str, | |
| 102 | + | extra_headers: &[(&str, String)], | |
| 103 | + | unsub_url: Option<&str>, | |
| 104 | + | ) -> Result<()> { | |
| 105 | + | match unsub_url { | |
| 106 | + | Some(url) => { | |
| 107 | + | let body_with_footer = format!( | |
| 108 | + | "{}\n\nUnsubscribe from these emails:\n{}", | |
| 109 | + | body, url | |
| 110 | + | ); | |
| 111 | + | let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec(); | |
| 112 | + | all_headers.push(("List-Unsubscribe", format!("<{}>", url))); | |
| 113 | + | all_headers.push(("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string())); | |
| 114 | + | self.send_email_inner(to, subject, &body_with_footer, &all_headers, None).await | |
| 115 | + | } | |
| 116 | + | None => self.send_email_inner(to, subject, body, extra_headers, None).await, | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 94 | 120 | /// Send via the broadcast stream with an optional unsubscribe link. | |
| 95 | 121 | pub(crate) async fn send_email_broadcast_with_unsub( | |
| 96 | 122 | &self, |
| @@ -269,6 +269,8 @@ We built Makenotwork on the principle of no lock-in, and we intend to honor that | |||
| 269 | 269 | author_username: &str, | |
| 270 | 270 | issue_url: &str, | |
| 271 | 271 | unsub_url: Option<&str>, | |
| 272 | + | reply_to: Option<&str>, | |
| 273 | + | message_id: Option<&str>, | |
| 272 | 274 | ) -> Result<()> { | |
| 273 | 275 | let subject = format!("New issue on {}/{}: {}", repo_owner, repo_name, issue_title); | |
| 274 | 276 | let body = format!( | |
| @@ -280,6 +282,8 @@ We built Makenotwork on the principle of no lock-in, and we intend to honor that | |||
| 280 | 282 | ||
| 281 | 283 | View it here: {url} | |
| 282 | 284 | ||
| 285 | + | Reply to this email to comment on this issue. | |
| 286 | + | ||
| 283 | 287 | - Makenotwork"#, | |
| 284 | 288 | name = crate::email::greeting(to_name), | |
| 285 | 289 | author = author_username, | |
| @@ -290,7 +294,15 @@ View it here: {url} | |||
| 290 | 294 | url = issue_url, | |
| 291 | 295 | ); | |
| 292 | 296 | ||
| 293 | - | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 297 | + | let mut headers: Vec<(&str, String)> = Vec::new(); | |
| 298 | + | if let Some(rt) = reply_to { | |
| 299 | + | headers.push(("Reply-To", rt.to_string())); | |
| 300 | + | } | |
| 301 | + | if let Some(mid) = message_id { | |
| 302 | + | headers.push(("Message-ID", mid.to_string())); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | self.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await | |
| 294 | 306 | } | |
| 295 | 307 | ||
| 296 | 308 | /// Notify about a new comment or status change on an issue. | |
| @@ -307,8 +319,11 @@ View it here: {url} | |||
| 307 | 319 | comment_preview: &str, | |
| 308 | 320 | issue_url: &str, | |
| 309 | 321 | unsub_url: Option<&str>, | |
| 322 | + | reply_to: Option<&str>, | |
| 323 | + | message_id: Option<&str>, | |
| 324 | + | in_reply_to: Option<&str>, | |
| 310 | 325 | ) -> Result<()> { | |
| 311 | - | let subject = format!("New comment on {}/{}#{}", repo_owner, repo_name, issue_number); | |
| 326 | + | let subject = format!("Re: New issue on {}/{}: {}", repo_owner, repo_name, issue_title); | |
| 312 | 327 | let body = format!( | |
| 313 | 328 | r#"Hi{name}, | |
| 314 | 329 | ||
| @@ -318,6 +333,8 @@ View it here: {url} | |||
| 318 | 333 | ||
| 319 | 334 | View it here: {url} | |
| 320 | 335 | ||
| 336 | + | Reply to this email to comment on this issue. | |
| 337 | + | ||
| 321 | 338 | - Makenotwork"#, | |
| 322 | 339 | name = crate::email::greeting(to_name), | |
| 323 | 340 | commenter = commenter_username, | |
| @@ -329,7 +346,19 @@ View it here: {url} | |||
| 329 | 346 | url = issue_url, | |
| 330 | 347 | ); | |
| 331 | 348 | ||
| 332 | - | self.send_email_with_unsub(to_email, &subject, &body, unsub_url).await | |
| 349 | + | let mut headers: Vec<(&str, String)> = Vec::new(); | |
| 350 | + | if let Some(rt) = reply_to { | |
| 351 | + | headers.push(("Reply-To", rt.to_string())); | |
| 352 | + | } | |
| 353 | + | if let Some(mid) = message_id { | |
| 354 | + | headers.push(("Message-ID", mid.to_string())); | |
| 355 | + | } | |
| 356 | + | if let Some(irt) = in_reply_to { | |
| 357 | + | headers.push(("In-Reply-To", irt.to_string())); | |
| 358 | + | headers.push(("References", irt.to_string())); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | self.send_email_with_headers_and_unsub(to_email, &subject, &body, &headers, unsub_url).await | |
| 333 | 362 | } | |
| 334 | 363 | ||
| 335 | 364 | /// Send an alert email (monitoring, ops notifications). |
| @@ -263,6 +263,71 @@ pub fn generate_deletion_signature( | |||
| 263 | 263 | hex::encode(mac.finalize().into_bytes()) | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | + | /// Generate a reply-to email address for an issue comment. | |
| 267 | + | /// | |
| 268 | + | /// Format: `issue+{issue_id}.{user_id}.{sig16}@reply.makenot.work` | |
| 269 | + | /// | |
| 270 | + | /// The signature is the first 16 hex chars of an HMAC-SHA256 over the issue and user IDs. | |
| 271 | + | /// This is stateless — no DB storage needed. The handler will parse and verify the address. | |
| 272 | + | pub fn generate_issue_reply_address( | |
| 273 | + | issue_id: crate::db::IssueId, | |
| 274 | + | user_id: UserId, | |
| 275 | + | secret: &str, | |
| 276 | + | ) -> String { | |
| 277 | + | use hmac::{Hmac, Mac}; | |
| 278 | + | use sha2::Sha256; | |
| 279 | + | ||
| 280 | + | let message = format!("issue-reply:{}:{}", issue_id, user_id); | |
| 281 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 282 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 283 | + | mac.update(message.as_bytes()); | |
| 284 | + | let sig = &hex::encode(mac.finalize().into_bytes())[..16]; | |
| 285 | + | ||
| 286 | + | format!("issue+{}.{}.{}@reply.makenot.work", issue_id, user_id, sig) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// Parse and verify an issue reply address local part. | |
| 290 | + | /// | |
| 291 | + | /// Input: the part before `@`, e.g. `issue+{issue_id}.{user_id}.{sig16}` | |
| 292 | + | /// | |
| 293 | + | /// Returns `Some((IssueId, UserId))` if the signature is valid, `None` otherwise. | |
| 294 | + | pub fn parse_issue_reply_token( | |
| 295 | + | local_part: &str, | |
| 296 | + | secret: &str, | |
| 297 | + | ) -> Option<(crate::db::IssueId, UserId)> { | |
| 298 | + | use hmac::{Hmac, Mac}; | |
| 299 | + | use sha2::Sha256; | |
| 300 | + | ||
| 301 | + | let payload = local_part.strip_prefix("issue+")?; | |
| 302 | + | let mut parts = payload.splitn(3, '.'); | |
| 303 | + | let issue_id_str = parts.next()?; | |
| 304 | + | let user_id_str = parts.next()?; | |
| 305 | + | let sig = parts.next()?; | |
| 306 | + | ||
| 307 | + | let issue_id: crate::db::IssueId = issue_id_str.parse().ok()?; | |
| 308 | + | let user_id: UserId = user_id_str.parse().ok()?; | |
| 309 | + | ||
| 310 | + | let message = format!("issue-reply:{}:{}", issue_id, user_id); | |
| 311 | + | let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) | |
| 312 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 313 | + | mac.update(message.as_bytes()); | |
| 314 | + | let expected = &hex::encode(mac.finalize().into_bytes())[..16]; | |
| 315 | + | ||
| 316 | + | // Constant-time comparison | |
| 317 | + | if expected.len() != sig.len() { | |
| 318 | + | return None; | |
| 319 | + | } | |
| 320 | + | let mut result = 0u8; | |
| 321 | + | for (a, b) in expected.bytes().zip(sig.bytes()) { | |
| 322 | + | result |= a ^ b; | |
| 323 | + | } | |
| 324 | + | if result != 0 { | |
| 325 | + | return None; | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | Some((issue_id, user_id)) | |
| 329 | + | } | |
| 330 | + | ||
| 266 | 331 | #[cfg(test)] | |
| 267 | 332 | mod tests { | |
| 268 | 333 | use super::*; | |
| @@ -497,4 +562,44 @@ mod tests { | |||
| 497 | 562 | assert!(!constant_time_compare("hello", "hell")); | |
| 498 | 563 | assert!(!constant_time_compare("short", "longer")); | |
| 499 | 564 | } | |
| 565 | + | ||
| 566 | + | // ── Issue reply token tests ── | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn issue_reply_round_trip() { | |
| 570 | + | let issue_id = crate::db::IssueId::new(); | |
| 571 | + | let user_id = UserId::new(); | |
| 572 | + | let secret = "reply-secret"; | |
| 573 | + | ||
| 574 | + | let addr = generate_issue_reply_address(issue_id, user_id, secret); | |
| 575 | + | assert!(addr.ends_with("@reply.makenot.work")); | |
| 576 | + | assert!(addr.starts_with("issue+")); | |
| 577 | + | ||
| 578 | + | // Extract local part | |
| 579 | + | let local = addr.split('@').next().unwrap(); | |
| 580 | + | let result = parse_issue_reply_token(local, secret); | |
| 581 | + | assert!(result.is_some()); | |
| 582 | + | let (parsed_issue, parsed_user) = result.unwrap(); | |
| 583 | + | assert_eq!(parsed_issue, issue_id); | |
| 584 | + | assert_eq!(parsed_user, user_id); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | #[test] | |
| 588 | + | fn issue_reply_wrong_secret_rejected() { | |
| 589 | + | let issue_id = crate::db::IssueId::new(); | |
| 590 | + | let user_id = UserId::new(); | |
| 591 | + | ||
| 592 | + | let addr = generate_issue_reply_address(issue_id, user_id, "real-secret"); | |
| 593 | + | let local = addr.split('@').next().unwrap(); | |
| 594 | + | assert!(parse_issue_reply_token(local, "wrong-secret").is_none()); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | #[test] | |
| 598 | + | fn issue_reply_malformed_input() { | |
| 599 | + | let secret = "test-secret"; | |
| 600 | + | assert!(parse_issue_reply_token("garbage", secret).is_none()); | |
| 601 | + | assert!(parse_issue_reply_token("issue+", secret).is_none()); | |
| 602 | + | assert!(parse_issue_reply_token("issue+a.b", secret).is_none()); | |
| 603 | + | assert!(parse_issue_reply_token("issue+not-uuid.not-uuid.abcd1234abcd1234", secret).is_none()); | |
| 604 | + | } | |
| 500 | 605 | } |
| @@ -1,21 +1,18 @@ | |||
| 1 | - | //! Issue CRUD: list, create, detail, edit, close/reopen, comments. | |
| 1 | + | //! Issue read-only views: list and detail. | |
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, Query, State}, | |
| 5 | - | response::{IntoResponse, Redirect}, | |
| 6 | - | Form, | |
| 5 | + | response::IntoResponse, | |
| 7 | 6 | }; | |
| 8 | 7 | use serde::Deserialize; | |
| 9 | 8 | use tower_sessions::Session; | |
| 10 | 9 | ||
| 11 | 10 | use crate::{ | |
| 12 | - | auth::{AuthUser, MaybeUser}, | |
| 13 | - | db::{self, IssueCommentId, IssueStatus, IssueLabelId, UserId}, | |
| 14 | - | email, | |
| 11 | + | auth::MaybeUser, | |
| 12 | + | db::{self, IssueStatus}, | |
| 15 | 13 | error::{AppError, Result}, | |
| 16 | 14 | helpers::get_csrf_token, | |
| 17 | 15 | templates::*, | |
| 18 | - | validation, | |
| 19 | 16 | AppState, | |
| 20 | 17 | }; | |
| 21 | 18 | ||
| @@ -56,11 +53,11 @@ pub(super) async fn issue_list( | |||
| 56 | 53 | ||
| 57 | 54 | let total_pages = (total + per_page - 1) / per_page; | |
| 58 | 55 | let (open_count, closed_count) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?; | |
| 59 | - | let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?; | |
| 60 | 56 | let current_ref = default_ref(&state, &owner, &repo_name); | |
| 61 | 57 | let csrf_token = get_csrf_token(&session).await; | |
| 62 | 58 | ||
| 63 | 59 | let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id); | |
| 60 | + | let email_address = format!("{}+{}@issues.makenot.work", owner, repo_name); | |
| 64 | 61 | ||
| 65 | 62 | Ok(GitIssueListTemplate { | |
| 66 | 63 | csrf_token, | |
| @@ -75,97 +72,11 @@ pub(super) async fn issue_list( | |||
| 75 | 72 | search_query: query.search.unwrap_or_default(), | |
| 76 | 73 | current_page: page, | |
| 77 | 74 | total_pages, | |
| 78 | - | labels, | |
| 79 | 75 | is_owner, | |
| 76 | + | email_address, | |
| 80 | 77 | }) | |
| 81 | 78 | } | |
| 82 | 79 | ||
| 83 | - | // ── New Issue ── | |
| 84 | - | ||
| 85 | - | /// `GET /git/{owner}/{repo}/issues/new` | |
| 86 | - | #[tracing::instrument(skip_all, name = "git_issues::new_form")] | |
| 87 | - | pub(super) async fn issue_new_form( | |
| 88 | - | State(state): State<AppState>, | |
| 89 | - | session: Session, | |
| 90 | - | AuthUser(user): AuthUser, | |
| 91 | - | Path((owner, repo_name)): Path<(String, String)>, | |
| 92 | - | ) -> Result<impl IntoResponse> { | |
| 93 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 94 | - | let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?; | |
| 95 | - | let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?; | |
| 96 | - | let current_ref = default_ref(&state, &owner, &repo_name); | |
| 97 | - | let csrf_token = get_csrf_token(&session).await; | |
| 98 | - | ||
| 99 | - | let is_owner = user.id == resolved.db_user.id; | |
| 100 | - | ||
| 101 | - | Ok(GitIssueNewTemplate { | |
| 102 | - | csrf_token, | |
| 103 | - | session_user: Some(user), | |
| 104 | - | owner, | |
| 105 | - | repo_name, | |
| 106 | - | current_ref, | |
| 107 | - | labels, | |
| 108 | - | open_issue_count, | |
| 109 | - | is_owner, | |
| 110 | - | }) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | #[derive(Deserialize)] | |
| 114 | - | pub(super) struct IssueCreateForm { | |
| 115 | - | title: String, | |
| 116 | - | body: Option<String>, | |
| 117 | - | labels: Option<String>, // comma-separated label IDs | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | /// `POST /git/{owner}/{repo}/issues` | |
| 121 | - | #[tracing::instrument(skip_all, name = "git_issues::create")] | |
| 122 | - | pub(super) async fn issue_create( | |
| 123 | - | State(state): State<AppState>, | |
| 124 | - | AuthUser(user): AuthUser, | |
| 125 | - | Path((owner, repo_name)): Path<(String, String)>, | |
| 126 | - | Form(form): Form<IssueCreateForm>, | |
| 127 | - | ) -> Result<impl IntoResponse> { | |
| 128 | - | user.check_not_suspended()?; | |
| 129 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 130 | - | ||
| 131 | - | let title = form.title.trim(); | |
| 132 | - | let body_md = form.body.as_deref().unwrap_or("").trim(); | |
| 133 | - | validation::validate_issue_title(title)?; | |
| 134 | - | validation::validate_issue_body(body_md)?; | |
| 135 | - | ||
| 136 | - | let body_html = if body_md.is_empty() { | |
| 137 | - | String::new() | |
| 138 | - | } else { | |
| 139 | - | docengine::render_permissive(body_md) | |
| 140 | - | }; | |
| 141 | - | ||
| 142 | - | let issue = db::issues::create_issue( | |
| 143 | - | &state.db, resolved.db_repo.id, user.id, title, body_md, &body_html, | |
| 144 | - | ).await?; | |
| 145 | - | ||
| 146 | - | // Assign labels if provided | |
| 147 | - | if let Some(label_ids_str) = &form.labels { | |
| 148 | - | for id_str in label_ids_str.split(',').filter(|s| !s.is_empty()) { | |
| 149 | - | if let Ok(label_id) = id_str.parse::<IssueLabelId>() { | |
| 150 | - | let _ = db::issues::add_label_to_issue(&state.db, issue.id, label_id).await; | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // Notify repo owner (if creator != owner and owner has notify_issues enabled) | |
| 156 | - | if user.id != resolved.db_user.id { | |
| 157 | - | notify_new_issue( | |
| 158 | - | &state, &resolved.db_user, &owner, &repo_name, | |
| 159 | - | issue.number, title, &user.username, | |
| 160 | - | ); | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | Ok(Redirect::to(&format!( | |
| 164 | - | "/git/{}/{}/issues/{}", | |
| 165 | - | owner, repo_name, issue.number | |
| 166 | - | ))) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 80 | // ── Issue Detail ── | |
| 170 | 81 | ||
| 171 | 82 | /// `GET /git/{owner}/{repo}/issues/{number}` | |
| @@ -187,18 +98,15 @@ pub(super) async fn issue_detail( | |||
| 187 | 98 | .ok_or(AppError::NotFound)?; | |
| 188 | 99 | ||
| 189 | 100 | let comments = db::issues::list_comments(&state.db, issue.id).await?; | |
| 190 | - | let labels = db::issues::get_labels_for_issue(&state.db, issue.id).await?; | |
| 191 | - | let assigned_label_ids: Vec<db::IssueLabelId> = labels.iter().map(|l| l.id).collect(); | |
| 192 | - | let all_labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?; | |
| 193 | 101 | ||
| 194 | - | let is_author = maybe_user.as_ref().map(|u| u.id) == Some(issue.author_user_id); | |
| 195 | 102 | let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id); | |
| 196 | - | let can_edit = is_author || is_owner; | |
| 197 | 103 | ||
| 198 | 104 | let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?; | |
| 199 | 105 | let current_ref = default_ref(&state, &owner, &repo_name); | |
| 200 | 106 | let csrf_token = get_csrf_token(&session).await; | |
| 201 | 107 | ||
| 108 | + | let email_address = format!("{}+{}@issues.makenot.work", owner, repo_name); | |
| 109 | + | ||
| 202 | 110 | Ok(GitIssueDetailTemplate { | |
| 203 | 111 | csrf_token, | |
| 204 | 112 | session_user: maybe_user, | |
| @@ -208,375 +116,8 @@ pub(super) async fn issue_detail( | |||
| 208 | 116 | issue, | |
| 209 | 117 | author_username: author.username.to_string(), | |
| 210 | 118 | comments, | |
| 211 | - | labels, | |
| 212 | - | all_labels, | |
| 213 | - | assigned_label_ids, | |
| 214 | - | can_edit, | |
| 215 | 119 | is_owner, | |
| 216 | 120 | open_issue_count, | |
| 121 | + | email_address, | |
| 217 | 122 | }) | |
| 218 | 123 | } | |
| 219 | - | ||
| 220 | - | // ── Edit Issue ── | |
| 221 | - | ||
| 222 | - | /// `GET /git/{owner}/{repo}/issues/{number}/edit` | |
| 223 | - | #[tracing::instrument(skip_all, name = "git_issues::edit_form")] | |
| 224 | - | pub(super) async fn issue_edit_form( | |
| 225 | - | State(state): State<AppState>, | |
| 226 | - | session: Session, | |
| 227 | - | AuthUser(user): AuthUser, | |
| 228 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 229 | - | ) -> Result<impl IntoResponse> { | |
| 230 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 231 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 232 | - | .await? | |
| 233 | - | .ok_or(AppError::NotFound)?; | |
| 234 | - | ||
| 235 | - | // Only author or repo owner can edit | |
| 236 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 237 | - | return Err(AppError::Forbidden); | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?; | |
| 241 | - | let assigned = db::issues::get_labels_for_issue(&state.db, issue.id).await?; | |
| 242 | - | let assigned_label_ids: Vec<IssueLabelId> = assigned.iter().map(|l| l.id).collect(); | |
| 243 | - | let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?; | |
| 244 | - | let current_ref = default_ref(&state, &owner, &repo_name); | |
| 245 | - | let csrf_token = get_csrf_token(&session).await; | |
| 246 | - | ||
| 247 | - | Ok(GitIssueEditTemplate { | |
| 248 | - | csrf_token, | |
| 249 | - | session_user: Some(user), | |
| 250 | - | owner, | |
| 251 | - | repo_name, | |
| 252 | - | current_ref, | |
| 253 | - | issue, | |
| 254 | - | labels, | |
| 255 | - | assigned_label_ids, | |
| 256 | - | open_issue_count, | |
| 257 | - | is_owner: true, // Only author or owner reaches this handler | |
| 258 | - | }) | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | #[derive(Deserialize)] | |
| 262 | - | pub(super) struct IssueEditForm { | |
| 263 | - | title: String, | |
| 264 | - | body: Option<String>, | |
| 265 | - | labels: Option<String>, | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | /// `POST /git/{owner}/{repo}/issues/{number}/edit` | |
| 269 | - | #[tracing::instrument(skip_all, name = "git_issues::edit")] | |
| 270 | - | pub(super) async fn issue_edit( | |
| 271 | - | State(state): State<AppState>, | |
| 272 | - | AuthUser(user): AuthUser, | |
| 273 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 274 | - | Form(form): Form<IssueEditForm>, | |
| 275 | - | ) -> Result<impl IntoResponse> { | |
| 276 | - | user.check_not_suspended()?; | |
| 277 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 278 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 279 | - | .await? | |
| 280 | - | .ok_or(AppError::NotFound)?; | |
| 281 | - | ||
| 282 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 283 | - | return Err(AppError::Forbidden); | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | let title = form.title.trim(); | |
| 287 | - | let body_md = form.body.as_deref().unwrap_or("").trim(); | |
| 288 | - | validation::validate_issue_title(title)?; | |
| 289 | - | validation::validate_issue_body(body_md)?; | |
| 290 | - | ||
| 291 | - | let body_html = if body_md.is_empty() { | |
| 292 | - | String::new() | |
| 293 | - | } else { | |
| 294 | - | docengine::render_permissive(body_md) | |
| 295 | - | }; | |
| 296 | - | ||
| 297 | - | db::issues::update_issue(&state.db, issue.id, title, body_md, &body_html).await?; | |
| 298 | - | ||
| 299 | - | // Sync labels: remove all existing, add new | |
| 300 | - | let existing = db::issues::get_labels_for_issue(&state.db, issue.id).await?; | |
| 301 | - | for label in &existing { | |
| 302 | - | db::issues::remove_label_from_issue(&state.db, issue.id, label.id).await?; | |
| 303 | - | } | |
| 304 | - | if let Some(label_ids_str) = &form.labels { | |
| 305 | - | for id_str in label_ids_str.split(',').filter(|s| !s.is_empty()) { | |
| 306 | - | if let Ok(label_id) = id_str.parse::<IssueLabelId>() { | |
| 307 | - | let _ = db::issues::add_label_to_issue(&state.db, issue.id, label_id).await; | |
| 308 | - | } | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | Ok(Redirect::to(&format!( | |
| 313 | - | "/git/{}/{}/issues/{}", | |
| 314 | - | owner, repo_name, number | |
| 315 | - | ))) | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | // ── Close / Reopen ── | |
| 319 | - | ||
| 320 | - | /// `POST /git/{owner}/{repo}/issues/{number}/close` | |
| 321 | - | #[tracing::instrument(skip_all, name = "git_issues::close")] | |
| 322 | - | pub(super) async fn issue_close( | |
| 323 | - | State(state): State<AppState>, | |
| 324 | - | AuthUser(user): AuthUser, | |
| 325 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 326 | - | ) -> Result<impl IntoResponse> { | |
| 327 | - | user.check_not_suspended()?; | |
| 328 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 329 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 330 | - | .await? | |
| 331 | - | .ok_or(AppError::NotFound)?; | |
| 332 | - | ||
| 333 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 334 | - | return Err(AppError::Forbidden); | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Closed).await?; | |
| 338 | - | ||
| 339 | - | // Notify issue author (if actor != author) | |
| 340 | - | if user.id != issue.author_user_id { | |
| 341 | - | let preview = format!("Issue was closed by {}.", user.username); | |
| 342 | - | notify_issue_comment( | |
| 343 | - | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 344 | - | &owner, &repo_name, number, &issue.title, | |
| 345 | - | &user.username, &preview, | |
| 346 | - | ); | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | Ok(Redirect::to(&format!( | |
| 350 | - | "/git/{}/{}/issues/{}", | |
| 351 | - | owner, repo_name, number | |
| 352 | - | ))) | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | /// `POST /git/{owner}/{repo}/issues/{number}/reopen` | |
| 356 | - | #[tracing::instrument(skip_all, name = "git_issues::reopen")] | |
| 357 | - | pub(super) async fn issue_reopen( | |
| 358 | - | State(state): State<AppState>, | |
| 359 | - | AuthUser(user): AuthUser, | |
| 360 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 361 | - | ) -> Result<impl IntoResponse> { | |
| 362 | - | user.check_not_suspended()?; | |
| 363 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 364 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 365 | - | .await? | |
| 366 | - | .ok_or(AppError::NotFound)?; | |
| 367 | - | ||
| 368 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 369 | - | return Err(AppError::Forbidden); | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await?; | |
| 373 | - | ||
| 374 | - | // Notify issue author (if actor != author) | |
| 375 | - | if user.id != issue.author_user_id { | |
| 376 | - | let preview = format!("Issue was reopened by {}.", user.username); | |
| 377 | - | notify_issue_comment( | |
| 378 | - | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 379 | - | &owner, &repo_name, number, &issue.title, | |
| 380 | - | &user.username, &preview, | |
| 381 | - | ); | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | Ok(Redirect::to(&format!( | |
| 385 | - | "/git/{}/{}/issues/{}", | |
| 386 | - | owner, repo_name, number | |
| 387 | - | ))) | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | // ── Comments ── | |
| 391 | - | ||
| 392 | - | #[derive(Deserialize)] | |
| 393 | - | pub(super) struct CommentForm { | |
| 394 | - | body: String, | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | /// `POST /git/{owner}/{repo}/issues/{number}/comment` | |
| 398 | - | #[tracing::instrument(skip_all, name = "git_issues::comment")] | |
| 399 | - | pub(super) async fn issue_comment( | |
| 400 | - | State(state): State<AppState>, | |
| 401 | - | AuthUser(user): AuthUser, | |
| 402 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 403 | - | Form(form): Form<CommentForm>, | |
| 404 | - | ) -> Result<impl IntoResponse> { | |
| 405 | - | user.check_not_suspended()?; | |
| 406 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 407 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 408 | - | .await? | |
| 409 | - | .ok_or(AppError::NotFound)?; | |
| 410 | - | ||
| 411 | - | let body_md = form.body.trim(); | |
| 412 | - | validation::validate_issue_comment_body(body_md)?; | |
| 413 | - | let body_html = docengine::render_permissive(body_md); | |
| 414 | - | ||
| 415 | - | db::issues::create_comment(&state.db, issue.id, user.id, body_md, &body_html).await?; | |
| 416 | - | ||
| 417 | - | // Notify repo owner + issue author (deduplicated, excluding commenter) | |
| 418 | - | notify_issue_comment( | |
| 419 | - | &state, &resolved.db_user, issue.author_user_id, user.id, | |
| 420 | - | &owner, &repo_name, issue.number, &issue.title, | |
| 421 | - | &user.username, body_md, | |
| 422 | - | ); | |
| 423 | - | ||
| 424 | - | Ok(Redirect::to(&format!( | |
| 425 | - | "/git/{}/{}/issues/{}", | |
| 426 | - | owner, repo_name, number | |
| 427 | - | ))) | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | /// `POST /git/{owner}/{repo}/issues/{number}/comment/{id}/delete` | |
| 431 | - | #[tracing::instrument(skip_all, name = "git_issues::comment_delete")] | |
| 432 | - | pub(super) async fn comment_delete( | |
| 433 | - | State(state): State<AppState>, | |
| 434 | - | AuthUser(user): AuthUser, | |
| 435 | - | Path((owner, repo_name, number, comment_id)): Path<(String, String, i32, IssueCommentId)>, | |
| 436 | - | ) -> Result<impl IntoResponse> { | |
| 437 | - | user.check_not_suspended()?; | |
| 438 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 439 | - | ||
| 440 | - | // Verify issue exists | |
| 441 | - | let _issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 442 | - | .await? | |
| 443 | - | .ok_or(AppError::NotFound)?; | |
| 444 | - | ||
| 445 | - | let comment = db::issues::get_comment(&state.db, comment_id) | |
| 446 | - | .await? | |
| 447 | - | .ok_or(AppError::NotFound)?; | |
| 448 | - | ||
| 449 | - | // Only comment author or repo owner can delete | |
| 450 | - | if user.id != comment.author_user_id && user.id != resolved.db_user.id { | |
| 451 | - | return Err(AppError::Forbidden); | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | db::issues::delete_comment(&state.db, comment_id).await?; | |
| 455 | - | ||
| 456 | - | Ok(Redirect::to(&format!( | |
| 457 | - | "/git/{}/{}/issues/{}", | |
| 458 | - | owner, repo_name, number | |
| 459 | - | ))) | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | // ── Email notification helpers ── | |
| 463 | - | ||
| 464 | - | /// Fire-and-forget notification to repo owner about a new issue. | |
| 465 | - | fn notify_new_issue( | |
| 466 | - | state: &AppState, | |
| 467 | - | repo_owner: &db::DbUser, | |
| 468 | - | owner_name: &str, | |
| 469 | - | repo_name: &str, | |
| 470 | - | issue_number: i32, | |
| 471 | - | issue_title: &str, | |
| 472 | - | author_username: &str, | |
| 473 | - | ) { | |
| 474 | - | if !repo_owner.notify_issues { | |
| 475 | - | return; | |
| 476 | - | } | |
| 477 | - | let email_client = state.email.clone(); | |
| 478 | - | let to_email = repo_owner.email.clone(); | |
| 479 | - | let to_name = repo_owner.display_name.clone(); | |
| 480 | - | let owner_id = repo_owner.id; | |
| 481 | - | let host_url = state.config.host_url.clone(); | |
| 482 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 483 | - | let owner_name = owner_name.to_string(); | |
| 484 | - | let repo_name = repo_name.to_string(); | |
| 485 | - | let issue_title = issue_title.to_string(); | |
| 486 | - | let author_username = author_username.to_string(); | |
| 487 | - | ||
| 488 | - | tokio::spawn(async move { | |
| 489 | - | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 490 | - | let unsub_url = email::generate_unsubscribe_url( | |
| 491 | - | &host_url, owner_id, "issue", &owner_id.to_string(), &signing_secret, | |
| 492 | - | ); | |
| 493 | - | if let Err(e) = email_client | |
| 494 | - | .send_new_issue_notification( | |
| 495 | - | &to_email, | |
| 496 | - | to_name.as_deref(), | |
| 497 | - | &owner_name, | |
| 498 | - | &repo_name, | |
| 499 | - | issue_number, | |
| 500 | - | &issue_title, | |
| 501 | - | &author_username, | |
| 502 | - | &issue_url, | |
| 503 | - | Some(&unsub_url), | |
| 504 | - | ) | |
| 505 | - | .await | |
| 506 | - | { | |
| 507 | - | tracing::error!(error = ?e, "failed to send new issue notification"); | |
| 508 | - | } | |
| 509 | - | }); | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | /// Fire-and-forget notification to repo owner + issue author about a comment or status change. | |
| 513 | - | #[allow(clippy::too_many_arguments)] | |
| 514 | - | fn notify_issue_comment( | |
| 515 | - | state: &AppState, | |
| 516 | - | repo_owner: &db::DbUser, | |
| 517 | - | issue_author_id: UserId, | |
| 518 | - | commenter_id: UserId, | |
| 519 | - | owner_name: &str, | |
| 520 | - | repo_name: &str, | |
| 521 | - | issue_number: i32, | |
| 522 | - | issue_title: &str, | |
| 523 | - | commenter_username: &str, | |
| 524 | - | comment_text: &str, | |
| 525 | - | ) { | |
| 526 | - | // Collect recipients: repo owner + issue author, minus the commenter, deduplicated | |
| 527 | - | let mut recipient_ids = Vec::new(); | |
| 528 | - | if repo_owner.id != commenter_id { | |
| 529 | - | recipient_ids.push(repo_owner.id); | |
| 530 | - | } | |
| 531 | - | if issue_author_id != commenter_id && issue_author_id != repo_owner.id { | |
| 532 | - | recipient_ids.push(issue_author_id); | |
| 533 | - | } | |
| 534 | - | if recipient_ids.is_empty() { | |
| 535 | - | return; | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | let db = state.db.clone(); | |
| 539 | - | let email_client = state.email.clone(); | |
| 540 | - | let host_url = state.config.host_url.clone(); | |
| 541 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 542 | - | let owner_name = owner_name.to_string(); | |
| 543 | - | let repo_name = repo_name.to_string(); | |
| 544 | - | let issue_title = issue_title.to_string(); | |
| 545 | - | let commenter_username = commenter_username.to_string(); | |
| 546 | - | // Truncate preview to ~200 chars | |
| 547 | - | let preview: String = comment_text.chars().take(200).collect(); | |
| 548 | - | ||
| 549 | - | tokio::spawn(async move { | |
| 550 | - | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 551 | - |
Lines truncated
| @@ -1,198 +0,0 @@ | |||
| 1 | - | //! Label CRUD and label assignment on issues. | |
| 2 | - | ||
| 3 | - | use axum::{ | |
| 4 | - | extract::{Path, State}, | |
| 5 | - | response::{IntoResponse, Redirect}, | |
| 6 | - | Form, | |
| 7 | - | }; | |
| 8 | - | use serde::Deserialize; | |
| 9 | - | use tower_sessions::Session; | |
| 10 | - | ||
| 11 | - | use crate::{ | |
| 12 | - | auth::AuthUser, | |
| 13 | - | db::{self, IssueLabelId}, | |
| 14 | - | error::{AppError, Result}, | |
| 15 | - | helpers::get_csrf_token, | |
| 16 | - | templates::*, | |
| 17 | - | validation, | |
| 18 | - | AppState, | |
| 19 | - | }; | |
| 20 | - | ||
| 21 | - | use super::default_ref; | |
| 22 | - | ||
| 23 | - | // ── Label CRUD ── | |
| 24 | - | ||
| 25 | - | /// `GET /git/{owner}/{repo}/issues/labels` — label management (owner only). | |
| 26 | - | #[tracing::instrument(skip_all, name = "git_issues::labels")] | |
| 27 | - | pub(super) async fn label_list( | |
| 28 | - | State(state): State<AppState>, | |
| 29 | - | session: Session, | |
| 30 | - | AuthUser(user): AuthUser, | |
| 31 | - | Path((owner, repo_name)): Path<(String, String)>, | |
| 32 | - | ) -> Result<impl IntoResponse> { | |
| 33 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 34 | - | ||
| 35 | - | if user.id != resolved.db_user.id { | |
| 36 | - | return Err(AppError::Forbidden); | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | let labels = db::issues::list_labels(&state.db, resolved.db_repo.id).await?; | |
| 40 | - | let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await?; | |
| 41 | - | let current_ref = default_ref(&state, &owner, &repo_name); | |
| 42 | - | let csrf_token = get_csrf_token(&session).await; | |
| 43 | - | ||
| 44 | - | Ok(GitIssueLabelManagerTemplate { | |
| 45 | - | csrf_token, | |
| 46 | - | session_user: Some(user), | |
| 47 | - | owner, | |
| 48 | - | repo_name, | |
| 49 | - | current_ref, | |
| 50 | - | labels, | |
| 51 | - | open_issue_count, | |
| 52 | - | is_owner: true, // Only owner reaches this handler | |
| 53 | - | }) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | #[derive(Deserialize)] | |
| 57 | - | pub(super) struct LabelForm { | |
| 58 | - | name: String, | |
| 59 | - | color: String, | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | /// `POST /git/{owner}/{repo}/issues/labels` | |
| 63 | - | #[tracing::instrument(skip_all, name = "git_issues::label_create")] | |
| 64 | - | pub(super) async fn label_create( | |
| 65 | - | State(state): State<AppState>, | |
| 66 | - | AuthUser(user): AuthUser, | |
| 67 | - | Path((owner, repo_name)): Path<(String, String)>, | |
| 68 | - | Form(form): Form<LabelForm>, | |
| 69 | - | ) -> Result<impl IntoResponse> { | |
| 70 | - | user.check_not_suspended()?; | |
| 71 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 72 | - | ||
| 73 | - | if user.id != resolved.db_user.id { | |
| 74 | - | return Err(AppError::Forbidden); | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | let name = form.name.trim(); | |
| 78 | - | let color = form.color.trim(); | |
| 79 | - | validation::validate_label_name(name)?; | |
| 80 | - | validation::validate_label_color(color)?; | |
| 81 | - | ||
| 82 | - | db::issues::create_label(&state.db, resolved.db_repo.id, name, color).await?; | |
| 83 | - | ||
| 84 | - | Ok(Redirect::to(&format!( | |
| 85 | - | "/git/{}/{}/issues/labels", | |
| 86 | - | owner, repo_name | |
| 87 | - | ))) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | /// `POST /git/{owner}/{repo}/issues/labels/{id}/edit` | |
| 91 | - | #[tracing::instrument(skip_all, name = "git_issues::label_edit")] | |
| 92 | - | pub(super) async fn label_edit( | |
| 93 | - | State(state): State<AppState>, | |
| 94 | - | AuthUser(user): AuthUser, | |
| 95 | - | Path((owner, repo_name, label_id)): Path<(String, String, IssueLabelId)>, | |
| 96 | - | Form(form): Form<LabelForm>, | |
| 97 | - | ) -> Result<impl IntoResponse> { | |
| 98 | - | user.check_not_suspended()?; | |
| 99 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 100 | - | ||
| 101 | - | if user.id != resolved.db_user.id { | |
| 102 | - | return Err(AppError::Forbidden); | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | let name = form.name.trim(); | |
| 106 | - | let color = form.color.trim(); | |
| 107 | - | validation::validate_label_name(name)?; | |
| 108 | - | validation::validate_label_color(color)?; | |
| 109 | - | ||
| 110 | - | db::issues::update_label(&state.db, label_id, name, color).await?; | |
| 111 | - | ||
| 112 | - | Ok(Redirect::to(&format!( | |
| 113 | - | "/git/{}/{}/issues/labels", | |
| 114 | - | owner, repo_name | |
| 115 | - | ))) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | /// `POST /git/{owner}/{repo}/issues/labels/{id}/delete` | |
| 119 | - | #[tracing::instrument(skip_all, name = "git_issues::label_delete")] | |
| 120 | - | pub(super) async fn label_delete( | |
| 121 | - | State(state): State<AppState>, | |
| 122 | - | AuthUser(user): AuthUser, | |
| 123 | - | Path((owner, repo_name, label_id)): Path<(String, String, IssueLabelId)>, | |
| 124 | - | ) -> Result<impl IntoResponse> { | |
| 125 | - | user.check_not_suspended()?; | |
| 126 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 127 | - | ||
| 128 | - | if user.id != resolved.db_user.id { | |
| 129 | - | return Err(AppError::Forbidden); | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | db::issues::delete_label(&state.db, label_id).await?; | |
| 133 | - | ||
| 134 | - | Ok(Redirect::to(&format!( | |
| 135 | - | "/git/{}/{}/issues/labels", | |
| 136 | - | owner, repo_name | |
| 137 | - | ))) | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | // ── Label assignment on issue detail ── | |
| 141 | - | ||
| 142 | - | #[derive(Deserialize)] | |
| 143 | - | pub(super) struct IssueLabelForm { | |
| 144 | - | label_id: IssueLabelId, | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | /// `POST /git/{owner}/{repo}/issues/{number}/labels/add` | |
| 148 | - | #[tracing::instrument(skip_all, name = "git_issues::add_label")] | |
| 149 | - | pub(super) async fn issue_add_label( | |
| 150 | - | State(state): State<AppState>, | |
| 151 | - | AuthUser(user): AuthUser, | |
| 152 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 153 | - | Form(form): Form<IssueLabelForm>, | |
| 154 | - | ) -> Result<impl IntoResponse> { | |
| 155 | - | user.check_not_suspended()?; | |
| 156 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 157 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 158 | - | .await? | |
| 159 | - | .ok_or(AppError::NotFound)?; | |
| 160 | - | ||
| 161 | - | // Only author or owner can manage labels | |
| 162 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 163 | - | return Err(AppError::Forbidden); | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | db::issues::add_label_to_issue(&state.db, issue.id, form.label_id).await?; | |
| 167 | - | ||
| 168 | - | Ok(Redirect::to(&format!( | |
| 169 | - | "/git/{}/{}/issues/{}", | |
| 170 | - | owner, repo_name, number | |
| 171 | - | ))) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | /// `POST /git/{owner}/{repo}/issues/{number}/labels/remove` | |
| 175 | - | #[tracing::instrument(skip_all, name = "git_issues::remove_label")] | |
| 176 | - | pub(super) async fn issue_remove_label( | |
| 177 | - | State(state): State<AppState>, | |
| 178 | - | AuthUser(user): AuthUser, | |
| 179 | - | Path((owner, repo_name, number)): Path<(String, String, i32)>, | |
| 180 | - | Form(form): Form<IssueLabelForm>, | |
| 181 | - | ) -> Result<impl IntoResponse> { | |
| 182 | - | user.check_not_suspended()?; | |
| 183 | - | let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?; | |
| 184 | - | let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number) | |
| 185 | - | .await? | |
| 186 | - | .ok_or(AppError::NotFound)?; | |
| 187 | - | ||
| 188 | - | if user.id != issue.author_user_id && user.id != resolved.db_user.id { | |
| 189 | - | return Err(AppError::Forbidden); | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | db::issues::remove_label_from_issue(&state.db, issue.id, form.label_id).await?; | |
| 193 | - | ||
| 194 | - | Ok(Redirect::to(&format!( | |
| 195 | - | "/git/{}/{}/issues/{}", | |
| 196 | - | owner, repo_name, number | |
| 197 | - | ))) | |
| 198 | - | } |
| @@ -1,8 +1,7 @@ | |||
| 1 | - | //! Git issue tracker routes: create, view, list, close/reopen, comment, labels. | |
| 1 | + | //! Git issue tracker routes: read-only list and detail views. | |
| 2 | 2 | //! Also includes the commit-message issue reference parser and process-push endpoint. | |
| 3 | 3 | ||
| 4 | 4 | mod issues; | |
| 5 | - | mod labels; | |
| 6 | 5 | mod push_refs; | |
| 7 | 6 | mod settings; | |
| 8 | 7 | ||
| @@ -19,26 +18,9 @@ use crate::{ | |||
| 19 | 18 | /// Register all git issue routes. | |
| 20 | 19 | pub fn git_issue_routes() -> Router<AppState> { | |
| 21 | 20 | Router::new() | |
| 22 | - | // Issue list + create | |
| 21 | + | // Issue list + detail (read-only) | |
| 23 | 22 | .route("/git/{owner}/{repo}/issues", get(issues::issue_list)) | |
| 24 | - | .route("/git/{owner}/{repo}/issues/new", get(issues::issue_new_form)) | |
| 25 | - | .route("/git/{owner}/{repo}/issues", post(issues::issue_create)) | |
| 26 | - | // Issue detail + actions | |
| 27 | 23 | .route("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail)) | |
| 28 | - | .route("/git/{owner}/{repo}/issues/{number}/edit", get(issues::issue_edit_form)) | |
| 29 | - | .route("/git/{owner}/{repo}/issues/{number}/edit", post(issues::issue_edit)) | |
| 30 | - | .route("/git/{owner}/{repo}/issues/{number}/close", post(issues::issue_close)) | |
| 31 | - | .route("/git/{owner}/{repo}/issues/{number}/reopen", post(issues::issue_reopen)) | |
| 32 | - | .route("/git/{owner}/{repo}/issues/{number}/comment", post(issues::issue_comment)) | |
| 33 | - | .route("/git/{owner}/{repo}/issues/{number}/comment/{id}/delete", post(issues::comment_delete)) | |
| 34 | - | // Labels | |
| 35 | - | .route("/git/{owner}/{repo}/issues/labels", get(labels::label_list)) | |
| 36 | - | .route("/git/{owner}/{repo}/issues/labels", post(labels::label_create)) | |
| 37 | - | .route("/git/{owner}/{repo}/issues/labels/{id}/edit", post(labels::label_edit)) | |
| 38 | - | .route("/git/{owner}/{repo}/issues/labels/{id}/delete", post(labels::label_delete)) | |
| 39 | - | // Label assignment on issues | |
| 40 | - | .route("/git/{owner}/{repo}/issues/{number}/labels/add", post(labels::issue_add_label)) | |
| 41 | - | .route("/git/{owner}/{repo}/issues/{number}/labels/remove", post(labels::issue_remove_label)) | |
| 42 | 24 | // Repo settings | |
| 43 | 25 | .route("/git/{owner}/{repo}/settings", get(settings::repo_settings_form)) | |
| 44 | 26 | .route("/git/{owner}/{repo}/settings", post(settings::repo_settings_save)) |
| @@ -23,6 +23,7 @@ use super::repos_root; | |||
| 23 | 23 | #[derive(Debug, PartialEq, Eq)] | |
| 24 | 24 | enum IssueRefAction { | |
| 25 | 25 | Close, | |
| 26 | + | Reopen, | |
| 26 | 27 | Reference, | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -43,6 +44,9 @@ fn parse_issue_refs(message: &str) -> Vec<IssueRef> { | |||
| 43 | 44 | static CLOSE_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 44 | 45 | Regex::new(r"(?i)(?:fix|close|resolve)(?:es|ed|s|d)?\s+#(\d+)").unwrap() | |
| 45 | 46 | }); | |
| 47 | + | static REOPEN_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 48 | + | Regex::new(r"(?i)reopen(?:s|ed)?\s+#(\d+)").unwrap() | |
| 49 | + | }); | |
| 46 | 50 | static REF_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 47 | 51 | Regex::new(r"(?i)(?:ref|reference)s?\s+#(\d+)").unwrap() | |
| 48 | 52 | }); | |
| @@ -51,11 +55,18 @@ fn parse_issue_refs(message: &str) -> Vec<IssueRef> { | |||
| 51 | 55 | ||
| 52 | 56 | for cap in CLOSE_RE.captures_iter(message) { | |
| 53 | 57 | if let Ok(n) = cap[1].parse::<i32>() { | |
| 54 | - | // Close always wins | |
| 58 | + | // Close always wins over reference | |
| 55 | 59 | by_number.insert(n, IssueRefAction::Close); | |
| 56 | 60 | } | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 63 | + | for cap in REOPEN_RE.captures_iter(message) { | |
| 64 | + | if let Ok(n) = cap[1].parse::<i32>() { | |
| 65 | + | // Reopen wins over reference but not close | |
| 66 | + | by_number.entry(n).or_insert(IssueRefAction::Reopen); | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 59 | 70 | for cap in REF_RE.captures_iter(message) { | |
| 60 | 71 | if let Ok(n) = cap[1].parse::<i32>() { | |
| 61 | 72 | by_number.entry(n).or_insert(IssueRefAction::Reference); | |
| @@ -184,6 +195,19 @@ pub(super) async fn process_push( | |||
| 184 | 195 | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 185 | 196 | ).await; | |
| 186 | 197 | } | |
| 198 | + | IssueRefAction::Reopen => { | |
| 199 | + | if issue.status == IssueStatus::Closed { | |
| 200 | + | let _ = db::issues::update_issue_status(&state.db, issue.id, IssueStatus::Open).await; | |
| 201 | + | } | |
| 202 | + | let body_md = format!( | |
| 203 | + | "Reopened via commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| 204 | + | short_oid, req.repo_owner, req.repo_name, oid_str, req.ref_name, | |
| 205 | + | ); | |
| 206 | + | let body_html = docengine::render_permissive(&body_md); | |
| 207 | + | let _ = db::issues::create_comment( | |
| 208 | + | &state.db, issue.id, owner_user.id, &body_md, &body_html, | |
| 209 | + | ).await; | |
| 210 | + | } | |
| 187 | 211 | IssueRefAction::Reference => { | |
| 188 | 212 | let body_md = format!( | |
| 189 | 213 | "Referenced in commit [`{}`](/git/{}/{}/commit/{}) on `{}`.", | |
| @@ -282,4 +306,40 @@ mod tests { | |||
| 282 | 306 | assert!(refs.contains(&IssueRef { number: 5, action: IssueRefAction::Close })); | |
| 283 | 307 | assert!(refs.contains(&IssueRef { number: 6, action: IssueRefAction::Reference })); | |
| 284 | 308 | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn parse_issue_refs_reopens() { | |
| 312 | + | assert_eq!( | |
| 313 | + | parse_issue_refs("Reopens #5"), | |
| 314 | + | vec![IssueRef { number: 5, action: IssueRefAction::Reopen }] | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn parse_issue_refs_reopened() { | |
| 320 | + | assert_eq!( | |
| 321 | + | parse_issue_refs("Reopened #3"), | |
| 322 | + | vec![IssueRef { number: 3, action: IssueRefAction::Reopen }] | |
| 323 | + | ); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn parse_issue_refs_reopen_bare() { | |
| 328 | + | assert_eq!( | |
| 329 | + | parse_issue_refs("reopen #7"), | |
| 330 | + | vec![IssueRef { number: 7, action: IssueRefAction::Reopen }] | |
| 331 | + | ); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn parse_issue_refs_close_wins_over_reopen() { | |
| 336 | + | let refs = parse_issue_refs("Reopens #1\nFixes #1"); | |
| 337 | + | assert_eq!(refs, vec![IssueRef { number: 1, action: IssueRefAction::Close }]); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn parse_issue_refs_reopen_wins_over_ref() { | |
| 342 | + | let refs = parse_issue_refs("Refs #2\nReopens #2"); | |
| 343 | + | assert_eq!(refs, vec![IssueRef { number: 2, action: IssueRefAction::Reopen }]); | |
| 344 | + | } | |
| 285 | 345 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! Postmark webhook endpoints for bounce/complaint events and inbound email (patches). | |
| 1 | + | //! Postmark webhook endpoints for bounce/complaint events, inbound patches, and inbound issues. | |
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::State, | |
| @@ -327,11 +327,421 @@ fn extract_project_slug(to: &str) -> Option<String> { | |||
| 327 | 327 | None | |
| 328 | 328 | } | |
| 329 | 329 | ||
| 330 | + | /// Handle Postmark inbound email webhook for git issues. | |
| 331 | + | /// | |
| 332 | + | /// Routes by To address domain: | |
| 333 | + | /// - `@issues.makenot.work` → new issue: `{owner}+{repo}@issues.makenot.work` | |
| 334 | + | /// - `@reply.makenot.work` → reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work` | |
| 335 | + | #[tracing::instrument(skip_all, name = "postmark::inbound_issues")] | |
| 336 | + | async fn postmark_inbound_issues( | |
| 337 | + | State(state): State<AppState>, | |
| 338 | + | headers: HeaderMap, | |
| 339 | + | Json(payload): Json<PostmarkInboundPayload>, | |
| 340 | + | ) -> impl IntoResponse { | |
| 341 | + | // 1. Auth — verify bearer token | |
| 342 | + | let token_ok = state.config.postmark_inbound_webhook_token.as_deref() | |
| 343 | + | .is_some_and(|t| verify_token(&headers, t)); | |
| 344 | + | ||
| 345 | + | if !token_ok { | |
| 346 | + | if state.config.postmark_inbound_webhook_token.is_none() { | |
| 347 | + | tracing::warn!("Postmark inbound-issues received but no token configured"); | |
| 348 | + | } else { | |
| 349 | + | tracing::warn!("Postmark inbound-issues: invalid bearer token"); | |
| 350 | + | } | |
| 351 | + | return StatusCode::UNAUTHORIZED; | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // 2. Route by domain | |
| 355 | + | if let Some((owner, repo)) = extract_issue_address(&payload.to) { | |
| 356 | + | handle_new_issue(&state, &payload, &owner, &repo).await | |
| 357 | + | } else if let Some(local) = extract_reply_local(&payload.to) { | |
| 358 | + | handle_issue_reply(&state, &payload, &local).await | |
| 359 | + | } else { | |
| 360 | + | tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address"); | |
| 361 | + | StatusCode::OK | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`. | |
| 366 | + | async fn handle_new_issue( | |
| 367 | + | state: &AppState, | |
| 368 | + | payload: &PostmarkInboundPayload, | |
| 369 | + | owner: &str, | |
| 370 | + | repo_name: &str, | |
| 371 | + | ) -> StatusCode { | |
| 372 | + | // Look up sender — must be a verified, non-suspended MNW user | |
| 373 | + | let sender_email = payload.from_full.email.to_lowercase(); | |
| 374 | + | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 375 | + | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 376 | + | Ok(Some(u)) if !u.email_verified => { | |
| 377 | + | tracing::info!(email = %sender_email, "inbound-issues: sender email not verified"); | |
| 378 | + | return StatusCode::OK; | |
| 379 | + | } | |
| 380 | + | Ok(Some(_)) => { | |
| 381 | + | tracing::info!(email = %sender_email, "inbound-issues: sender is suspended"); | |
| 382 | + | return StatusCode::OK; | |
| 383 | + | } | |
| 384 | + | Ok(None) => { | |
| 385 | + | tracing::info!(email = %sender_email, "inbound-issues: sender has no MNW account"); | |
| 386 | + | return StatusCode::OK; | |
| 387 | + | } | |
| 388 | + | Err(e) => { | |
| 389 | + | tracing::error!(error = ?e, "inbound-issues: user lookup failed"); | |
| 390 | + | return StatusCode::OK; | |
| 391 | + | } | |
| 392 | + | }; | |
| 393 | + | ||
| 394 | + | // Look up repo owner + repo | |
| 395 | + | let owner_user = match db::users::get_user_by_username( | |
| 396 | + | &state.db, | |
| 397 | + | &db::Username::from_trusted(owner.to_string()), | |
| 398 | + | ).await { | |
| 399 | + | Ok(Some(u)) => u, | |
| 400 | + | Ok(None) => { | |
| 401 | + | tracing::info!(owner = %owner, "inbound-issues: repo owner not found"); | |
| 402 | + | return StatusCode::OK; | |
| 403 | + | } | |
| 404 | + | Err(e) => { | |
| 405 | + | tracing::error!(error = ?e, "inbound-issues: owner lookup failed"); | |
| 406 | + | return StatusCode::OK; | |
| 407 | + | } | |
| 408 | + | }; | |
| 409 | + | ||
| 410 | + | let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await { | |
| 411 | + | Ok(Some(r)) => r, | |
| 412 | + | Ok(None) => { | |
| 413 | + | tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found"); | |
| 414 | + | return StatusCode::OK; | |
| 415 | + | } | |
| 416 | + | Err(e) => { | |
| 417 | + | tracing::error!(error = ?e, "inbound-issues: repo lookup failed"); | |
| 418 | + | return StatusCode::OK; | |
| 419 | + | } | |
| 420 | + | }; | |
| 421 | + | ||
| 422 | + | // Create the issue | |
| 423 | + | let title = payload.subject.trim(); | |
| 424 | + | if title.is_empty() { | |
| 425 | + | tracing::info!("inbound-issues: empty subject, skipping"); | |
| 426 | + | return StatusCode::OK; | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | let body_md = payload.text_body.trim(); | |
| 430 | + | let body_html = if body_md.is_empty() { | |
| 431 | + | String::new() | |
| 432 | + | } else { | |
| 433 | + | docengine::render_permissive(body_md) | |
| 434 | + | }; | |
| 435 | + | ||
| 436 | + | let issue = match db::issues::create_issue( | |
| 437 | + | &state.db, repo.id, sender.id, title, body_md, &body_html, | |
| 438 | + | ).await { | |
| 439 | + | Ok(i) => i, | |
| 440 | + | Err(e) => { | |
| 441 | + | tracing::error!(error = ?e, "inbound-issues: failed to create issue"); | |
| 442 | + | return StatusCode::OK; | |
| 443 | + | } | |
| 444 | + | }; | |
| 445 | + | ||
| 446 | + | // Store message ID mapping for threading | |
| 447 | + | if let Err(e) = db::issues::insert_issue_message_id( | |
| 448 | + | &state.db, &payload.message_id, issue.id, | |
| 449 | + | ).await { | |
| 450 | + | tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping"); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | tracing::info!( | |
| 454 | + | issue_number = issue.number, | |
| 455 | + | message_id = %payload.message_id, | |
| 456 | + | "inbound-issues: new issue created" | |
| 457 | + | ); | |
| 458 | + | ||
| 459 | + | // Notify repo owner (if different from sender) | |
| 460 | + | if sender.id != owner_user.id && owner_user.notify_issues { | |
| 461 | + | let email_client = state.email.clone(); | |
| 462 | + | let host_url = state.config.host_url.clone(); | |
| 463 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 464 | + | let to_email = owner_user.email.clone(); | |
| 465 | + | let to_name = owner_user.display_name.clone(); | |
| 466 | + | let owner_id = owner_user.id; | |
| 467 | + | let owner_name = owner.to_string(); | |
| 468 | + | let repo_name = repo_name.to_string(); | |
| 469 | + | let issue_title = title.to_string(); | |
| 470 | + | let author_username = sender.username.to_string(); | |
| 471 | + | let issue_number = issue.number; | |
| 472 | + | let issue_id = issue.id; | |
| 473 | + | ||
| 474 | + | tokio::spawn(async move { | |
| 475 | + | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 476 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 477 | + | &host_url, owner_id, "issue", &owner_id.to_string(), &signing_secret, | |
| 478 | + | ); | |
| 479 | + | let reply_to = crate::email::generate_issue_reply_address(issue_id, owner_id, &signing_secret); | |
| 480 | + | let msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp()); | |
| 481 | + | ||
| 482 | + | if let Err(e) = email_client | |
| 483 | + | .send_new_issue_notification( | |
| 484 | + | &to_email, | |
| 485 | + | to_name.as_deref(), | |
| 486 | + | &owner_name, | |
| 487 | + | &repo_name, | |
| 488 | + | issue_number, | |
| 489 | + | &issue_title, | |
| 490 | + | &author_username, | |
| 491 | + | &issue_url, | |
| 492 | + | Some(&unsub_url), | |
| 493 | + | Some(&reply_to), | |
| 494 | + | Some(&msg_id), | |
| 495 | + | ) | |
| 496 | + | .await | |
| 497 | + | { | |
| 498 | + | tracing::error!(error = ?e, "failed to send new issue notification"); | |
| 499 | + | } | |
| 500 | + | }); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | StatusCode::OK | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`. | |
| 507 | + | async fn handle_issue_reply( | |
| 508 | + | state: &AppState, | |
| 509 | + | payload: &PostmarkInboundPayload, | |
| 510 | + | local_part: &str, | |
| 511 | + | ) -> StatusCode { | |
| 512 | + | // Parse and verify the reply token | |
| 513 | + | let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token( | |
| 514 | + | local_part, &state.config.signing_secret, | |
| 515 | + | ) { | |
| 516 | + | Some(ids) => ids, | |
| 517 | + | None => { | |
| 518 | + | tracing::info!(local = %local_part, "inbound-issues: invalid reply token"); | |
| 519 | + | return StatusCode::OK; | |
| 520 | + | } | |
| 521 | + | }; | |
| 522 | + | ||
| 523 | + | // Look up sender and verify they match the token | |
| 524 | + | let sender_email = payload.from_full.email.to_lowercase(); | |
| 525 | + | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 526 | + | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 527 | + | Ok(Some(_)) => { | |
| 528 | + | tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended"); | |
| 529 | + | return StatusCode::OK; | |
| 530 | + | } | |
| 531 | + | Ok(None) => { | |
| 532 | + | tracing::info!(email = %sender_email, "inbound-issues: reply sender has no MNW account"); | |
| 533 | + | return StatusCode::OK; | |
| 534 | + | } | |
| 535 | + | Err(e) => { | |
| 536 | + | tracing::error!(error = ?e, "inbound-issues: reply sender lookup failed"); | |
| 537 | + | return StatusCode::OK; | |
| 538 | + | } | |
| 539 | + | }; | |
| 540 | + | ||
| 541 | + | if sender.id != expected_user_id { | |
| 542 | + | tracing::info!( | |
| 543 | + | sender = %sender.id, | |
| 544 | + | expected = %expected_user_id, | |
| 545 | + | "inbound-issues: reply sender does not match token user_id" | |
| 546 | + | ); | |
| 547 | + | return StatusCode::OK; | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | // Look up the issue | |
| 551 | + | let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await { | |
| 552 | + | Ok(Some(i)) => i, | |
| 553 | + | Ok(None) => { | |
| 554 | + | tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply"); | |
| 555 | + | return StatusCode::OK; | |
| 556 | + | } | |
| 557 | + | Err(e) => { | |
| 558 | + | tracing::error!(error = ?e, "inbound-issues: issue lookup failed"); | |
| 559 | + | return StatusCode::OK; | |
| 560 | + | } | |
| 561 | + | }; | |
| 562 | + | ||
| 563 | + | // Strip quoted text from the reply body | |
| 564 | + | let body_md = strip_quoted_text(&payload.text_body); | |
| 565 | + | let body_md = body_md.trim(); | |
| 566 | + | if body_md.is_empty() { | |
| 567 | + | tracing::info!("inbound-issues: empty reply body after stripping quotes"); | |
| 568 | + | return StatusCode::OK; | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | let body_html = docengine::render_permissive(body_md); | |
| 572 | + | ||
| 573 | + | // Create the comment | |
| 574 | + | if let Err(e) = db::issues::create_comment( | |
| 575 | + | &state.db, issue.id, sender.id, body_md, &body_html, | |
| 576 | + | ).await { | |
| 577 | + | tracing::error!(error = ?e, "inbound-issues: failed to create comment"); | |
| 578 | + | return StatusCode::OK; | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | // Store message ID mapping for threading | |
| 582 | + | if let Err(e) = db::issues::insert_issue_message_id( | |
| 583 | + | &state.db, &payload.message_id, issue.id, | |
| 584 | + | ).await { | |
| 585 | + | tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id"); | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | tracing::info!( | |
| 589 | + | issue_id = %issue.id, | |
| 590 | + | message_id = %payload.message_id, | |
| 591 | + | "inbound-issues: reply comment created" | |
| 592 | + | ); | |
| 593 | + | ||
| 594 | + | // Notify all participants (minus the commenter) | |
| 595 | + | let db = state.db.clone(); | |
| 596 | + | let email_client = state.email.clone(); | |
| 597 | + | let host_url = state.config.host_url.clone(); | |
| 598 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 599 | + | let commenter_id = sender.id; | |
| 600 | + | let commenter_username = sender.username.to_string(); | |
| 601 | + | let preview: String = body_md.chars().take(200).collect(); | |
| 602 | + | let issue_title = issue.title.clone(); | |
| 603 | + | let issue_number = issue.number; | |
| 604 | + | let issue_id = issue.id; | |
| 605 | + | let repo_id = issue.repo_id; | |
| 606 | + | ||
| 607 | + | tokio::spawn(async move { | |
| 608 | + | // Look up repo to get owner name | |
| 609 | + | let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await { | |
| 610 | + | Ok(Some(r)) => r, | |
| 611 | + | _ => return, | |
| 612 | + | }; | |
| 613 | + | let owner_user = match db::users::get_user_by_id(&db, repo.user_id).await { | |
| 614 | + | Ok(Some(u)) => u, | |
| 615 | + | _ => return, | |
| 616 | + | }; | |
| 617 | + | let owner_name = owner_user.username.to_string(); | |
| 618 | + | let repo_name = repo.name.clone(); | |
| 619 | + | ||
| 620 | + | let participants = match db::issues::get_issue_participants(&db, issue_id).await { | |
| 621 | + | Ok(p) => p, | |
| 622 | + | Err(e) => { | |
| 623 | + | tracing::error!(error = ?e, "failed to get issue participants for notification"); | |
| 624 | + | return; | |
| 625 | + | } | |
| 626 | + | }; | |
| 627 | + | ||
| 628 | + | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 629 | + | let original_msg_id = format!("<issue-{}-{}@makenot.work>", issue_id, chrono::Utc::now().timestamp()); | |
| 630 | + | ||
| 631 | + | for participant_id in participants { | |
| 632 | + | if participant_id == commenter_id { | |
| 633 | + | continue; | |
| 634 | + | } | |
| 635 | + | let user = match db::users::get_user_by_id(&db, participant_id).await { | |
| 636 | + | Ok(Some(u)) => u, | |
| 637 | + | _ => continue, | |
| 638 | + | }; | |
| 639 | + | if !user.notify_issues { | |
| 640 | + | continue; | |
| 641 | + | } | |
| 642 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 643 | + | &host_url, participant_id, "issue", &participant_id.to_string(), &signing_secret, | |
| 644 | + | ); | |
| 645 | + | let reply_to = crate::email::generate_issue_reply_address(issue_id, participant_id, &signing_secret); | |
| 646 | + | ||
| 647 | + | if let Err(e) = email_client | |
| 648 | + | .send_issue_comment_notification( | |
| 649 | + | &user.email, | |
| 650 | + | user.display_name.as_deref(), | |
| 651 | + | &owner_name, | |
| 652 | + | &repo_name, | |
| 653 | + | issue_number, | |
| 654 | + | &issue_title, | |
| 655 | + | &commenter_username, | |
| 656 | + | &preview, | |
| 657 | + | &issue_url, | |
| 658 | + | Some(&unsub_url), | |
| 659 | + | Some(&reply_to), | |
| 660 | + | Some(&original_msg_id), | |
| 661 | + | None, | |
| 662 | + | ) | |
| 663 | + | .await | |
| 664 | + | { | |
| 665 | + | tracing::error!(error = ?e, recipient = %participant_id, "failed to send issue comment notification"); | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | }); | |
| 669 | + | ||
| 670 | + | StatusCode::OK | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | /// Extract `(owner, repo)` from a To address like `{owner}+{repo}@issues.makenot.work`. | |
| 674 | + | fn extract_issue_address(to: &str) -> Option<(String, String)> { | |
| 675 | + | for addr in to.split(',') { | |
| 676 | + | let addr = addr.trim(); | |
| 677 | + | let email = if let Some(start) = addr.find('<') { | |
| 678 | + | addr[start + 1..].trim_end_matches('>') | |
| 679 | + | } else { | |
| 680 | + | addr | |
| 681 | + | }; | |
| 682 | + | let email = email.trim().to_lowercase(); | |
| 683 | + | if let Some(local) = email.strip_suffix("@issues.makenot.work") { | |
| 684 | + | if let Some((owner, repo)) = local.split_once('+') { | |
| 685 | + | if !owner.is_empty() && !repo.is_empty() { | |
| 686 | + | return Some((owner.to_string(), repo.to_string())); | |
| 687 | + | } | |
| 688 | + | } | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | None | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | /// Extract the local part of a `issue+...@reply.makenot.work` address. | |
| 695 | + | fn extract_reply_local(to: &str) -> Option<String> { | |
| 696 | + | for addr in to.split(',') { | |
| 697 | + | let addr = addr.trim(); | |
| 698 | + | let email = if let Some(start) = addr.find('<') { | |
| 699 | + | addr[start + 1..].trim_end_matches('>') | |
| 700 | + | } else { | |
| 701 | + | addr | |
| 702 | + | }; | |
| 703 | + | let email = email.trim().to_lowercase(); | |
| 704 | + | if let Some(local) = email.strip_suffix("@reply.makenot.work") { | |
| 705 | + | if local.starts_with("issue+") { | |
| 706 | + | return Some(local.to_string()); | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | } | |
| 710 | + | None | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | /// Strip quoted text from email replies. | |
| 714 | + | /// | |
| 715 | + | /// Removes: | |
| 716 | + | /// - Lines starting with `>` | |
| 717 | + | /// - "On ... wrote:" preamble lines and everything after | |
| 718 | + | fn strip_quoted_text(text: &str) -> String { | |
| 719 | + | let mut result = Vec::new(); | |
| 720 | + | for line in text.lines() { | |
| 721 | + | // Stop at "On ... wrote:" preamble | |
| 722 | + | let trimmed = line.trim(); | |
| 723 | + | if trimmed.starts_with("On ") && trimmed.ends_with("wrote:") { | |
| 724 | + | break; | |
| 725 | + | } | |
| 726 | + | // Skip quoted lines | |
| 727 | + | if trimmed.starts_with('>') { | |
| 728 | + | continue; | |
| 729 | + | } | |
| 730 | + | result.push(line); | |
| 731 | + | } | |
| 732 | + | // Trim trailing empty lines | |
| 733 | + | while result.last().is_some_and(|l| l.trim().is_empty()) { | |
| 734 | + | result.pop(); | |
| 735 | + | } | |
| 736 | + | result.join("\n") | |
| 737 | + | } | |
| 738 | + | ||
| 330 | 739 | /// Register Postmark webhook routes. | |
| 331 | 740 | pub fn postmark_routes() -> Router<AppState> { | |
| 332 | 741 | Router::new() | |
| 333 | 742 | .route("/postmark/webhook", post(postmark_webhook)) | |
| 334 | 743 | .route("/postmark/inbound", post(postmark_inbound)) | |
| 744 | + | .route("/postmark/inbound-issues", post(postmark_inbound_issues)) | |
| 335 | 745 | } | |
| 336 | 746 | ||
| 337 | 747 | #[cfg(test)] | |
| @@ -379,4 +789,99 @@ mod tests { | |||
| 379 | 789 | Some("my-proj".to_string()) | |
| 380 | 790 | ); | |
| 381 | 791 | } | |
| 792 | + | ||
| 793 | + | // ── Issue address parsing ── | |
| 794 | + | ||
| 795 | + | #[test] | |
| 796 | + | fn extract_issue_addr_simple() { | |
| 797 | + | assert_eq!( | |
| 798 | + | extract_issue_address("alice+myrepo@issues.makenot.work"), | |
| 799 | + | Some(("alice".to_string(), "myrepo".to_string())) | |
| 800 | + | ); | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | #[test] | |
| 804 | + | fn extract_issue_addr_with_display_name() { | |
| 805 | + | assert_eq!( | |
| 806 | + | extract_issue_address("Alice <alice+myrepo@issues.makenot.work>"), | |
| 807 | + | Some(("alice".to_string(), "myrepo".to_string())) | |
| 808 | + | ); | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | #[test] | |
| 812 | + | fn extract_issue_addr_multiple_recipients() { | |
| 813 | + | assert_eq!( | |
| 814 | + | extract_issue_address("other@example.com, alice+myrepo@issues.makenot.work"), | |
| 815 | + | Some(("alice".to_string(), "myrepo".to_string())) | |
| 816 | + | ); | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | #[test] | |
| 820 | + | fn extract_issue_addr_wrong_domain() { | |
| 821 | + | assert_eq!(extract_issue_address("alice+myrepo@example.com"), None); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | #[test] | |
| 825 | + | fn extract_issue_addr_no_plus() { | |
| 826 | + | assert_eq!(extract_issue_address("alice@issues.makenot.work"), None); | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | #[test] | |
| 830 | + | fn extract_issue_addr_case_insensitive() { | |
| 831 | + | assert_eq!( | |
| 832 | + | extract_issue_address("Alice+MyRepo@Issues.Makenot.Work"), | |
| 833 | + | Some(("alice".to_string(), "myrepo".to_string())) | |
| 834 | + | ); | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | #[test] | |
| 838 | + | fn extract_issue_addr_empty_parts() { | |
| 839 | + | assert_eq!(extract_issue_address("+repo@issues.makenot.work"), None); | |
| 840 | + | assert_eq!(extract_issue_address("owner+@issues.makenot.work"), None); | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | // ── Reply local parsing ── | |
| 844 | + | ||
| 845 | + | #[test] | |
| 846 | + | fn extract_reply_simple() { | |
| 847 | + | assert_eq!( | |
| 848 | + | extract_reply_local("issue+abc.def.1234@reply.makenot.work"), | |
| 849 | + | Some("issue+abc.def.1234".to_string()) | |
| 850 | + | ); | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | #[test] | |
| 854 | + | fn extract_reply_not_issue_prefix() { | |
| 855 | + | assert_eq!(extract_reply_local("other+abc@reply.makenot.work"), None); | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | #[test] | |
| 859 | + | fn extract_reply_wrong_domain() { | |
| 860 | + | assert_eq!(extract_reply_local("issue+abc@example.com"), None); | |
| 861 | + | } | |
| 862 | + |
Lines truncated
| @@ -172,9 +172,6 @@ impl_into_response!( | |||
| 172 | 172 | // Git issues | |
| 173 | 173 | GitIssueListTemplate, | |
| 174 | 174 | GitIssueDetailTemplate, | |
| 175 | - | GitIssueNewTemplate, | |
| 176 | - | GitIssueEditTemplate, | |
| 177 | - | GitIssueLabelManagerTemplate, | |
| 178 | 175 | GitRepoSettingsTemplate, | |
| 179 | 176 | // Join wizard | |
| 180 | 177 | WizardJoinTemplate, |
| @@ -865,7 +865,7 @@ pub struct GitFileLogTemplate { | |||
| 865 | 865 | // Git Issues | |
| 866 | 866 | // ============================================================================ | |
| 867 | 867 | ||
| 868 | - | /// Issue list page with status tabs and search. | |
| 868 | + | /// Issue list page with status tabs and search (read-only). | |
| 869 | 869 | #[derive(Template)] | |
| 870 | 870 | #[template(path = "pages/git/issues.html")] | |
| 871 | 871 | pub struct GitIssueListTemplate { | |
| @@ -881,12 +881,13 @@ pub struct GitIssueListTemplate { | |||
| 881 | 881 | pub search_query: String, | |
| 882 | 882 | pub current_page: i64, | |
| 883 | 883 | pub total_pages: i64, | |
| 884 | - | pub labels: Vec<crate::db::DbIssueLabel>, | |
| 885 | 884 | /// Whether the current viewer is the repo owner (for settings link). | |
| 886 | 885 | pub is_owner: bool, | |
| 886 | + | /// Email address for submitting new issues. | |
| 887 | + | pub email_address: String, | |
| 887 | 888 | } | |
| 888 | 889 | ||
| 889 | - | /// Issue detail page with comments and status actions. | |
| 890 | + | /// Issue detail page with comments (read-only). | |
| 890 | 891 | #[derive(Template)] | |
| 891 | 892 | #[template(path = "pages/git/issue.html")] | |
| 892 | 893 | pub struct GitIssueDetailTemplate { | |
| @@ -898,59 +899,10 @@ pub struct GitIssueDetailTemplate { | |||
| 898 | 899 | pub issue: crate::db::DbIssue, | |
| 899 | 900 | pub author_username: String, | |
| 900 | 901 | pub comments: Vec<crate::db::DbIssueCommentWithAuthor>, | |
| 901 | - | pub labels: Vec<crate::db::DbIssueLabel>, | |
| 902 | - | pub all_labels: Vec<crate::db::DbIssueLabel>, | |
| 903 | - | pub assigned_label_ids: Vec<crate::db::IssueLabelId>, | |
| 904 | - | pub can_edit: bool, | |
| 905 | 902 | pub is_owner: bool, | |
| 906 | 903 | pub open_issue_count: i64, | |
| 907 | - | } | |
| 908 | - | ||
| 909 | - | /// New issue form. | |
| 910 | - | #[derive(Template)] | |
| 911 | - | #[template(path = "pages/git/issue_new.html")] | |
| 912 | - | pub struct GitIssueNewTemplate { | |
| 913 | - | pub csrf_token: CsrfTokenOption, | |
| 914 | - | pub session_user: Option<SessionUser>, | |
| 915 | - | pub owner: String, | |
| 916 | - | pub repo_name: String, | |
| 917 | - | pub current_ref: String, | |
| 918 | - | pub labels: Vec<crate::db::DbIssueLabel>, | |
| 919 | - | pub open_issue_count: i64, | |
| 920 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 921 | - | pub is_owner: bool, | |
| 922 | - | } | |
| 923 | - | ||
| 924 | - | /// Edit issue form. | |
| 925 | - | #[derive(Template)] | |
| 926 | - | #[template(path = "pages/git/issue_edit.html")] | |
| 927 | - | pub struct GitIssueEditTemplate { | |
| 928 | - | pub csrf_token: CsrfTokenOption, | |
| 929 | - | pub session_user: Option<SessionUser>, | |
| 930 | - | pub owner: String, | |
| 931 | - | pub repo_name: String, | |
| 932 | - | pub current_ref: String, | |
| 933 | - | pub issue: crate::db::DbIssue, | |
| 934 | - | pub labels: Vec<crate::db::DbIssueLabel>, | |
| 935 | - | pub assigned_label_ids: Vec<crate::db::IssueLabelId>, | |
| 936 | - | pub open_issue_count: i64, | |
| 937 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 938 | - | pub is_owner: bool, | |
| 939 | - | } | |
| 940 | - | ||
| 941 | - | /// Label management page (owner only). | |
| 942 | - | #[derive(Template)] | |
| 943 | - | #[template(path = "pages/git/labels.html")] | |
| 944 | - | pub struct GitIssueLabelManagerTemplate { | |
| 945 | - | pub csrf_token: CsrfTokenOption, | |
| 946 | - | pub session_user: Option<SessionUser>, | |
| 947 | - | pub owner: String, | |
| 948 | - | pub repo_name: String, | |
| 949 | - | pub current_ref: String, | |
| 950 | - | pub labels: Vec<crate::db::DbIssueLabel>, | |
| 951 | - | pub open_issue_count: i64, | |
| 952 | - | /// Whether the current viewer is the repo owner (for settings link). | |
| 953 | - | pub is_owner: bool, | |
| 904 | + | /// Email address for submitting new issues. | |
| 905 | + | pub email_address: String, | |
| 954 | 906 | } | |
| 955 | 907 | ||
| 956 | 908 | /// Repository settings page (owner only). |
| @@ -32,59 +32,6 @@ | |||
| 32 | 32 | </div> | |
| 33 | 33 | </div> | |
| 34 | 34 | ||
| 35 | - | {% if can_edit %} | |
| 36 | - | <div class="issue-detail-actions"> | |
| 37 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/edit" class="btn btn-small">Edit</a> | |
| 38 | - | {% match issue.status %} | |
| 39 | - | {% when crate::db::IssueStatus::Open %} | |
| 40 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/close" style="display:inline"> | |
| 41 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 42 | - | <button type="submit" class="btn btn-small btn-secondary">Close issue</button> | |
| 43 | - | </form> | |
| 44 | - | {% when crate::db::IssueStatus::Closed %} | |
| 45 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/reopen" style="display:inline"> | |
| 46 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 47 | - | <button type="submit" class="btn btn-small btn-primary">Reopen issue</button> | |
| 48 | - | </form> | |
| 49 | - | {% endmatch %} | |
| 50 | - | </div> | |
| 51 | - | {% endif %} | |
| 52 | - | ||
| 53 | - | {% if !labels.is_empty() %} | |
| 54 | - | <div class="issue-labels"> | |
| 55 | - | {% for label in labels %} | |
| 56 | - | <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span> | |
| 57 | - | {% endfor %} | |
| 58 | - | </div> | |
| 59 | - | {% endif %} | |
| 60 | - | ||
| 61 | - | {% if is_owner && !all_labels.is_empty() %} | |
| 62 | - | <div class="issue-label-manage"> | |
| 63 | - | <details> | |
| 64 | - | <summary>Manage labels</summary> | |
| 65 | - | <div class="issue-label-forms"> | |
| 66 | - | {% for label in all_labels %} | |
| 67 | - | <div class="issue-label-toggle"> | |
| 68 | - | {% if assigned_label_ids.contains(&label.id) %} | |
| 69 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/labels/remove" style="display:inline"> | |
| 70 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 71 | - | <input type="hidden" name="label_id" value="{{ label.id }}"> | |
| 72 | - | <button type="submit" class="issue-label active" style="background-color: {{ label.color }}">{{ label.name }} ×</button> | |
| 73 | - | </form> | |
| 74 | - | {% else %} | |
| 75 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/labels/add" style="display:inline"> | |
| 76 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 77 | - | <input type="hidden" name="label_id" value="{{ label.id }}"> | |
| 78 | - | <button type="submit" class="issue-label inactive">{{ label.name }} +</button> | |
| 79 | - | </form> | |
| 80 | - | {% endif %} | |
| 81 | - | </div> | |
| 82 | - | {% endfor %} | |
| 83 | - | </div> | |
| 84 | - | </details> | |
| 85 | - | </div> | |
| 86 | - | {% endif %} | |
| 87 | - | ||
| 88 | 35 | {% if !issue.body_html.is_empty() %} | |
| 89 | 36 | <div class="issue-body markdown-body"> | |
| 90 | 37 | {{ issue.body_html|safe }} | |
| @@ -98,14 +45,6 @@ | |||
| 98 | 45 | <div class="issue-comment"> | |
| 99 | 46 | <div class="issue-comment-header"> | |
| 100 | 47 | <strong>{{ comment.author_username }}</strong> | |
| 101 | - | {% if let Some(user) = &session_user %} | |
| 102 | - | {% if user.id == comment.author_user_id || is_owner %} | |
| 103 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/comment/{{ comment.id }}/delete" style="display:inline"> | |
| 104 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 105 | - | <button type="submit" class="btn-link btn-danger-link" onclick="return confirm('Delete this comment?')">delete</button> | |
| 106 | - | </form> | |
| 107 | - | {% endif %} | |
| 108 | - | {% endif %} | |
| 109 | 48 | </div> | |
| 110 | 49 | <div class="issue-comment-body markdown-body"> | |
| 111 | 50 | {{ comment.body_html|safe }} | |
| @@ -115,16 +54,7 @@ | |||
| 115 | 54 | </div> | |
| 116 | 55 | {% endif %} | |
| 117 | 56 | ||
| 118 | - | {% if session_user.is_some() %} | |
| 119 | - | <div class="issue-comment-form"> | |
| 120 | - | <h3>Add a comment</h3> | |
| 121 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/comment"> | |
| 122 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 123 | - | <textarea name="body" rows="6" placeholder="Leave a comment (Markdown supported)..." required></textarea> | |
| 124 | - | <button type="submit" class="btn btn-primary">Comment</button> | |
| 125 | - | </form> | |
| 126 | - | </div> | |
| 127 | - | {% endif %} | |
| 57 | + | <p class="issue-email-hint">To comment, reply to the notification email. To report a new issue, email <code>{{ email_address }}</code>.</p> | |
| 128 | 58 | </div> | |
| 129 | 59 | ||
| 130 | 60 | <footer class="text-reader-footer"> |
| @@ -1,72 +0,0 @@ | |||
| 1 | - | {% extends "base.html" %} | |
| 2 | - | ||
| 3 | - | {% block title %}Edit #{{ issue.number }} - {{ repo_name }} - Makenot.work{% endblock %} | |
| 4 | - | {% block body_attrs %} class="padded-page"{% endblock %} | |
| 5 | - | ||
| 6 | - | {% block content %} | |
| 7 | - | {% include "partials/site_header.html" %} | |
| 8 | - | ||
| 9 | - | <h1 class="git-repo-name"> | |
| 10 | - | <a href="/git/{{ owner }}">{{ owner }}</a> | |
| 11 | - | <span class="sep">/</span> | |
| 12 | - | <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a> | |
| 13 | - | </h1> | |
| 14 | - | ||
| 15 | - | <div class="git-ref-bar"> | |
| 16 | - | <nav class="git-nav-links"> | |
| 17 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a> | |
| 18 | - | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a> | |
| 19 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="active">Issues ({{ open_issue_count }})</a> | |
| 20 | - | {% if is_owner %} | |
| 21 | - | <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a> | |
| 22 | - | {% endif %} | |
| 23 | - | </nav> | |
| 24 | - | </div> | |
| 25 | - | ||
| 26 | - | <div class="issue-form-container"> | |
| 27 | - | <h2>Edit Issue #{{ issue.number }}</h2> | |
| 28 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}/edit"> | |
| 29 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 30 | - | <div class="form-group"> | |
| 31 | - | <label for="title">Title</label> | |
| 32 | - | <input type="text" id="title" name="title" required maxlength="200" value="{{ issue.title }}" class="form-input"> | |
| 33 | - | </div> | |
| 34 | - | <div class="form-group"> | |
| 35 | - | <label for="body">Description (Markdown)</label> | |
| 36 | - | <textarea id="body" name="body" rows="10" class="form-textarea">{{ issue.body_markdown }}</textarea> | |
| 37 | - | </div> | |
| 38 | - | {% if !labels.is_empty() %} | |
| 39 | - | <div class="form-group"> | |
| 40 | - | <label>Labels</label> | |
| 41 | - | <div class="label-checkboxes"> | |
| 42 | - | {% for label in labels %} | |
| 43 | - | <label class="label-checkbox"> | |
| 44 | - | <input type="checkbox" name="label_{{ label.id }}" value="{{ label.id }}" | |
| 45 | - | {% if assigned_label_ids.contains(&label.id) %}checked{% endif %}> | |
| 46 | - | <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span> | |
| 47 | - | </label> | |
| 48 | - | {% endfor %} | |
| 49 | - | </div> | |
| 50 | - | </div> | |
| 51 | - | <input type="hidden" id="labels-hidden" name="labels" value=""> | |
| 52 | - | <script> | |
| 53 | - | document.querySelector('.issue-form-container form').addEventListener('submit', function() { | |
| 54 | - | var checked = []; | |
| 55 | - | document.querySelectorAll('.label-checkboxes input:checked').forEach(function(cb) { | |
| 56 | - | checked.push(cb.value); | |
| 57 | - | }); | |
| 58 | - | document.getElementById('labels-hidden').value = checked.join(','); | |
| 59 | - | }); | |
| 60 | - | </script> | |
| 61 | - | {% endif %} | |
| 62 | - | <div class="form-actions"> | |
| 63 | - | <button type="submit" class="btn btn-primary">Save changes</button> | |
| 64 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues/{{ issue.number }}" class="btn btn-secondary">Cancel</a> | |
| 65 | - | </div> | |
| 66 | - | </form> | |
| 67 | - | </div> | |
| 68 | - | ||
| 69 | - | <footer class="text-reader-footer"> | |
| 70 | - | <a href="/">Makenot<span class="dot">.</span>work</a> | |
| 71 | - | </footer> | |
| 72 | - | {% endblock %} |
| @@ -1,68 +0,0 @@ | |||
| 1 | - | {% extends "base.html" %} | |
| 2 | - | ||
| 3 | - | {% block title %}New Issue - {{ repo_name }} - Makenot.work{% endblock %} | |
| 4 | - | {% block body_attrs %} class="padded-page"{% endblock %} | |
| 5 | - | ||
| 6 | - | {% block content %} | |
| 7 | - | {% include "partials/site_header.html" %} | |
| 8 | - | ||
| 9 | - | <h1 class="git-repo-name"> | |
| 10 | - | <a href="/git/{{ owner }}">{{ owner }}</a> | |
| 11 | - | <span class="sep">/</span> | |
| 12 | - | <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a> | |
| 13 | - | </h1> | |
| 14 | - | ||
| 15 | - | <div class="git-ref-bar"> | |
| 16 | - | <nav class="git-nav-links"> | |
| 17 | - | <a href="/git/{{ owner }}/{{ repo_name }}/tree/{{ current_ref }}">Files</a> | |
| 18 | - | <a href="/git/{{ owner }}/{{ repo_name }}/commits/{{ current_ref }}">Commits</a> | |
| 19 | - | <a href="/git/{{ owner }}/{{ repo_name }}/issues" class="active">Issues ({{ open_issue_count }})</a> | |
| 20 | - | {% if is_owner %} | |
| 21 | - | <a href="/git/{{ owner }}/{{ repo_name }}/settings">Settings</a> | |
| 22 | - | {% endif %} | |
| 23 | - | </nav> | |
| 24 | - | </div> | |
| 25 | - | ||
| 26 | - | <div class="issue-form-container"> | |
| 27 | - | <h2>New Issue</h2> | |
| 28 | - | <form method="post" action="/git/{{ owner }}/{{ repo_name }}/issues"> | |
| 29 | - | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 30 | - | <div class="form-group"> | |
| 31 | - | <label for="title">Title</label> | |
| 32 | - | <input type="text" id="title" name="title" required maxlength="200" placeholder="Issue title" class="form-input"> | |
| 33 | - | </div> | |
| 34 | - | <div class="form-group"> | |
| 35 | - | <label for="body">Description (Markdown)</label> | |
| 36 | - | <textarea id="body" name="body" rows="10" placeholder="Describe the issue..." class="form-textarea"></textarea> | |
| 37 | - | </div> | |
| 38 | - | {% if !labels.is_empty() %} | |
| 39 | - | <div class="form-group"> | |
| 40 | - | <label>Labels</label> | |
| 41 | - | <div class="label-checkboxes"> | |
| 42 | - | {% for label in labels %} | |
| 43 | - | <label class="label-checkbox"> | |
| 44 | - | <input type="checkbox" name="label_{{ label.id }}" value="{{ label.id }}"> | |
| 45 | - | <span class="issue-label" style="background-color: {{ label.color }}">{{ label.name }}</span> | |
| 46 | - | </label> | |
| 47 | - | {% endfor %} | |
| 48 | - | </div> | |
| 49 | - | </div> | |
| 50 | - | <input type="hidden" id="labels-hidden" name="labels" value=""> | |
| 51 | - | <script> | |
| 52 | - | document.querySelector('.issue-form-container form').addEventListener('submit', function() { | |
| 53 | - | var checked = []; | |
| 54 | - | document.querySelectorAll('.label-checkboxes input:checked').forEach(function(cb) { | |
| 55 | - | checked.push(cb.value); | |
| 56 | - | }); | |
| 57 | - | document.getElementById('labels-hidden').value = checked.join(','); | |
| 58 | - | }); | |
| 59 | - | </script> | |
| 60 | - | {% endif %} | |
| 61 | - | <button type="submit" class="btn btn-primary">Submit new issue</button> | |
| 62 | - | </form> | |
| 63 | - | </div> | |
| 64 | - | ||
| 65 | - | <footer class="text-reader-footer"> | |
| 66 | - | <a href="/">Makenot<span class="dot">.</span>work</a> | |
| 67 | - | </footer> | |
| 68 | - | {% endblock %} |