max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+911 insertions,
-466 deletions
| @@ -2,6 +2,34 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use serde::{Deserialize, Serialize}; | |
| 4 | 4 | ||
| 5 | + | /// A repository as `repo list` renders it. | |
| 6 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 7 | + | pub(crate) struct CliRepo { | |
| 8 | + | pub name: String, | |
| 9 | + | pub visibility: String, | |
| 10 | + | pub description: String, | |
| 11 | + | pub created_at: String, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// A repository plus its issue counts, for `repo info`. | |
| 15 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 16 | + | pub(crate) struct CliRepoInfo { | |
| 17 | + | pub name: String, | |
| 18 | + | pub visibility: String, | |
| 19 | + | pub description: String, | |
| 20 | + | pub created_at: String, | |
| 21 | + | pub open_issues: i64, | |
| 22 | + | pub closed_issues: i64, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// An SSH key as `key list` renders it. | |
| 26 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 27 | + | pub(crate) struct CliSshKey { | |
| 28 | + | pub fingerprint: String, | |
| 29 | + | pub label: String, | |
| 30 | + | pub created_at: String, | |
| 31 | + | } | |
| 32 | + | ||
| 5 | 33 | /// User info returned from the SSH key lookup endpoint. | |
| 6 | 34 | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 7 | 35 | pub(crate) struct UserInfo { | |
| @@ -1614,4 +1642,122 @@ | |||
| 1614 | 1642 | .await?; | |
| 1615 | 1643 | empty_response(resp, "remove_domain").await | |
| 1616 | 1644 | } | |
| 1645 | + | ||
| 1646 | + | // ── Git repositories and SSH keys ── | |
| 1647 | + | // | |
| 1648 | + | // Addressed by repo NAME, matching the CLI's own vocabulary. The browser | |
| 1649 | + | // API keys on the repo id because a page has the row loaded; a person at a | |
| 1650 | + | // terminal does not. | |
| 1651 | + | ||
| 1652 | + | pub(crate) async fn repo_list(&self, user_id: &str) -> anyhow::Result<Vec<CliRepo>> { | |
| 1653 | + | let url = format!("{}/api/internal/creator/repos", self.base_url); | |
| 1654 | + | let resp = self | |
| 1655 | + | .http | |
| 1656 | + | .get(&url) | |
| 1657 | + | .bearer_auth(&self.service_token) | |
| 1658 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1659 | + | .query(&[("user_id", user_id)]) | |
| 1660 | + | .send() | |
| 1661 | + | .await?; | |
| 1662 | + | json_response(resp, "repo_list").await | |
| 1663 | + | } | |
| 1664 | + | ||
| 1665 | + | pub(crate) async fn repo_info(&self, user_id: &str, name: &str) -> anyhow::Result<CliRepoInfo> { | |
| 1666 | + | let url = format!("{}/api/internal/creator/repos/{name}", self.base_url); | |
| 1667 | + | let resp = self | |
| 1668 | + | .http | |
| 1669 | + | .get(&url) | |
| 1670 | + | .bearer_auth(&self.service_token) | |
| 1671 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1672 | + | .query(&[("user_id", user_id)]) | |
| 1673 | + | .send() | |
| 1674 | + | .await?; | |
| 1675 | + | json_response(resp, "repo_info").await | |
| 1676 | + | } | |
| 1677 | + | ||
| 1678 | + | pub(crate) async fn repo_set_visibility( | |
| 1679 | + | &self, | |
| 1680 | + | user_id: &str, | |
| 1681 | + | name: &str, | |
| 1682 | + | visibility: &str, | |
| 1683 | + | ) -> anyhow::Result<()> { | |
| 1684 | + | let url = format!( | |
| 1685 | + | "{}/api/internal/creator/repos/{name}/visibility", | |
| 1686 | + | self.base_url | |
| 1687 | + | ); | |
| 1688 | + | let resp = self | |
| 1689 | + | .http | |
| 1690 | + | .put(&url) | |
| 1691 | + | .bearer_auth(&self.service_token) | |
| 1692 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1693 | + | .query(&[("user_id", user_id)]) | |
| 1694 | + | .json(&serde_json::json!({ "visibility": visibility })) | |
| 1695 | + | .send() | |
| 1696 | + | .await?; | |
| 1697 | + | empty_response(resp, "repo_set_visibility").await | |
| 1698 | + | } | |
| 1699 | + | ||
| 1700 | + | pub(crate) async fn repo_set_description( | |
| 1701 | + | &self, | |
| 1702 | + | user_id: &str, | |
| 1703 | + | name: &str, | |
| 1704 | + | description: &str, | |
| 1705 | + | ) -> anyhow::Result<()> { | |
| 1706 | + | let url = format!( | |
| 1707 | + | "{}/api/internal/creator/repos/{name}/description", | |
| 1708 | + | self.base_url | |
| 1709 | + | ); | |
| 1710 | + | let resp = self | |
| 1711 | + | .http | |
| 1712 | + | .put(&url) | |
| 1713 | + | .bearer_auth(&self.service_token) | |
| 1714 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1715 | + | .query(&[("user_id", user_id)]) | |
| 1716 | + | .json(&serde_json::json!({ "description": description })) | |
| 1717 | + | .send() | |
| 1718 | + | .await?; | |
| 1719 | + | empty_response(resp, "repo_set_description").await | |
| 1720 | + | } | |
| 1721 | + | ||
| 1722 | + | pub(crate) async fn repo_delete(&self, user_id: &str, name: &str) -> anyhow::Result<()> { | |
| 1723 | + | let url = format!("{}/api/internal/creator/repos/{name}", self.base_url); | |
| 1724 | + | let resp = self | |
| 1725 | + | .http | |
| 1726 | + | .delete(&url) | |
| 1727 | + | .bearer_auth(&self.service_token) | |
| 1728 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1729 | + | .query(&[("user_id", user_id)]) | |
| 1730 | + | .send() | |
| 1731 | + | .await?; | |
| 1732 | + | empty_response(resp, "repo_delete").await | |
| 1733 | + | } | |
| 1734 | + | ||
| 1735 | + | pub(crate) async fn key_list(&self, user_id: &str) -> anyhow::Result<Vec<CliSshKey>> { | |
| 1736 | + | let url = format!("{}/api/internal/creator/ssh-keys", self.base_url); | |
| 1737 | + | let resp = self | |
| 1738 | + | .http | |
| 1739 | + | .get(&url) | |
| 1740 | + | .bearer_auth(&self.service_token) | |
| 1741 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1742 | + | .query(&[("user_id", user_id)]) | |
| 1743 | + | .send() | |
| 1744 | + | .await?; | |
| 1745 | + | json_response(resp, "key_list").await | |
| 1746 | + | } | |
| 1747 | + | ||
| 1748 | + | pub(crate) async fn key_remove(&self, user_id: &str, fingerprint: &str) -> anyhow::Result<()> { | |
| 1749 | + | let url = format!( | |
| 1750 | + | "{}/api/internal/creator/ssh-keys/{fingerprint}", | |
| 1751 | + | self.base_url | |
| 1752 | + | ); | |
| 1753 | + | let resp = self | |
| 1754 | + | .http | |
| 1755 | + | .delete(&url) | |
| 1756 | + | .bearer_auth(&self.service_token) | |
| 1757 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1758 | + | .query(&[("user_id", user_id)]) | |
| 1759 | + | .send() | |
| 1760 | + | .await?; | |
| 1761 | + | empty_response(resp, "key_remove").await | |
| 1762 | + | } | |
| 1617 | 1763 | } |
| @@ -94,6 +94,48 @@ | |||
| 94 | 94 | Some("remove") => cmd_domain_remove(user, api).await, | |
| 95 | 95 | _ => cmd_domain_show(user, api).await, | |
| 96 | 96 | }, | |
| 97 | + | "repo" => match parts.get(1).copied() { | |
| 98 | + | Some("list") => cmd_repo_list(user, api, json).await, | |
| 99 | + | Some("info") => cmd_repo_info(user, api, parts.get(2).unwrap_or(&""), json).await, | |
| 100 | + | Some("set-visibility") => { | |
| 101 | + | cmd_repo_set_visibility( | |
| 102 | + | user, | |
| 103 | + | api, | |
| 104 | + | parts.get(2).unwrap_or(&""), | |
| 105 | + | parts.get(3).unwrap_or(&""), | |
| 106 | + | ) | |
| 107 | + | .await | |
| 108 | + | } | |
| 109 | + | // The description is the rest of the line, so it is rejoined rather | |
| 110 | + | // than read from one slot: a description is prose and almost always | |
| 111 | + | // has a space in it. | |
| 112 | + | Some("set-description") => { | |
| 113 | + | let desc = parts.get(3..).map(|r| r.join(" ")).unwrap_or_default(); | |
| 114 | + | cmd_repo_set_description( | |
| 115 | + | user, | |
| 116 | + | api, | |
| 117 | + | parts.get(2).unwrap_or(&""), | |
| 118 | + | unquote(&desc), | |
| 119 | + | ) | |
| 120 | + | .await | |
| 121 | + | } | |
| 122 | + | // --confirm is required and deliberately not inferable. This is the | |
| 123 | + | // only verb here that destroys anything. | |
| 124 | + | Some("delete") => { | |
| 125 | + | if parts.contains(&"--confirm") { | |
| 126 | + | cmd_repo_delete(user, api, parts.get(2).unwrap_or(&"")).await | |
| 127 | + | } else { | |
| 128 | + | b"repo delete requires --confirm\r\nUsage: repo delete <name> --confirm\r\n" | |
| 129 | + | .to_vec() | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | _ => b"Usage: repo list | info NAME | set-visibility NAME public|unlisted|private | set-description NAME TEXT | delete NAME --confirm\r\n".to_vec(), | |
| 133 | + | }, | |
| 134 | + | "key" => match parts.get(1).copied() { | |
| 135 | + | Some("list") => cmd_key_list(user, api, json).await, | |
| 136 | + | Some("rm") => cmd_key_remove(user, api, parts.get(2).unwrap_or(&"")).await, | |
| 137 | + | _ => b"Usage: key list | key rm FINGERPRINT\r\n".to_vec(), | |
| 138 | + | }, | |
| 97 | 139 | "help" | "--help" | "-h" => help_text(), | |
| 98 | 140 | other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n") | |
| 99 | 141 | .into_bytes(), | |
| @@ -536,6 +578,158 @@ | |||
| 536 | 578 | )) | |
| 537 | 579 | } | |
| 538 | 580 | ||
| 581 | + | // ── Git repositories and SSH keys ── | |
| 582 | + | // | |
| 583 | + | // These verbs existed in the server's git_ssh.rs, reachable only through the | |
| 584 | + | // sshd `command=` path that the live SSH front door does not use. They are | |
| 585 | + | // here now because this is the door people actually knock on. | |
| 586 | + | ||
| 587 | + | async fn cmd_repo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 588 | + | match api.repo_list(&user.user_id).await { | |
| 589 | + | Ok(repos) => { | |
| 590 | + | if json { | |
| 591 | + | return serde_json::to_vec_pretty(&repos).unwrap_or_default(); | |
| 592 | + | } | |
| 593 | + | if repos.is_empty() { | |
| 594 | + | return b"No repositories.\r\n".to_vec(); | |
| 595 | + | } | |
| 596 | + | let mut out = format!("{:<30} {:<10} {}\r\n", "Name", "Visibility", "Description"); | |
| 597 | + | out.push_str(&"-".repeat(70)); | |
| 598 | + | out.push_str("\r\n"); | |
| 599 | + | for r in &repos { | |
| 600 | + | let desc = if r.description.is_empty() { | |
| 601 | + | "-" | |
| 602 | + | } else { | |
| 603 | + | truncate(&r.description, 28) | |
| 604 | + | }; | |
| 605 | + | write!( | |
| 606 | + | out, | |
| 607 | + | "{:<30} {:<10} {}\r\n", | |
| 608 | + | truncate(&r.name, 29), | |
| 609 | + | r.visibility, | |
| 610 | + | desc | |
| 611 | + | ) | |
| 612 | + | .unwrap(); | |
| 613 | + | } | |
| 614 | + | write!(out, "\r\n{} repository(ies).\r\n", repos.len()).unwrap(); | |
| 615 | + | out.into_bytes() | |
| 616 | + | } | |
| 617 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | async fn cmd_repo_info(user: &UserInfo, api: &MnwApiClient, name: &str, json: bool) -> Vec<u8> { | |
| 622 | + | if name.is_empty() { | |
| 623 | + | return b"Usage: repo info <name>\r\n".to_vec(); | |
| 624 | + | } | |
| 625 | + | match api.repo_info(&user.user_id, name).await { | |
| 626 | + | Ok(r) => { | |
| 627 | + | if json { | |
| 628 | + | return serde_json::to_vec_pretty(&r).unwrap_or_default(); | |
| 629 | + | } | |
| 630 | + | let desc = if r.description.is_empty() { | |
| 631 | + | "-" | |
| 632 | + | } else { | |
| 633 | + | &r.description | |
| 634 | + | }; | |
| 635 | + | format!( | |
| 636 | + | "Name: {}\r\nVisibility: {}\r\nDescription: {}\r\nCreated: {}\r\nIssues: {} open, {} closed\r\n", | |
| 637 | + | r.name, r.visibility, desc, r.created_at, r.open_issues, r.closed_issues, | |
| 638 | + | ) | |
| 639 | + | .into_bytes() | |
| 640 | + | } | |
| 641 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 642 | + | } | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | async fn cmd_repo_set_visibility( | |
| 646 | + | user: &UserInfo, | |
| 647 | + | api: &MnwApiClient, | |
| 648 | + | name: &str, | |
| 649 | + | visibility: &str, | |
| 650 | + | ) -> Vec<u8> { | |
| 651 | + | if name.is_empty() || visibility.is_empty() { | |
| 652 | + | return b"Usage: repo set-visibility <name> <public|unlisted|private>\r\n".to_vec(); | |
| 653 | + | } | |
| 654 | + | if !matches!(visibility, "public" | "unlisted" | "private") { | |
| 655 | + | return b"Visibility must be public, unlisted, or private.\r\n".to_vec(); | |
| 656 | + | } | |
| 657 | + | match api | |
| 658 | + | .repo_set_visibility(&user.user_id, name, visibility) | |
| 659 | + | .await | |
| 660 | + | { | |
| 661 | + | Ok(()) => format!("Set visibility of '{name}' to '{visibility}'.\r\n").into_bytes(), | |
| 662 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | async fn cmd_repo_set_description( | |
| 667 | + | user: &UserInfo, | |
| 668 | + | api: &MnwApiClient, | |
| 669 | + | name: &str, | |
| 670 | + | description: &str, | |
| 671 | + | ) -> Vec<u8> { | |
| 672 | + | if name.is_empty() { | |
| 673 | + | return b"Usage: repo set-description <name> <description>\r\n".to_vec(); | |
| 674 | + | } | |
| 675 | + | match api | |
| 676 | + | .repo_set_description(&user.user_id, name, description) | |
| 677 | + | .await | |
| 678 | + | { | |
| 679 | + | Ok(()) => format!("Updated description of '{name}'.\r\n").into_bytes(), | |
| 680 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 681 | + | } | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | async fn cmd_repo_delete(user: &UserInfo, api: &MnwApiClient, name: &str) -> Vec<u8> { | |
| 685 | + | if name.is_empty() { | |
| 686 | + | return b"Usage: repo delete <name> --confirm\r\n".to_vec(); | |
| 687 | + | } | |
| 688 | + | match api.repo_delete(&user.user_id, name).await { | |
| 689 | + | Ok(()) => format!("Deleted repository '{name}'.\r\n").into_bytes(), | |
| 690 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | async fn cmd_key_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 695 | + | match api.key_list(&user.user_id).await { | |
| 696 | + | Ok(keys) => { | |
| 697 | + | if json { | |
| 698 | + | return serde_json::to_vec_pretty(&keys).unwrap_or_default(); | |
| 699 | + | } | |
| 700 | + | if keys.is_empty() { | |
| 701 | + | return b"No SSH keys.\r\n".to_vec(); | |
| 702 | + | } | |
| 703 | + | let mut out = format!("{:<50} {:<20} {}\r\n", "Fingerprint", "Label", "Added"); | |
| 704 | + | out.push_str(&"-".repeat(80)); | |
| 705 | + | out.push_str("\r\n"); | |
| 706 | + | for k in &keys { | |
| 707 | + | let label = if k.label.is_empty() { | |
| 708 | + | "-" | |
| 709 | + | } else { | |
| 710 | + | truncate(&k.label, 20) | |
| 711 | + | }; | |
| 712 | + | // The shared endpoint returns RFC3339; the table wants a date. | |
| 713 | + | let added = k.created_at.get(..10).unwrap_or(&k.created_at); | |
| 714 | + | write!(out, "{:<50} {:<20} {}\r\n", k.fingerprint, label, added).unwrap(); | |
| 715 | + | } | |
| 716 | + | write!(out, "\r\n{} key(s).\r\n", keys.len()).unwrap(); | |
| 717 | + | out.into_bytes() | |
| 718 | + | } | |
| 719 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 720 | + | } | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | async fn cmd_key_remove(user: &UserInfo, api: &MnwApiClient, fingerprint: &str) -> Vec<u8> { | |
| 724 | + | if fingerprint.is_empty() { | |
| 725 | + | return b"Usage: key rm <fingerprint>\r\n".to_vec(); | |
| 726 | + | } | |
| 727 | + | match api.key_remove(&user.user_id, fingerprint).await { | |
| 728 | + | Ok(()) => format!("Removed SSH key '{fingerprint}'.\r\n").into_bytes(), | |
| 729 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | ||
| 539 | 733 | pub(crate) fn help_text() -> Vec<u8> { | |
| 540 | 734 | b"Usage: ssh cli.makenot.work <command>\r\n\ | |
| 541 | 735 | \r\n\ | |
| @@ -555,6 +749,13 @@ | |||
| 555 | 749 | \x20 domain verify Verify DNS record\r\n\ | |
| 556 | 750 | \x20 domain remove Remove custom domain\r\n\ | |
| 557 | 751 | \x20 upload [args] Pipe upload (see below)\r\n\ | |
| 752 | + | \x20 repo list List your git repositories\r\n\ | |
| 753 | + | \x20 repo info NAME Show one repository\r\n\ | |
| 754 | + | \x20 repo set-visibility NAME public|unlisted|private\r\n\ | |
| 755 | + | \x20 repo set-description NAME TEXT\r\n\ | |
| 756 | + | \x20 repo delete NAME --confirm Delete a repository\r\n\ | |
| 757 | + | \x20 key list List your SSH keys\r\n\ | |
| 758 | + | \x20 key rm FINGERPRINT Remove an SSH key\r\n\ | |
| 558 | 759 | \r\n\ | |
| 559 | 760 | Add --json to any command for machine-readable output.\r\n\ | |
| 560 | 761 | \r\n\ | |
| @@ -603,6 +804,26 @@ | |||
| 603 | 804 | None | |
| 604 | 805 | } | |
| 605 | 806 | ||
| 807 | + | /// Strip one layer of matching surrounding quotes. | |
| 808 | + | /// | |
| 809 | + | /// The command line is split on whitespace, so a quoted argument arrives as | |
| 810 | + | /// several parts and is rejoined by the caller, which puts the quote characters | |
| 811 | + | /// back into the middle of the value: `set-description r "A cool project"` | |
| 812 | + | /// would otherwise store the description with the quotes attached. The old | |
| 813 | + | /// sshd-side parser tokenized with quote awareness and this is what replaces | |
| 814 | + | /// that, at the one place where an argument is prose rather than an identifier. | |
| 815 | + | fn unquote(s: &str) -> &str { | |
| 816 | + | let bytes = s.as_bytes(); | |
| 817 | + | if bytes.len() >= 2 | |
| 818 | + | && (bytes[0] == b'"' || bytes[0] == b'\'') | |
| 819 | + | && bytes[bytes.len() - 1] == bytes[0] | |
| 820 | + | { | |
| 821 | + | &s[1..s.len() - 1] | |
| 822 | + | } else { | |
| 823 | + | s | |
| 824 | + | } | |
| 825 | + | } | |
| 826 | + | ||
| 606 | 827 | fn truncate(s: &str, max_len: usize) -> &str { | |
| 607 | 828 | if s.len() <= max_len { | |
| 608 | 829 | s | |
| @@ -615,6 +836,33 @@ | |||
| 615 | 836 | mod tests { | |
| 616 | 837 | use super::*; | |
| 617 | 838 | ||
| 839 | + | #[test] | |
| 840 | + | fn unquote_strips_matching_double_quotes() { | |
| 841 | + | assert_eq!(unquote("\"A cool project\""), "A cool project"); | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | #[test] | |
| 845 | + | fn unquote_strips_matching_single_quotes() { | |
| 846 | + | assert_eq!(unquote("'A cool project'"), "A cool project"); | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | #[test] | |
| 850 | + | fn unquote_leaves_unquoted_text_alone() { | |
| 851 | + | assert_eq!(unquote("A cool project"), "A cool project"); | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | #[test] | |
| 855 | + | fn unquote_leaves_mismatched_quotes_alone() { | |
| 856 | + | // A lone quote is part of the description, not a delimiter. | |
| 857 | + | assert_eq!(unquote("\"unterminated"), "\"unterminated"); | |
| 858 | + | assert_eq!(unquote("it's"), "it's"); | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | #[test] | |
| 862 | + | fn unquote_handles_the_empty_quoted_string() { | |
| 863 | + | assert_eq!(unquote("\"\""), ""); | |
| 864 | + | } | |
| 865 | + | ||
| 618 | 866 | #[test] | |
| 619 | 867 | fn truncate_short_string() { | |
| 620 | 868 | assert_eq!(truncate("hello", 10), "hello"); |
| @@ -77,11 +77,21 @@ | |||
| 77 | 77 | anyhow::bail!("account is deactivated"); | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | // Dispatch: git operations start with "git-", everything else is a management command | |
| 80 | + | // Only git transport is served here. The management verbs (repo list, | |
| 81 | + | // key rm, ...) moved to mnw-cli on 2026-07-31 and are reached through | |
| 82 | + | // cli.makenot.work, the SSH front door that is actually live. They used to | |
| 83 | + | // be implemented in this file and dispatched below, where nothing could | |
| 84 | + | // reach them: the git transport had already migrated to mnw-cli's russh | |
| 85 | + | // server and these did not follow. So `repo set-visibility` existed, worked | |
| 86 | + | // and was unreachable, which is how a repo came to be published with no | |
| 87 | + | // supported way to unpublish it. | |
| 88 | + | let _ = &ssh_username; | |
| 81 | 89 | if original_cmd.starts_with("git-") { | |
| 82 | 90 | exec_git_operation(pool, user_id, &original_cmd).await | |
| 83 | 91 | } else { | |
| 84 | - | exec_management_command(pool, user_id, &ssh_username, &original_cmd).await | |
| 92 | + | anyhow::bail!( | |
| 93 | + | "management commands have moved; run `ssh cli.makenot.work repo list` (or `help`)" | |
| 94 | + | ) | |
| 85 | 95 | } | |
| 86 | 96 | } | |
| 87 | 97 | ||
| @@ -281,306 +291,12 @@ | |||
| 281 | 291 | Ok(()) | |
| 282 | 292 | } | |
| 283 | 293 | ||
| 284 | - | // ── SSH management commands ── | |
| 285 | - | ||
| 286 | - | #[derive(Debug, PartialEq)] | |
| 287 | - | enum ManagementCommand { | |
| 288 | - | RepoList, | |
| 289 | - | RepoInfo { | |
| 290 | - | name: String, | |
| 291 | - | }, | |
| 292 | - | RepoDelete { | |
| 293 | - | name: String, | |
| 294 | - | }, | |
| 295 | - | RepoSetVisibility { | |
| 296 | - | name: String, | |
| 297 | - | visibility: db::Visibility, | |
| 298 | - | }, | |
| 299 | - | RepoSetDescription { | |
| 300 | - | name: String, | |
| 301 | - | description: String, | |
| 302 | - | }, | |
| 303 | - | KeyList, | |
| 304 | - | KeyRemove { | |
| 305 | - | fingerprint: String, | |
| 306 | - | }, | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | /// Split a command string on whitespace, respecting double-quoted segments. | |
| 310 | - | fn shell_tokenize(input: &str) -> Vec<String> { | |
| 311 | - | let mut tokens = Vec::new(); | |
| 312 | - | let mut current = String::new(); | |
| 313 | - | let mut in_quotes = false; | |
| 314 | - | ||
| 315 | - | for ch in input.chars() { | |
| 316 | - | if in_quotes { | |
| 317 | - | if ch == '"' { | |
| 318 | - | in_quotes = false; | |
| 319 | - | } else { | |
| 320 | - | current.push(ch); | |
| 321 | - | } | |
| 322 | - | } else if ch == '"' { | |
| 323 | - | in_quotes = true; | |
| 324 | - | } else if ch.is_ascii_whitespace() { | |
| 325 | - | if !current.is_empty() { | |
| 326 | - | tokens.push(std::mem::take(&mut current)); | |
| 327 | - | } | |
| 328 | - | } else { | |
| 329 | - | current.push(ch); | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | if !current.is_empty() { | |
| 334 | - | tokens.push(current); | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | tokens | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> { | |
| 341 | - | let strs: Vec<&str> = tokens.iter().map(std::string::String::as_str).collect(); | |
| 342 | - | ||
| 343 | - | match strs.as_slice() { | |
| 344 | - | ["repo", "list"] => Ok(ManagementCommand::RepoList), | |
| 345 | - | ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { | |
| 346 | - | name: name.to_string(), | |
| 347 | - | }), | |
| 348 | - | ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { | |
| 349 | - | name: name.to_string(), | |
| 350 | - | }), | |
| 351 | - | ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"), | |
| 352 | - | ["repo", "set-visibility", name, vis] => { | |
| 353 | - | let visibility: db::Visibility = vis | |
| 354 | - | .parse() | |
| 355 | - | .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?; | |
| 356 | - | Ok(ManagementCommand::RepoSetVisibility { | |
| 357 | - | name: name.to_string(), | |
| 358 | - | visibility, | |
| 359 | - | }) | |
| 360 | - | } | |
| 361 | - | ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription { | |
| 362 | - | name: name.to_string(), | |
| 363 | - | description: desc.to_string(), | |
| 364 | - | }), | |
| 365 | - | ["key", "list"] => Ok(ManagementCommand::KeyList), | |
| 366 | - | ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { | |
| 367 | - | fingerprint: fingerprint.to_string(), | |
| 368 | - | }), | |
| 369 | - | _ => anyhow::bail!( | |
| 370 | - | "unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm" | |
| 371 | - | ), | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | async fn exec_management_command( | |
| 376 | - | pool: &PgPool, | |
| 377 | - | user_id: UserId, | |
| 378 | - | username: &str, | |
| 379 | - | original_cmd: &str, | |
| 380 | - | ) -> anyhow::Result<()> { | |
| 381 | - | let tokens = shell_tokenize(original_cmd); | |
| 382 | - | let cmd = parse_management_command(&tokens)?; | |
| 383 | - | ||
| 384 | - | match cmd { | |
| 385 | - | ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await, | |
| 386 | - | ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await, | |
| 387 | - | ManagementCommand::RepoDelete { name } => { | |
| 388 | - | cmd_ssh_repo_delete(pool, user_id, username, &name).await | |
| 389 | - | } | |
| 390 | - | ManagementCommand::RepoSetVisibility { name, visibility } => { | |
| 391 | - | cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await | |
| 392 | - | } | |
| 393 | - | ManagementCommand::RepoSetDescription { name, description } => { | |
| 394 | - | cmd_ssh_repo_set_description(pool, user_id, &name, &description).await | |
| 395 | - | } | |
| 396 | - | ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await, | |
| 397 | - | ManagementCommand::KeyRemove { fingerprint } => { | |
| 398 | - | cmd_ssh_key_remove(pool, user_id, &fingerprint).await | |
| 399 | - | } | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | /// Render a value for a fixed-width table column: "-" if empty, ellipsized if | |
| 404 | - | /// wider than `max_width` (chars), otherwise the value unchanged. | |
| 405 | - | fn display_with_ellipsis(value: &str, max_width: usize) -> String { | |
| 406 | - | if value.is_empty() { | |
| 407 | - | "-".to_string() | |
| 408 | - | } else if value.chars().count() > max_width { | |
| 409 | - | let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect(); | |
| 410 | - | format!("{truncated}...") | |
| 411 | - | } else { | |
| 412 | - | value.to_string() | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> { | |
| 417 | - | let repos = db::git_repos::get_repos_by_user(pool, user_id).await?; | |
| 418 | - | ||
| 419 | - | if repos.is_empty() { | |
| 420 | - | println!("No repositories."); | |
| 421 | - | return Ok(()); | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | println!("{:<30} {:<10} Description", "Name", "Visibility"); | |
| 425 | - | println!("{}", "-".repeat(70)); | |
| 426 | - | ||
| 427 | - | for repo in &repos { | |
| 428 | - | let desc = display_with_ellipsis(&repo.description, 28); | |
| 429 | - | println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc); | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | println!("\n{} repo(s).", repos.len()); | |
| 433 | - | Ok(()) | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | async fn cmd_ssh_repo_info(pool: &PgPool, user_id: UserId, name: &str) -> anyhow::Result<()> { | |
| 437 | - | // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus | |
| 438 | - | // name and the delete path canonicalize-guards the FS path, but reject | |
| 439 | - | // traversal/control characters before building any path) (ultra-fuzz Sec M2). | |
| 440 | - | validate_git_repo_name(name)?; | |
| 441 | - | ||
| 442 | - | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 443 | - | .await? | |
| 444 | - | .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?; | |
| 445 | - | ||
| 446 | - | let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?; | |
| 447 | - | ||
| 448 | - | println!("Name: {}", repo.name); | |
| 449 | - | println!("Visibility: {}", repo.visibility); | |
| 450 | - | println!( | |
| 451 | - | "Description: {}", | |
| 452 | - | if repo.description.is_empty() { | |
| 453 | - | "-" | |
| 454 | - | } else { | |
| 455 | - | &repo.description | |
| 456 | - | } | |
| 457 | - | ); | |
| 458 | - | println!( | |
| 459 | - | "Created: {}", | |
| 460 | - | repo.created_at.format("%Y-%m-%d %H:%M UTC") | |
| 461 | - | ); | |
| 462 | - | println!("Issues: {open_issues} open, {closed_issues} closed"); | |
| 463 | - | ||
| 464 | - | Ok(()) | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | async fn cmd_ssh_repo_delete( | |
| 468 | - | pool: &PgPool, | |
| 469 | - | user_id: UserId, | |
| 470 | - | username: &str, | |
| 471 | - | name: &str, | |
| 472 | - | ) -> anyhow::Result<()> { | |
| 473 | - | // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus | |
| 474 | - | // name and the delete path canonicalize-guards the FS path, but reject | |
| 475 | - | // traversal/control characters before building any path) (ultra-fuzz Sec M2). | |
| 476 | - | validate_git_repo_name(name)?; | |
| 477 | - | ||
| 478 | - | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 479 | - | .await? | |
| 480 | - | .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?; | |
| 481 | - | ||
| 482 | - | db::git_repos::delete_repo(pool, repo.id).await?; | |
| 483 | - | ||
| 484 | - | let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()); | |
| 485 | - | let git_root_path = std::path::Path::new(&git_root); | |
| 486 | - | let repo_dir = git_root_path.join(username).join(format!("{name}.git")); | |
| 487 | - | ||
| 488 | - | if repo_dir.exists() { | |
| 489 | - | let canonical = repo_dir.canonicalize()?; | |
| 490 | - | let canonical_root = git_root_path.canonicalize()?; | |
| 491 | - | if !canonical.starts_with(&canonical_root) { | |
| 492 | - | anyhow::bail!("repo path escapes git root"); | |
| 493 | - | } | |
| 494 | - | std::fs::remove_dir_all(&canonical)?; | |
| 495 | - | } | |
| 496 | - | ||
| 497 | - | println!("Deleted repository '{name}'."); | |
| 498 | - | Ok(()) | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | async fn cmd_ssh_repo_set_visibility( | |
| 502 | - | pool: &PgPool, | |
| 503 | - | user_id: UserId, | |
| 504 | - | name: &str, | |
| 505 | - | visibility: db::Visibility, | |
| 506 | - | ) -> anyhow::Result<()> { | |
| 507 | - | // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus | |
| 508 | - | // name and the delete path canonicalize-guards the FS path, but reject | |
| 509 | - | // traversal/control characters before building any path) (ultra-fuzz Sec M2). | |
| 510 | - | validate_git_repo_name(name)?; | |
| 511 | - | ||
| 512 | - | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 513 | - | .await? | |
| 514 | - | .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?; | |
| 515 | - | ||
| 516 | - | db::git_repos::update_visibility(pool, repo.id, visibility).await?; | |
| 517 | - | ||
| 518 | - | println!("Set visibility of '{name}' to '{visibility}'."); | |
| 519 | - | Ok(()) | |
| 520 | - | } | |
| 521 | - | ||
| 522 | - | async fn cmd_ssh_repo_set_description( | |
| 523 | - | pool: &PgPool, | |
| 524 | - | user_id: UserId, | |
| 525 | - | name: &str, | |
| 526 | - | description: &str, | |
| 527 | - | ) -> anyhow::Result<()> { | |
| 528 | - | // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus | |
| 529 | - | // name and the delete path canonicalize-guards the FS path, but reject | |
| 530 | - | // traversal/control characters before building any path) (ultra-fuzz Sec M2). | |
| 531 | - | validate_git_repo_name(name)?; | |
| 532 | - | ||
| 533 | - | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 534 | - | .await? | |
| 535 | - | .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?; | |
| 536 | - | ||
| 537 | - | db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?; | |
| 538 | - | ||
| 539 | - | println!("Updated description of '{name}'."); | |
| 540 | - | Ok(()) | |
| 541 | - | } | |
| 542 | - | ||
| 543 | - | async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> { | |
| 544 | - | let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?; | |
| 545 | - | ||
| 546 | - | if keys.is_empty() { | |
| 547 | - | println!("No SSH keys."); | |
| 548 | - | return Ok(()); | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | println!("{:<50} {:<20} Added", "Fingerprint", "Label"); | |
| 552 | - | println!("{}", "-".repeat(80)); | |
| 553 | - | ||
| 554 | - | for key in &keys { | |
| 555 | - | let label = display_with_ellipsis(&key.label, 20); | |
| 556 | - | println!( | |
| 557 | - | "{:<50} {:<20} {}", | |
| 558 | - | key.fingerprint, | |
| 559 | - | label, | |
| 560 | - | key.created_at.format("%Y-%m-%d"), | |
| 561 | - | ); | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | println!("\n{} key(s).", keys.len()); | |
| 565 | - | Ok(()) | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | async fn cmd_ssh_key_remove( | |
| 569 | - | pool: &PgPool, | |
| 570 | - | user_id: UserId, | |
| 571 | - | fingerprint: &str, | |
| 572 | - | ) -> anyhow::Result<()> { | |
| 573 | - | let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?; | |
| 574 | - | ||
| 575 | - | if !deleted { | |
| 576 | - | anyhow::bail!("SSH key with fingerprint '{fingerprint}' not found"); | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | write_authorized_keys(pool, true).await?; | |
| 580 | - | ||
| 581 | - | println!("Removed SSH key '{fingerprint}'."); | |
| 582 | - | Ok(()) | |
| 583 | - | } | |
| 294 | + | // ── authorized_keys ── | |
| 295 | + | // | |
| 296 | + | // All that remains of the management half. mnw-cli authenticates from the | |
| 297 | + | // database rather than this file, so it is written for whatever still consults | |
| 298 | + | // sshd: a key removed from one door but not the other is a key the user | |
| 299 | + | // believes is gone. | |
| 584 | 300 | ||
| 585 | 301 | /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership. | |
| 586 | 302 | pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> { | |
| @@ -626,51 +342,6 @@ | |||
| 626 | 342 | mod tests { | |
| 627 | 343 | use super::*; | |
| 628 | 344 | ||
| 629 | - | // ── shell_tokenize ── | |
| 630 | - | ||
| 631 | - | #[test] | |
| 632 | - | fn tokenize_simple() { | |
| 633 | - | assert_eq!(shell_tokenize("repo list"), vec!["repo", "list"]); | |
| 634 | - | } | |
| 635 | - | ||
| 636 | - | #[test] | |
| 637 | - | fn tokenize_extra_whitespace() { | |
| 638 | - | assert_eq!( | |
| 639 | - | shell_tokenize(" repo info myrepo "), | |
| 640 | - | vec!["repo", "info", "myrepo"], | |
| 641 | - | ); | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | #[test] | |
| 645 | - | fn tokenize_quoted_string() { | |
| 646 | - | assert_eq!( | |
| 647 | - | shell_tokenize(r#"repo set-description myrepo "A cool project""#), | |
| 648 | - | vec!["repo", "set-description", "myrepo", "A cool project"], | |
| 649 | - | ); | |
| 650 | - | } | |
| 651 | - | ||
| 652 | - | #[test] | |
| 653 | - | fn tokenize_empty_quotes() { | |
| 654 | - | assert_eq!( | |
| 655 | - | shell_tokenize(r#"repo set-description myrepo """#), | |
| 656 | - | vec!["repo", "set-description", "myrepo"], | |
| 657 | - | ); | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | #[test] | |
| 661 | - | fn tokenize_unterminated_quote() { | |
| 662 | - | assert_eq!( | |
| 663 | - | shell_tokenize(r#"repo set-description myrepo "unterminated"#), | |
| 664 | - | vec!["repo", "set-description", "myrepo", "unterminated"], | |
| 665 | - | ); | |
| 666 | - | } | |
| 667 | - | ||
| 668 | - | #[test] | |
| 669 | - | fn tokenize_empty_input() { | |
| 670 | - | assert!(shell_tokenize("").is_empty()); | |
| 671 | - | assert!(shell_tokenize(" ").is_empty()); | |
| 672 | - | } | |
| 673 | - | ||
| 674 | 345 | // ── parse_ssh_command ── | |
| 675 | 346 | ||
| 676 | 347 | #[test] | |
| @@ -753,123 +424,4 @@ | |||
| 753 | 424 | fn parse_repo_path_bare_git_suffix_only() { | |
| 754 | 425 | assert!(parse_repo_path("/owner/.git").is_err()); | |
| 755 | 426 | } | |
| 756 | - | ||
| 757 | - | // ── parse_management_command ── | |
| 758 | - | ||
| 759 | - | #[test] | |
| 760 | - | fn parse_repo_list() { | |
| 761 | - | let tokens: Vec<String> = vec!["repo".into(), "list".into()]; | |
| 762 | - | assert_eq!( | |
| 763 | - | parse_management_command(&tokens).unwrap(), | |
| 764 | - | ManagementCommand::RepoList | |
| 765 | - | ); | |
| 766 | - | } | |
| 767 | - | ||
| 768 | - | #[test] | |
| 769 | - | fn parse_repo_info() { | |
| 770 | - | let tokens: Vec<String> = vec!["repo".into(), "info".into(), "docengine".into()]; | |
| 771 | - | assert_eq!( | |
| 772 | - | parse_management_command(&tokens).unwrap(), | |
| 773 | - | ManagementCommand::RepoInfo { | |
| 774 | - | name: "docengine".into() | |
| 775 | - | }, | |
| 776 | - | ); | |
| 777 | - | } | |
| 778 | - | ||
| 779 | - | #[test] | |
| 780 | - | fn parse_repo_delete_with_confirm() { | |
| 781 | - | let tokens: Vec<String> = vec![ | |
| 782 | - | "repo".into(), | |
| 783 | - | "delete".into(), | |
| 784 | - | "old".into(), | |
| 785 | - | "--confirm".into(), | |
| 786 | - | ]; | |
| 787 | - | assert_eq!( | |
| 788 | - | parse_management_command(&tokens).unwrap(), | |
| 789 | - | ManagementCommand::RepoDelete { name: "old".into() }, | |
| 790 | - | ); | |
| 791 | - | } | |
| 792 | - | ||
| 793 | - | #[test] | |
| 794 | - | fn parse_repo_delete_without_confirm_fails() { | |
| 795 | - | let tokens: Vec<String> = vec!["repo".into(), "delete".into(), "old".into()]; | |
| 796 | - | assert!(parse_management_command(&tokens).is_err()); | |
| 797 | - | } | |
| 798 | - | ||
| 799 | - | #[test] | |
| 800 | - | fn parse_repo_set_visibility() { | |
| 801 | - | let tokens: Vec<String> = vec![ | |
| 802 | - | "repo".into(), | |
| 803 | - | "set-visibility".into(), | |
| 804 | - | "myrepo".into(), | |
| 805 | - | "private".into(), | |
| 806 | - | ]; | |
| 807 | - | assert_eq!( | |
| 808 | - | parse_management_command(&tokens).unwrap(), | |
| 809 | - | ManagementCommand::RepoSetVisibility { | |
| 810 | - | name: "myrepo".into(), | |
| 811 | - | visibility: db::Visibility::Private | |
| 812 | - | }, | |
| 813 | - | ); | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | #[test] | |
| 817 | - | fn parse_repo_set_visibility_invalid() { | |
| 818 | - | let tokens: Vec<String> = vec![ | |
| 819 | - | "repo".into(), | |
| 820 | - | "set-visibility".into(), | |
| 821 | - | "myrepo".into(), | |
| 822 | - | "secret".into(), | |
| 823 | - | ]; | |
| 824 | - | assert!(parse_management_command(&tokens).is_err()); | |
| 825 | - | } | |
| 826 | - | ||
| 827 | - | #[test] | |
| 828 | - | fn parse_repo_set_description() { | |
| 829 | - | let tokens: Vec<String> = vec![ | |
| 830 | - | "repo".into(), | |
| 831 | - | "set-description".into(), | |
| 832 | - | "myrepo".into(), | |
| 833 | - | "A new description".into(), | |
| 834 | - | ]; | |
| 835 | - | assert_eq!( | |
| 836 | - | parse_management_command(&tokens).unwrap(), | |
| 837 | - | ManagementCommand::RepoSetDescription { | |
| 838 | - | name: "myrepo".into(), | |
| 839 | - | description: "A new description".into() | |
| 840 | - | }, | |
| 841 | - | ); | |
| 842 | - | } | |
| 843 | - | ||
| 844 | - | #[test] | |
| 845 | - | fn parse_key_list() { | |
| 846 | - | let tokens: Vec<String> = vec!["key".into(), "list".into()]; | |
| 847 | - | assert_eq!( | |
| 848 | - | parse_management_command(&tokens).unwrap(), | |
| 849 | - | ManagementCommand::KeyList | |
| 850 | - | ); | |
| 851 | - | } | |
| 852 | - | ||
| 853 | - | #[test] | |
| 854 | - | fn parse_key_rm() { | |
| 855 | - | let tokens: Vec<String> = vec!["key".into(), "rm".into(), "SHA256:abc123".into()]; | |
| 856 | - | assert_eq!( | |
| 857 | - | parse_management_command(&tokens).unwrap(), | |
| 858 | - | ManagementCommand::KeyRemove { | |
| 859 | - | fingerprint: "SHA256:abc123".into() | |
| 860 | - | }, | |
| 861 | - | ); | |
| 862 | - | } | |
| 863 | - | ||
| 864 | - | #[test] | |
| 865 | - | fn parse_invalid_command() { | |
| 866 | - | let tokens: Vec<String> = vec!["frobnicate".into()]; |
Lines truncated
| @@ -615,10 +615,18 @@ | |||
| 615 | 615 | make_test_repo(tmp.path()); | |
| 616 | 616 | let mut h = setup_git_harness(&tmp).await; | |
| 617 | 617 | ||
| 618 | - | // Visit repo to auto-register it (default visibility is public) | |
| 618 | + | // Visit repo to auto-register it. Auto-registration lands private since | |
| 619 | + | // migration 182, so publish it explicitly: what this test is about is the | |
| 620 | + | // explore page listing a public repo, not what the default happens to be. | |
| 621 | + | // Its sibling below covers the private case. | |
| 619 | 622 | let resp = h.client.get("/git/testowner/testrepo").await; | |
| 620 | 623 | assert!(resp.status.is_success()); | |
| 621 | 624 | ||
| 625 | + | sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'") | |
| 626 | + | .execute(&h.db) | |
| 627 | + | .await | |
| 628 | + | .unwrap(); | |
| 629 | + | ||
| 622 | 630 | let resp = h.client.get("/git").await; | |
| 623 | 631 | assert_eq!(resp.status, 200); | |
| 624 | 632 | assert!(resp.text.contains("testowner"), "Should show owner name"); |
| @@ -1043,6 +1043,15 @@ | |||
| 1043 | 1043 | make_test_repo(tmp.path()); | |
| 1044 | 1044 | let mut h = setup(&tmp).await; | |
| 1045 | 1045 | ||
| 1046 | + | // Publish it first. This test is about authorization -- the repo exists and | |
| 1047 | + | // is visible, and you still may not open its settings -- so it needs a repo | |
| 1048 | + | // the other user can see. Left private (the default since migration 182) | |
| 1049 | + | // the answer is 404, which is the right answer to a different question. | |
| 1050 | + | sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'") | |
| 1051 | + | .execute(&h.db) | |
| 1052 | + | .await | |
| 1053 | + | .unwrap(); | |
| 1054 | + | ||
| 1046 | 1055 | h.client.post_form("/logout", "").await; | |
| 1047 | 1056 | h.signup("otheruser", "other@example.com", "password123") | |
| 1048 | 1057 | .await; |
| @@ -1,7 +1,9 @@ | |||
| 1 | - | //! SSH management command integration tests. | |
| 1 | + | //! Repo and SSH key management integration tests. | |
| 2 | 2 | //! | |
| 3 | - | //! Tests repo CRUD (via DB functions used by the SSH handlers), | |
| 4 | - | //! SSH key delete-by-fingerprint, and issue counts after repo operations. | |
| 3 | + | //! Tests repo CRUD, SSH key delete-by-fingerprint, and issue counts after repo | |
| 4 | + | //! operations, at the DB layer the management commands sit on. Those commands | |
| 5 | + | //! moved from the sshd path (`git_ssh.rs`) to mnw-cli on 2026-07-31; this layer | |
| 6 | + | //! is shared by both and did not move. | |
| 5 | 7 | ||
| 6 | 8 | use crate::harness::TestHarness; | |
| 7 | 9 | use makenotwork::db; | |
| @@ -27,7 +29,9 @@ | |||
| 27 | 29 | .await | |
| 28 | 30 | .unwrap(); | |
| 29 | 31 | assert_eq!(repo.name, "myproject"); | |
| 30 | - | assert_eq!(repo.visibility, "public"); | |
| 32 | + | // Private by default since migration 182: creating a repo and publishing it | |
| 33 | + | // are two acts, and push-create only does the first. | |
| 34 | + | assert_eq!(repo.visibility, db::Visibility::Private); | |
| 31 | 35 | ||
| 32 | 36 | // List should show 1 repo | |
| 33 | 37 | let repos = db::git_repos::get_repos_by_user(&h.db, user.id) | |
| @@ -53,9 +57,11 @@ | |||
| 53 | 57 | let repo = db::git_repos::create_repo(&h.db, user.id, "secret") | |
| 54 | 58 | .await | |
| 55 | 59 | .unwrap(); | |
| 56 | - | assert_eq!(repo.visibility, db::Visibility::Public); | |
| 60 | + | assert_eq!(repo.visibility, db::Visibility::Private); | |
| 57 | 61 | ||
| 58 | - | db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Private) | |
| 62 | + | // The interesting direction now: private is where a repo starts, so | |
| 63 | + | // publishing is the transition worth asserting. | |
| 64 | + | db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Public) | |
| 59 | 65 | .await | |
| 60 | 66 | .unwrap(); | |
| 61 | 67 | ||
| @@ -63,7 +69,7 @@ | |||
| 63 | 69 | .await | |
| 64 | 70 | .unwrap() | |
| 65 | 71 | .unwrap(); | |
| 66 | - | assert_eq!(updated.visibility, db::Visibility::Private); | |
| 72 | + | assert_eq!(updated.visibility, db::Visibility::Public); | |
| 67 | 73 | ||
| 68 | 74 | // Also test unlisted | |
| 69 | 75 | db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Unlisted) | |
| @@ -234,3 +240,196 @@ | |||
| 234 | 240 | .unwrap(); | |
| 235 | 241 | assert!(!deleted); | |
| 236 | 242 | } | |
| 243 | + | ||
| 244 | + | // ── The CLI's management endpoints ── | |
| 245 | + | // | |
| 246 | + | // The verbs above are exercised at the DB layer. These cover the HTTP surface | |
| 247 | + | // mnw-cli actually calls, which is the half that was missing: the old | |
| 248 | + | // implementation was reachable only through an sshd path the live front door | |
| 249 | + | // does not use, so `repo set-visibility` worked and could not be run. | |
| 250 | + | ||
| 251 | + | /// A harness with the internal API configured, as mnw-cli talks to it. | |
| 252 | + | /// | |
| 253 | + | /// `TestHarness::new` leaves `cli_service_token` unset, and without it every | |
| 254 | + | /// internal route answers 503 "Internal API not configured" rather than | |
| 255 | + | /// exercising the handler. | |
| 256 | + | async fn cli_harness() -> TestHarness { | |
| 257 | + | TestHarness::build(crate::harness::BuildOptions { | |
| 258 | + | cli_service_token: Some("test-cli-token".to_string()), | |
| 259 | + | git_repos_path: Some( | |
| 260 | + | tempfile::TempDir::new() | |
| 261 | + | .unwrap() | |
| 262 | + | .keep() | |
| 263 | + | .to_string_lossy() | |
| 264 | + | .into_owned(), | |
| 265 | + | ), | |
| 266 | + | ..Default::default() | |
| 267 | + | }) | |
| 268 | + | .await | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// Authenticate the test client as `user_id` for the internal API. | |
| 272 | + | async fn as_cli_actor(h: &mut TestHarness, user_id: db::UserId) { | |
| 273 | + | h.client.set_bearer_token("test-cli-token"); | |
| 274 | + | let actor = makenotwork::crypto::mint_internal_actor_token( | |
| 275 | + | user_id, | |
| 276 | + | chrono::Utc::now().timestamp() + 3600, | |
| 277 | + | "test-signing-secret-for-integration-tests", | |
| 278 | + | ); | |
| 279 | + | h.client.set_actor_token(&actor); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[tokio::test] | |
| 283 | + | async fn cli_repo_list_and_info_over_http() { | |
| 284 | + | let mut h = cli_harness().await; | |
| 285 | + | h.signup("carol", "carol@example.com", "password123").await; | |
| 286 | + | let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into())) | |
| 287 | + | .await | |
| 288 | + | .unwrap() | |
| 289 | + | .unwrap(); | |
| 290 | + | db::git_repos::create_repo(&h.db, user.id, "myproject") | |
| 291 | + | .await | |
| 292 | + | .unwrap(); | |
| 293 | + | ||
| 294 | + | as_cli_actor(&mut h, user.id).await; | |
| 295 | + | ||
| 296 | + | let resp = h.client.get("/api/internal/creator/repos").await; | |
| 297 | + | assert!(resp.status.is_success(), "repo list failed: {}", resp.text); | |
| 298 | + | assert!(resp.text.contains("myproject")); | |
| 299 | + | // Private is what push-create now produces, so the CLI must report it. | |
| 300 | + | assert!(resp.text.contains("private")); | |
| 301 | + | ||
| 302 | + | let resp = h.client.get("/api/internal/creator/repos/myproject").await; | |
| 303 | + | assert!(resp.status.is_success(), "repo info failed: {}", resp.text); | |
| 304 | + | assert!(resp.text.contains("open_issues")); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[tokio::test] | |
| 308 | + | async fn cli_repo_set_visibility_over_http() { | |
| 309 | + | let mut h = cli_harness().await; | |
| 310 | + | h.signup("dave", "dave@example.com", "password123").await; | |
| 311 | + | let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into())) | |
| 312 | + | .await | |
| 313 | + | .unwrap() | |
| 314 | + | .unwrap(); | |
| 315 | + | let repo = db::git_repos::create_repo(&h.db, user.id, "toolate") | |
| 316 | + | .await | |
| 317 | + | .unwrap(); | |
| 318 | + | ||
| 319 | + | as_cli_actor(&mut h, user.id).await; | |
| 320 | + | ||
| 321 | + | // The whole point of the port: publishing, and unpublishing again, without | |
| 322 | + | // touching the web UI. | |
| 323 | + | let resp = h | |
| 324 | + | .client | |
| 325 | + | .put_json( | |
| 326 | + | "/api/internal/creator/repos/toolate/visibility", | |
| 327 | + | r#"{"visibility":"public"}"#, | |
| 328 | + | ) | |
| 329 | + | .await; | |
| 330 | + | assert!(resp.status.is_success(), "set public failed: {}", resp.text); | |
| 331 | + | let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) | |
| 332 | + | .await | |
| 333 | + | .unwrap() | |
| 334 | + | .unwrap(); | |
| 335 | + | assert_eq!(updated.visibility, db::Visibility::Public); | |
| 336 | + | ||
| 337 | + | let resp = h | |
| 338 | + | .client | |
| 339 | + | .put_json( | |
| 340 | + | "/api/internal/creator/repos/toolate/visibility", | |
| 341 | + | r#"{"visibility":"private"}"#, | |
| 342 | + | ) | |
| 343 | + | .await; | |
| 344 | + | assert!( | |
| 345 | + | resp.status.is_success(), | |
| 346 | + | "set private failed: {}", | |
| 347 | + | resp.text | |
| 348 | + | ); | |
| 349 | + | let updated = db::git_repos::get_repo_by_id(&h.db, repo.id) | |
| 350 | + | .await | |
| 351 | + | .unwrap() | |
| 352 | + | .unwrap(); | |
| 353 | + | assert_eq!(updated.visibility, db::Visibility::Private); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[tokio::test] | |
| 357 | + | async fn cli_repo_endpoints_do_not_reach_another_users_repo() { | |
| 358 | + | let mut h = cli_harness().await; | |
| 359 | + | h.signup("erin", "erin@example.com", "password123").await; | |
| 360 | + | let owner = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("erin".into())) | |
| 361 | + | .await | |
| 362 | + | .unwrap() | |
| 363 | + | .unwrap(); | |
| 364 | + | let repo = db::git_repos::create_repo(&h.db, owner.id, "private-thing") | |
| 365 | + | .await | |
| 366 | + | .unwrap(); | |
| 367 | + | ||
| 368 | + | h.client.post_form("/logout", "").await; | |
| 369 | + | h.signup("frank", "frank@example.com", "password123").await; | |
| 370 | + | let other = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("frank".into())) | |
| 371 | + | .await | |
| 372 | + | .unwrap() | |
| 373 | + | .unwrap(); | |
| 374 | + | ||
| 375 | + | // Every verb keys on (actor, name), so another user's repo is simply not | |
| 376 | + | // found rather than found-and-refused. The delete case is the one that | |
| 377 | + | // matters: it removes a directory tree. | |
| 378 | + | as_cli_actor(&mut h, other.id).await; | |
| 379 | + | ||
| 380 | + | let resp = h | |
| 381 | + | .client | |
| 382 | + | .get("/api/internal/creator/repos/private-thing") | |
| 383 | + | .await; | |
| 384 | + | assert_eq!(resp.status, 404, "info leaked another user's repo"); | |
| 385 | + | ||
| 386 | + | let resp = h | |
| 387 | + | .client | |
| 388 | + | .put_json( | |
| 389 | + | "/api/internal/creator/repos/private-thing/visibility", | |
| 390 | + | r#"{"visibility":"public"}"#, | |
| 391 | + | ) | |
| 392 | + | .await; | |
| 393 | + | assert_eq!( | |
| 394 | + | resp.status, 404, | |
| 395 | + | "set-visibility reached another user's repo" | |
| 396 | + | ); | |
| 397 | + | ||
| 398 | + | let resp = h | |
| 399 | + | .client | |
| 400 | + | .delete("/api/internal/creator/repos/private-thing") | |
| 401 | + | .await; | |
| 402 | + | assert_eq!(resp.status, 404, "delete reached another user's repo"); | |
| 403 | + | ||
| 404 | + | assert!( | |
| 405 | + | db::git_repos::get_repo_by_id(&h.db, repo.id) | |
| 406 | + | .await | |
| 407 | + | .unwrap() | |
| 408 | + | .is_some(), | |
| 409 | + | "the repo survived none of that" | |
| 410 | + | ); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | #[tokio::test] | |
| 414 | + | async fn cli_repo_name_traversal_is_rejected() { | |
| 415 | + | let mut h = cli_harness().await; | |
| 416 | + | h.signup("grace", "grace@example.com", "password123").await; | |
| 417 | + | let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into())) | |
| 418 | + | .await | |
| 419 | + | .unwrap() | |
| 420 | + | .unwrap(); | |
| 421 | + | ||
| 422 | + | as_cli_actor(&mut h, user.id).await; | |
| 423 | + | ||
| 424 | + | // Delete builds a filesystem path from this string, so the validation runs | |
| 425 | + | // before the lookup rather than after. | |
| 426 | + | let resp = h | |
| 427 | + | .client | |
| 428 | + | .delete("/api/internal/creator/repos/..%2F..%2Fetc") | |
| 429 | + | .await; | |
| 430 | + | assert!( | |
| 431 | + | resp.status == 404 || resp.status == 400, | |
| 432 | + | "traversal answered {}", | |
| 433 | + | resp.status | |
| 434 | + | ); | |
| 435 | + | } |
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | mod creators; | |
| 10 | 10 | mod git; | |
| 11 | 11 | mod items; | |
| 12 | + | mod repos; | |
| 12 | 13 | mod synckit; | |
| 13 | 14 | mod uploads; | |
| 14 | 15 | ||
| @@ -46,6 +47,29 @@ | |||
| 46 | 47 | get(creators::creator_project_items), | |
| 47 | 48 | ) | |
| 48 | 49 | .route_get("/api/internal/creator/stats", get(creators::creator_stats)) | |
| 50 | + | // Git repo + SSH key management for the CLI. Keyed by repo NAME, not | |
| 51 | + | // id: the caller is a person at a terminal who has the name and would | |
| 52 | + | // otherwise have to look a UUID up first. See internal/repos.rs. | |
| 53 | + | .route_get("/api/internal/creator/repos", get(repos::repo_list)) | |
| 54 | + | .route( | |
| 55 | + | "/api/internal/creator/repos/{name}", | |
| 56 | + | with_csrf_skip( | |
| 57 | + | INTERNAL_SKIP, | |
| 58 | + | get(repos::repo_info).delete(repos::repo_delete), | |
| 59 | + | ), | |
| 60 | + | ) | |
| 61 | + | .route( | |
| 62 | + | "/api/internal/creator/repos/{name}/visibility", | |
| 63 | + | put_csrf_skip(INTERNAL_SKIP, repos::repo_set_visibility), | |
| 64 | + | ) | |
| 65 | + | .route( | |
| 66 | + | "/api/internal/creator/repos/{name}/description", | |
| 67 | + | put_csrf_skip(INTERNAL_SKIP, repos::repo_set_description), | |
| 68 | + | ) | |
| 69 | + | .route( | |
| 70 | + | "/api/internal/creator/ssh-keys/{fingerprint}", | |
| 71 | + | delete_csrf_skip(INTERNAL_SKIP, repos::key_remove), | |
| 72 | + | ) | |
| 49 | 73 | .route( | |
| 50 | 74 | "/api/internal/creator/items", | |
| 51 | 75 | post_csrf_skip(INTERNAL_SKIP, items::create_item), |
| @@ -1,0 +1,17 @@ | |||
| 1 | + | -- Push-created repositories start private. | |
| 2 | + | -- | |
| 3 | + | -- Migration 023 set this column's default to 'public', so the first push to a | |
| 4 | + | -- name that did not exist yet both created the repository and published it, | |
| 5 | + | -- listed on the /git explore page, in one action that git itself reports as an | |
| 6 | + | -- ordinary push. There was no confirmation and, until the CLI gained `repo | |
| 7 | + | -- set-visibility`, no supported way back: the management verbs lived behind an | |
| 8 | + | -- sshd path the live SSH front door does not use. | |
| 9 | + | -- | |
| 10 | + | -- Publishing should be something a person does on purpose. Creating a repo and | |
| 11 | + | -- publishing it are now two acts rather than one, and the second is | |
| 12 | + | -- `repo set-visibility <name> public`. | |
| 13 | + | -- | |
| 14 | + | -- Existing rows are deliberately untouched. A column default applies only to | |
| 15 | + | -- inserts, so every repository already published stays published; flipping | |
| 16 | + | -- those would silently unpublish work people have linked to. | |
| 17 | + | ALTER TABLE git_repos ALTER COLUMN visibility SET DEFAULT 'private'; |
| @@ -1,0 +1,234 @@ | |||
| 1 | + | //! Internal git repository and SSH key management for the CLI. | |
| 2 | + | //! | |
| 3 | + | //! These verbs used to live only in [`crate::git_ssh`], reachable through | |
| 4 | + | //! sshd's `command=` prefix in `authorized_keys` calling `mnw-admin git-auth`. | |
| 5 | + | //! The git transport moved to mnw-cli's russh server and the management | |
| 6 | + | //! commands did not come with it, so `ssh cli.makenot.work repo list` answered | |
| 7 | + | //! "Unknown command: repo" while the implementation sat in the tree, working, | |
| 8 | + | //! behind a door nothing knocked on. That gap is how an accidental push | |
| 9 | + | //! published a repo with no supported way to unpublish it. | |
| 10 | + | //! | |
| 11 | + | //! Repos are addressed by NAME here rather than by id. The browser API keys on | |
| 12 | + | //! `GitRepoId` because a page has already loaded the row it is acting on; a | |
| 13 | + | //! person typing into a terminal has the name and nothing else, and making them | |
| 14 | + | //! look a UUID up first would be the kind of friction that sends them back to | |
| 15 | + | //! the web UI. | |
| 16 | + | ||
| 17 | + | use axum::{ | |
| 18 | + | Json, | |
| 19 | + | extract::{Path, State}, | |
| 20 | + | response::IntoResponse, | |
| 21 | + | }; | |
| 22 | + | use serde::{Deserialize, Serialize}; | |
| 23 | + | use sqlx::PgPool; | |
| 24 | + | ||
| 25 | + | use crate::{ | |
| 26 | + | auth::{InternalActor, ServiceAuth}, | |
| 27 | + | db::{self, UserId, Visibility}, | |
| 28 | + | error::{AppError, Result}, | |
| 29 | + | validation::validate_git_repo_name, | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | // ── Wire types ── | |
| 33 | + | ||
| 34 | + | /// One repository, as the CLI renders it in `repo list`. | |
| 35 | + | #[derive(Serialize)] | |
| 36 | + | pub(super) struct CliRepo { | |
| 37 | + | name: String, | |
| 38 | + | visibility: Visibility, | |
| 39 | + | description: String, | |
| 40 | + | created_at: String, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// A single repository plus the counts `repo info` prints. | |
| 44 | + | #[derive(Serialize)] | |
| 45 | + | pub(super) struct CliRepoInfo { | |
| 46 | + | name: String, | |
| 47 | + | visibility: Visibility, | |
| 48 | + | description: String, | |
| 49 | + | created_at: String, | |
| 50 | + | open_issues: i64, | |
| 51 | + | closed_issues: i64, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Deserialize)] | |
| 55 | + | pub(super) struct SetVisibilityRequest { | |
| 56 | + | pub visibility: Visibility, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[derive(Deserialize)] | |
| 60 | + | pub(super) struct SetDescriptionRequest { | |
| 61 | + | pub description: String, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Resolve a repo by name for the acting user, or 404. | |
| 65 | + | /// | |
| 66 | + | /// The name is validated before the lookup rather than after. The query would | |
| 67 | + | /// simply miss on a bogus name, but `repo delete` builds a filesystem path from | |
| 68 | + | /// this same string, and rejecting traversal and control characters at the one | |
| 69 | + | /// place every verb passes through is cheaper than trusting each of them to | |
| 70 | + | /// remember (ultra-fuzz Sec M2). | |
| 71 | + | async fn resolve_repo(db: &PgPool, user_id: UserId, name: &str) -> Result<db::DbGitRepo> { | |
| 72 | + | validate_git_repo_name(name).map_err(|_| AppError::NotFound)?; | |
| 73 | + | db::git_repos::get_repo_by_user_and_name(db, user_id, name) | |
| 74 | + | .await? | |
| 75 | + | .ok_or(AppError::NotFound) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ── Repositories ── | |
| 79 | + | ||
| 80 | + | /// GET /api/internal/creator/repos | |
| 81 | + | #[tracing::instrument(skip_all, name = "internal::cli_repo_list")] | |
| 82 | + | pub(super) async fn repo_list( | |
| 83 | + | State(db): State<PgPool>, | |
| 84 | + | actor: InternalActor, | |
| 85 | + | _auth: ServiceAuth, | |
| 86 | + | ) -> Result<impl IntoResponse> { | |
| 87 | + | let repos = db::git_repos::get_repos_by_user(&db, actor.user_id()).await?; | |
| 88 | + | let data: Vec<CliRepo> = repos | |
| 89 | + | .into_iter() | |
| 90 | + | .map(|r| CliRepo { | |
| 91 | + | name: r.name, | |
| 92 | + | visibility: r.visibility, | |
| 93 | + | description: r.description, | |
| 94 | + | created_at: r.created_at.format("%Y-%m-%d").to_string(), | |
| 95 | + | }) | |
| 96 | + | .collect(); | |
| 97 | + | Ok(Json(data)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// GET /api/internal/creator/repos/{name} | |
| 101 | + | #[tracing::instrument(skip_all, name = "internal::cli_repo_info")] | |
| 102 | + | pub(super) async fn repo_info( | |
| 103 | + | State(db): State<PgPool>, | |
| 104 | + | actor: InternalActor, | |
| 105 | + | _auth: ServiceAuth, | |
| 106 | + | Path(name): Path<String>, | |
| 107 | + | ) -> Result<impl IntoResponse> { | |
| 108 | + | let repo = resolve_repo(&db, actor.user_id(), &name).await?; | |
| 109 | + | let (open_issues, closed_issues) = db::issues::get_issue_counts(&db, repo.id).await?; | |
| 110 | + | ||
| 111 | + | Ok(Json(CliRepoInfo { | |
| 112 | + | name: repo.name, | |
| 113 | + | visibility: repo.visibility, | |
| 114 | + | description: repo.description, | |
| 115 | + | created_at: repo.created_at.format("%Y-%m-%d %H:%M UTC").to_string(), | |
| 116 | + | open_issues, | |
| 117 | + | closed_issues, | |
| 118 | + | })) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// PUT /api/internal/creator/repos/{name}/visibility | |
| 122 | + | #[tracing::instrument(skip_all, name = "internal::cli_repo_set_visibility")] | |
| 123 | + | pub(super) async fn repo_set_visibility( | |
| 124 | + | State(db): State<PgPool>, | |
| 125 | + | actor: InternalActor, | |
| 126 | + | _auth: ServiceAuth, | |
| 127 | + | Path(name): Path<String>, | |
| 128 | + | Json(req): Json<SetVisibilityRequest>, | |
| 129 | + | ) -> Result<impl IntoResponse> { | |
| 130 | + | let repo = resolve_repo(&db, actor.user_id(), &name).await?; | |
| 131 | + | db::git_repos::update_visibility(&db, repo.id, req.visibility).await?; | |
| 132 | + | Ok(Json(serde_json::json!({ "visibility": req.visibility }))) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// PUT /api/internal/creator/repos/{name}/description | |
| 136 | + | #[tracing::instrument(skip_all, name = "internal::cli_repo_set_description")] | |
| 137 | + | pub(super) async fn repo_set_description( | |
| 138 | + | State(db): State<PgPool>, | |
| 139 | + | actor: InternalActor, | |
| 140 | + | _auth: ServiceAuth, | |
| 141 | + | Path(name): Path<String>, | |
| 142 | + | Json(req): Json<SetDescriptionRequest>, | |
| 143 | + | ) -> Result<impl IntoResponse> { | |
| 144 | + | let repo = resolve_repo(&db, actor.user_id(), &name).await?; | |
| 145 | + | db::git_repos::update_repo_settings(&db, repo.id, &req.description, repo.visibility).await?; | |
| 146 | + | Ok(Json(serde_json::json!({ "description": req.description }))) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// DELETE /api/internal/creator/repos/{name} | |
| 150 | + | /// | |
| 151 | + | /// Drops the row and then the bare repo on disk. The row goes first: a failure | |
| 152 | + | /// to remove the directory leaves an orphaned directory, which is recoverable, | |
| 153 | + | /// where the other order could leave a row pointing at nothing, which reads as | |
| 154 | + | /// a repo that exists and cannot be served. | |
| 155 | + | #[tracing::instrument(skip_all, name = "internal::cli_repo_delete")] | |
| 156 | + | pub(super) async fn repo_delete( | |
| 157 | + | State(db): State<PgPool>, | |
| 158 | + | actor: InternalActor, | |
| 159 | + | _auth: ServiceAuth, | |
| 160 | + | Path(name): Path<String>, | |
| 161 | + | ) -> Result<impl IntoResponse> { | |
| 162 | + | let repo = resolve_repo(&db, actor.user_id(), &name).await?; | |
| 163 | + | let username = db::users::get_user_by_id(&db, actor.user_id()) | |
| 164 | + | .await? | |
| 165 | + | .ok_or(AppError::NotFound)? | |
| 166 | + | .username; | |
| 167 | + | ||
| 168 | + | db::git_repos::delete_repo(&db, repo.id).await?; | |
| 169 | + | ||
| 170 | + | let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()); | |
| 171 | + | let git_root_path = std::path::Path::new(&git_root); | |
| 172 | + | let repo_dir = git_root_path | |
| 173 | + | .join(username.as_str()) | |
| 174 | + | .join(format!("{name}.git")); | |
| 175 | + | ||
| 176 | + | if repo_dir.exists() { | |
| 177 | + | // Canonicalize both sides and re-check containment. `name` is already | |
| 178 | + | // validated, so this is belt and braces, but the operation is a | |
| 179 | + | // recursive delete and the cost of being wrong is unbounded. | |
| 180 | + | let canonical = repo_dir | |
| 181 | + | .canonicalize() | |
| 182 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize repo path: {e}")))?; | |
| 183 | + | let canonical_root = git_root_path | |
| 184 | + | .canonicalize() | |
| 185 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize git root: {e}")))?; | |
| 186 | + | if !canonical.starts_with(&canonical_root) { | |
| 187 | + | return Err(AppError::Internal(anyhow::anyhow!( | |
| 188 | + | "repo path escapes git root" | |
| 189 | + | ))); | |
| 190 | + | } | |
| 191 | + | std::fs::remove_dir_all(&canonical) | |
| 192 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("remove repo directory: {e}")))?; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | Ok(Json(serde_json::json!({ "deleted": name }))) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // ── SSH keys ── | |
| 199 | + | ||
| 200 | + | // `key list` is served by the pre-existing `git::list_ssh_keys` on this same | |
| 201 | + | // path; adding a second handler for it collided at router build time. Only the | |
| 202 | + | // removal verb is new. | |
| 203 | + | ||
| 204 | + | /// DELETE /api/internal/creator/ssh-keys/{fingerprint} | |
| 205 | + | /// | |
| 206 | + | /// Rewrites `authorized_keys` after the row is gone, exactly as the old | |
| 207 | + | /// `key rm` did. mnw-cli authenticates from the database and does not read that | |
| 208 | + | /// file, so this is not what makes the removal take effect; it is there because | |
| 209 | + | /// the sshd path may still be wired on a host, and a key removed from one door | |
| 210 | + | /// but not the other is a key the user believes is gone. A failure to rewrite is | |
| 211 | + | /// logged rather than returned: the authoritative removal already succeeded, and | |
| 212 | + | /// reporting failure would invite a retry that cannot help. | |
| 213 | + | #[tracing::instrument(skip_all, name = "internal::cli_key_remove")] | |
| 214 | + | pub(super) async fn key_remove( | |
| 215 | + | State(db): State<PgPool>, | |
| 216 | + | actor: InternalActor, | |
| 217 | + | _auth: ServiceAuth, | |
| 218 | + | Path(fingerprint): Path<String>, | |
| 219 | + | ) -> Result<impl IntoResponse> { | |
| 220 | + | let deleted = | |
| 221 | + | db::ssh_keys::delete_key_by_fingerprint(&db, actor.user_id(), &fingerprint).await?; | |
| 222 | + | if !deleted { | |
| 223 | + | return Err(AppError::NotFound); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | if let Err(e) = crate::git_ssh::write_authorized_keys(&db, false).await { | |
| 227 | + | tracing::warn!( | |
| 228 | + | error = %e, | |
| 229 | + | "SSH key removed from the database but authorized_keys could not be rewritten" | |
| 230 | + | ); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | Ok(Json(serde_json::json!({ "removed": fingerprint }))) | |
| 234 | + | } |