max / mnw-cli
18 files changed,
+1719 insertions,
-565 deletions
| @@ -231,7 +231,7 @@ version = "0.12.0" | |||
| 231 | 231 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | 232 | checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" | |
| 233 | 233 | dependencies = [ | |
| 234 | - | "hybrid-array 0.4.8", | |
| 234 | + | "hybrid-array", | |
| 235 | 235 | ] | |
| 236 | 236 | ||
| 237 | 237 | [[package]] | |
| @@ -430,6 +430,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 430 | 430 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 431 | 431 | ||
| 432 | 432 | [[package]] | |
| 433 | + | name = "core-models" | |
| 434 | + | version = "0.0.4" | |
| 435 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 436 | + | checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" | |
| 437 | + | dependencies = [ | |
| 438 | + | "hax-lib", | |
| 439 | + | "pastey", | |
| 440 | + | "rand 0.9.2", | |
| 441 | + | ] | |
| 442 | + | ||
| 443 | + | [[package]] | |
| 433 | 444 | name = "cpufeatures" | |
| 434 | 445 | version = "0.2.17" | |
| 435 | 446 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -530,7 +541,7 @@ version = "0.2.1" | |||
| 530 | 541 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 531 | 542 | checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" | |
| 532 | 543 | dependencies = [ | |
| 533 | - | "hybrid-array 0.4.8", | |
| 544 | + | "hybrid-array", | |
| 534 | 545 | ] | |
| 535 | 546 | ||
| 536 | 547 | [[package]] | |
| @@ -849,7 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 849 | 860 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 850 | 861 | dependencies = [ | |
| 851 | 862 | "libc", | |
| 852 | - | "windows-sys 0.61.2", | |
| 863 | + | "windows-sys 0.52.0", | |
| 853 | 864 | ] | |
| 854 | 865 | ||
| 855 | 866 | [[package]] | |
| @@ -1163,6 +1174,43 @@ dependencies = [ | |||
| 1163 | 1174 | ] | |
| 1164 | 1175 | ||
| 1165 | 1176 | [[package]] | |
| 1177 | + | name = "hax-lib" | |
| 1178 | + | version = "0.3.5" | |
| 1179 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1180 | + | checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" | |
| 1181 | + | dependencies = [ | |
| 1182 | + | "hax-lib-macros", | |
| 1183 | + | "num-bigint", | |
| 1184 | + | "num-traits", | |
| 1185 | + | ] | |
| 1186 | + | ||
| 1187 | + | [[package]] | |
| 1188 | + | name = "hax-lib-macros" | |
| 1189 | + | version = "0.3.5" | |
| 1190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1191 | + | checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" | |
| 1192 | + | dependencies = [ | |
| 1193 | + | "hax-lib-macros-types", | |
| 1194 | + | "proc-macro-error2", | |
| 1195 | + | "proc-macro2", | |
| 1196 | + | "quote", | |
| 1197 | + | "syn 2.0.117", | |
| 1198 | + | ] | |
| 1199 | + | ||
| 1200 | + | [[package]] | |
| 1201 | + | name = "hax-lib-macros-types" | |
| 1202 | + | version = "0.3.5" | |
| 1203 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1204 | + | checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" | |
| 1205 | + | dependencies = [ | |
| 1206 | + | "proc-macro2", | |
| 1207 | + | "quote", | |
| 1208 | + | "serde", | |
| 1209 | + | "serde_json", | |
| 1210 | + | "uuid", | |
| 1211 | + | ] | |
| 1212 | + | ||
| 1213 | + | [[package]] | |
| 1166 | 1214 | name = "heck" | |
| 1167 | 1215 | version = "0.5.0" | |
| 1168 | 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1245,15 +1293,6 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |||
| 1245 | 1293 | ||
| 1246 | 1294 | [[package]] | |
| 1247 | 1295 | name = "hybrid-array" | |
| 1248 | - | version = "0.2.3" | |
| 1249 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1250 | - | checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" | |
| 1251 | - | dependencies = [ | |
| 1252 | - | "typenum", | |
| 1253 | - | ] | |
| 1254 | - | ||
| 1255 | - | [[package]] | |
| 1256 | - | name = "hybrid-array" | |
| 1257 | 1296 | version = "0.4.8" | |
| 1258 | 1297 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1259 | 1298 | checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" | |
| @@ -1600,25 +1639,6 @@ dependencies = [ | |||
| 1600 | 1639 | ] | |
| 1601 | 1640 | ||
| 1602 | 1641 | [[package]] | |
| 1603 | - | name = "keccak" | |
| 1604 | - | version = "0.1.6" | |
| 1605 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1606 | - | checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" | |
| 1607 | - | dependencies = [ | |
| 1608 | - | "cpufeatures 0.2.17", | |
| 1609 | - | ] | |
| 1610 | - | ||
| 1611 | - | [[package]] | |
| 1612 | - | name = "kem" | |
| 1613 | - | version = "0.3.0-pre.0" | |
| 1614 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1615 | - | checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" | |
| 1616 | - | dependencies = [ | |
| 1617 | - | "rand_core 0.6.4", | |
| 1618 | - | "zeroize", | |
| 1619 | - | ] | |
| 1620 | - | ||
| 1621 | - | [[package]] | |
| 1622 | 1642 | name = "lab" | |
| 1623 | 1643 | version = "0.11.0" | |
| 1624 | 1644 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1646,6 +1666,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1646 | 1666 | checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" | |
| 1647 | 1667 | ||
| 1648 | 1668 | [[package]] | |
| 1669 | + | name = "libcrux-intrinsics" | |
| 1670 | + | version = "0.0.4" | |
| 1671 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1672 | + | checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" | |
| 1673 | + | dependencies = [ | |
| 1674 | + | "core-models", | |
| 1675 | + | "hax-lib", | |
| 1676 | + | ] | |
| 1677 | + | ||
| 1678 | + | [[package]] | |
| 1679 | + | name = "libcrux-ml-kem" | |
| 1680 | + | version = "0.0.4" | |
| 1681 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1682 | + | checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" | |
| 1683 | + | dependencies = [ | |
| 1684 | + | "hax-lib", | |
| 1685 | + | "libcrux-intrinsics", | |
| 1686 | + | "libcrux-platform", | |
| 1687 | + | "libcrux-secrets", | |
| 1688 | + | "libcrux-sha3", | |
| 1689 | + | "libcrux-traits", | |
| 1690 | + | "rand 0.9.2", | |
| 1691 | + | "tls_codec", | |
| 1692 | + | ] | |
| 1693 | + | ||
| 1694 | + | [[package]] | |
| 1695 | + | name = "libcrux-platform" | |
| 1696 | + | version = "0.0.2" | |
| 1697 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1698 | + | checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" | |
| 1699 | + | dependencies = [ | |
| 1700 | + | "libc", | |
| 1701 | + | ] | |
| 1702 | + | ||
| 1703 | + | [[package]] | |
| 1704 | + | name = "libcrux-secrets" | |
| 1705 | + | version = "0.0.4" | |
| 1706 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1707 | + | checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" | |
| 1708 | + | dependencies = [ | |
| 1709 | + | "hax-lib", | |
| 1710 | + | ] | |
| 1711 | + | ||
| 1712 | + | [[package]] | |
| 1713 | + | name = "libcrux-sha3" | |
| 1714 | + | version = "0.0.4" | |
| 1715 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1716 | + | checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" | |
| 1717 | + | dependencies = [ | |
| 1718 | + | "hax-lib", | |
| 1719 | + | "libcrux-intrinsics", | |
| 1720 | + | "libcrux-platform", | |
| 1721 | + | "libcrux-traits", | |
| 1722 | + | ] | |
| 1723 | + | ||
| 1724 | + | [[package]] | |
| 1725 | + | name = "libcrux-traits" | |
| 1726 | + | version = "0.0.4" | |
| 1727 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1728 | + | checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" | |
| 1729 | + | dependencies = [ | |
| 1730 | + | "libcrux-secrets", | |
| 1731 | + | "rand 0.9.2", | |
| 1732 | + | ] | |
| 1733 | + | ||
| 1734 | + | [[package]] | |
| 1649 | 1735 | name = "libm" | |
| 1650 | 1736 | version = "0.2.16" | |
| 1651 | 1737 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1783,18 +1869,6 @@ dependencies = [ | |||
| 1783 | 1869 | ] | |
| 1784 | 1870 | ||
| 1785 | 1871 | [[package]] | |
| 1786 | - | name = "ml-kem" | |
| 1787 | - | version = "0.2.3" | |
| 1788 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1789 | - | checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" | |
| 1790 | - | dependencies = [ | |
| 1791 | - | "hybrid-array 0.2.3", | |
| 1792 | - | "kem", | |
| 1793 | - | "rand_core 0.6.4", | |
| 1794 | - | "sha3", | |
| 1795 | - | ] | |
| 1796 | - | ||
| 1797 | - | [[package]] | |
| 1798 | 1872 | name = "mnw-cli" | |
| 1799 | 1873 | version = "0.1.0" | |
| 1800 | 1874 | dependencies = [ | |
| @@ -1841,7 +1915,7 @@ version = "0.50.3" | |||
| 1841 | 1915 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1842 | 1916 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 1843 | 1917 | dependencies = [ | |
| 1844 | - | "windows-sys 0.61.2", | |
| 1918 | + | "windows-sys 0.60.2", | |
| 1845 | 1919 | ] | |
| 1846 | 1920 | ||
| 1847 | 1921 | [[package]] | |
| @@ -2049,6 +2123,12 @@ dependencies = [ | |||
| 2049 | 2123 | ] | |
| 2050 | 2124 | ||
| 2051 | 2125 | [[package]] | |
| 2126 | + | name = "pastey" | |
| 2127 | + | version = "0.1.1" | |
| 2128 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2129 | + | checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" | |
| 2130 | + | ||
| 2131 | + | [[package]] | |
| 2052 | 2132 | name = "pbkdf2" | |
| 2053 | 2133 | version = "0.12.2" | |
| 2054 | 2134 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2309,6 +2389,28 @@ dependencies = [ | |||
| 2309 | 2389 | ] | |
| 2310 | 2390 | ||
| 2311 | 2391 | [[package]] | |
| 2392 | + | name = "proc-macro-error-attr2" | |
| 2393 | + | version = "2.0.0" | |
| 2394 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2395 | + | checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" | |
| 2396 | + | dependencies = [ | |
| 2397 | + | "proc-macro2", | |
| 2398 | + | "quote", | |
| 2399 | + | ] | |
| 2400 | + | ||
| 2401 | + | [[package]] | |
| 2402 | + | name = "proc-macro-error2" | |
| 2403 | + | version = "2.0.1" | |
| 2404 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2405 | + | checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" | |
| 2406 | + | dependencies = [ | |
| 2407 | + | "proc-macro-error-attr2", | |
| 2408 | + | "proc-macro2", | |
| 2409 | + | "quote", | |
| 2410 | + | "syn 2.0.117", | |
| 2411 | + | ] | |
| 2412 | + | ||
| 2413 | + | [[package]] | |
| 2312 | 2414 | name = "proc-macro2" | |
| 2313 | 2415 | version = "1.0.106" | |
| 2314 | 2416 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2664,9 +2766,9 @@ dependencies = [ | |||
| 2664 | 2766 | ||
| 2665 | 2767 | [[package]] | |
| 2666 | 2768 | name = "russh" | |
| 2667 | - | version = "0.58.1" | |
| 2769 | + | version = "0.58.0" | |
| 2668 | 2770 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2669 | - | checksum = "68d53bd2e1d6c49e32ae183c09bdc710ace41e7c8c564cc8a2286aad3bffe10d" | |
| 2771 | + | checksum = "30f6ce4f5d5105b934cfb4b8b3028aab4d5dcdff863cb8dda9edd06d39b8c4e8" | |
| 2670 | 2772 | dependencies = [ | |
| 2671 | 2773 | "aes", | |
| 2672 | 2774 | "aws-lc-rs", | |
| @@ -2693,9 +2795,9 @@ dependencies = [ | |||
| 2693 | 2795 | "hmac", | |
| 2694 | 2796 | "inout", | |
| 2695 | 2797 | "internal-russh-forked-ssh-key", | |
| 2798 | + | "libcrux-ml-kem", | |
| 2696 | 2799 | "log", | |
| 2697 | 2800 | "md5", | |
| 2698 | - | "ml-kem", | |
| 2699 | 2801 | "num-bigint", | |
| 2700 | 2802 | "p256", | |
| 2701 | 2803 | "p384", | |
| @@ -2789,7 +2891,7 @@ dependencies = [ | |||
| 2789 | 2891 | "errno", | |
| 2790 | 2892 | "libc", | |
| 2791 | 2893 | "linux-raw-sys", | |
| 2792 | - | "windows-sys 0.61.2", | |
| 2894 | + | "windows-sys 0.52.0", | |
| 2793 | 2895 | ] | |
| 2794 | 2896 | ||
| 2795 | 2897 | [[package]] | |
| @@ -3001,16 +3103,6 @@ dependencies = [ | |||
| 3001 | 3103 | ] | |
| 3002 | 3104 | ||
| 3003 | 3105 | [[package]] | |
| 3004 | - | name = "sha3" | |
| 3005 | - | version = "0.10.8" | |
| 3006 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3007 | - | checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" | |
| 3008 | - | dependencies = [ | |
| 3009 | - | "digest 0.10.7", | |
| 3010 | - | "keccak", | |
| 3011 | - | ] | |
| 3012 | - | ||
| 3013 | - | [[package]] | |
| 3014 | 3106 | name = "sharded-slab" | |
| 3015 | 3107 | version = "0.1.7" | |
| 3016 | 3108 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3107,7 +3199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3107 | 3199 | checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" | |
| 3108 | 3200 | dependencies = [ | |
| 3109 | 3201 | "libc", | |
| 3110 | - | "windows-sys 0.61.2", | |
| 3202 | + | "windows-sys 0.60.2", | |
| 3111 | 3203 | ] | |
| 3112 | 3204 | ||
| 3113 | 3205 | [[package]] | |
| @@ -3420,6 +3512,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3420 | 3512 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | |
| 3421 | 3513 | ||
| 3422 | 3514 | [[package]] | |
| 3515 | + | name = "tls_codec" | |
| 3516 | + | version = "0.4.2" | |
| 3517 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3518 | + | checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" | |
| 3519 | + | dependencies = [ | |
| 3520 | + | "tls_codec_derive", | |
| 3521 | + | "zeroize", | |
| 3522 | + | ] | |
| 3523 | + | ||
| 3524 | + | [[package]] | |
| 3525 | + | name = "tls_codec_derive" | |
| 3526 | + | version = "0.4.2" | |
| 3527 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3528 | + | checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" | |
| 3529 | + | dependencies = [ | |
| 3530 | + | "proc-macro2", | |
| 3531 | + | "quote", | |
| 3532 | + | "syn 2.0.117", | |
| 3533 | + | ] | |
| 3534 | + | ||
| 3535 | + | [[package]] | |
| 3423 | 3536 | name = "tokio" | |
| 3424 | 3537 | version = "1.50.0" | |
| 3425 | 3538 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4391,6 +4504,20 @@ name = "zeroize" | |||
| 4391 | 4504 | version = "1.8.2" | |
| 4392 | 4505 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4393 | 4506 | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" | |
| 4507 | + | dependencies = [ | |
| 4508 | + | "zeroize_derive", | |
| 4509 | + | ] | |
| 4510 | + | ||
| 4511 | + | [[package]] | |
| 4512 | + | name = "zeroize_derive" | |
| 4513 | + | version = "1.4.3" | |
| 4514 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4515 | + | checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" | |
| 4516 | + | dependencies = [ | |
| 4517 | + | "proc-macro2", | |
| 4518 | + | "quote", | |
| 4519 | + | "syn 2.0.117", | |
| 4520 | + | ] | |
| 4394 | 4521 | ||
| 4395 | 4522 | [[package]] | |
| 4396 | 4523 | name = "zerotrie" |
| @@ -0,0 +1,125 @@ | |||
| 1 | + | # mnw-cli | |
| 2 | + | ||
| 3 | + | SSH-based CLI and TUI for the Makenotwork creator platform. Authenticates via SSH key fingerprint, provides an interactive terminal UI for managing projects, and supports non-interactive commands for scripting. | |
| 4 | + | ||
| 5 | + | ## Prerequisites | |
| 6 | + | ||
| 7 | + | - **Rust** (stable toolchain, 2024 edition) | |
| 8 | + | - **Cross-compilation** (for deployment): `zig`, `cargo-zigbuild`, `x86_64-unknown-linux-gnu` target | |
| 9 | + | ||
| 10 | + | ## Build and Run | |
| 11 | + | ||
| 12 | + | ```sh | |
| 13 | + | # Local development (needs MNW server running on localhost:3000) | |
| 14 | + | cargo run | |
| 15 | + | ||
| 16 | + | # Run with custom port | |
| 17 | + | SSH_PORT=2222 MNW_API_URL=http://localhost:3000 MNW_SERVICE_TOKEN=<token> cargo run | |
| 18 | + | ||
| 19 | + | # Cross-compile for production | |
| 20 | + | cargo zigbuild --release --target x86_64-unknown-linux-gnu | |
| 21 | + | ``` | |
| 22 | + | ||
| 23 | + | ## Architecture | |
| 24 | + | ||
| 25 | + | mnw-cli is an SSH server built on [russh](https://docs.rs/russh). Each connection spawns an independent handler that authenticates via SSH key fingerprint lookup against the MNW API, then dispatches to either the interactive TUI or a non-interactive command. | |
| 26 | + | ||
| 27 | + | | Layer | Modules | Role | | |
| 28 | + | |-------|---------|------| | |
| 29 | + | | SSH | `ssh/handler.rs`, `ssh/mod.rs` | Connection handling, public key auth, channel dispatch | | |
| 30 | + | | TUI | `tui/mod.rs` + 9 screen modules | Interactive terminal UI via [ratatui](https://ratatui.rs/) | | |
| 31 | + | | Commands | `commands.rs` | Non-interactive text output for scripting | | |
| 32 | + | | API | `api.rs` | HTTP client for MNW internal API (50+ methods) | | |
| 33 | + | | SFTP | `ssh/sftp.rs` | File upload handling with per-user staging | | |
| 34 | + | | Git | `ssh/git.rs` | Git proxy (upload-pack, receive-pack) via subprocess | | |
| 35 | + | | Staging | `staging.rs` | 1 GB per-user upload quota, 24h TTL auto-cleanup | | |
| 36 | + | ||
| 37 | + | ### Authentication | |
| 38 | + | ||
| 39 | + | 1. Client presents SSH public key | |
| 40 | + | 2. Server computes SHA-256 fingerprint | |
| 41 | + | 3. Calls MNW internal API to look up the fingerprint | |
| 42 | + | 4. Returns user info (username, creator tier, suspended status) | |
| 43 | + | 5. Suspended users are rejected at auth stage | |
| 44 | + | ||
| 45 | + | ## Interactive TUI | |
| 46 | + | ||
| 47 | + | Connect via `ssh cli.makenot.work` for a full terminal interface with these screens: | |
| 48 | + | ||
| 49 | + | | Screen | Features | | |
| 50 | + | |--------|----------| | |
| 51 | + | | Home | Project list, revenue/sales/follower stats with period comparison | | |
| 52 | + | | Project | Items in a project, publish/unpublish, navigation | | |
| 53 | + | | Upload | SFTP staged files, metadata editor, S3 presigned upload flow | | |
| 54 | + | | Item | Item details, versions, edit fields, delete | | |
| 55 | + | | Blog | Blog posts, create with markdown, publish/draft toggle | | |
| 56 | + | | Promo | Promo codes, create with discount %, delete | | |
| 57 | + | | Keys | License keys, generate, revoke | | |
| 58 | + | | Analytics | Timeseries revenue, period comparison, top projects | | |
| 59 | + | | Settings | SSH keys, storage usage, profile info | | |
| 60 | + | ||
| 61 | + | Navigation: vim keys (h/j/k/l), Enter to select, q/Esc to go back, Tab to switch sections. | |
| 62 | + | ||
| 63 | + | ## Non-Interactive Commands | |
| 64 | + | ||
| 65 | + | ```sh | |
| 66 | + | ssh cli.makenot.work projects # List projects (table format) | |
| 67 | + | ssh cli.makenot.work projects --json # JSON output for scripting | |
| 68 | + | ssh cli.makenot.work analytics # Revenue stats (7d default) | |
| 69 | + | ssh cli.makenot.work analytics --range=30 # 30-day analytics | |
| 70 | + | ssh cli.makenot.work transactions # Recent transactions | |
| 71 | + | ssh cli.makenot.work export sales # Export sales as CSV | |
| 72 | + | ssh cli.makenot.work promo list # List promo codes | |
| 73 | + | ssh cli.makenot.work promo create CODE 20 # Create 20% discount code | |
| 74 | + | ssh cli.makenot.work blog list SLUG # List blog posts for a project | |
| 75 | + | ssh cli.makenot.work help # Show all commands | |
| 76 | + | ``` | |
| 77 | + | ||
| 78 | + | All commands support `--json` for machine-readable output. | |
| 79 | + | ||
| 80 | + | ## File Upload Flow | |
| 81 | + | ||
| 82 | + | 1. Upload file via SFTP to the SSH server | |
| 83 | + | 2. File lands in per-user staging directory (1 GB quota) | |
| 84 | + | 3. TUI shows staged files with type classification | |
| 85 | + | 4. Fill in metadata (title, project, price) | |
| 86 | + | 5. Server gets presigned S3 URL, uploads file, confirms with MNW API | |
| 87 | + | 6. Staging directory auto-cleaned on 24-hour TTL | |
| 88 | + | ||
| 89 | + | ## Configuration | |
| 90 | + | ||
| 91 | + | | Variable | Default | Purpose | | |
| 92 | + | |----------|---------|---------| | |
| 93 | + | | `SSH_PORT` | 2222 | SSH listen port | | |
| 94 | + | | `MNW_API_URL` | `http://localhost:3000` | MNW server base URL | | |
| 95 | + | | `MNW_SERVICE_TOKEN` | *(required)* | Bearer token for internal API | | |
| 96 | + | | `SSH_HOST_KEY` | `host_ed25519` | Path to host key (auto-generated if missing) | | |
| 97 | + | | `STAGING_DIR` | `/var/lib/mnw-cli/staging` | Per-user upload staging | | |
| 98 | + | | `GIT_SUDO_USER` | `git` | System user for git subprocess ops | | |
| 99 | + | ||
| 100 | + | ## Deployment | |
| 101 | + | ||
| 102 | + | ```sh | |
| 103 | + | ./deploy/deploy.sh # Full: build + upload + config + restart | |
| 104 | + | ./deploy/deploy.sh --quick # Build + binary + restart | |
| 105 | + | ./deploy/deploy.sh --config # Config files only | |
| 106 | + | ``` | |
| 107 | + | ||
| 108 | + | Deploys to hetzner (`100.120.174.96`) as a systemd service with security hardening (ProtectSystem=strict, PrivateTmp, NoNewPrivileges). | |
| 109 | + | ||
| 110 | + | ## Key Paths | |
| 111 | + | ||
| 112 | + | | What | Where | | |
| 113 | + | |------|-------| | |
| 114 | + | | SSH server + auth | `src/ssh/handler.rs` | | |
| 115 | + | | TUI app state + event loop | `src/tui/mod.rs` | | |
| 116 | + | | API client (50+ methods) | `src/api.rs` | | |
| 117 | + | | Non-interactive commands | `src/commands.rs` | | |
| 118 | + | | SFTP + staging | `src/ssh/sftp.rs`, `src/staging.rs` | | |
| 119 | + | | Git proxy | `src/ssh/git.rs` | | |
| 120 | + | | Deploy script | `deploy/deploy.sh` | | |
| 121 | + | | systemd unit | `deploy/mnw-cli.service` | | |
| 122 | + | ||
| 123 | + | ## License | |
| 124 | + | ||
| 125 | + | PolyForm Noncommercial 1.0.0 |
| @@ -0,0 +1,152 @@ | |||
| 1 | + | # mnw-cli -- Architecture | |
| 2 | + | ||
| 3 | + | ## Overview | |
| 4 | + | ||
| 5 | + | SSH server that authenticates MNW users via SSH key fingerprint lookup, then dispatches to either an interactive TUI (ratatui) or non-interactive text commands. Also handles SFTP file uploads and git proxy for SSH-based git operations. | |
| 6 | + | ||
| 7 | + | ## Module Map | |
| 8 | + | ||
| 9 | + | ``` | |
| 10 | + | src/ | |
| 11 | + | main.rs Entry point: config, host key, SSH server, signal handling | |
| 12 | + | config.rs Environment variable configuration (6 vars) | |
| 13 | + | api.rs HTTP client for MNW internal API (~50 methods, ~890 LOC) | |
| 14 | + | commands.rs Non-interactive command handlers (8 commands) | |
| 15 | + | format.rs Display formatting (prices, tiers, project types) | |
| 16 | + | staging.rs Per-user upload staging (1 GB quota, 24h TTL) | |
| 17 | + | ||
| 18 | + | ssh/ | |
| 19 | + | mod.rs Server factory (russh::server::Server impl) | |
| 20 | + | handler.rs Per-connection handler: auth, PTY, channel dispatch (~400 LOC) | |
| 21 | + | terminal.rs TerminalHandle: adapts ratatui's Write to SSH channel via mpsc | |
| 22 | + | sftp.rs SFTP subsystem for file uploads | |
| 23 | + | git.rs Git proxy: parses git commands, spawns subprocesses (~80 LOC) | |
| 24 | + | ||
| 25 | + | tui/ | |
| 26 | + | mod.rs App state, event loop, screen dispatch, data loading (~82 KB) | |
| 27 | + | home.rs Project list, revenue/sales/follower stats | |
| 28 | + | project.rs Items in a project, publish/unpublish | |
| 29 | + | upload.rs Staged files, metadata editor, presign + S3 upload | |
| 30 | + | item.rs Item details, versions, edit fields, delete | |
| 31 | + | blog.rs Blog posts, create/edit markdown, publish/draft | |
| 32 | + | promo.rs Promo codes, create/delete | |
| 33 | + | keys.rs License keys, generate/revoke | |
| 34 | + | analytics.rs Timeseries revenue, period comparison | |
| 35 | + | settings.rs SSH keys, storage usage, profile | |
| 36 | + | widgets.rs Shared table rendering widget | |
| 37 | + | ``` | |
| 38 | + | ||
| 39 | + | ## Design Decisions | |
| 40 | + | ||
| 41 | + | ### SSH-first (not HTTP) | |
| 42 | + | ||
| 43 | + | The CLI authenticates via SSH public keys, not passwords or API tokens. This means: | |
| 44 | + | - Users don't need to manage API keys or copy tokens | |
| 45 | + | - Authentication reuses existing SSH key infrastructure (`ssh-keygen`, `~/.ssh/`) | |
| 46 | + | - Git operations work natively through the same connection | |
| 47 | + | - Non-interactive commands work from any SSH client (`ssh cli.makenot.work projects`) | |
| 48 | + | ||
| 49 | + | ### Per-connection isolation | |
| 50 | + | ||
| 51 | + | Each SSH connection spawns an independent `MnwHandler`. No shared mutable state between connections. The handler owns: | |
| 52 | + | - Authenticated user identity (from fingerprint lookup) | |
| 53 | + | - Terminal channel (for TUI rendering) | |
| 54 | + | - SFTP channel (for file uploads) | |
| 55 | + | - Per-user staging directory | |
| 56 | + | ||
| 57 | + | ### TUI as primary interface | |
| 58 | + | ||
| 59 | + | The interactive TUI is the default mode (launched when no command is specified). It provides full CRUD for projects, items, uploads, blog posts, promo codes, and license keys. The non-interactive commands are a subset for scripting. | |
| 60 | + | ||
| 61 | + | ### Service-to-service auth | |
| 62 | + | ||
| 63 | + | mnw-cli authenticates to the MNW server via a bearer token (`MNW_SERVICE_TOKEN`). All internal API calls include the authenticated user's ID so the server can enforce authorization. The CLI itself is trusted infrastructure, not a third-party client. | |
| 64 | + | ||
| 65 | + | ### Staging-based uploads | |
| 66 | + | ||
| 67 | + | File uploads go through a staging directory rather than streaming directly to S3: | |
| 68 | + | 1. SFTP lands files in `/var/lib/mnw-cli/staging/{user_id}/` | |
| 69 | + | 2. TUI classifies files by extension, lets creator fill in metadata | |
| 70 | + | 3. Server issues presigned S3 URL, CLI uploads with reqwest | |
| 71 | + | 4. Background task cleans up staged files after 24 hours | |
| 72 | + | ||
| 73 | + | This avoids partial uploads to S3 and gives creators a chance to review metadata before publishing. | |
| 74 | + | ||
| 75 | + | ## Data Flow | |
| 76 | + | ||
| 77 | + | ### Authentication | |
| 78 | + | ``` | |
| 79 | + | SSH client -> SSH handshake -> public key offered | |
| 80 | + | -> MnwHandler computes SHA-256 fingerprint | |
| 81 | + | -> GET /api/internal/ssh-key-lookup?fingerprint=... | |
| 82 | + | -> MNW server returns UserInfo (or 404) | |
| 83 | + | -> accept/reject connection | |
| 84 | + | ``` | |
| 85 | + | ||
| 86 | + | ### Interactive TUI | |
| 87 | + | ``` | |
| 88 | + | SSH PTY allocated -> TerminalHandle wraps channel | |
| 89 | + | -> ratatui renders to TerminalHandle | |
| 90 | + | -> crossterm parses raw input bytes | |
| 91 | + | -> AppEvent dispatched (Input/Resize/DataLoaded) | |
| 92 | + | -> Screen handlers update state + trigger API calls | |
| 93 | + | -> API calls load data async via mpsc -> DataLoaded events | |
| 94 | + | ``` | |
| 95 | + | ||
| 96 | + | ### Non-interactive commands | |
| 97 | + | ``` | |
| 98 | + | SSH exec request -> parse command string | |
| 99 | + | -> commands.rs handler runs | |
| 100 | + | -> API calls to MNW server | |
| 101 | + | -> format output (table or JSON) | |
| 102 | + | -> write to channel -> close | |
| 103 | + | ``` | |
| 104 | + | ||
| 105 | + | ### SFTP upload | |
| 106 | + | ``` | |
| 107 | + | SSH subsystem "sftp" -> russh-sftp handler | |
| 108 | + | -> file written to staging/{user_id}/{filename} | |
| 109 | + | -> TUI upload screen reads staging directory | |
| 110 | + | -> creator fills metadata -> presign -> upload to S3 -> confirm | |
| 111 | + | ``` | |
| 112 | + | ||
| 113 | + | ### Git proxy | |
| 114 | + | ``` | |
| 115 | + | SSH exec "git-upload-pack repo.git" -> parse command | |
| 116 | + | -> lookup repo via API (verify user access) | |
| 117 | + | -> spawn git subprocess with sudo as GIT_SUDO_USER | |
| 118 | + | -> wire subprocess stdin/stdout to SSH channel | |
| 119 | + | ``` | |
| 120 | + | ||
| 121 | + | ## Key Dependencies | |
| 122 | + | ||
| 123 | + | | Crate | Role | | |
| 124 | + | |-------|------| | |
| 125 | + | | russh | SSH server protocol | | |
| 126 | + | | russh-sftp | SFTP subsystem | | |
| 127 | + | | ratatui | Terminal UI rendering | | |
| 128 | + | | crossterm | Terminal input handling | | |
| 129 | + | | tokio | Async runtime | | |
| 130 | + | | reqwest (rustls-tls) | HTTP client for MNW API | | |
| 131 | + | | serde/serde_json | API serialization | | |
| 132 | + | | tracing | Structured logging | | |
| 133 | + | | anyhow | Error handling | | |
| 134 | + | ||
| 135 | + | ## Deployment | |
| 136 | + | ||
| 137 | + | Cross-compiled on macOS via `cargo zigbuild`, deployed to hetzner as a systemd service. The service runs as a dedicated `mnw-cli` user with filesystem and privilege restrictions. | |
| 138 | + | ||
| 139 | + | Target: port 22 on hetzner (after migrating sshd to port 2200 on Tailscale only). | |
| 140 | + | ||
| 141 | + | ## Key Paths | |
| 142 | + | ||
| 143 | + | | What | Where | | |
| 144 | + | |------|-------| | |
| 145 | + | | SSH handler + auth | `src/ssh/handler.rs` | | |
| 146 | + | | TUI app + event loop | `src/tui/mod.rs` | | |
| 147 | + | | API client | `src/api.rs` | | |
| 148 | + | | Commands | `src/commands.rs` | | |
| 149 | + | | Config | `src/config.rs` | | |
| 150 | + | | Deploy | `deploy/deploy.sh` | | |
| 151 | + | | systemd unit | `deploy/mnw-cli.service` | | |
| 152 | + | | Todo | `docs/todo.md` | |
| @@ -0,0 +1,86 @@ | |||
| 1 | + | # mnw-cli AI Anti-Pattern Cleanup | |
| 2 | + | ||
| 3 | + | Audit of mnw-cli (Rust SSH server with ratatui TUI) for silent error handling and AI-induced anti-patterns. | |
| 4 | + | ||
| 5 | + | **Summary:** 3 MEDIUM, 2 LOW. Zero HIGH. No dead code, no stubs, no string-typing, no `#[allow(dead_code)]`, no `todo!()`/`unimplemented!()`. One memory leak in render code. | |
| 6 | + | ||
| 7 | + | ## Fixes (MEDIUM) | |
| 8 | + | ||
| 9 | + | ### M1. Memory leak via `.leak()` in upload render | |
| 10 | + | ||
| 11 | + | `src/tui/upload.rs:169` — `staging::derive_title(&sf.filename).leak()` converts an owned `String` to `&'static str` by permanently leaking the allocation. Called on every render of the upload screen for every staged file without metadata. Since the result is immediately consumed by `title.to_string()` on line 188, the leak produces no benefit. | |
| 12 | + | ||
| 13 | + | **Fix:** Use owned `String` instead of `&str` reference to avoid the leak: | |
| 14 | + | ```rust | |
| 15 | + | let title = meta | |
| 16 | + | .and_then(|m| m.title.clone()) | |
| 17 | + | .unwrap_or_else(|| staging::derive_title(&sf.filename)); | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | ### M2. Silent error response body loss in API client | |
| 21 | + | ||
| 22 | + | `src/api.rs:217,230` — `resp.text().await.unwrap_or_default()` in `json_response` and `empty_response`. If body extraction itself fails (connection reset mid-read, encoding issue), the error message becomes "HTTP 500" instead of "HTTP 500 — connection reset during body read". Same pattern as GO/BB L1. | |
| 23 | + | ||
| 24 | + | **Fix:** Replace with `.unwrap_or_else(|e| format!("[body read failed: {e}]"))`. | |
| 25 | + | ||
| 26 | + | ### M3. Silent staging file deletion failure after publish | |
| 27 | + | ||
| 28 | + | `src/tui/mod.rs:1956` — `tokio::fs::remove_file(file_path).await.ok()` after a successful publish. If deletion fails (permissions, file locked), the file stays in the staging directory, still counting against the staging quota. The user sees it reappear in the upload list and might accidentally re-publish (creating a duplicate item). | |
| 29 | + | ||
| 30 | + | **Fix:** Replace `.ok()` with `if let Err(e)` + `tracing::warn!` including the filename. | |
| 31 | + | ||
| 32 | + | ## Fixes (LOW) | |
| 33 | + | ||
| 34 | + | ### L1. Silent API data load failures in TUI | |
| 35 | + | ||
| 36 | + | `src/tui/mod.rs` — Eight `load_*` functions silently swallow API errors with `.unwrap_or_default()` or `.ok()`: `load_home_data` (line 1962-1975), `load_project_items` (1991), `load_staged_files` (2005), `load_blog_posts` (2053), `load_promo_codes` (2063), `load_license_keys` (2077), `load_transactions` (2107), `load_settings` (2114-2115). When the API call fails, the user sees empty data with no indication that loading failed. Note: `load_analytics` and `load_item_detail` already handle errors correctly by sending `GenericError`/`ItemActionError` payloads. | |
| 37 | + | ||
| 38 | + | **Fix:** Add `tracing::warn!` before the fallback in each function. Keep the `.unwrap_or_default()` behavior (showing empty is fine for a TUI). | |
| 39 | + | ||
| 40 | + | ### L2. Silent git_authorize error body loss | |
| 41 | + | ||
| 42 | + | `src/api.rs:863-868` — `resp.text().await.unwrap_or_default()` followed by JSON parse with `.ok()`. If the body read fails, the error becomes a generic "HTTP 403" instead of the actual authorization error message. | |
| 43 | + | ||
| 44 | + | **Fix:** Same as M2 — replace with `.unwrap_or_else(|e| format!("[body read failed: {e}]"))`. | |
| 45 | + | ||
| 46 | + | ## Skipping (intentional design) | |
| 47 | + | ||
| 48 | + | **SSH channel cleanup (handler.rs, 14 instances):** All `let _ = handle.data/close/eof/exit_status_request/extended_data` calls in exec_request, SCP error response, and command output. These are fire-and-forget SSH protocol sequences — if the channel is already closed (client disconnected), these fail harmlessly. | |
| 49 | + | ||
| 50 | + | **Git subprocess I/O (handler.rs:330, git.rs:106,121,132-133,136-137,140-142):** `let _ = stdin.write_all(data).await` pipes SSH input to git subprocess. Read errors in stdout/stderr forwarding loops break the loop (normal EOF). JoinHandle awaits are cleanup. Exit code `.unwrap_or(1)` handles signal kills (no exit code). All correct. | |
| 51 | + | ||
| 52 | + | **TUI event channel sends (~40 instances in tui/mod.rs):** All `let _ = tx.send(AppEvent::DataLoaded(...)).await` are channel sends from background tasks to the TUI event loop. If the receiver is dropped (TUI exiting), these fail, which is expected. | |
| 53 | + | ||
| 54 | + | **AppHandle channel sends (tui/mod.rs:111,115):** `let _ = self.tx.send(AppEvent::Input/Resize).await` — fire-and-forget input forwarding. If TUI is shutting down, benign. | |
| 55 | + | ||
| 56 | + | **Session close on quit (tui/mod.rs:364):** `let _ = session_handle.close(channel_id).await` — best-effort SSH session close when user presses `q`. | |
| 57 | + | ||
| 58 | + | **Terminal resize (tui/mod.rs:428):** `let _ = terminal.resize(rect)` — ratatui terminal resize. Failure means the next render uses the old size, which is harmless. | |
| 59 | + | ||
| 60 | + | **Initialization panics (main.rs:92, api.rs:252):** `.expect()` on SIGTERM handler registration and HTTP client construction. Correct — these are process-fatal startup conditions. | |
| 61 | + | ||
| 62 | + | **Ctrl+C fallback (main.rs:99):** `ctrl_c.await.ok()` — non-Unix signal handling fallback. | |
| 63 | + | ||
| 64 | + | **Git path parsing (git.rs:34,43,51):** `.unwrap_or(path)` / `.unwrap_or(repo_name)` for stripping quotes/prefix/suffix. Correct fallback — returns unmodified input. | |
| 65 | + | ||
| 66 | + | **User input parsing (tui/mod.rs:1581,2136-2141):** `.parse().unwrap_or(0)` on discount percentage and price input. Correct — invalid user input defaults to 0. | |
| 67 | + | ||
| 68 | + | **JSON serialization (commands.rs, multiple):** `.unwrap_or_default()` on `serde_json::to_vec_pretty()` — infallible for valid `Serialize` types. | |
| 69 | + | ||
| 70 | + | **Environment variable defaults (config.rs, 5 instances):** `.unwrap_or_else(|_| ...)` on env var reads with sensible defaults. | |
| 71 | + | ||
| 72 | + | **System time fallbacks (staging.rs:58,117):** `.unwrap_or(UNIX_EPOCH)` on metadata.modified(), `.unwrap_or_default()` on duration_since. Platform guarantees make failure impossible in practice. | |
| 73 | + | ||
| 74 | + | **Best-effort empty dir cleanup (staging.rs:130):** `let _ = fs::remove_dir(user_dir.path()).await` — removes empty user staging dirs during periodic cleanup. Failure is harmless. | |
| 75 | + | ||
| 76 | + | **Display-only formatting (all TUI render files):** `.unwrap_or("...")` / `.get(..10).unwrap_or(...)` on date truncation, tier labels, display names, etc. Pure display fallbacks with no state impact. | |
| 77 | + | ||
| 78 | + | **SFTP spawn (handler.rs:218-220):** `russh_sftp::server::run(stream, sftp_session).await` runs in a spawned task without error handling. Session cleanup is russh_sftp's responsibility. | |
| 79 | + | ||
| 80 | + | **RUSSH SFTP handler (sftp.rs):** Returns `StatusCode` errors to the SFTP client — correct protocol behavior, not silent swallowing. | |
| 81 | + | ||
| 82 | + | ## Verification | |
| 83 | + | ||
| 84 | + | ```sh | |
| 85 | + | cd ~/Code/MNW/mnw-cli && cargo check && cargo test | |
| 86 | + | ``` |
| @@ -0,0 +1,118 @@ | |||
| 1 | + | # mnw-cli — Code Review | |
| 2 | + | ||
| 3 | + | **Date:** 2026-04-12 | |
| 4 | + | **Version:** 0.1.0 (pre-deployment) | |
| 5 | + | **Reviewer:** Claude (Opus 4.6) | |
| 6 | + | **Scope:** Full codebase review — all Rust source, deploy config, tests, docs | |
| 7 | + | ||
| 8 | + | ## Summary | |
| 9 | + | ||
| 10 | + | mnw-cli is an SSH-first CLI server (~6,870 LOC Rust, ~8,600 total) for the MNW creator platform. SSH-based auth eliminates API key management. Users connect via standard SSH clients for an interactive TUI, non-interactive commands, SFTP file uploads, or git operations. Built on russh + ratatui + reqwest. 46 unit tests, 0 clippy warnings. | |
| 11 | + | ||
| 12 | + | **Overall: A** — clean architecture, strong security posture. All original findings resolved: UTF-8 panic fixed, price parsing fixed, 18 clippy warnings fixed, tui/mod.rs split, 36 tests added, exit status added, confirmation dialogs for all destructive operations. | |
| 13 | + | ||
| 14 | + | --- | |
| 15 | + | ||
| 16 | + | ## Findings | |
| 17 | + | ||
| 18 | + | ### [MEDIUM] `tui/mod.rs` at 2,211 lines — well over 500-line guideline | |
| 19 | + | ||
| 20 | + | This file contains the event loop, all 9 screen input handlers, 9 data loaders, the multi-step publish flow, and utility functions. All branching logic. Should be split into at minimum: | |
| 21 | + | - `tui/input.rs` — screen-specific input handlers (~800 lines) | |
| 22 | + | - `tui/loading.rs` — data loading functions (~300 lines) | |
| 23 | + | - `tui/publish.rs` — the publish flow (~100 lines) | |
| 24 | + | ||
| 25 | + | ### [MEDIUM] `truncate()` in commands.rs panics on multi-byte UTF-8 | |
| 26 | + | ||
| 27 | + | ```rust | |
| 28 | + | fn truncate(s: &str, max_len: usize) -> &str { | |
| 29 | + | if s.len() <= max_len { s } else { &s[..max_len] } | |
| 30 | + | } | |
| 31 | + | ``` | |
| 32 | + | ||
| 33 | + | Slicing at a byte offset panics if `max_len` falls within a multi-byte character. Any project/blog title with accented letters, CJK, or emoji could trigger this. Fix: use `s.floor_char_boundary(max_len)` (stable since Rust 1.82). | |
| 34 | + | ||
| 35 | + | ### [MEDIUM] 18 clippy warnings | |
| 36 | + | ||
| 37 | + | Includes: collapsible if-statements (7), `&PathBuf` instead of `&Path` (2), useless `format!` (1), useless `.into()` (1), too many function arguments (2), clamp-like pattern (1), trim-before-split_whitespace (1), unused struct fields (2). Should be cleaned up before deployment. | |
| 38 | + | ||
| 39 | + | ### [MEDIUM] `parse_price("5.5")` returns 505 cents ($5.05), not 550 ($5.50) | |
| 40 | + | ||
| 41 | + | The function takes at most 2 chars from the cents portion: `cents.get(..2)` on `"5"` yields `5`, so `5 * 100 + 5 = 505`. A user typing "5.5" almost certainly means $5.50. Fix: pad single-digit cents with a trailing zero. | |
| 42 | + | ||
| 43 | + | ### [LOW] `russh` 0.58.1 was yanked | |
| 44 | + | ||
| 45 | + | Resolved by `cargo update` downgrading to 0.58.0. Version 0.60.0 is available but is a breaking change. Remaining advisories (rsa, rand) are transitive with no direct fix. | |
| 46 | + | ||
| 47 | + | ### [LOW] No exit status on non-interactive commands | |
| 48 | + | ||
| 49 | + | `exec_request` in handler.rs spawns command execution but never sends `exit_status_request` to the SSH channel. SSH clients can't distinguish success from failure. The git proxy path correctly sends exit status, but the command path does not. | |
| 50 | + | ||
| 51 | + | ### [LOW] No confirmation for destructive operations | |
| 52 | + | ||
| 53 | + | Delete item, delete blog post, delete promo code, and revoke license key all execute immediately on a single keypress (`d` or `x`). No confirmation dialog. One accidental keypress permanently deletes. | |
| 54 | + | ||
| 55 | + | ### [LOW] `api.rs` at 897 lines | |
| 56 | + | ||
| 57 | + | Contains 18 data types and 23 API methods. The types (structs + enums) are flat data definitions (~400 lines, exempt from the 500-line rule), so the branching logic is within limits. But if more endpoints are added, consider splitting types into `api/types.rs`. | |
| 58 | + | ||
| 59 | + | ### [LOW] Missing unit tests for pure functions | |
| 60 | + | ||
| 61 | + | `staging.rs` has 6 testable pure functions (`sanitize_filename`, `classify_extension`, `derive_title`, `is_allowed_extension`, `format_bytes`, `cleanup_stale` logic). `format.rs` has 5 pure formatters. `commands.rs` has `truncate` and price parsing. None have tests. The git proxy has 10 tests — the same pattern should be applied to these modules. | |
| 62 | + | ||
| 63 | + | ### [INFO] `i64` to `u64` casts in render code | |
| 64 | + | ||
| 65 | + | `storage_used_bytes as u64` and `max_storage_bytes as u64` in upload.rs, settings.rs, item.rs, and analytics.rs. If the server returns negative values (bug), these wrap silently. Extremely unlikely but could use `.try_into().unwrap_or(0)`. | |
| 66 | + | ||
| 67 | + | ### [INFO] Non-interactive promo creation only supports percentage discounts | |
| 68 | + | ||
| 69 | + | Both CLI (`promo create CODE PCT`) and TUI always send `"percentage"` discount type. The API supports fixed-amount discounts but neither interface exposes it. Minor — percentage is the common case. | |
| 70 | + | ||
| 71 | + | --- | |
| 72 | + | ||
| 73 | + | ## Strengths | |
| 74 | + | ||
| 75 | + | - **SSH-first auth is elegant.** Eliminates API key management entirely. Users authenticate with their existing SSH keys. Public key fingerprint looked up against MNW server. | |
| 76 | + | - **Clean per-connection isolation.** No shared mutable state between connections. Each connection gets its own `MnwHandler` with independent API client, staging handle, and TUI state. | |
| 77 | + | - **SFTP implementation is thorough.** Tier checking, extension validation, per-user quota enforcement (checked on both `open` and `write`), path traversal prevention, virtual filesystem abstraction. | |
| 78 | + | - **Git proxy is well-tested.** 10 unit tests covering command parsing and path traversal prevention. Subprocess management with proper stdin/stdout/stderr piping. | |
| 79 | + | - **Security hardening.** systemd service has ProtectSystem=strict, PrivateTmp, NoNewPrivileges, etc. Path traversal blocked in both SFTP and git. Suspended users rejected at auth. | |
| 80 | + | - **Prior audit findings all resolved.** The 5 issues from cleanup.md (memory leak, silent errors, silent deletions) are all fixed in the current code. | |
| 81 | + | - **Good documentation.** README, architecture.md, todo.md, and cleanup.md all present and current. | |
| 82 | + | ||
| 83 | + | ## Security Checklist | |
| 84 | + | ||
| 85 | + | | Check | Status | | |
| 86 | + | |-------|--------| | |
| 87 | + | | Auth bypass | Pass — SSH public key auth, fingerprint verified against MNW API | | |
| 88 | + | | Path traversal (SFTP) | Pass — filename sanitization, no directory creation | | |
| 89 | + | | Path traversal (git) | Pass — repo path parsed, `..` rejected, nested paths rejected | | |
| 90 | + | | Suspended user access | Pass — rejected at `auth_publickey_offered` | | |
| 91 | + | | Tier enforcement (SFTP) | Pass — Basic tier rejected at `open`, quota checked per write | | |
| 92 | + | | Service token exposure | Pass — loaded from env, not logged | | |
| 93 | + | | Command injection (git) | Pass — command and repo path parsed separately, no shell interpolation | | |
| 94 | + | ||
| 95 | + | ## Metrics | |
| 96 | + | ||
| 97 | + | | Metric | Value | | |
| 98 | + | |--------|-------| | |
| 99 | + | | Rust source LOC | ~6,870 | | |
| 100 | + | | Total LOC (all files) | ~8,600 | | |
| 101 | + | | Source files | 24 | | |
| 102 | + | | Unit tests | 46 | | |
| 103 | + | | Integration test assertions | ~61 (bash script) | | |
| 104 | + | | Clippy warnings | 0 | | |
| 105 | + | | Dependency advisories | 0 yanked, 3 allowed transitive | | |
| 106 | + | | TUI screens | 9 | | |
| 107 | + | | Non-interactive commands | 8 | | |
| 108 | + | | API client methods | 23 | | |
| 109 | + | ||
| 110 | + | ## Action Items | |
| 111 | + | ||
| 112 | + | 1. ~~**[MEDIUM]** Split `tui/mod.rs` (2,211 lines) into input handlers, data loaders, and publish flow~~ — Done. mod.rs (767), input.rs (1,198), loading.rs (265). | |
| 113 | + | 2. ~~**[MEDIUM]** Fix `truncate()` UTF-8 panic — use `floor_char_boundary`~~ — Done. | |
| 114 | + | 3. ~~**[MEDIUM]** Fix all 18 clippy warnings~~ — Done. 0 warnings. | |
| 115 | + | 4. ~~**[MEDIUM]** Fix `parse_price("5.5")` — pad single-digit cents~~ — Done. Single-digit cents multiplied by 10. | |
| 116 | + | 5. ~~**[LOW]** Send exit status on non-interactive command completion~~ — Done. Added `exit_status_request(0)` + `eof` before close. | |
| 117 | + | 6. ~~**[LOW]** Add confirmation dialogs for destructive operations (delete, revoke)~~ — Done. Double-press confirmation on all 4 destructive actions (delete item, blog post, promo code; revoke license key). | |
| 118 | + | 7. ~~**[LOW]** Add unit tests for pure functions in staging.rs, format.rs, commands.rs~~ — Done. 36 new tests (10 → 46 total). |
| @@ -1,13 +1,24 @@ | |||
| 1 | 1 | # mnw-cli TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Phases 1-8 implemented. Git proxy (Parts A-C) implemented. Code quality pass complete (format.rs, api.rs helpers, widgets.rs). Not yet deployed — needs cargo check, astra testing, then hetzner deploy. Design doc: `docs/mnw/cli.md`. | |
| 4 | + | Done: Phases 1-8, Git proxy A-C, code quality pass, code review remediation. Active: None. Next: Verify + deploy. | |
| 5 | + | ||
| 6 | + | Not yet deployed — needs astra testing, then hetzner deploy. | |
| 5 | 7 | ||
| 6 | 8 | --- | |
| 7 | 9 | ||
| 8 | - | ## Verify Code Quality Pass | |
| 9 | - | - [ ] Run `cargo check` (format.rs extraction, api.rs response helpers, tui/widgets.rs table renderer) | |
| 10 | - | - [ ] Run `cargo clippy` for warnings | |
| 10 | + | ## Code Review Remediation (2026-04-12) | |
| 11 | + | ||
| 12 | + | ### Done | |
| 13 | + | - [x] Fix `truncate()` UTF-8 panic — use `floor_char_boundary` (commands.rs) | |
| 14 | + | - [x] Fix `parse_price("5.5")` returning 505 — pad single-digit cents (tui/mod.rs) | |
| 15 | + | - [x] Fix all 18 clippy warnings (0 remaining) | |
| 16 | + | - [x] Send exit status on non-interactive commands (handler.rs) | |
| 17 | + | - [x] Add 36 unit tests for pure functions (staging.rs, format.rs, commands.rs, tui/mod.rs) | |
| 18 | + | - [x] Split `tui/mod.rs` (2,211 → 767 lines) into input.rs (1,198) and loading.rs (265) | |
| 19 | + | - [x] Resolve yanked russh 0.58.1 → 0.58.0 | |
| 20 | + | ||
| 21 | + | - [x] Add confirmation dialogs for destructive operations (delete item, blog post, promo code, revoke key) | |
| 11 | 22 | ||
| 12 | 23 | ## Git Proxy — Part D: Server Configuration | |
| 13 | 24 | Port 22 takeover — mnw-cli owns port 22, sshd moves to 2200 (Tailscale only). | |
| @@ -39,8 +50,8 @@ Port 22 takeover — mnw-cli owns port 22, sshd moves to 2200 (Tailscale only). | |||
| 39 | 50 | mnw-cli/src/ | |
| 40 | 51 | main.rs, config.rs, api.rs, commands.rs, format.rs, staging.rs | |
| 41 | 52 | ssh/ (mod.rs, handler.rs, terminal.rs, sftp.rs, git.rs) | |
| 42 | - | tui/ (mod.rs, home.rs, project.rs, upload.rs, item.rs, analytics.rs, | |
| 43 | - | blog.rs, promo.rs, keys.rs, settings.rs, widgets.rs) | |
| 53 | + | tui/ (mod.rs, input.rs, loading.rs, home.rs, project.rs, upload.rs, | |
| 54 | + | item.rs, analytics.rs, blog.rs, promo.rs, keys.rs, settings.rs, widgets.rs) | |
| 44 | 55 | mnw-cli/deploy/ (deploy.sh, mnw-cli.service) | |
| 45 | 56 | docs/mnw/server/cli.md (design doc) | |
| 46 | 57 | ``` |
| @@ -51,6 +51,7 @@ pub struct CreatorStats { | |||
| 51 | 51 | ||
| 52 | 52 | /// Response from the create-item internal endpoint. | |
| 53 | 53 | #[derive(Debug, Deserialize)] | |
| 54 | + | #[allow(dead_code)] | |
| 54 | 55 | pub struct ItemCreated { | |
| 55 | 56 | pub item_id: String, | |
| 56 | 57 | pub project_id: String, | |
| @@ -58,6 +59,7 @@ pub struct ItemCreated { | |||
| 58 | 59 | ||
| 59 | 60 | /// Response from the presign-upload internal endpoint. | |
| 60 | 61 | #[derive(Debug, Deserialize)] | |
| 62 | + | #[allow(dead_code)] | |
| 61 | 63 | pub struct PresignResponse { | |
| 62 | 64 | pub upload_url: String, | |
| 63 | 65 | pub s3_key: String, | |
| @@ -214,7 +216,10 @@ async fn json_response<T: serde::de::DeserializeOwned>( | |||
| 214 | 216 | ) -> anyhow::Result<T> { | |
| 215 | 217 | if !resp.status().is_success() { | |
| 216 | 218 | let status = resp.status(); | |
| 217 | - | let body = resp.text().await.unwrap_or_default(); | |
| 219 | + | let body = resp.text().await.unwrap_or_else(|e| { | |
| 220 | + | tracing::warn!(error = %e, %context, "failed to read error response body"); | |
| 221 | + | String::new() | |
| 222 | + | }); | |
| 218 | 223 | if body.is_empty() { | |
| 219 | 224 | anyhow::bail!("{context} failed: HTTP {status}"); | |
| 220 | 225 | } | |
| @@ -227,7 +232,10 @@ async fn json_response<T: serde::de::DeserializeOwned>( | |||
| 227 | 232 | async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { | |
| 228 | 233 | if !resp.status().is_success() { | |
| 229 | 234 | let status = resp.status(); | |
| 230 | - | let body = resp.text().await.unwrap_or_default(); | |
| 235 | + | let body = resp.text().await.unwrap_or_else(|e| { | |
| 236 | + | tracing::warn!(error = %e, %context, "failed to read error response body"); | |
| 237 | + | String::new() | |
| 238 | + | }); | |
| 231 | 239 | if body.is_empty() { | |
| 232 | 240 | anyhow::bail!("{context} failed: HTTP {status}"); | |
| 233 | 241 | } | |
| @@ -860,7 +868,10 @@ impl MnwApiClient { | |||
| 860 | 868 | ||
| 861 | 869 | if !resp.status().is_success() { | |
| 862 | 870 | let status = resp.status(); | |
| 863 | - | let body = resp.text().await.unwrap_or_default(); | |
| 871 | + | let body = resp.text().await.unwrap_or_else(|e| { | |
| 872 | + | tracing::warn!(error = %e, "failed to read git_authorize error body"); | |
| 873 | + | String::new() | |
| 874 | + | }); | |
| 864 | 875 | // Parse JSON error if available, fall back to status text | |
| 865 | 876 | let msg = serde_json::from_str::<serde_json::Value>(&body) | |
| 866 | 877 | .ok() |
| @@ -13,7 +13,7 @@ pub async fn execute( | |||
| 13 | 13 | user: &UserInfo, | |
| 14 | 14 | api: &MnwApiClient, | |
| 15 | 15 | ) -> Vec<u8> { | |
| 16 | - | let parts: Vec<&str> = command_line.trim().split_whitespace().collect(); | |
| 16 | + | let parts: Vec<&str> = command_line.split_whitespace().collect(); | |
| 17 | 17 | if parts.is_empty() { | |
| 18 | 18 | return help_text(); | |
| 19 | 19 | } | |
| @@ -305,6 +305,40 @@ fn truncate(s: &str, max_len: usize) -> &str { | |||
| 305 | 305 | if s.len() <= max_len { | |
| 306 | 306 | s | |
| 307 | 307 | } else { | |
| 308 | - | &s[..max_len] | |
| 308 | + | &s[..s.floor_char_boundary(max_len)] | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[cfg(test)] | |
| 313 | + | mod tests { | |
| 314 | + | use super::*; | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn truncate_short_string() { | |
| 318 | + | assert_eq!(truncate("hello", 10), "hello"); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn truncate_exact_length() { | |
| 323 | + | assert_eq!(truncate("hello", 5), "hello"); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn truncate_long_string() { | |
| 328 | + | assert_eq!(truncate("hello world", 5), "hello"); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn truncate_multibyte_utf8() { | |
| 333 | + | // "café" is 5 bytes (é = 2 bytes), truncating at 4 should not panic | |
| 334 | + | let result = truncate("café", 4); | |
| 335 | + | assert_eq!(result, "caf"); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn truncate_emoji() { | |
| 340 | + | // Each emoji is 4 bytes | |
| 341 | + | let result = truncate("🎵🎶🎸", 5); | |
| 342 | + | assert_eq!(result, "🎵"); | |
| 309 | 343 | } | |
| 310 | 344 | } |
| @@ -61,3 +61,60 @@ pub fn format_item_type(it: &str) -> &str { | |||
| 61 | 61 | other => other, | |
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | + | ||
| 65 | + | #[cfg(test)] | |
| 66 | + | mod tests { | |
| 67 | + | use super::*; | |
| 68 | + | ||
| 69 | + | #[test] | |
| 70 | + | fn format_cents_zero() { | |
| 71 | + | assert_eq!(format_cents(0), "$0"); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | #[test] | |
| 75 | + | fn format_cents_positive() { | |
| 76 | + | assert_eq!(format_cents(999), "$9.99"); | |
| 77 | + | assert_eq!(format_cents(100), "$1.00"); | |
| 78 | + | assert_eq!(format_cents(1050), "$10.50"); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[test] | |
| 82 | + | fn format_price_free() { | |
| 83 | + | assert_eq!(format_price(0), "Free"); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | #[test] | |
| 87 | + | fn format_price_nonzero() { | |
| 88 | + | assert_eq!(format_price(550), "$5.50"); | |
| 89 | + | assert_eq!(format_price(1299), "$12.99"); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn format_tier_known() { | |
| 94 | + | assert_eq!(format_tier("basic"), "Basic"); | |
| 95 | + | assert_eq!(format_tier("small_files"), "Small Files"); | |
| 96 | + | assert_eq!(format_tier("streaming"), "Streaming"); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn format_tier_unknown() { | |
| 101 | + | assert_eq!(format_tier("custom"), "custom"); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[test] | |
| 105 | + | fn format_project_type_known() { | |
| 106 | + | assert_eq!(format_project_type("software"), "Software"); | |
| 107 | + | assert_eq!(format_project_type("music"), "Music"); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn format_item_type_known() { | |
| 112 | + | assert_eq!(format_item_type("audio"), "Audio"); | |
| 113 | + | assert_eq!(format_item_type("plugin"), "Plugin"); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn format_item_type_unknown() { | |
| 118 | + | assert_eq!(format_item_type("other_thing"), "other_thing"); | |
| 119 | + | } | |
| 120 | + | } |
| @@ -125,6 +125,6 @@ fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKe | |||
| 125 | 125 | std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; | |
| 126 | 126 | } | |
| 127 | 127 | tracing::info!(path = %path.display(), "host key saved"); | |
| 128 | - | Ok(key.into()) | |
| 128 | + | Ok(key) | |
| 129 | 129 | } | |
| 130 | 130 | } |
| @@ -237,58 +237,58 @@ impl russh::server::Handler for MnwHandler { | |||
| 237 | 237 | }; | |
| 238 | 238 | ||
| 239 | 239 | // Git operations: bidirectional streaming via subprocess proxy | |
| 240 | - | if let Some((operation, raw_path)) = git::parse_git_command(&command_line) { | |
| 241 | - | if let Some((owner, repo_name)) = git::parse_repo_path(raw_path) { | |
| 242 | - | tracing::info!( | |
| 243 | - | user = %user.username, | |
| 244 | - | %operation, | |
| 245 | - | %owner, | |
| 246 | - | %repo_name, | |
| 247 | - | "git operation" | |
| 248 | - | ); | |
| 249 | - | ||
| 250 | - | match self | |
| 251 | - | .api | |
| 252 | - | .git_authorize(&user.user_id, operation, owner, repo_name) | |
| 240 | + | if let Some((operation, raw_path)) = git::parse_git_command(&command_line) | |
| 241 | + | && let Some((owner, repo_name)) = git::parse_repo_path(raw_path) | |
| 242 | + | { | |
| 243 | + | tracing::info!( | |
| 244 | + | user = %user.username, | |
| 245 | + | %operation, | |
| 246 | + | %owner, | |
| 247 | + | %repo_name, | |
| 248 | + | "git operation" | |
| 249 | + | ); | |
| 250 | + | ||
| 251 | + | match self | |
| 252 | + | .api | |
| 253 | + | .git_authorize(&user.user_id, operation, owner, repo_name) | |
| 254 | + | .await | |
| 255 | + | { | |
| 256 | + | Ok(auth) => { | |
| 257 | + | match git::spawn_git_process( | |
| 258 | + | &self.git_user, | |
| 259 | + | operation, | |
| 260 | + | &auth.repo_path, | |
| 261 | + | channel, | |
| 262 | + | handle.clone(), | |
| 263 | + | ) | |
| 253 | 264 | .await | |
| 254 | - | { | |
| 255 | - | Ok(auth) => { | |
| 256 | - | match git::spawn_git_process( | |
| 257 | - | &self.git_user, | |
| 258 | - | operation, | |
| 259 | - | &auth.repo_path, | |
| 260 | - | channel, | |
| 261 | - | handle.clone(), | |
| 262 | - | ) | |
| 263 | - | .await | |
| 264 | - | { | |
| 265 | - | Ok(stdin) => { | |
| 266 | - | self.git_processes.insert(channel, stdin); | |
| 267 | - | session.channel_success(channel)?; | |
| 268 | - | } | |
| 269 | - | Err(e) => { | |
| 270 | - | tracing::error!(error = ?e, "failed to spawn git process"); | |
| 271 | - | let msg = bytes::Bytes::from(format!( | |
| 272 | - | "fatal: internal error\r\n" | |
| 273 | - | )); | |
| 274 | - | let _ = handle.data(channel, msg).await; | |
| 275 | - | let _ = handle.exit_status_request(channel, 1).await; | |
| 276 | - | let _ = handle.eof(channel).await; | |
| 277 | - | let _ = handle.close(channel).await; | |
| 278 | - | } | |
| 265 | + | { | |
| 266 | + | Ok(stdin) => { | |
| 267 | + | self.git_processes.insert(channel, stdin); | |
| 268 | + | session.channel_success(channel)?; | |
| 269 | + | } | |
| 270 | + | Err(e) => { | |
| 271 | + | tracing::error!(error = ?e, "failed to spawn git process"); | |
| 272 | + | let msg = bytes::Bytes::from( | |
| 273 | + | "fatal: internal error\r\n".to_string(), | |
| 274 | + | ); | |
| 275 | + | let _ = handle.data(channel, msg).await; | |
| 276 | + | let _ = handle.exit_status_request(channel, 1).await; | |
| 277 | + | let _ = handle.eof(channel).await; | |
| 278 | + | let _ = handle.close(channel).await; | |
| 279 | 279 | } | |
| 280 | - | } | |
| 281 | - | Err(e) => { | |
| 282 | - | let msg = | |
| 283 | - | bytes::Bytes::from(format!("fatal: {e}\r\n")); | |
| 284 | - | let _ = handle.extended_data(channel, 1, msg).await; | |
| 285 | - | let _ = handle.exit_status_request(channel, 1).await; | |
| 286 | - | let _ = handle.eof(channel).await; | |
| 287 | - | let _ = handle.close(channel).await; | |
| 288 | 280 | } | |
| 289 | 281 | } | |
| 290 | - | return Ok(()); | |
| 282 | + | Err(e) => { | |
| 283 | + | let msg = | |
| 284 | + | bytes::Bytes::from(format!("fatal: {e}\r\n")); | |
| 285 | + | let _ = handle.extended_data(channel, 1, msg).await; | |
| 286 | + | let _ = handle.exit_status_request(channel, 1).await; | |
| 287 | + | let _ = handle.eof(channel).await; | |
| 288 | + | let _ = handle.close(channel).await; | |
| 289 | + | } | |
| 291 | 290 | } | |
| 291 | + | return Ok(()); | |
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | 294 | // Check if this looks like a legacy SCP transfer | |
| @@ -313,6 +313,8 @@ impl russh::server::Handler for MnwHandler { | |||
| 313 | 313 | let output = crate::commands::execute(&cmd, &user, &api).await; | |
| 314 | 314 | let bytes = bytes::Bytes::from(output); | |
| 315 | 315 | let _ = handle.data(channel, bytes).await; | |
| 316 | + | let _ = handle.exit_status_request(channel, 0).await; | |
| 317 | + | let _ = handle.eof(channel).await; | |
| 316 | 318 | let _ = handle.close(channel).await; | |
| 317 | 319 | }); | |
| 318 | 320 |
| @@ -181,11 +181,11 @@ impl russh_sftp::server::Handler for SftpSession { | |||
| 181 | 181 | return Ok(Attrs { id, attrs }); | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | - | if let Some(of) = self.open_files.get(&handle) { | |
| 185 | - | if let Ok(metadata) = of.file.metadata().await { | |
| 186 | - | let attrs = FileAttributes::from(&metadata); | |
| 187 | - | return Ok(Attrs { id, attrs }); | |
| 188 | - | } | |
| 184 | + | if let Some(of) = self.open_files.get(&handle) | |
| 185 | + | && let Ok(metadata) = of.file.metadata().await | |
| 186 | + | { | |
| 187 | + | let attrs = FileAttributes::from(&metadata); | |
| 188 | + | return Ok(Attrs { id, attrs }); | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 191 | Err(StatusCode::Failure) |
| @@ -72,10 +72,10 @@ pub async fn staging_usage(dir: &Path) -> u64 { | |||
| 72 | 72 | }; | |
| 73 | 73 | ||
| 74 | 74 | while let Ok(Some(entry)) = entries.next_entry().await { | |
| 75 | - | if let Ok(metadata) = entry.metadata().await { | |
| 76 | - | if metadata.is_file() { | |
| 77 | - | total += metadata.len(); | |
| 78 | - | } | |
| 75 | + | if let Ok(metadata) = entry.metadata().await | |
| 76 | + | && metadata.is_file() | |
| 77 | + | { | |
| 78 | + | total += metadata.len(); | |
| 79 | 79 | } | |
| 80 | 80 | } | |
| 81 | 81 | ||
| @@ -281,3 +281,85 @@ pub fn format_bytes(bytes: u64) -> String { | |||
| 281 | 281 | format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 282 | 282 | } | |
| 283 | 283 | } | |
| 284 | + | ||
| 285 | + | #[cfg(test)] | |
| 286 | + | mod tests { | |
| 287 | + | use super::*; | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn sanitize_filename_basic() { | |
| 291 | + | assert_eq!(sanitize_filename("song.mp3"), "song.mp3"); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn sanitize_filename_path_traversal() { | |
| 296 | + | assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); | |
| 297 | + | assert_eq!(sanitize_filename("..\\..\\secret.txt"), "secret.txt"); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn sanitize_filename_strips_special_chars() { | |
| 302 | + | assert_eq!(sanitize_filename("my<>file|name.zip"), "myfilename.zip"); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn sanitize_filename_empty_and_dots() { | |
| 307 | + | assert_eq!(sanitize_filename(""), "upload"); | |
| 308 | + | assert_eq!(sanitize_filename("."), "upload"); | |
| 309 | + | assert_eq!(sanitize_filename(".."), "upload"); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn sanitize_filename_length_limit() { | |
| 314 | + | let long_name = "a".repeat(300); | |
| 315 | + | assert_eq!(sanitize_filename(&long_name).len(), 200); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn classify_extension_audio() { | |
| 320 | + | let c = classify_extension("mp3").unwrap(); | |
| 321 | + | assert_eq!(c.item_type, "audio"); | |
| 322 | + | assert_eq!(c.content_type, "audio/mpeg"); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn classify_extension_digital() { | |
| 327 | + | let c = classify_extension("zip").unwrap(); | |
| 328 | + | assert_eq!(c.item_type, "digital"); | |
| 329 | + | assert_eq!(c.file_type, "download"); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[test] | |
| 333 | + | fn classify_extension_unknown() { | |
| 334 | + | assert!(classify_extension("txt").is_none()); | |
| 335 | + | assert!(classify_extension("").is_none()); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn derive_title_basic() { | |
| 340 | + | assert_eq!(derive_title("my-cool-song.mp3"), "My Cool Song"); | |
| 341 | + | assert_eq!(derive_title("hello_world.zip"), "Hello World"); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn derive_title_no_extension() { | |
| 346 | + | assert_eq!(derive_title("readme"), "Readme"); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn is_allowed_extension_cases() { | |
| 351 | + | assert!(is_allowed_extension("MP3")); | |
| 352 | + | assert!(is_allowed_extension("wav")); | |
| 353 | + | assert!(!is_allowed_extension("txt")); | |
| 354 | + | assert!(!is_allowed_extension("")); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn format_bytes_ranges() { | |
| 359 | + | assert_eq!(format_bytes(0), "0 B"); | |
| 360 | + | assert_eq!(format_bytes(512), "512 B"); | |
| 361 | + | assert_eq!(format_bytes(1024), "1.0 KB"); | |
| 362 | + | assert_eq!(format_bytes(1_048_576), "1.0 MB"); | |
| 363 | + | assert_eq!(format_bytes(1_073_741_824), "1.00 GB"); | |
| 364 | + | } | |
| 365 | + | } |
| @@ -193,8 +193,7 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout | |||
| 193 | 193 | (chunks[1].width as usize) | |
| 194 | 194 | .checked_div(bars.len().max(1)) | |
| 195 | 195 | .unwrap_or(3) | |
| 196 | - | .min(8) | |
| 197 | - | .max(1) as u16, | |
| 196 | + | .clamp(1, 8) as u16, | |
| 198 | 197 | ) | |
| 199 | 198 | .bar_gap(1) | |
| 200 | 199 | .max(max_val as u64); |
| @@ -0,0 +1,1267 @@ | |||
| 1 | + | //! Screen-specific input handlers. | |
| 2 | + | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 5 | + | use crossterm::event::{KeyCode, KeyEvent}; | |
| 6 | + | use tokio::sync::mpsc; | |
| 7 | + | ||
| 8 | + | use crate::api::MnwApiClient; | |
| 9 | + | ||
| 10 | + | use super::loading::*; | |
| 11 | + | use super::{ | |
| 12 | + | item, App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep, | |
| 13 | + | Screen, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | pub(super) async fn handle_home_input( | |
| 17 | + | key: KeyEvent, | |
| 18 | + | app: &mut App, | |
| 19 | + | screen: &mut Screen, | |
| 20 | + | api: &MnwApiClient, | |
| 21 | + | tx: &mpsc::Sender<AppEvent>, | |
| 22 | + | staging_dir: &Path, | |
| 23 | + | ) { | |
| 24 | + | match key.code { | |
| 25 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 26 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 27 | + | KeyCode::Enter => { | |
| 28 | + | if !app.projects.is_empty() { | |
| 29 | + | let idx = app.selected_index; | |
| 30 | + | let project_id = app.projects[idx].id.clone(); | |
| 31 | + | let user_id = app.user.user_id.clone(); | |
| 32 | + | *screen = Screen::Project(idx); | |
| 33 | + | app.items.clear(); | |
| 34 | + | app.selected_index = 0; | |
| 35 | + | app.loading = true; | |
| 36 | + | ||
| 37 | + | let api = api.clone(); | |
| 38 | + | let tx = tx.clone(); | |
| 39 | + | tokio::spawn(async move { | |
| 40 | + | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 41 | + | }); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | KeyCode::Char('u') | KeyCode::Char('U') => { | |
| 45 | + | *screen = Screen::Upload; | |
| 46 | + | app.selected_index = 0; | |
| 47 | + | app.loading = true; | |
| 48 | + | app.upload_status = None; | |
| 49 | + | app.editing_field = None; | |
| 50 | + | ||
| 51 | + | let staging_dir = staging_dir.to_path_buf(); | |
| 52 | + | let api = api.clone(); | |
| 53 | + | let user_id = app.user.user_id.clone(); | |
| 54 | + | let tx = tx.clone(); | |
| 55 | + | tokio::spawn(async move { | |
| 56 | + | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 57 | + | }); | |
| 58 | + | } | |
| 59 | + | KeyCode::Char('a') | KeyCode::Char('A') => { | |
| 60 | + | *screen = Screen::Analytics; | |
| 61 | + | app.analytics_data = None; | |
| 62 | + | app.analytics_status = None; | |
| 63 | + | app.analytics_show_transactions = false; | |
| 64 | + | app.selected_index = 0; | |
| 65 | + | app.loading = true; | |
| 66 | + | ||
| 67 | + | let api = api.clone(); | |
| 68 | + | let user_id = app.user.user_id.clone(); | |
| 69 | + | let range = app.analytics_range.clone(); | |
| 70 | + | let tx = tx.clone(); | |
| 71 | + | tokio::spawn(async move { | |
| 72 | + | load_analytics(&api, &user_id, &range, &tx).await; | |
| 73 | + | }); | |
| 74 | + | } | |
| 75 | + | KeyCode::Char('p') | KeyCode::Char('P') => { | |
| 76 | + | *screen = Screen::Promo; | |
| 77 | + | app.promo_codes.clear(); | |
| 78 | + | app.promo_status = None; | |
| 79 | + | app.promo_editing_step = None; | |
| 80 | + | app.selected_index = 0; | |
| 81 | + | app.loading = true; | |
| 82 | + | ||
| 83 | + | let api = api.clone(); | |
| 84 | + | let user_id = app.user.user_id.clone(); | |
| 85 | + | let tx = tx.clone(); | |
| 86 | + | tokio::spawn(async move { | |
| 87 | + | load_promo_codes(&api, &user_id, &tx).await; | |
| 88 | + | }); | |
| 89 | + | } | |
| 90 | + | KeyCode::Char('s') | KeyCode::Char('S') => { | |
| 91 | + | *screen = Screen::Settings; | |
| 92 | + | app.ssh_keys.clear(); | |
| 93 | + | app.settings_status = None; | |
| 94 | + | app.selected_index = 0; | |
| 95 | + | app.loading = true; | |
| 96 | + | ||
| 97 | + | let api = api.clone(); | |
| 98 | + | let user_id = app.user.user_id.clone(); | |
| 99 | + | let tx = tx.clone(); | |
| 100 | + | tokio::spawn(async move { | |
| 101 | + | load_settings(&api, &user_id, &tx).await; | |
| 102 | + | }); | |
| 103 | + | } | |
| 104 | + | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 105 | + | app.loading = true; | |
| 106 | + | let api = api.clone(); | |
| 107 | + | let user_id = app.user.user_id.clone(); | |
| 108 | + | let tx = tx.clone(); | |
| 109 | + | tokio::spawn(async move { | |
| 110 | + | load_home_data(&api, &user_id, &tx).await; | |
| 111 | + | }); | |
| 112 | + | } | |
| 113 | + | _ => {} | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | pub(super) async fn handle_project_input( | |
| 118 | + | key: KeyEvent, | |
| 119 | + | app: &mut App, | |
| 120 | + | screen: &mut Screen, | |
| 121 | + | api: &MnwApiClient, | |
| 122 | + | tx: &mpsc::Sender<AppEvent>, | |
| 123 | + | ) { | |
| 124 | + | match key.code { | |
| 125 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 126 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 127 | + | KeyCode::Esc => { | |
| 128 | + | *screen = Screen::Home; | |
| 129 | + | app.items.clear(); | |
| 130 | + | app.selected_index = 0; | |
| 131 | + | } | |
| 132 | + | KeyCode::Enter => { | |
| 133 | + | if let Screen::Project(pidx) = screen | |
| 134 | + | && !app.items.is_empty() | |
| 135 | + | { | |
| 136 | + | let item_id = app.items[app.selected_index].id.clone(); | |
| 137 | + | let pidx = *pidx; | |
| 138 | + | *screen = Screen::Item(pidx, item_id.clone()); | |
| 139 | + | app.item_detail = None; | |
| 140 | + | app.item_versions.clear(); | |
| 141 | + | app.item_status = None; | |
| 142 | + | app.item_editing = None; | |
| 143 | + | app.selected_index = 0; | |
| 144 | + | app.loading = true; | |
| 145 | + | ||
| 146 | + | let api = api.clone(); | |
| 147 | + | let user_id = app.user.user_id.clone(); | |
| 148 | + | let tx = tx.clone(); | |
| 149 | + | tokio::spawn(async move { | |
| 150 | + | load_item_detail(&api, &user_id, &item_id, &tx).await; | |
| 151 | + | }); | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | KeyCode::Char('b') | KeyCode::Char('B') => { | |
| 155 | + | // Open blog screen for this project | |
| 156 | + | if let Screen::Project(idx) = screen | |
| 157 | + | && let Some(p) = app.projects.get(*idx) | |
| 158 | + | { | |
| 159 | + | let pidx = *idx; | |
| 160 | + | let project_id = p.id.clone(); | |
| 161 | + | let project_title = p.title.clone(); | |
| 162 | + | *screen = Screen::Blog(pidx, project_id.clone()); | |
| 163 | + | app.blog_posts.clear(); | |
| 164 | + | app.blog_project_title = Some(project_title); | |
| 165 | + | app.blog_status = None; | |
| 166 | + | app.blog_create_step = None; | |
| 167 | + | app.selected_index = 0; | |
| 168 | + | app.loading = true; | |
| 169 | + | ||
| 170 | + | let api = api.clone(); | |
| 171 | + | let user_id = app.user.user_id.clone(); | |
| 172 | + | let tx = tx.clone(); | |
| 173 | + | tokio::spawn(async move { | |
| 174 | + | load_blog_posts(&api, &user_id, &project_id, &tx).await; | |
| 175 | + | }); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 179 | + | if let Screen::Project(idx) = screen | |
| 180 | + | && let Some(p) = app.projects.get(*idx) | |
| 181 | + | { | |
| 182 | + | app.loading = true; | |
| 183 | + | let api = api.clone(); | |
| 184 | + | let project_id = p.id.clone(); | |
| 185 | + | let user_id = app.user.user_id.clone(); | |
| 186 | + | let tx = tx.clone(); | |
| 187 | + | tokio::spawn(async move { | |
| 188 | + | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 189 | + | }); | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | _ => {} | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | pub(super) async fn handle_upload_input( | |
| 197 | + | key: KeyEvent, | |
| 198 | + | app: &mut App, | |
| 199 | + | screen: &mut Screen, | |
| 200 | + | api: &MnwApiClient, | |
| 201 | + | tx: &mpsc::Sender<AppEvent>, | |
| 202 | + | staging_dir: &Path, | |
| 203 | + | ) { | |
| 204 | + | // Handle editing mode | |
| 205 | + | if let Some(field) = app.editing_field { | |
| 206 | + | match key.code { | |
| 207 | + | KeyCode::Esc => { | |
| 208 | + | app.editing_field = None; | |
| 209 | + | app.edit_buffer.clear(); | |
| 210 | + | } | |
| 211 | + | KeyCode::Enter => { | |
| 212 | + | let idx = app.selected_index; | |
| 213 | + | if idx < app.file_metadata.len() { | |
| 214 | + | match field { | |
| 215 | + | EditField::Title => { | |
| 216 | + | if !app.edit_buffer.is_empty() { | |
| 217 | + | app.file_metadata[idx].title = Some(app.edit_buffer.clone()); | |
| 218 | + | } | |
| 219 | + | // Show project selection | |
| 220 | + | app.edit_buffer.clear(); | |
| 221 | + | app.editing_field = Some(EditField::Project); | |
| 222 | + | let project_list = app | |
| 223 | + | .projects | |
| 224 | + | .iter() | |
| 225 | + | .enumerate() | |
| 226 | + | .map(|(i, p)| format!("{}:{}", i + 1, p.title)) | |
| 227 | + | .collect::<Vec<_>>() | |
| 228 | + | .join(" "); | |
| 229 | + | app.upload_status = | |
| 230 | + | Some(format!("Project #: _ | {}", project_list)); | |
| 231 | + | return; | |
| 232 | + | } | |
| 233 | + | EditField::Project => { | |
| 234 | + | if let Ok(n) = app.edit_buffer.parse::<usize>() | |
| 235 | + | && n > 0 && n <= app.projects.len() | |
| 236 | + | { | |
| 237 | + | let pidx = n - 1; | |
| 238 | + | app.file_metadata[idx].project_idx = Some(pidx); | |
| 239 | + | app.file_metadata[idx].project_name = | |
| 240 | + | Some(app.projects[pidx].title.clone()); | |
| 241 | + | } | |
| 242 | + | // Advance to Price field | |
| 243 | + | app.edit_buffer.clear(); | |
| 244 | + | app.editing_field = Some(EditField::Price); | |
| 245 | + | app.upload_status = | |
| 246 | + | Some("Price ($): _ (0 or empty for free)".to_string()); | |
| 247 | + | return; | |
| 248 | + | } | |
| 249 | + | EditField::Price => { | |
| 250 | + | let cents = super::parse_price(&app.edit_buffer); | |
| 251 | + | app.file_metadata[idx].price_cents = cents; | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | app.editing_field = None; | |
| 256 | + | app.edit_buffer.clear(); | |
| 257 | + | app.upload_status = None; | |
| 258 | + | } | |
| 259 | + | KeyCode::Backspace => { | |
| 260 | + | app.edit_buffer.pop(); | |
| 261 | + | } | |
| 262 | + | KeyCode::Char(c) => { | |
| 263 | + | app.edit_buffer.push(c); | |
| 264 | + | if let Some(field) = app.editing_field { | |
| 265 | + | app.upload_status = Some(super::format_edit_prompt(field, &app.edit_buffer)); | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | _ => {} | |
| 269 | + | } | |
| 270 | + | return; | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Normal mode | |
| 274 | + | match key.code { | |
| 275 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 276 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 277 | + | KeyCode::Esc => { | |
| 278 | + | *screen = Screen::Home; | |
| 279 | + | app.staged_files.clear(); | |
| 280 | + | app.file_metadata.clear(); | |
| 281 | + | app.upload_status = None; | |
| 282 | + | app.selected_index = 0; | |
| 283 | + | } | |
| 284 | + | KeyCode::Char('e') | KeyCode::Char('E') => { | |
| 285 | + | if !app.staged_files.is_empty() { | |
| 286 | + | let idx = app.selected_index; | |
| 287 | + | let current_title = app.file_metadata.get(idx).and_then(|m| m.title.clone()); | |
| 288 | + | app.editing_field = Some(EditField::Title); | |
| 289 | + | app.edit_buffer = current_title.unwrap_or_default(); | |
| 290 | + | app.upload_status = | |
| 291 | + | Some(super::format_edit_prompt(EditField::Title, &app.edit_buffer)); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | KeyCode::Char('p') | KeyCode::Char('P') => { | |
| 295 | + | if !app.staged_files.is_empty() && !app.publishing { | |
| 296 | + | let idx = app.selected_index; | |
| 297 | + | let file = &app.staged_files[idx]; | |
| 298 | + | let meta = app.file_metadata.get(idx).cloned().unwrap_or_default(); | |
| 299 | + | ||
| 300 | + | // Validate | |
| 301 | + | let Some(classification) = file.classification else { | |
| 302 | + | app.upload_status = Some("Unsupported file type".to_string()); | |
| 303 | + | return; | |
| 304 | + | }; | |
| 305 | + | let Some(project_idx) = meta.project_idx else { | |
| 306 | + | app.upload_status = | |
| 307 | + | Some("Set project first (press [e] to edit)".to_string()); | |
| 308 | + | return; | |
| 309 | + | }; | |
| 310 | + | let Some(project) = app.projects.get(project_idx) else { | |
| 311 | + | app.upload_status = Some("Invalid project".to_string()); | |
| 312 | + | return; | |
| 313 | + | }; | |
| 314 | + | ||
| 315 | + | let title = meta | |
| 316 | + | .title | |
| 317 | + | .unwrap_or_else(|| crate::staging::derive_title(&file.filename)); | |
| 318 | + | let project_id = project.id.clone(); | |
| 319 | + | let user_id = app.user.user_id.clone(); | |
| 320 | + | let filename = file.filename.clone(); | |
| 321 | + | let file_path = staging_dir.join(&filename); | |
| 322 | + | let price_cents = meta.price_cents; | |
| 323 | + | let item_type = classification.item_type.to_string(); | |
| 324 | + | let file_type = classification.file_type.to_string(); | |
| 325 | + | let content_type = classification.content_type.to_string(); | |
| 326 | + | let api = api.clone(); | |
| 327 | + | let tx = tx.clone(); | |
| 328 | + | ||
| 329 | + | app.publishing = true; | |
| 330 | + | app.upload_status = Some(format!("Publishing {}...", filename)); | |
| 331 | + | ||
| 332 | + | tokio::spawn(async move { | |
| 333 | + | let result = publish_file( | |
| 334 | + | &api, | |
| 335 | + | &user_id, | |
| 336 | + | &project_id, | |
| 337 | + | &title, | |
| 338 | + | &item_type, | |
| 339 | + | &file_type, | |
| 340 | + | &filename, | |
| 341 | + | &content_type, | |
| 342 | + | price_cents, | |
| 343 | + | &file_path, | |
| 344 | + | ) | |
| 345 | + | .await; | |
| 346 | + | ||
| 347 | + | let (success, error) = match result { | |
| 348 | + | Ok(()) => (true, None), | |
| 349 | + | Err(e) => (false, Some(e.to_string())), | |
| 350 | + | }; | |
| 351 | + | ||
| 352 | + | let _ = tx | |
| 353 | + | .send(AppEvent::DataLoaded(DataPayload::PublishResult { | |
| 354 | + | filename, | |
| 355 | + | success, | |
| 356 | + | error, | |
| 357 | + | })) | |
| 358 | + | .await; | |
| 359 | + | }); | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | KeyCode::Char('d') | KeyCode::Char('D') => { | |
| 363 | + | if !app.staged_files.is_empty() { | |
| 364 | + | let idx = app.selected_index; | |
| 365 | + | let filename = app.staged_files[idx].filename.clone(); | |
| 366 | + | let file_path = staging_dir.join(&filename); | |
| 367 | + | let staging_dir = staging_dir.to_path_buf(); | |
| 368 | + | let api = api.clone(); | |
| 369 | + | let user_id = app.user.user_id.clone(); | |
| 370 | + | let tx = tx.clone(); | |
| 371 | + | ||
| 372 | + | app.upload_status = Some(format!("Deleting {}...", filename)); | |
| 373 | + | tokio::spawn(async move { | |
| 374 | + | if let Err(e) = tokio::fs::remove_file(&file_path).await { | |
| 375 | + | tracing::warn!(error = %e, "failed to delete staged file"); | |
| 376 | + | } | |
| 377 | + | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 378 | + | }); | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 382 | + | app.loading = true; | |
| 383 | + | let staging_dir = staging_dir.to_path_buf(); | |
| 384 | + | let api = api.clone(); | |
| 385 | + | let user_id = app.user.user_id.clone(); | |
| 386 | + | let tx = tx.clone(); | |
| 387 | + | tokio::spawn(async move { | |
| 388 | + | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 389 | + | }); | |
| 390 | + | } | |
| 391 | + | _ => {} | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | pub(super) async fn handle_item_input( | |
| 396 | + | key: KeyEvent, | |
| 397 | + | app: &mut App, | |
| 398 | + | screen: &mut Screen, | |
| 399 | + | api: &MnwApiClient, | |
| 400 | + | tx: &mpsc::Sender<AppEvent>, | |
| 401 | + | ) { | |
| 402 | + | use item::ItemEditField; | |
| 403 | + | ||
| 404 | + | // Cancel pending confirmation on any key other than the confirmation key | |
| 405 | + | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) { | |
| 406 | + | app.confirm_action = None; | |
| 407 | + | app.item_status = None; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // Handle editing mode | |
| 411 | + | if let Some(field) = app.item_editing { | |
| 412 | + | match key.code { | |
| 413 | + | KeyCode::Esc => { | |
| 414 | + | app.item_editing = None; | |
| 415 | + | app.edit_buffer.clear(); | |
| 416 | + | app.item_status = None; | |
| 417 | + | } | |
| 418 | + | KeyCode::Enter => { | |
| 419 | + | if let Some(ref detail) = app.item_detail { | |
| 420 | + | let item_id = detail.id.clone(); | |
| 421 | + | let user_id = app.user.user_id.clone(); | |
| 422 | + | let api = api.clone(); | |
| 423 | + | let tx = tx.clone(); | |
| 424 | + | let buffer = app.edit_buffer.clone(); | |
| 425 | + | ||
| 426 | + | match field { | |
| 427 | + | ItemEditField::Title => { | |
| 428 | + | if !buffer.is_empty() { | |
| 429 | + | let title = buffer; | |
| 430 | + | tokio::spawn(async move { | |
| 431 | + | match api | |
| 432 | + | .update_item(&user_id, &item_id, Some(&title), None, None, None) | |
| 433 | + | .await | |
| 434 | + | { | |
| 435 | + | Ok(d) => { | |
| 436 | + | let _ = tx | |
| 437 | + | .send(AppEvent::DataLoaded( | |
| 438 | + | DataPayload::ItemUpdated { detail: d }, | |
| 439 | + | )) | |
| 440 | + | .await; | |
| 441 | + | } | |
| 442 | + | Err(e) => { | |
| 443 | + | let _ = tx | |
| 444 | + | .send(AppEvent::DataLoaded( | |
| 445 | + | DataPayload::ItemActionError { | |
| 446 | + | error: e.to_string(), | |
| 447 | + | }, | |
| 448 | + | )) | |
| 449 | + | .await; | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | }); | |
| 453 | + | } else { | |
| 454 | + | app.item_editing = None; | |
| 455 | + | app.edit_buffer.clear(); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ItemEditField::Description => { | |
| 459 | + | let desc = if buffer.is_empty() { | |
| 460 | + | None | |
| 461 | + | } else { | |
| 462 | + | Some(buffer.as_str().to_string()) | |
| 463 | + | }; | |
| 464 | + | tokio::spawn(async move { | |
| 465 | + | match api | |
| 466 | + | .update_item( | |
| 467 | + | &user_id, | |
| 468 | + | &item_id, | |
| 469 | + | None, | |
| 470 | + | desc.as_deref(), | |
| 471 | + | None, | |
| 472 | + | None, | |
| 473 | + | ) | |
| 474 | + | .await | |
| 475 | + | { | |
| 476 | + | Ok(d) => { | |
| 477 | + | let _ = tx | |
| 478 | + | .send(AppEvent::DataLoaded( | |
| 479 | + | DataPayload::ItemUpdated { detail: d }, | |
| 480 | + | )) | |
| 481 | + | .await; | |
| 482 | + | } | |
| 483 | + | Err(e) => { | |
| 484 | + | let _ = tx | |
| 485 | + | .send(AppEvent::DataLoaded( | |
| 486 | + | DataPayload::ItemActionError { | |
| 487 | + | error: e.to_string(), | |
| 488 | + | }, | |
| 489 | + | )) | |
| 490 | + | .await; | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | }); | |
| 494 | + | } | |
| 495 | + | ItemEditField::Price => { | |
| 496 | + | let cents = super::parse_price(&buffer); | |
| 497 | + | tokio::spawn(async move { | |
| 498 | + | match api | |
| 499 | + | .update_item(&user_id, &item_id, None, None, Some(cents), None) | |
| 500 | + | .await |
Lines truncated
| @@ -0,0 +1,265 @@ | |||
| 1 | + | //! Async data loading functions and the publish flow. | |
| 2 | + | ||
| 3 | + | use tokio::sync::mpsc; | |
| 4 | + | ||
| 5 | + | use crate::api::{CreatorStats, MnwApiClient}; | |
| 6 | + | use crate::staging; | |
| 7 | + | ||
| 8 | + | use super::{AppEvent, DataPayload}; | |
| 9 | + | ||
| 10 | + | pub(super) async fn load_home_data( | |
| 11 | + | api: &MnwApiClient, | |
| 12 | + | user_id: &str, | |
| 13 | + | tx: &mpsc::Sender<AppEvent>, | |
| 14 | + | ) { | |
| 15 | + | let projects = api.get_projects(user_id).await.unwrap_or_else(|e| { | |
| 16 | + | tracing::warn!(error = %e, "failed to load projects"); | |
| 17 | + | Vec::new() | |
| 18 | + | }); | |
| 19 | + | let stats = api | |
| 20 | + | .get_stats(user_id, "30d") | |
| 21 | + | .await | |
| 22 | + | .unwrap_or_else(|e| { | |
| 23 | + | tracing::warn!(error = %e, "failed to load stats"); | |
| 24 | + | CreatorStats { | |
| 25 | + | current_revenue_cents: 0, | |
| 26 | + | previous_revenue_cents: 0, | |
| 27 | + | current_sales: 0, | |
| 28 | + | previous_sales: 0, | |
| 29 | + | current_followers: 0, | |
| 30 | + | previous_followers: 0, | |
| 31 | + | total_projects: 0, | |
| 32 | + | total_items: 0, | |
| 33 | + | } | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | let _ = tx | |
| 37 | + | .send(AppEvent::DataLoaded(DataPayload::Home { projects, stats })) | |
| 38 | + | .await; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | pub(super) async fn load_project_items( | |
| 42 | + | api: &MnwApiClient, | |
| 43 | + | project_id: &str, | |
| 44 | + | user_id: &str, | |
| 45 | + | tx: &mpsc::Sender<AppEvent>, | |
| 46 | + | ) { | |
| 47 | + | let items = api | |
| 48 | + | .get_project_items(project_id, user_id) | |
| 49 | + | .await | |
| 50 | + | .unwrap_or_else(|e| { | |
| 51 | + | tracing::warn!(error = %e, %project_id, "failed to load project items"); | |
| 52 | + | Vec::new() | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | let _ = tx | |
| 56 | + | .send(AppEvent::DataLoaded(DataPayload::ProjectItems { items })) | |
| 57 | + | .await; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | pub(super) async fn load_staged_files( | |
| 61 | + | staging_dir: &std::path::Path, | |
| 62 | + | api: &MnwApiClient, | |
| 63 | + | user_id: &str, | |
| 64 | + | tx: &mpsc::Sender<AppEvent>, | |
| 65 | + | ) { | |
| 66 | + | let files = staging::list_staged_files(staging_dir).await; | |
| 67 | + | let storage = match api.get_storage_info(user_id).await { | |
| 68 | + | Ok(s) => Some(s), | |
| 69 | + | Err(e) => { | |
| 70 | + | tracing::warn!(error = %e, "failed to load storage info"); | |
| 71 | + | None | |
| 72 | + | } | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | let _ = tx | |
| 76 | + | .send(AppEvent::DataLoaded(DataPayload::StagedFiles { | |
| 77 | + | files, | |
| 78 | + | storage, | |
| 79 | + | })) | |
| 80 | + | .await; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | pub(super) async fn load_item_detail( | |
| 84 | + | api: &MnwApiClient, | |
| 85 | + | user_id: &str, | |
| 86 | + | item_id: &str, | |
| 87 | + | tx: &mpsc::Sender<AppEvent>, | |
| 88 | + | ) { | |
| 89 | + | let detail = api.get_item_detail(user_id, item_id).await; | |
| 90 | + | let versions = api.get_item_versions(user_id, item_id).await; | |
| 91 | + | ||
| 92 | + | match detail { | |
| 93 | + | Ok(detail) => { | |
| 94 | + | let versions = versions.unwrap_or_default(); | |
| 95 | + | let _ = tx | |
| 96 | + | .send(AppEvent::DataLoaded(DataPayload::ItemDetail { | |
| 97 | + | detail, | |
| 98 | + | versions, | |
| 99 | + | })) | |
| 100 | + | .await; | |
| 101 | + | } | |
| 102 | + | Err(e) => { | |
| 103 | + | let _ = tx | |
| 104 | + | .send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 105 | + | error: e.to_string(), | |
| 106 | + | })) | |
| 107 | + | .await; | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | pub(super) async fn load_blog_posts( | |
| 113 | + | api: &MnwApiClient, | |
| 114 | + | user_id: &str, | |
| 115 | + | project_id: &str, | |
| 116 | + | tx: &mpsc::Sender<AppEvent>, | |
| 117 | + | ) { | |
| 118 | + | let posts = api | |
| 119 | + | .list_blog_posts(user_id, project_id) | |
| 120 | + | .await | |
| 121 | + | .unwrap_or_else(|e| { | |
| 122 | + | tracing::warn!(error = %e, %project_id, "failed to load blog posts"); | |
| 123 | + | Vec::new() | |
| 124 | + | }); | |
| 125 | + | let _ = tx | |
| 126 | + | .send(AppEvent::DataLoaded(DataPayload::BlogPosts { posts })) | |
| 127 | + | .await; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | pub(super) async fn load_promo_codes( | |
| 131 | + | api: &MnwApiClient, | |
| 132 | + | user_id: &str, | |
| 133 | + | tx: &mpsc::Sender<AppEvent>, | |
| 134 | + | ) { | |
| 135 | + | let codes = api | |
| 136 | + | .list_promo_codes(user_id) | |
| 137 | + | .await | |
| 138 | + | .unwrap_or_else(|e| { | |
| 139 | + | tracing::warn!(error = %e, "failed to load promo codes"); | |
| 140 | + | Vec::new() | |
| 141 | + | }); | |
| 142 | + | let _ = tx | |
| 143 | + | .send(AppEvent::DataLoaded(DataPayload::PromoCodes { codes })) | |
| 144 | + | .await; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | pub(super) async fn load_license_keys( | |
| 148 | + | api: &MnwApiClient, | |
| 149 | + | user_id: &str, | |
| 150 | + | item_id: &str, | |
| 151 | + | tx: &mpsc::Sender<AppEvent>, | |
| 152 | + | ) { | |
| 153 | + | let keys = api | |
| 154 | + | .list_license_keys(user_id, item_id) | |
| 155 | + | .await | |
| 156 | + | .unwrap_or_else(|e| { | |
| 157 | + | tracing::warn!(error = %e, %item_id, "failed to load license keys"); | |
| 158 | + | Vec::new() | |
| 159 | + | }); | |
| 160 | + | let _ = tx | |
| 161 | + | .send(AppEvent::DataLoaded(DataPayload::LicenseKeys { keys })) | |
| 162 | + | .await; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | pub(super) async fn load_analytics( | |
| 166 | + | api: &MnwApiClient, | |
| 167 | + | user_id: &str, | |
| 168 | + | range: &str, | |
| 169 | + | tx: &mpsc::Sender<AppEvent>, | |
| 170 | + | ) { | |
| 171 | + | match api.get_analytics(user_id, range).await { | |
| 172 | + | Ok(data) => { | |
| 173 | + | let _ = tx | |
| 174 | + | .send(AppEvent::DataLoaded(DataPayload::Analytics { data })) | |
| 175 | + | .await; | |
| 176 | + | } | |
| 177 | + | Err(e) => { | |
| 178 | + | let _ = tx | |
| 179 | + | .send(AppEvent::DataLoaded(DataPayload::GenericError { | |
| 180 | + | error: e.to_string(), | |
| 181 | + | })) | |
| 182 | + | .await; | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | pub(super) async fn load_transactions( | |
| 188 | + | api: &MnwApiClient, | |
| 189 | + | user_id: &str, | |
| 190 | + | tx: &mpsc::Sender<AppEvent>, | |
| 191 | + | ) { | |
| 192 | + | let txs = api.get_transactions(user_id).await.unwrap_or_else(|e| { | |
| 193 | + | tracing::warn!(error = %e, "failed to load transactions"); | |
| 194 | + | Vec::new() | |
| 195 | + | }); | |
| 196 | + | let _ = tx | |
| 197 | + | .send(AppEvent::DataLoaded(DataPayload::Transactions { txs })) | |
| 198 | + | .await; | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | pub(super) async fn load_settings( | |
| 202 | + | api: &MnwApiClient, | |
| 203 | + | user_id: &str, | |
| 204 | + | tx: &mpsc::Sender<AppEvent>, | |
| 205 | + | ) { | |
| 206 | + | let keys = api.list_ssh_keys(user_id).await.unwrap_or_else(|e| { | |
| 207 | + | tracing::warn!(error = %e, "failed to load SSH keys"); | |
| 208 | + | Vec::new() | |
| 209 | + | }); | |
| 210 | + | let storage = match api.get_storage_info(user_id).await { | |
| 211 | + | Ok(s) => Some(s), | |
| 212 | + | Err(e) => { | |
| 213 | + | tracing::warn!(error = %e, "failed to load storage info for settings"); | |
| 214 | + | None | |
| 215 | + | } | |
| 216 | + | }; | |
| 217 | + | let _ = tx | |
| 218 | + | .send(AppEvent::DataLoaded(DataPayload::Settings { keys, storage })) | |
| 219 | + | .await; | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Full publish flow: create item -> presign -> upload to S3 -> confirm -> delete staging file. | |
| 223 | + | #[allow(clippy::too_many_arguments)] | |
| 224 | + | pub(super) async fn publish_file( | |
| 225 | + | api: &MnwApiClient, | |
| 226 | + | user_id: &str, | |
| 227 | + | project_id: &str, | |
| 228 | + | title: &str, | |
| 229 | + | item_type: &str, | |
| 230 | + | file_type: &str, | |
| 231 | + | filename: &str, | |
| 232 | + | content_type: &str, | |
| 233 | + | price_cents: i32, | |
| 234 | + | file_path: &std::path::Path, | |
| 235 | + | ) -> anyhow::Result<()> { | |
| 236 | + | // Step 1: Create item | |
| 237 | + | let item = api | |
| 238 | + | .create_item(user_id, project_id, title, item_type, price_cents) | |
| 239 | + | .await?; | |
| 240 | + | ||
| 241 | + | // Step 2: Get presigned URL | |
| 242 | + | let presign = api | |
| 243 | + | .presign_upload(user_id, &item.item_id, file_type, filename, content_type) | |
| 244 | + | .await?; | |
| 245 | + | ||
| 246 | + | // Step 3: Upload to S3 | |
| 247 | + | api.upload_to_s3( | |
| 248 | + | &presign.upload_url, | |
| 249 | + | file_path, | |
| 250 | + | content_type, | |
| 251 | + | presign.cache_control.as_deref(), | |
| 252 | + | ) | |
| 253 | + | .await?; | |
| 254 | + | ||
| 255 | + | // Step 4: Confirm upload | |
| 256 | + | api.confirm_upload(user_id, &item.item_id, file_type, &presign.s3_key) | |
| 257 | + | .await?; | |
| 258 | + | ||
| 259 | + | // Step 5: Delete staging file | |
| 260 | + | if let Err(e) = tokio::fs::remove_file(file_path).await { | |
| 261 | + | tracing::warn!(error = %e, path = %file_path.display(), "failed to delete staging file after publish"); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | Ok(()) | |
| 265 | + | } |
| @@ -3,8 +3,10 @@ | |||
| 3 | 3 | pub mod analytics; | |
| 4 | 4 | pub mod blog; | |
| 5 | 5 | pub mod home; | |
| 6 | + | mod input; | |
| 6 | 7 | pub mod item; | |
| 7 | 8 | pub mod keys; | |
| 9 | + | mod loading; | |
| 8 | 10 | pub mod project; | |
| 9 | 11 | pub mod promo; | |
| 10 | 12 | pub mod settings; | |
| @@ -25,6 +27,9 @@ use crate::api::{ | |||
| 25 | 27 | use crate::ssh::terminal::TerminalHandle; | |
| 26 | 28 | use crate::staging::{self, StagedFile}; | |
| 27 | 29 | ||
| 30 | + | use input::*; | |
| 31 | + | use loading::*; | |
| 32 | + | ||
| 28 | 33 | /// Events sent to the TUI event loop. | |
| 29 | 34 | pub enum AppEvent { | |
| 30 | 35 | /// Raw input bytes from the SSH channel. | |
| @@ -65,6 +70,7 @@ pub enum DataPayload { | |||
| 65 | 70 | error: String, | |
| 66 | 71 | }, | |
| 67 | 72 | /// Signal to reload the project items list after a mutation. | |
| 73 | + | #[allow(dead_code)] | |
| 68 | 74 | ProjectReload { | |
| 69 | 75 | project_idx: usize, | |
| 70 | 76 | }, | |
| @@ -168,6 +174,15 @@ pub(crate) enum PromoCreateStep { | |||
| 168 | 174 | Discount, | |
| 169 | 175 | } | |
| 170 | 176 | ||
| 177 | + | /// Pending destructive action awaiting confirmation. | |
| 178 | + | #[derive(Debug, Clone)] | |
| 179 | + | pub(crate) enum ConfirmAction { | |
| 180 | + | DeleteItem, | |
| 181 | + | DeleteBlogPost { post_idx: usize }, | |
| 182 | + | DeletePromoCode { code_idx: usize }, | |
| 183 | + | RevokeLicenseKey { key_idx: usize }, | |
| 184 | + | } | |
| 185 | + | ||
| 171 | 186 | /// Application state shared across screens. | |
| 172 | 187 | pub struct App { | |
| 173 | 188 | pub user: UserInfo, | |
| @@ -213,6 +228,8 @@ pub struct App { | |||
| 213 | 228 | // Settings | |
| 214 | 229 | pub ssh_keys: Vec<SshKeyInfo>, | |
| 215 | 230 | pub settings_status: Option<String>, | |
| 231 | + | // Confirmation dialog | |
| 232 | + | pub confirm_action: Option<ConfirmAction>, | |
| 216 | 233 | } | |
| 217 | 234 | ||
| 218 | 235 | impl App { | |
| @@ -256,6 +273,7 @@ impl App { | |||
| 256 | 273 | transactions: Vec::new(), | |
| 257 | 274 | ssh_keys: Vec::new(), | |
| 258 | 275 | settings_status: None, | |
| 276 | + | confirm_action: None, | |
| 259 | 277 | } | |
| 260 | 278 | } | |
| 261 | 279 | ||
| @@ -312,6 +330,7 @@ impl App { | |||
| 312 | 330 | } | |
| 313 | 331 | ||
| 314 | 332 | /// Launch the TUI event loop in a background task. | |
| 333 | + | #[allow(clippy::too_many_arguments)] | |
| 315 | 334 | pub fn launch( | |
| 316 | 335 | writer: TerminalHandle, | |
| 317 | 336 | user: UserInfo, | |
| @@ -629,1495 +648,6 @@ pub fn launch( | |||
| 629 | 648 | Ok(handle) | |
| 630 | 649 | } | |
| 631 | 650 | ||
| 632 | - | async fn handle_home_input( | |
| 633 | - | key: KeyEvent, | |
| 634 | - | app: &mut App, | |
| 635 | - | screen: &mut Screen, | |
| 636 | - | api: &MnwApiClient, | |
| 637 | - | tx: &mpsc::Sender<AppEvent>, | |
| 638 | - | staging_dir: &PathBuf, | |
| 639 | - | ) { | |
| 640 | - | match key.code { | |
| 641 | - | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 642 | - | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 643 | - | KeyCode::Enter => { | |
| 644 | - | if !app.projects.is_empty() { | |
| 645 | - | let idx = app.selected_index; | |
| 646 | - | let project_id = app.projects[idx].id.clone(); | |
| 647 | - | let user_id = app.user.user_id.clone(); | |
| 648 | - | *screen = Screen::Project(idx); | |
| 649 | - | app.items.clear(); | |
| 650 | - | app.selected_index = 0; | |
| 651 | - | app.loading = true; | |
| 652 | - | ||
| 653 | - | let api = api.clone(); | |
| 654 | - | let tx = tx.clone(); | |
| 655 | - | tokio::spawn(async move { | |
| 656 | - | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 657 | - | }); | |
| 658 | - | } | |
| 659 | - | } | |
| 660 | - | KeyCode::Char('u') | KeyCode::Char('U') => { | |
| 661 | - | *screen = Screen::Upload; | |
| 662 | - | app.selected_index = 0; | |
| 663 | - | app.loading = true; | |
| 664 | - | app.upload_status = None; | |
| 665 | - | app.editing_field = None; | |
| 666 | - | ||
| 667 | - | let staging_dir = staging_dir.clone(); | |
| 668 | - | let api = api.clone(); | |
| 669 | - | let user_id = app.user.user_id.clone(); | |
| 670 | - | let tx = tx.clone(); | |
| 671 | - | tokio::spawn(async move { | |
| 672 | - | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 673 | - | }); | |
| 674 | - | } | |
| 675 | - | KeyCode::Char('a') | KeyCode::Char('A') => { | |
| 676 | - | *screen = Screen::Analytics; | |
| 677 | - | app.analytics_data = None; | |
| 678 | - | app.analytics_status = None; | |
| 679 | - | app.analytics_show_transactions = false; | |
| 680 | - | app.transactions.clear(); | |
| 681 | - | app.selected_index = 0; | |
| 682 | - | app.loading = true; | |
| 683 | - | ||
| 684 | - | let api = api.clone(); | |
| 685 | - | let user_id = app.user.user_id.clone(); | |
| 686 | - | let range = app.analytics_range.clone(); | |
| 687 | - | let tx = tx.clone(); | |
| 688 | - | tokio::spawn(async move { | |
| 689 | - | load_analytics(&api, &user_id, &range, &tx).await; | |
| 690 | - | }); | |
| 691 | - | } | |
| 692 | - | KeyCode::Char('c') | KeyCode::Char('C') => { | |
| 693 | - | *screen = Screen::Promo; | |
| 694 | - | app.promo_codes.clear(); | |
| 695 | - | app.promo_status = None; | |
| 696 | - | app.promo_editing_step = None; | |
| 697 | - | app.selected_index = 0; | |
| 698 | - | app.loading = true; | |
| 699 | - | ||
| 700 | - | let api = api.clone(); | |
| 701 | - | let user_id = app.user.user_id.clone(); | |
| 702 | - | let tx = tx.clone(); | |
| 703 | - | tokio::spawn(async move { | |
| 704 | - | load_promo_codes(&api, &user_id, &tx).await; | |
| 705 | - | }); | |
| 706 | - | } | |
| 707 | - | KeyCode::Char('s') | KeyCode::Char('S') => { | |
| 708 | - | *screen = Screen::Settings; | |
| 709 | - | app.ssh_keys.clear(); | |
| 710 | - | app.settings_status = None; | |
| 711 | - | app.selected_index = 0; | |
| 712 | - | app.loading = true; | |
| 713 | - | ||
| 714 | - | let api = api.clone(); | |
| 715 | - | let user_id = app.user.user_id.clone(); | |
| 716 | - | let tx = tx.clone(); | |
| 717 | - | tokio::spawn(async move { | |
| 718 | - | load_settings(&api, &user_id, &tx).await; | |
| 719 | - | }); | |
| 720 | - | } | |
| 721 | - | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 722 | - | app.loading = true; | |
| 723 | - | let api = api.clone(); | |
| 724 | - | let user_id = app.user.user_id.clone(); | |
| 725 | - | let tx = tx.clone(); | |
| 726 | - | tokio::spawn(async move { | |
| 727 | - | load_home_data(&api, &user_id, &tx).await; | |
| 728 | - | }); | |
| 729 | - | } | |
| 730 | - | _ => {} | |
| 731 | - | } | |
| 732 | - | } | |
| 733 | - | ||
| 734 | - | async fn handle_project_input( | |
| 735 | - | key: KeyEvent, | |
| 736 | - | app: &mut App, | |
| 737 | - | screen: &mut Screen, | |
| 738 | - | api: &MnwApiClient, | |
| 739 | - | tx: &mpsc::Sender<AppEvent>, | |
| 740 | - | ) { | |
| 741 | - | match key.code { | |
| 742 | - | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 743 | - | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 744 | - | KeyCode::Esc => { | |
| 745 | - | // Go back to home | |
| 746 | - | *screen = Screen::Home; | |
| 747 | - | app.items.clear(); | |
| 748 | - | app.selected_index = 0; | |
| 749 | - | } | |
| 750 | - | KeyCode::Enter => { | |
| 751 | - | // Open item detail | |
| 752 | - | if !app.items.is_empty() { | |
| 753 | - | let idx = app.selected_index; | |
| 754 | - | let item_id = app.items[idx].id.clone(); | |
| 755 | - | if let Screen::Project(project_idx) = screen { | |
| 756 | - | let pidx = *project_idx; | |
| 757 | - | *screen = Screen::Item(pidx, item_id.clone()); | |
| 758 | - | app.item_detail = None; | |
| 759 | - | app.item_versions.clear(); | |
| 760 | - | app.item_status = None; | |
| 761 | - | app.item_editing = None; | |
| 762 | - | app.selected_index = 0; | |
| 763 | - | app.loading = true; | |
| 764 | - | ||
| 765 | - | let api = api.clone(); | |
| 766 | - | let user_id = app.user.user_id.clone(); | |
| 767 | - | let tx = tx.clone(); | |
| 768 | - | tokio::spawn(async move { | |
| 769 | - | load_item_detail(&api, &user_id, &item_id, &tx).await; | |
| 770 | - | }); | |
| 771 | - | } | |
| 772 | - | } | |
| 773 | - | } | |
| 774 | - | KeyCode::Char('p') | KeyCode::Char('P') => { | |
| 775 | - | // Toggle publish/unpublish on selected item | |
| 776 | - | if !app.items.is_empty() { | |
| 777 | - | let idx = app.selected_index; | |
| 778 | - | let item = &app.items[idx]; | |
| 779 | - | let item_id = item.id.clone(); | |
| 780 | - | let is_public = item.is_public; | |
| 781 | - | let user_id = app.user.user_id.clone(); | |
| 782 | - | let api = api.clone(); | |
| 783 | - | let tx = tx.clone(); | |
| 784 | - | ||
| 785 | - | if let Screen::Project(pidx) = screen { | |
| 786 | - | let pidx = *pidx; | |
| 787 | - | tokio::spawn(async move { | |
| 788 | - | let result = if is_public { | |
| 789 | - | api.unpublish_item(&user_id, &item_id).await | |
| 790 | - | } else { | |
| 791 | - | api.publish_item(&user_id, &item_id).await | |
| 792 | - | }; | |
| 793 | - | ||
| 794 | - | match result { | |
| 795 | - | Ok(_) => { | |
| 796 | - | let _ = tx | |
| 797 | - | .send(AppEvent::DataLoaded(DataPayload::ProjectReload { | |
| 798 | - | project_idx: pidx, | |
| 799 | - | })) | |
| 800 | - | .await; | |
| 801 | - | } | |
| 802 | - | Err(e) => { | |
| 803 | - | let _ = tx | |
| 804 | - | .send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 805 | - | error: e.to_string(), | |
| 806 | - | })) | |
| 807 | - | .await; | |
| 808 | - | } | |
| 809 | - | } | |
| 810 | - | }); | |
| 811 | - | } | |
| 812 | - | } | |
| 813 | - | } | |
| 814 | - | KeyCode::Char('d') | KeyCode::Char('D') => { | |
| 815 | - | // Delete selected item | |
| 816 | - | if !app.items.is_empty() { | |
| 817 | - | let idx = app.selected_index; | |
| 818 | - | let item_id = app.items[idx].id.clone(); | |
| 819 | - | let user_id = app.user.user_id.clone(); | |
| 820 | - | let api = api.clone(); | |
| 821 | - | let tx = tx.clone(); | |
| 822 | - | ||
| 823 | - | if let Screen::Project(pidx) = screen { | |
| 824 | - | let pidx = *pidx; | |
| 825 | - | tokio::spawn(async move { | |
| 826 | - | match api.delete_item(&user_id, &item_id).await { | |
| 827 | - | Ok(()) => { | |
| 828 | - | let _ = tx | |
| 829 | - | .send(AppEvent::DataLoaded(DataPayload::ProjectReload { | |
| 830 | - | project_idx: pidx, | |
| 831 | - | })) | |
| 832 | - | .await; | |
| 833 | - | } | |
| 834 | - | Err(e) => { | |
| 835 | - | let _ = tx | |
| 836 | - | .send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 837 | - | error: e.to_string(), | |
| 838 | - | })) | |
| 839 | - | .await; | |
| 840 | - | } | |
| 841 | - | } | |
| 842 | - | }); | |
| 843 | - | } | |
| 844 | - | } | |
| 845 | - | } | |
| 846 | - | KeyCode::Char('b') | KeyCode::Char('B') => { | |
| 847 | - | // Open blog screen for this project | |
| 848 | - | if let Screen::Project(idx) = screen { | |
| 849 | - | if let Some(p) = app.projects.get(*idx) { | |
| 850 | - | let pidx = *idx; | |
| 851 | - | let project_id = p.id.clone(); | |
| 852 | - | let project_title = p.title.clone(); | |
| 853 | - | *screen = Screen::Blog(pidx, project_id.clone()); | |
| 854 | - | app.blog_posts.clear(); | |
| 855 | - | app.blog_project_title = Some(project_title); | |
| 856 | - | app.blog_status = None; | |
| 857 | - | app.blog_create_step = None; | |
| 858 | - | app.selected_index = 0; | |
| 859 | - | app.loading = true; | |
| 860 | - | ||
| 861 | - | let api = api.clone(); | |
| 862 | - | let user_id = app.user.user_id.clone(); | |
| 863 | - | let tx = tx.clone(); | |
| 864 | - | tokio::spawn(async move { | |
| 865 | - | load_blog_posts(&api, &user_id, &project_id, &tx).await; | |
| 866 | - | }); | |
| 867 | - | } | |
| 868 | - | } | |
| 869 | - | } | |
| 870 | - | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 871 | - | if let Screen::Project(idx) = screen { | |
| 872 | - | if let Some(p) = app.projects.get(*idx) { | |
| 873 | - | app.loading = true; | |
| 874 | - | let api = api.clone(); | |
| 875 | - | let project_id = p.id.clone(); | |
| 876 | - | let user_id = app.user.user_id.clone(); | |
| 877 | - | let tx = tx.clone(); | |
| 878 | - | tokio::spawn(async move { | |
| 879 | - | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 880 | - | }); | |
| 881 | - | } | |
| 882 | - | } | |
| 883 | - | } | |
| 884 | - | _ => {} | |
| 885 | - | } | |
| 886 | - | } | |
| 887 | - | ||
| 888 | - | async fn handle_upload_input( | |
| 889 | - | key: KeyEvent, | |
| 890 | - | app: &mut App, | |
| 891 | - | screen: &mut Screen, | |
| 892 | - | api: &MnwApiClient, | |
| 893 | - | tx: &mpsc::Sender<AppEvent>, | |
| 894 | - | staging_dir: &PathBuf, | |
| 895 | - | ) { | |
| 896 | - | // Handle editing mode | |
| 897 | - | if let Some(field) = app.editing_field { | |
| 898 | - | match key.code { | |
| 899 | - | KeyCode::Esc => { | |
| 900 | - | app.editing_field = None; | |
| 901 | - | app.edit_buffer.clear(); | |
| 902 | - | } | |
| 903 | - | KeyCode::Enter => { | |
| 904 | - | let idx = app.selected_index; | |
| 905 | - | if idx < app.file_metadata.len() { | |
| 906 | - | match field { | |
| 907 | - | EditField::Title => { | |
| 908 | - | if !app.edit_buffer.is_empty() { | |
| 909 | - | app.file_metadata[idx].title = Some(app.edit_buffer.clone()); | |
| 910 | - | } | |
| 911 | - | // Advance to Project field | |
| 912 | - | app.edit_buffer.clear(); | |
| 913 | - | app.editing_field = Some(EditField::Project); | |
| 914 | - | let project_list: String = app | |
| 915 | - | .projects | |
| 916 | - | .iter() | |
| 917 | - | .enumerate() | |
| 918 | - | .map(|(i, p)| format!("{}:{}", i + 1, p.title)) | |
| 919 | - | .collect::<Vec<_>>() | |
| 920 | - | .join(" "); | |
| 921 | - | app.upload_status = | |
| 922 | - | Some(format!("Project #: _ | {}", project_list)); | |
| 923 | - | return; | |
| 924 | - | } | |
| 925 | - | EditField::Project => { | |
| 926 | - | if let Ok(n) = app.edit_buffer.parse::<usize>() { | |
| 927 | - | if n > 0 && n <= app.projects.len() { | |
| 928 | - | let pidx = n - 1; | |
| 929 | - | app.file_metadata[idx].project_idx = Some(pidx); | |
| 930 | - | app.file_metadata[idx].project_name = | |
| 931 | - | Some(app.projects[pidx].title.clone()); | |
| 932 | - | } | |
| 933 | - | } | |
| 934 | - | // Advance to Price field | |
| 935 | - | app.edit_buffer.clear(); | |
| 936 | - | app.editing_field = Some(EditField::Price); | |
| 937 | - | app.upload_status = | |
| 938 | - | Some("Price ($): _ (0 or empty for free)".to_string()); | |
| 939 | - | return; | |
| 940 | - | } | |
| 941 | - | EditField::Price => { | |
| 942 | - | let cents = parse_price(&app.edit_buffer); | |
| 943 | - | app.file_metadata[idx].price_cents = cents; | |
| 944 | - | } | |
| 945 | - | } | |
| 946 | - | } | |
| 947 | - | app.editing_field = None; | |
| 948 | - | app.edit_buffer.clear(); | |
| 949 | - | app.upload_status = None; | |
| 950 | - | } | |
| 951 | - | KeyCode::Backspace => { | |
| 952 | - | app.edit_buffer.pop(); | |
| 953 | - | // Update status to show current buffer | |
| 954 | - | app.upload_status = Some(format_edit_prompt(field, &app.edit_buffer)); | |
| 955 | - | } | |
| 956 | - | KeyCode::Char(c) => { | |
| 957 | - | app.edit_buffer.push(c); | |
| 958 | - | app.upload_status = Some(format_edit_prompt(field, &app.edit_buffer)); | |
| 959 | - | } | |
| 960 | - | _ => {} | |
| 961 | - | } | |
| 962 | - | return; | |
| 963 | - | } | |
| 964 | - | ||
| 965 | - | // Normal mode | |
| 966 | - | match key.code { | |
| 967 | - | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 968 | - | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 969 | - | KeyCode::Esc => { | |
| 970 | - | *screen = Screen::Home; | |
| 971 | - | app.selected_index = 0; | |
| 972 | - | app.upload_status = None; | |
| 973 | - | } | |
| 974 | - | KeyCode::Char('e') | KeyCode::Char('E') => { | |
| 975 | - | if !app.staged_files.is_empty() { | |
| 976 | - | // Cycle through: Title -> Project -> Price -> Title | |
| 977 | - | app.editing_field = Some(EditField::Title); | |
| 978 | - | app.edit_buffer.clear(); | |
| 979 | - | ||
| 980 | - | // Show project list for reference | |
| 981 | - | let project_list: String = app | |
| 982 | - | .projects | |
| 983 | - | .iter() | |
| 984 | - | .enumerate() | |
| 985 | - | .map(|(i, p)| format!("{}:{}", i + 1, p.title)) | |
| 986 | - | .collect::<Vec<_>>() | |
| 987 | - | .join(" "); | |
| 988 | - | ||
| 989 | - | app.upload_status = Some(format!("Title: _ | Projects: {}", project_list)); | |
| 990 | - | } | |
| 991 | - | } | |
| 992 | - | KeyCode::Tab => { | |
| 993 | - | // When pressed during non-edit mode after a recent edit, | |
| 994 | - | // this could cycle fields. But for simplicity, just re-enter edit. | |
| 995 | - | } | |
| 996 | - | KeyCode::Char('d') | KeyCode::Char('D') => { | |
| 997 | - | if !app.staged_files.is_empty() { | |
| 998 | - | let idx = app.selected_index; | |
| 999 | - | let filename = app.staged_files[idx].filename.clone(); | |
| 1000 | - | let file_path = staging_dir.join(&filename); | |
| 1001 | - | ||
| 1002 | - | match tokio::fs::remove_file(&file_path).await { | |
| 1003 | - | Ok(()) => { | |
| 1004 | - | app.upload_status = Some(format!("Deleted {}", filename)); | |
| 1005 | - | app.staged_files.remove(idx); | |
| 1006 | - | if idx > 0 && idx >= app.staged_files.len() { | |
| 1007 | - | app.selected_index = app.staged_files.len().saturating_sub(1); | |
| 1008 | - | } | |
| 1009 | - | app.sync_metadata(); | |
| 1010 | - | } | |
| 1011 | - | Err(e) => { | |
| 1012 | - | app.upload_status = Some(format!("Error deleting: {}", e)); | |
| 1013 | - | } | |
| 1014 | - | } | |
| 1015 | - | } | |
| 1016 | - | } | |
| 1017 | - | KeyCode::Char('p') | KeyCode::Char('P') => { | |
| 1018 | - | if !app.staged_files.is_empty() && !app.publishing { | |
| 1019 | - | let idx = app.selected_index; | |
| 1020 | - | let meta = app.file_metadata.get(idx).cloned().unwrap_or_default(); | |
| 1021 | - | ||
| 1022 | - | // Validate: must have a project selected | |
| 1023 | - | let project_idx = match meta.project_idx { | |
| 1024 | - | Some(pidx) => pidx, | |
| 1025 | - | None => { | |
| 1026 | - | app.upload_status = | |
| 1027 | - | Some("Select a project first: press [e] then choose project".to_string()); | |
| 1028 | - | return; | |
| 1029 | - | } | |
| 1030 | - | }; | |
| 1031 | - | ||
| 1032 | - | let Some(project) = app.projects.get(project_idx) else { | |
| 1033 | - | app.upload_status = Some("Invalid project selection".to_string()); | |
| 1034 | - | return; | |
| 1035 | - | }; | |
| 1036 | - | ||
| 1037 | - | let sf = &app.staged_files[idx]; | |
| 1038 | - | let Some(classification) = sf.classification else { | |
| 1039 | - | app.upload_status = Some("Unrecognized file type".to_string()); | |
| 1040 | - | return; | |
| 1041 | - | }; | |
| 1042 | - | ||
| 1043 | - | let title = meta | |
| 1044 | - | .title | |
| 1045 | - | .unwrap_or_else(|| staging::derive_title(&sf.filename)); | |
| 1046 | - | let file_path = staging_dir.join(&sf.filename); | |
| 1047 | - | let filename = sf.filename.clone(); | |
| 1048 | - | ||
| 1049 | - | app.publishing = true; | |
| 1050 | - | app.upload_status = Some(format!("Publishing {}...", filename)); | |
| 1051 | - | ||
| 1052 | - | let api = api.clone(); | |
| 1053 | - | let tx = tx.clone(); | |
| 1054 | - | let user_id = app.user.user_id.clone(); | |
| 1055 | - | let project_id = project.id.clone(); | |
| 1056 | - | let item_type = classification.item_type.to_string(); | |
| 1057 | - | let file_type = classification.file_type.to_string(); | |
| 1058 | - | let content_type = classification.content_type.to_string(); | |
| 1059 | - | let price_cents = meta.price_cents; | |
| 1060 | - | ||
| 1061 | - | tokio::spawn(async move { | |
| 1062 | - | let result = publish_file( | |
| 1063 | - | &api, | |
| 1064 | - | &user_id, | |
| 1065 | - | &project_id, |
Lines truncated
| @@ -165,8 +165,8 @@ fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) | |||
| 165 | 165 | // Check if user has edited metadata for this file | |
| 166 | 166 | let meta = app.file_metadata.get(i); | |
| 167 | 167 | let title = meta | |
| 168 | - | .and_then(|m| m.title.as_deref()) | |
| 169 | - | .unwrap_or_else(|| staging::derive_title(&sf.filename).leak()); | |
| 168 | + | .and_then(|m| m.title.clone()) | |
| 169 | + | .unwrap_or_else(|| staging::derive_title(&sf.filename)); | |
| 170 | 170 | let project = meta | |
| 171 | 171 | .and_then(|m| m.project_name.as_deref()) | |
| 172 | 172 | .unwrap_or("[none]"); |