max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
37 files changed,
+2316 insertions,
-81 deletions
| @@ -3385,7 +3385,7 @@ | |||
| 3385 | 3385 | ||
| 3386 | 3386 | [[package]] | |
| 3387 | 3387 | name = "makenotwork" | |
| 3388 | - | version = "0.4.6" | |
| 3388 | + | version = "0.4.7" | |
| 3389 | 3389 | dependencies = [ | |
| 3390 | 3390 | "anyhow", | |
| 3391 | 3391 | "argon2", |
| @@ -23,14 +23,15 @@ | |||
| 23 | 23 | - [ ] Add PoM health check for mnw-cli (port 22 SSH banner check) | |
| 24 | 24 | ||
| 25 | 25 | ## Remaining Features (from design doc) | |
| 26 | - | - [ ] Bulk item operations (select multiple, publish/unpublish/delete) | |
| 27 | - | - [ ] Tag management in TUI | |
| 28 | - | - [ ] Pipe mode uploads (`cat file | ssh cli.makenot.work upload ...`) | |
| 29 | - | - [ ] Blog post scheduling | |
| 30 | - | - [ ] Subscription tier management | |
| 31 | - | - [ ] Collection management | |
| 32 | - | - [ ] Custom domain management screen | |
| 33 | - | - [ ] Broadcast to followers | |
| 26 | + | - [x] Bulk item operations — multi-select (Space) on project items screen, bulk publish/unpublish/delete with confirmation dialog. Selection count shown in status bar. | |
| 27 | + | - [x] Pipe mode uploads — `cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug [--title TITLE] [--price CENTS]`. Reads stdin via SSH channel, auto-creates item, uploads to S3, publishes. | |
| 28 | + | - [x] Broadcast to followers — SSH command: `broadcast "Subject" "Body"`. Server internal endpoint with 24h rate limit, fire-and-forget email delivery. | |
| 29 | + | - [x] Custom domain management — SSH commands: `domain`, `domain add`, `domain verify`, `domain remove`. Full flow: add domain, DNS TXT verification via Cloudflare DoH, removal with cache invalidation. | |
| 30 | + | - [x] Collection management — SSH command: `collections` (list). Server internal endpoints for create/delete. API client wired. | |
| 31 | + | - [x] Tag management — Server internal endpoints for list/add/remove item tags + tag search. API client wired. TUI screen deferred. | |
| 32 | + | - [x] Subscription tier management — Server internal endpoint for listing tiers by project. API client wired. TUI screen deferred (read-only — tier creation requires Stripe). | |
| 33 | + | - [x] Blog post scheduling — server internal blog create now accepts `publish_at` (ISO 8601). TUI blog create flow: Title -> Body -> Schedule (enter datetime or leave empty for draft). Scheduled posts shown as "sched YYYY-MM-DDTHH:MM" in post list. Server scheduler auto-publishes when time arrives. | |
| 34 | + | - [x] TUI screens — Tags: inline on item detail screen (shows current tags, `t` to search+add, Enter to confirm). Tiers: `t` from project screen opens read-only tier list. Collections: `c` from home screen opens collection list with navigation. | |
| 34 | 35 | ||
| 35 | 36 | ## Key Paths | |
| 36 | 37 | ``` |
| @@ -109,6 +109,7 @@ | |||
| 109 | 109 | pub title: String, | |
| 110 | 110 | pub slug: String, | |
| 111 | 111 | pub is_published: bool, | |
| 112 | + | pub publish_at: Option<String>, | |
| 112 | 113 | pub created_at: String, | |
| 113 | 114 | pub updated_at: String, | |
| 114 | 115 | } | |
| @@ -203,6 +204,60 @@ | |||
| 203 | 204 | pub created_at: String, | |
| 204 | 205 | } | |
| 205 | 206 | ||
| 207 | + | /// A tag on an item or from search. | |
| 208 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 209 | + | pub struct TagInfo { | |
| 210 | + | pub id: String, | |
| 211 | + | pub name: String, | |
| 212 | + | pub slug: String, | |
| 213 | + | pub is_primary: bool, | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Result of a broadcast send. | |
| 217 | + | #[derive(Debug, Deserialize)] | |
| 218 | + | pub struct BroadcastResult { | |
| 219 | + | pub success: bool, | |
| 220 | + | pub recipient_count: usize, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// A subscription tier. | |
| 224 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 225 | + | pub struct TierInfo { | |
| 226 | + | pub id: String, | |
| 227 | + | pub name: String, | |
| 228 | + | pub description: String, | |
| 229 | + | pub price_cents: i32, | |
| 230 | + | pub is_active: bool, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// A collection. | |
| 234 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 235 | + | pub struct CollectionInfo { | |
| 236 | + | pub id: String, | |
| 237 | + | pub slug: String, | |
| 238 | + | pub title: String, | |
| 239 | + | pub description: String, | |
| 240 | + | pub is_public: bool, | |
| 241 | + | pub item_count: i64, | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Custom domain info. | |
| 245 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 246 | + | pub struct DomainInfo { | |
| 247 | + | pub id: String, | |
| 248 | + | pub domain: String, | |
| 249 | + | pub verified: bool, | |
| 250 | + | pub verification_token: String, | |
| 251 | + | pub instructions: Option<String>, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// Domain verification result. | |
| 255 | + | #[derive(Debug, Deserialize)] | |
| 256 | + | pub struct DomainVerifyResult { | |
| 257 | + | pub verified: bool, | |
| 258 | + | pub message: String, | |
| 259 | + | } | |
| 260 | + | ||
| 206 | 261 | /// Response from the git authorize endpoint. | |
| 207 | 262 | #[derive(Debug, Deserialize)] | |
| 208 | 263 | pub struct GitAuthResponse { | |
| @@ -619,7 +674,7 @@ | |||
| 619 | 674 | json_response(resp, "list_blog_posts").await | |
| 620 | 675 | } | |
| 621 | 676 | ||
| 622 | - | /// Create a blog post. | |
| 677 | + | /// Create a blog post, optionally scheduled for future publication. | |
| 623 | 678 | pub async fn create_blog_post( | |
| 624 | 679 | &self, | |
| 625 | 680 | user_id: &str, | |
| @@ -627,19 +682,24 @@ | |||
| 627 | 682 | title: &str, | |
| 628 | 683 | body_markdown: &str, | |
| 629 | 684 | publish: bool, | |
| 685 | + | publish_at: Option<&str>, | |
| 630 | 686 | ) -> anyhow::Result<BlogPost> { | |
| 631 | 687 | let url = format!("{}/api/internal/creator/blog", self.base_url); | |
| 688 | + | let mut body = serde_json::json!({ | |
| 689 | + | "user_id": user_id, | |
| 690 | + | "project_id": project_id, | |
| 691 | + | "title": title, | |
| 692 | + | "body_markdown": body_markdown, | |
| 693 | + | "publish": publish, | |
| 694 | + | }); | |
| 695 | + | if let Some(pa) = publish_at { | |
| 696 | + | body["publish_at"] = serde_json::Value::String(pa.to_string()); | |
| 697 | + | } | |
| 632 | 698 | let resp = self | |
| 633 | 699 | .http | |
| 634 | 700 | .post(&url) | |
| 635 | 701 | .bearer_auth(&self.service_token) | |
| 636 | - | .json(&serde_json::json!({ | |
| 637 | - | "user_id": user_id, | |
| 638 | - | "project_id": project_id, | |
| 639 | - | "title": title, | |
| 640 | - | "body_markdown": body_markdown, | |
| 641 | - | "publish": publish, | |
| 642 | - | })) | |
| 702 | + | .json(&body) | |
| 643 | 703 | .send() | |
| 644 | 704 | .await?; | |
| 645 | 705 | ||
| @@ -896,4 +956,112 @@ | |||
| 896 | 956 | ||
| 897 | 957 | json_response(resp, "list_ssh_keys").await | |
| 898 | 958 | } | |
| 959 | + | ||
| 960 | + | // ── Tags ── | |
| 961 | + | ||
| 962 | + | pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 963 | + | let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id); | |
| 964 | + | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 965 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 966 | + | json_response(resp, "list_item_tags").await | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 970 | + | let url = format!("{}/api/internal/tags/search", self.base_url); | |
| 971 | + | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 972 | + | .query(&[("q", query)]).send().await?; | |
| 973 | + | json_response(resp, "search_tags").await | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 977 | + | let url = format!("{}/api/internal/creator/items/tags", self.base_url); | |
| 978 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 979 | + | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 980 | + | .send().await?; | |
| 981 | + | empty_response(resp, "add_item_tag").await | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 985 | + | let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url); | |
| 986 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 987 | + | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 988 | + | .send().await?; | |
| 989 | + | empty_response(resp, "remove_item_tag").await | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | // ── Broadcast ── | |
| 993 | + | ||
| 994 | + | pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> { | |
| 995 | + | let url = format!("{}/api/internal/creator/broadcast", self.base_url); | |
| 996 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 997 | + | .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body})) | |
| 998 | + | .send().await?; | |
| 999 | + | json_response(resp, "send_broadcast").await | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | // ── Tiers ── | |
| 1003 | + | ||
| 1004 | + | pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> { | |
| 1005 | + | let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id); | |
| 1006 | + | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1007 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1008 | + | json_response(resp, "list_tiers").await | |
| 1009 | + | } | |
| 1010 | + | ||
| 1011 | + | // ── Collections ── | |
| 1012 | + | ||
| 1013 | + | pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> { | |
| 1014 | + | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1015 | + | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1016 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1017 | + | json_response(resp, "list_collections").await | |
| 1018 | + | } | |
| 1019 | + | ||
| 1020 | + | pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> { | |
| 1021 | + | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1022 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1023 | + | .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title})) | |
| 1024 | + | .send().await?; | |
| 1025 | + | json_response(resp, "create_collection").await | |
| 1026 | + | } | |
| 1027 | + | ||
| 1028 | + | pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> { | |
| 1029 | + | let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id); | |
| 1030 | + | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1031 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1032 | + | empty_response(resp, "delete_collection").await | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | // ── Custom Domains ── | |
| 1036 | + | ||
| 1037 | + | pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> { | |
| 1038 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1039 | + | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1040 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1041 | + | let val: serde_json::Value = json_response(resp, "get_domain").await?; | |
| 1042 | + | if val.is_null() { return Ok(None); } | |
| 1043 | + | Ok(serde_json::from_value(val).ok()) | |
| 1044 | + | } | |
| 1045 | + | ||
| 1046 | + | pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> { | |
| 1047 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1048 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1049 | + | .json(&serde_json::json!({"user_id": user_id, "domain": domain})) | |
| 1050 | + | .send().await?; | |
| 1051 | + | json_response(resp, "add_domain").await | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> { | |
| 1055 | + | let url = format!("{}/api/internal/creator/domain/verify", self.base_url); | |
| 1056 | + | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1057 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1058 | + | json_response(resp, "verify_domain").await | |
| 1059 | + | } | |
| 1060 | + | ||
| 1061 | + | pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> { | |
| 1062 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1063 | + | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1064 | + | .query(&[("user_id", user_id)]).send().await?; | |
| 1065 | + | empty_response(resp, "remove_domain").await | |
| 1066 | + | } | |
| 899 | 1067 | } |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use crate::api::{MnwApiClient, UserInfo}; | |
| 8 | 8 | use crate::format; | |
| 9 | + | use crate::staging; | |
| 9 | 10 | ||
| 10 | 11 | /// Sanitize an API error for display to SSH clients. | |
| 11 | 12 | /// | |
| @@ -42,6 +43,9 @@ | |||
| 42 | 43 | let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect(); | |
| 43 | 44 | ||
| 44 | 45 | match parts[0] { | |
| 46 | + | "upload" => { | |
| 47 | + | return b"Pipe uploads use stdin. Example:\r\n cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-project\r\n".to_vec(); | |
| 48 | + | } | |
| 45 | 49 | "projects" => cmd_projects(user, api, json).await, | |
| 46 | 50 | "analytics" => { | |
| 47 | 51 | let range = parts | |
| @@ -68,6 +72,21 @@ | |||
| 68 | 72 | } | |
| 69 | 73 | _ => b"Usage: blog list <project-slug>\r\n".to_vec(), | |
| 70 | 74 | }, | |
| 75 | + | "broadcast" => { | |
| 76 | + | let subject = parts.get(1).unwrap_or(&""); | |
| 77 | + | let body = parts.get(2).unwrap_or(&""); | |
| 78 | + | cmd_broadcast(user, api, subject, body).await | |
| 79 | + | } | |
| 80 | + | "collections" => cmd_collections(user, api, json).await, | |
| 81 | + | "domain" => match parts.get(1).copied() { | |
| 82 | + | Some("add") => { | |
| 83 | + | let domain = parts.get(2).unwrap_or(&""); | |
| 84 | + | cmd_domain_add(user, api, domain).await | |
| 85 | + | } | |
| 86 | + | Some("verify") => cmd_domain_verify(user, api).await, | |
| 87 | + | Some("remove") => cmd_domain_remove(user, api).await, | |
| 88 | + | _ => cmd_domain_show(user, api).await, | |
| 89 | + | }, | |
| 71 | 90 | "help" | "--help" | "-h" => help_text(), | |
| 72 | 91 | other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n") | |
| 73 | 92 | .into_bytes(), | |
| @@ -305,19 +324,188 @@ | |||
| 305 | 324 | } | |
| 306 | 325 | } | |
| 307 | 326 | ||
| 327 | + | async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> { | |
| 328 | + | if subject.is_empty() || body.is_empty() { | |
| 329 | + | return b"Usage: broadcast \"Subject\" \"Body text\"\r\n".to_vec(); | |
| 330 | + | } | |
| 331 | + | match api.send_broadcast(&user.user_id, subject, body).await { | |
| 332 | + | Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(), | |
| 333 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | async fn cmd_collections(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 338 | + | match api.list_collections(&user.user_id).await { | |
| 339 | + | Ok(collections) => { | |
| 340 | + | if json { | |
| 341 | + | return serde_json::to_vec_pretty(&collections).unwrap_or_default(); | |
| 342 | + | } | |
| 343 | + | if collections.is_empty() { | |
| 344 | + | return b"No collections.\r\n".to_vec(); | |
| 345 | + | } | |
| 346 | + | let mut out = format!( | |
| 347 | + | "{:<25} {:<25} {:<8} {:<6}\r\n", | |
| 348 | + | "Title", "Slug", "Status", "Items" | |
| 349 | + | ); | |
| 350 | + | out.push_str(&"-".repeat(66)); | |
| 351 | + | out.push_str("\r\n"); | |
| 352 | + | for c in &collections { | |
| 353 | + | let status = if c.is_public { "public" } else { "draft" }; | |
| 354 | + | out.push_str(&format!( | |
| 355 | + | "{:<25} {:<25} {:<8} {:<6}\r\n", | |
| 356 | + | truncate(&c.title, 24), | |
| 357 | + | truncate(&c.slug, 24), | |
| 358 | + | status, | |
| 359 | + | c.item_count, | |
| 360 | + | )); | |
| 361 | + | } | |
| 362 | + | out.into_bytes() | |
| 363 | + | } | |
| 364 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 369 | + | match api.get_domain(&user.user_id).await { | |
| 370 | + | Ok(Some(d)) => { | |
| 371 | + | let status = if d.verified { "verified" } else { "pending" }; | |
| 372 | + | let mut out = format!("Domain: {} ({})\r\n", d.domain, status); | |
| 373 | + | if !d.verified { | |
| 374 | + | if let Some(ref instr) = d.instructions { | |
| 375 | + | out.push_str(&format!("{}\r\n", instr)); | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | out.into_bytes() | |
| 379 | + | } | |
| 380 | + | Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(), | |
| 381 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | async fn cmd_domain_add(user: &UserInfo, api: &MnwApiClient, domain: &str) -> Vec<u8> { | |
| 386 | + | if domain.is_empty() { | |
| 387 | + | return b"Usage: domain add <domain>\r\n".to_vec(); | |
| 388 | + | } | |
| 389 | + | match api.add_domain(&user.user_id, domain).await { | |
| 390 | + | Ok(d) => { | |
| 391 | + | let mut out = format!("Domain added: {}\r\n", d.domain); | |
| 392 | + | if let Some(ref instr) = d.instructions { | |
| 393 | + | out.push_str(&format!("{}\r\n", instr)); | |
| 394 | + | } | |
| 395 | + | out.push_str("Run `domain verify` after adding the DNS record.\r\n"); | |
| 396 | + | out.into_bytes() | |
| 397 | + | } | |
| 398 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | async fn cmd_domain_verify(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 403 | + | match api.verify_domain(&user.user_id).await { | |
| 404 | + | Ok(result) => format!("{}\r\n", result.message).into_bytes(), | |
| 405 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | async fn cmd_domain_remove(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 410 | + | match api.remove_domain(&user.user_id).await { | |
| 411 | + | Ok(()) => b"Domain removed.\r\n".to_vec(), | |
| 412 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | /// Execute a pipe-mode file upload (called from handler after stdin EOF). | |
| 417 | + | pub async fn execute_pipe_upload( | |
| 418 | + | api: &MnwApiClient, | |
| 419 | + | upload: crate::ssh::handler::PipeUpload, | |
| 420 | + | ) -> anyhow::Result<String> { | |
| 421 | + | let user = &upload.user; | |
| 422 | + | if upload.data.is_empty() { | |
| 423 | + | anyhow::bail!("no data received on stdin"); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| 427 | + | let classification = staging::classify_extension(&ext) | |
| 428 | + | .ok_or_else(|| anyhow::anyhow!("unsupported file type: .{ext}"))?; | |
| 429 | + | ||
| 430 | + | // Find project by slug | |
| 431 | + | let projects = api.get_projects(&user.user_id).await?; | |
| 432 | + | let project = projects.iter() | |
| 433 | + | .find(|p| p.slug == upload.project_slug) | |
| 434 | + | .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?; | |
| 435 | + | ||
| 436 | + | // Create item | |
| 437 | + | let item = api.create_item( | |
| 438 | + | &user.user_id, | |
| 439 | + | &project.id, | |
| 440 | + | &upload.title, | |
| 441 | + | classification.item_type, | |
| 442 | + | upload.price_cents, | |
| 443 | + | ).await?; | |
| 444 | + | ||
| 445 | + | // Presign upload | |
| 446 | + | let presign = api.presign_upload( | |
| 447 | + | &user.user_id, | |
| 448 | + | &item.item_id, | |
| 449 | + | classification.file_type, | |
| 450 | + | &upload.filename, | |
| 451 | + | classification.content_type, | |
| 452 | + | ).await?; | |
| 453 | + | ||
| 454 | + | // Upload data directly to S3 | |
| 455 | + | let resp = reqwest::Client::new() | |
| 456 | + | .put(&presign.upload_url) | |
| 457 | + | .header("content-type", classification.content_type) | |
| 458 | + | .body(upload.data) | |
| 459 | + | .send() | |
| 460 | + | .await?; | |
| 461 | + | ||
| 462 | + | if !resp.status().is_success() { | |
| 463 | + | anyhow::bail!("S3 upload failed: HTTP {}", resp.status()); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | // Confirm | |
| 467 | + | api.confirm_upload( | |
| 468 | + | &user.user_id, | |
| 469 | + | &item.item_id, | |
| 470 | + | classification.file_type, | |
| 471 | + | &presign.s3_key, | |
| 472 | + | ).await?; | |
| 473 | + | ||
| 474 | + | // Publish | |
| 475 | + | api.publish_item(&user.user_id, &item.item_id).await?; | |
| 476 | + | ||
| 477 | + | Ok(format!( | |
| 478 | + | "Uploaded and published: {} ({}, {})\r\n", | |
| 479 | + | upload.title, | |
| 480 | + | staging::format_bytes(resp.content_length().unwrap_or(0)), | |
| 481 | + | classification.item_type, | |
| 482 | + | )) | |
| 483 | + | } | |
| 484 | + | ||
| 308 | 485 | fn help_text() -> Vec<u8> { | |
| 309 | 486 | b"Usage: ssh cli.makenot.work <command>\r\n\ | |
| 310 | 487 | \r\n\ | |
| 311 | 488 | Commands:\r\n\ | |
| 312 | - | \x20 projects List your projects\r\n\ | |
| 313 | - | \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\ | |
| 314 | - | \x20 transactions Recent transactions\r\n\ | |
| 315 | - | \x20 export sales Export sales as CSV\r\n\ | |
| 316 | - | \x20 promo list List promo codes\r\n\ | |
| 317 | - | \x20 promo create CODE PCT Create a promo code\r\n\ | |
| 318 | - | \x20 blog list SLUG List blog posts for project\r\n\ | |
| 489 | + | \x20 projects List your projects\r\n\ | |
| 490 | + | \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\ | |
| 491 | + | \x20 transactions Recent transactions\r\n\ | |
| 492 | + | \x20 export sales Export sales as CSV\r\n\ | |
| 493 | + | \x20 promo list List promo codes\r\n\ | |
| 494 | + | \x20 promo create CODE PCT Create a promo code\r\n\ | |
| 495 | + | \x20 blog list SLUG List blog posts for project\r\n\ | |
| 496 | + | \x20 broadcast SUBJ BODY Email followers (1/24h limit)\r\n\ | |
| 497 | + | \x20 collections List your collections\r\n\ | |
| 498 | + | \x20 domain Show custom domain\r\n\ | |
| 499 | + | \x20 domain add DOMAIN Add a custom domain\r\n\ | |
| 500 | + | \x20 domain verify Verify DNS record\r\n\ | |
| 501 | + | \x20 domain remove Remove custom domain\r\n\ | |
| 502 | + | \x20 upload [args] Pipe upload (see upload --help)\r\n\ | |
| 319 | 503 | \r\n\ | |
| 320 | - | Add --json to any command for machine-readable output.\r\n" | |
| 504 | + | Add --json to any command for machine-readable output.\r\n\ | |
| 505 | + | \r\n\ | |
| 506 | + | Pipe uploads:\r\n\ | |
| 507 | + | \x20 cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug\r\n\ | |
| 508 | + | \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n" | |
| 321 | 509 | .to_vec() | |
| 322 | 510 | } | |
| 323 | 511 |
| @@ -49,9 +49,9 @@ | |||
| 49 | 49 | - [ ] Add key rotation mechanism (requires server-side re-encryption of all sync_log entries) — deferred post-beta | |
| 50 | 50 | ||
| 51 | 51 | ### Git Access Provisioning | |
| 52 | - | - [ ] Dashboard page for SSH key management (API + HTMX partials exist at `routes/api/ssh_keys.rs`, needs dashboard tab) | |
| 53 | - | - [ ] Per-repo collaborator access (grant push by MNW username, stored in DB, wired to authorized_keys rebuild) | |
| 54 | - | - [ ] Replace manual `setup-ssh-keys.sh` with account-driven key management | |
| 52 | + | - [x] Dashboard page for SSH key management — promoted from collapsed `<details>` in Account tab to dedicated "SSH Keys" dashboard tab. Tab conditionally shown when `git_enabled`. Reuses existing API endpoints and HTMX partials. | |
| 53 | + | - [x] Per-repo collaborator access — `repo_collaborators` table (migration 087) with per-user `can_push` flag. SSH auth (`git_ssh.rs`) checks collaborator table for push and private repo read access. API: `POST/GET/DELETE /api/repos/{id}/collaborators`. Dashboard Code tab shows collaborators per linked repo with add/remove UI. | |
| 54 | + | - [x] Replace manual `setup-ssh-keys.sh` — added `mnw-admin setup-git` subcommand that creates `/opt/git/.ssh`, sets permissions, installs sudoers rule, and verifies syntax. Shell script superseded. | |
| 55 | 55 | ||
| 56 | 56 | ### Moderation | |
| 57 | 57 | - [x] Admin "send warning" action: `POST /api/admin/users/{id}/warn` sends policy-violation email without suspending. Records in moderation_actions. |
| @@ -95,16 +95,27 @@ | |||
| 95 | 95 | } | |
| 96 | 96 | }; | |
| 97 | 97 | ||
| 98 | - | // Permission check | |
| 98 | + | // Permission check — owner always has full access, collaborators checked via DB | |
| 99 | + | let is_owner = user_id == owner_user.id; | |
| 99 | 100 | match operation { | |
| 100 | 101 | GitOperation::ReceivePack => { | |
| 101 | - | if user_id != owner_user.id { | |
| 102 | - | anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name); | |
| 102 | + | if !is_owner { | |
| 103 | + | let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id) | |
| 104 | + | .await | |
| 105 | + | .unwrap_or(false); | |
| 106 | + | if !can_push { | |
| 107 | + | anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name); | |
| 108 | + | } | |
| 103 | 109 | } | |
| 104 | 110 | } | |
| 105 | 111 | GitOperation::UploadPack | GitOperation::Archive => { | |
| 106 | - | if repo.visibility == db::Visibility::Private && user_id != owner_user.id { | |
| 107 | - | anyhow::bail!("repository not found"); | |
| 112 | + | if repo.visibility == db::Visibility::Private && !is_owner { | |
| 113 | + | let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id) | |
| 114 | + | .await | |
| 115 | + | .unwrap_or(false); | |
| 116 | + | if !is_collab { | |
| 117 | + | anyhow::bail!("repository not found"); | |
| 118 | + | } | |
| 108 | 119 | } | |
| 109 | 120 | } | |
| 110 | 121 | } |