max / mnw-cli
24 files changed,
+5276 insertions,
-0 deletions
| @@ -0,0 +1 @@ | |||
| 1 | + | /target |
| @@ -0,0 +1,4432 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "adler2" | |
| 7 | + | version = "2.0.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "aead" | |
| 13 | + | version = "0.5.2" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 16 | + | dependencies = [ | |
| 17 | + | "crypto-common 0.1.7", | |
| 18 | + | "generic-array 0.14.7", | |
| 19 | + | ] | |
| 20 | + | ||
| 21 | + | [[package]] | |
| 22 | + | name = "aes" | |
| 23 | + | version = "0.8.4" | |
| 24 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | + | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | |
| 26 | + | dependencies = [ | |
| 27 | + | "cfg-if", | |
| 28 | + | "cipher", | |
| 29 | + | "cpufeatures 0.2.17", | |
| 30 | + | ] | |
| 31 | + | ||
| 32 | + | [[package]] | |
| 33 | + | name = "aes-gcm" | |
| 34 | + | version = "0.10.3" | |
| 35 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | + | checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | |
| 37 | + | dependencies = [ | |
| 38 | + | "aead", | |
| 39 | + | "aes", | |
| 40 | + | "cipher", | |
| 41 | + | "ctr", | |
| 42 | + | "ghash", | |
| 43 | + | "subtle", | |
| 44 | + | ] | |
| 45 | + | ||
| 46 | + | [[package]] | |
| 47 | + | name = "ahash" | |
| 48 | + | version = "0.8.12" | |
| 49 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 50 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 51 | + | dependencies = [ | |
| 52 | + | "cfg-if", | |
| 53 | + | "const-random", | |
| 54 | + | "once_cell", | |
| 55 | + | "version_check", | |
| 56 | + | "zerocopy", | |
| 57 | + | ] | |
| 58 | + | ||
| 59 | + | [[package]] | |
| 60 | + | name = "aho-corasick" | |
| 61 | + | version = "1.1.4" | |
| 62 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 64 | + | dependencies = [ | |
| 65 | + | "memchr", | |
| 66 | + | ] | |
| 67 | + | ||
| 68 | + | [[package]] | |
| 69 | + | name = "allocator-api2" | |
| 70 | + | version = "0.2.21" | |
| 71 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 73 | + | ||
| 74 | + | [[package]] | |
| 75 | + | name = "android_system_properties" | |
| 76 | + | version = "0.1.5" | |
| 77 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 78 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 79 | + | dependencies = [ | |
| 80 | + | "libc", | |
| 81 | + | ] | |
| 82 | + | ||
| 83 | + | [[package]] | |
| 84 | + | name = "anyhow" | |
| 85 | + | version = "1.0.102" | |
| 86 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 87 | + | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | |
| 88 | + | ||
| 89 | + | [[package]] | |
| 90 | + | name = "argon2" | |
| 91 | + | version = "0.5.3" | |
| 92 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 93 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 94 | + | dependencies = [ | |
| 95 | + | "base64ct", | |
| 96 | + | "blake2", | |
| 97 | + | "cpufeatures 0.2.17", | |
| 98 | + | "password-hash", | |
| 99 | + | ] | |
| 100 | + | ||
| 101 | + | [[package]] | |
| 102 | + | name = "atomic" | |
| 103 | + | version = "0.6.1" | |
| 104 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 105 | + | checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" | |
| 106 | + | dependencies = [ | |
| 107 | + | "bytemuck", | |
| 108 | + | ] | |
| 109 | + | ||
| 110 | + | [[package]] | |
| 111 | + | name = "atomic-waker" | |
| 112 | + | version = "1.1.2" | |
| 113 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 115 | + | ||
| 116 | + | [[package]] | |
| 117 | + | name = "autocfg" | |
| 118 | + | version = "1.5.0" | |
| 119 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 120 | + | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | |
| 121 | + | ||
| 122 | + | [[package]] | |
| 123 | + | name = "aws-lc-rs" | |
| 124 | + | version = "1.16.2" | |
| 125 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 126 | + | checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" | |
| 127 | + | dependencies = [ | |
| 128 | + | "aws-lc-sys", | |
| 129 | + | "untrusted 0.7.1", | |
| 130 | + | "zeroize", | |
| 131 | + | ] | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "aws-lc-sys" | |
| 135 | + | version = "0.39.1" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" | |
| 138 | + | dependencies = [ | |
| 139 | + | "cc", | |
| 140 | + | "cmake", | |
| 141 | + | "dunce", | |
| 142 | + | "fs_extra", | |
| 143 | + | ] | |
| 144 | + | ||
| 145 | + | [[package]] | |
| 146 | + | name = "base16ct" | |
| 147 | + | version = "0.2.0" | |
| 148 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 149 | + | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | |
| 150 | + | ||
| 151 | + | [[package]] | |
| 152 | + | name = "base16ct" | |
| 153 | + | version = "1.0.0" | |
| 154 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 155 | + | checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" | |
| 156 | + | ||
| 157 | + | [[package]] | |
| 158 | + | name = "base64" | |
| 159 | + | version = "0.22.1" | |
| 160 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 161 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 162 | + | ||
| 163 | + | [[package]] | |
| 164 | + | name = "base64ct" | |
| 165 | + | version = "1.8.3" | |
| 166 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 167 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 168 | + | ||
| 169 | + | [[package]] | |
| 170 | + | name = "bcrypt-pbkdf" | |
| 171 | + | version = "0.10.0" | |
| 172 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 173 | + | checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" | |
| 174 | + | dependencies = [ | |
| 175 | + | "blowfish", | |
| 176 | + | "pbkdf2", | |
| 177 | + | "sha2 0.10.9", | |
| 178 | + | ] | |
| 179 | + | ||
| 180 | + | [[package]] | |
| 181 | + | name = "bit-set" | |
| 182 | + | version = "0.5.3" | |
| 183 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 184 | + | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" | |
| 185 | + | dependencies = [ | |
| 186 | + | "bit-vec", | |
| 187 | + | ] | |
| 188 | + | ||
| 189 | + | [[package]] | |
| 190 | + | name = "bit-vec" | |
| 191 | + | version = "0.6.3" | |
| 192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 193 | + | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" | |
| 194 | + | ||
| 195 | + | [[package]] | |
| 196 | + | name = "bitflags" | |
| 197 | + | version = "1.3.2" | |
| 198 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 199 | + | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 200 | + | ||
| 201 | + | [[package]] | |
| 202 | + | name = "bitflags" | |
| 203 | + | version = "2.11.0" | |
| 204 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 205 | + | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" | |
| 206 | + | dependencies = [ | |
| 207 | + | "serde_core", | |
| 208 | + | ] | |
| 209 | + | ||
| 210 | + | [[package]] | |
| 211 | + | name = "blake2" | |
| 212 | + | version = "0.10.6" | |
| 213 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 214 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 215 | + | dependencies = [ | |
| 216 | + | "digest 0.10.7", | |
| 217 | + | ] | |
| 218 | + | ||
| 219 | + | [[package]] | |
| 220 | + | name = "block-buffer" | |
| 221 | + | version = "0.10.4" | |
| 222 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 223 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 224 | + | dependencies = [ | |
| 225 | + | "generic-array 0.14.7", | |
| 226 | + | ] | |
| 227 | + | ||
| 228 | + | [[package]] | |
| 229 | + | name = "block-buffer" | |
| 230 | + | version = "0.12.0" | |
| 231 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | + | checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" | |
| 233 | + | dependencies = [ | |
| 234 | + | "hybrid-array 0.4.8", | |
| 235 | + | ] | |
| 236 | + | ||
| 237 | + | [[package]] | |
| 238 | + | name = "block-padding" | |
| 239 | + | version = "0.3.3" | |
| 240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | + | checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" | |
| 242 | + | dependencies = [ | |
| 243 | + | "generic-array 0.14.7", | |
| 244 | + | ] | |
| 245 | + | ||
| 246 | + | [[package]] | |
| 247 | + | name = "blowfish" | |
| 248 | + | version = "0.9.1" | |
| 249 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 250 | + | checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" | |
| 251 | + | dependencies = [ | |
| 252 | + | "byteorder", | |
| 253 | + | "cipher", | |
| 254 | + | ] | |
| 255 | + | ||
| 256 | + | [[package]] | |
| 257 | + | name = "bumpalo" | |
| 258 | + | version = "3.20.2" | |
| 259 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 260 | + | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" | |
| 261 | + | ||
| 262 | + | [[package]] | |
| 263 | + | name = "bytemuck" | |
| 264 | + | version = "1.25.0" | |
| 265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 266 | + | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | |
| 267 | + | ||
| 268 | + | [[package]] | |
| 269 | + | name = "byteorder" | |
| 270 | + | version = "1.5.0" | |
| 271 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 272 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 273 | + | ||
| 274 | + | [[package]] | |
| 275 | + | name = "bytes" | |
| 276 | + | version = "1.11.1" | |
| 277 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 278 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 279 | + | ||
| 280 | + | [[package]] | |
| 281 | + | name = "castaway" | |
| 282 | + | version = "0.2.4" | |
| 283 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 284 | + | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 285 | + | dependencies = [ | |
| 286 | + | "rustversion", | |
| 287 | + | ] | |
| 288 | + | ||
| 289 | + | [[package]] | |
| 290 | + | name = "cbc" | |
| 291 | + | version = "0.1.2" | |
| 292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | + | checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" | |
| 294 | + | dependencies = [ | |
| 295 | + | "cipher", | |
| 296 | + | ] | |
| 297 | + | ||
| 298 | + | [[package]] | |
| 299 | + | name = "cc" | |
| 300 | + | version = "1.2.58" | |
| 301 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 302 | + | checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" | |
| 303 | + | dependencies = [ | |
| 304 | + | "find-msvc-tools", | |
| 305 | + | "jobserver", | |
| 306 | + | "libc", | |
| 307 | + | "shlex", | |
| 308 | + | ] | |
| 309 | + | ||
| 310 | + | [[package]] | |
| 311 | + | name = "cfg-if" | |
| 312 | + | version = "1.0.4" | |
| 313 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 314 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 315 | + | ||
| 316 | + | [[package]] | |
| 317 | + | name = "cfg_aliases" | |
| 318 | + | version = "0.2.1" | |
| 319 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 320 | + | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | |
| 321 | + | ||
| 322 | + | [[package]] | |
| 323 | + | name = "chacha20" | |
| 324 | + | version = "0.9.1" | |
| 325 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 326 | + | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | |
| 327 | + | dependencies = [ | |
| 328 | + | "cfg-if", | |
| 329 | + | "cipher", | |
| 330 | + | "cpufeatures 0.2.17", | |
| 331 | + | ] | |
| 332 | + | ||
| 333 | + | [[package]] | |
| 334 | + | name = "chrono" | |
| 335 | + | version = "0.4.44" | |
| 336 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 337 | + | checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" | |
| 338 | + | dependencies = [ | |
| 339 | + | "iana-time-zone", | |
| 340 | + | "js-sys", | |
| 341 | + | "num-traits", | |
| 342 | + | "wasm-bindgen", | |
| 343 | + | "windows-link", | |
| 344 | + | ] | |
| 345 | + | ||
| 346 | + | [[package]] | |
| 347 | + | name = "cipher" | |
| 348 | + | version = "0.4.4" | |
| 349 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 350 | + | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 351 | + | dependencies = [ | |
| 352 | + | "crypto-common 0.1.7", | |
| 353 | + | "inout", | |
| 354 | + | ] | |
| 355 | + | ||
| 356 | + | [[package]] | |
| 357 | + | name = "cmake" | |
| 358 | + | version = "0.1.58" | |
| 359 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 360 | + | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 361 | + | dependencies = [ | |
| 362 | + | "cc", | |
| 363 | + | ] | |
| 364 | + | ||
| 365 | + | [[package]] | |
| 366 | + | name = "cmov" | |
| 367 | + | version = "0.5.0-pre.0" | |
| 368 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 369 | + | checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" | |
| 370 | + | ||
| 371 | + | [[package]] | |
| 372 | + | name = "compact_str" | |
| 373 | + | version = "0.9.0" | |
| 374 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 375 | + | checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" | |
| 376 | + | dependencies = [ | |
| 377 | + | "castaway", | |
| 378 | + | "cfg-if", | |
| 379 | + | "itoa", | |
| 380 | + | "rustversion", | |
| 381 | + | "ryu", | |
| 382 | + | "static_assertions", | |
| 383 | + | ] | |
| 384 | + | ||
| 385 | + | [[package]] | |
| 386 | + | name = "const-oid" | |
| 387 | + | version = "0.9.6" | |
| 388 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 389 | + | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 390 | + | ||
| 391 | + | [[package]] | |
| 392 | + | name = "const-oid" | |
| 393 | + | version = "0.10.2" | |
| 394 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 395 | + | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 396 | + | ||
| 397 | + | [[package]] | |
| 398 | + | name = "const-random" | |
| 399 | + | version = "0.1.18" | |
| 400 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 401 | + | checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" | |
| 402 | + | dependencies = [ | |
| 403 | + | "const-random-macro", | |
| 404 | + | ] | |
| 405 | + | ||
| 406 | + | [[package]] | |
| 407 | + | name = "const-random-macro" | |
| 408 | + | version = "0.1.16" | |
| 409 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | + | checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" | |
| 411 | + | dependencies = [ | |
| 412 | + | "getrandom 0.2.17", | |
| 413 | + | "once_cell", | |
| 414 | + | "tiny-keccak", | |
| 415 | + | ] | |
| 416 | + | ||
| 417 | + | [[package]] | |
| 418 | + | name = "convert_case" | |
| 419 | + | version = "0.10.0" | |
| 420 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 421 | + | checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" | |
| 422 | + | dependencies = [ | |
| 423 | + | "unicode-segmentation", | |
| 424 | + | ] | |
| 425 | + | ||
| 426 | + | [[package]] | |
| 427 | + | name = "core-foundation-sys" | |
| 428 | + | version = "0.8.7" | |
| 429 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 430 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 431 | + | ||
| 432 | + | [[package]] | |
| 433 | + | name = "cpufeatures" | |
| 434 | + | version = "0.2.17" | |
| 435 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 436 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 437 | + | dependencies = [ | |
| 438 | + | "libc", | |
| 439 | + | ] | |
| 440 | + | ||
| 441 | + | [[package]] | |
| 442 | + | name = "cpufeatures" | |
| 443 | + | version = "0.3.0" | |
| 444 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 445 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 446 | + | dependencies = [ | |
| 447 | + | "libc", | |
| 448 | + | ] | |
| 449 | + | ||
| 450 | + | [[package]] | |
| 451 | + | name = "crc32fast" | |
| 452 | + | version = "1.5.0" | |
| 453 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 454 | + | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | |
| 455 | + | dependencies = [ | |
| 456 | + | "cfg-if", | |
| 457 | + | ] | |
| 458 | + | ||
| 459 | + | [[package]] | |
| 460 | + | name = "crossterm" | |
| 461 | + | version = "0.29.0" | |
| 462 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 463 | + | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" | |
| 464 | + | dependencies = [ | |
| 465 | + | "bitflags 2.11.0", | |
| 466 | + | "crossterm_winapi", | |
| 467 | + | "derive_more", | |
| 468 | + | "document-features", | |
| 469 | + | "mio", | |
| 470 | + | "parking_lot", | |
| 471 | + | "rustix", | |
| 472 | + | "signal-hook", | |
| 473 | + | "signal-hook-mio", | |
| 474 | + | "winapi", | |
| 475 | + | ] | |
| 476 | + | ||
| 477 | + | [[package]] | |
| 478 | + | name = "crossterm_winapi" | |
| 479 | + | version = "0.9.1" | |
| 480 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 481 | + | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" | |
| 482 | + | dependencies = [ | |
| 483 | + | "winapi", | |
| 484 | + | ] | |
| 485 | + | ||
| 486 | + | [[package]] | |
| 487 | + | name = "crunchy" | |
| 488 | + | version = "0.2.4" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 491 | + | ||
| 492 | + | [[package]] | |
| 493 | + | name = "crypto-bigint" | |
| 494 | + | version = "0.5.5" | |
| 495 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 496 | + | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | |
| 497 | + | dependencies = [ | |
| 498 | + | "generic-array 0.14.7", | |
| 499 | + | "rand_core 0.6.4", | |
| 500 | + | "subtle", |
Lines truncated
| @@ -0,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "mnw-cli" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | ||
| 6 | + | [dependencies] | |
| 7 | + | russh = "0.58" | |
| 8 | + | russh-sftp = "2.1" | |
| 9 | + | ratatui = "0.30" | |
| 10 | + | crossterm = "0.29" | |
| 11 | + | tokio = { version = "1", features = ["full"] } | |
| 12 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 13 | + | serde = { version = "1", features = ["derive"] } | |
| 14 | + | serde_json = "1" | |
| 15 | + | tracing = "0.1" | |
| 16 | + | tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 17 | + | anyhow = "1" | |
| 18 | + | bytes = "1" |
| @@ -0,0 +1,92 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # MNW CLI Deployment Script | |
| 3 | + | # Cross-compiles for x86_64 Linux on macOS, uploads binary, restarts service. | |
| 4 | + | # Run from the mnw-cli directory. | |
| 5 | + | # | |
| 6 | + | # Usage: | |
| 7 | + | # ./deploy/deploy.sh # Full deploy (build + upload + config + restart) | |
| 8 | + | # ./deploy/deploy.sh --quick # Quick deploy (build + upload binary + restart) | |
| 9 | + | # ./deploy/deploy.sh --config # Config only (upload systemd unit) | |
| 10 | + | # | |
| 11 | + | # Prerequisites (one-time): | |
| 12 | + | # brew install zig | |
| 13 | + | # cargo install cargo-zigbuild | |
| 14 | + | # rustup target add x86_64-unknown-linux-gnu | |
| 15 | + | ||
| 16 | + | set -e | |
| 17 | + | ||
| 18 | + | # Configuration | |
| 19 | + | SERVER="root@100.120.174.96" | |
| 20 | + | REMOTE_DIR="/opt/mnw-cli" | |
| 21 | + | STAGING_DIR="/var/lib/mnw-cli/staging" | |
| 22 | + | BINARY_NAME="mnw-cli" | |
| 23 | + | TARGET="x86_64-unknown-linux-gnu" | |
| 24 | + | DEPLOY_DIR="deploy" | |
| 25 | + | ||
| 26 | + | # Check we're in the right directory | |
| 27 | + | if [ ! -f "Cargo.toml" ]; then | |
| 28 | + | echo "Error: Run this script from the mnw-cli/ directory" | |
| 29 | + | exit 1 | |
| 30 | + | fi | |
| 31 | + | ||
| 32 | + | upload_config() { | |
| 33 | + | echo "[config] Uploading configuration files..." | |
| 34 | + | scp $DEPLOY_DIR/mnw-cli.service $SERVER:/etc/systemd/system/mnw-cli.service | |
| 35 | + | ssh $SERVER "systemctl daemon-reload" | |
| 36 | + | ||
| 37 | + | # Ensure directories exist | |
| 38 | + | ssh $SERVER "mkdir -p $REMOTE_DIR $STAGING_DIR" | |
| 39 | + | ssh $SERVER "chown makenotwork:makenotwork $STAGING_DIR" | |
| 40 | + | ||
| 41 | + | echo "[config] Done" | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | build_binary() { | |
| 45 | + | echo "[build] Cross-compiling for $TARGET..." | |
| 46 | + | ulimit -n 65536 2>/dev/null || true | |
| 47 | + | cargo zigbuild --release --target $TARGET | |
| 48 | + | echo "[build] Done: target/$TARGET/release/$BINARY_NAME" | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | upload_binary() { | |
| 52 | + | echo "[upload] Stopping service and uploading binary..." | |
| 53 | + | ssh $SERVER "systemctl stop mnw-cli || true" | |
| 54 | + | scp target/$TARGET/release/$BINARY_NAME $SERVER:$REMOTE_DIR/$BINARY_NAME | |
| 55 | + | ssh $SERVER "chmod +x $REMOTE_DIR/$BINARY_NAME" | |
| 56 | + | echo "[upload] Done" | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | restart_app() { | |
| 60 | + | echo "[restart] Restarting mnw-cli..." | |
| 61 | + | ssh $SERVER "systemctl restart mnw-cli" | |
| 62 | + | sleep 1 | |
| 63 | + | echo "" | |
| 64 | + | ssh $SERVER "systemctl status mnw-cli --no-pager" | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | case "${1:-full}" in | |
| 68 | + | --quick) | |
| 69 | + | echo "=== Quick Deploy ===" | |
| 70 | + | build_binary | |
| 71 | + | upload_binary | |
| 72 | + | restart_app | |
| 73 | + | ;; | |
| 74 | + | --config) | |
| 75 | + | echo "=== Config Deploy ===" | |
| 76 | + | upload_config | |
| 77 | + | ;; | |
| 78 | + | full|"") | |
| 79 | + | echo "=== Full Deploy ===" | |
| 80 | + | build_binary | |
| 81 | + | upload_config | |
| 82 | + | upload_binary | |
| 83 | + | restart_app | |
| 84 | + | ;; | |
| 85 | + | *) | |
| 86 | + | echo "Usage: $0 [--quick|--config]" | |
| 87 | + | exit 1 | |
| 88 | + | ;; | |
| 89 | + | esac | |
| 90 | + | ||
| 91 | + | echo "" | |
| 92 | + | echo "=== Deploy Complete ===" |
| @@ -0,0 +1,26 @@ | |||
| 1 | + | [Unit] | |
| 2 | + | Description=MNW CLI SSH Server | |
| 3 | + | After=network.target | |
| 4 | + | Wants=network-online.target | |
| 5 | + | ||
| 6 | + | [Service] | |
| 7 | + | Type=simple | |
| 8 | + | User=makenotwork | |
| 9 | + | Group=makenotwork | |
| 10 | + | ExecStart=/opt/mnw-cli/mnw-cli | |
| 11 | + | WorkingDirectory=/opt/mnw-cli | |
| 12 | + | EnvironmentFile=/opt/mnw-cli/.env | |
| 13 | + | Restart=on-failure | |
| 14 | + | RestartSec=5 | |
| 15 | + | StandardOutput=journal | |
| 16 | + | StandardError=journal | |
| 17 | + | ||
| 18 | + | # Security hardening | |
| 19 | + | NoNewPrivileges=true | |
| 20 | + | ProtectSystem=strict | |
| 21 | + | ProtectHome=true | |
| 22 | + | ReadWritePaths=/opt/mnw-cli /var/lib/mnw-cli | |
| 23 | + | PrivateTmp=true | |
| 24 | + | ||
| 25 | + | [Install] | |
| 26 | + | WantedBy=multi-user.target |
| @@ -0,0 +1,957 @@ | |||
| 1 | + | //! HTTP client for the MNW internal API. | |
| 2 | + | ||
| 3 | + | use serde::{Deserialize, Serialize}; | |
| 4 | + | ||
| 5 | + | /// User info returned from the SSH key lookup endpoint. | |
| 6 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 7 | + | #[allow(dead_code)] | |
| 8 | + | pub struct UserInfo { | |
| 9 | + | pub user_id: String, | |
| 10 | + | pub username: String, | |
| 11 | + | pub display_name: Option<String>, | |
| 12 | + | pub creator_tier: Option<String>, | |
| 13 | + | pub can_create_projects: bool, | |
| 14 | + | pub suspended: bool, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// A creator's project with item count and revenue. | |
| 18 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 19 | + | pub struct Project { | |
| 20 | + | pub id: String, | |
| 21 | + | pub slug: String, | |
| 22 | + | pub title: String, | |
| 23 | + | pub project_type: String, | |
| 24 | + | pub is_public: bool, | |
| 25 | + | pub item_count: i64, | |
| 26 | + | pub revenue_cents: i64, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// An item within a project. | |
| 30 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 31 | + | #[allow(dead_code)] | |
| 32 | + | pub struct Item { | |
| 33 | + | pub id: String, | |
| 34 | + | pub title: String, | |
| 35 | + | pub item_type: String, | |
| 36 | + | pub price_cents: i32, | |
| 37 | + | pub is_public: bool, | |
| 38 | + | pub sort_order: i32, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Period comparison stats for the creator. | |
| 42 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 43 | + | #[allow(dead_code)] | |
| 44 | + | pub struct CreatorStats { | |
| 45 | + | pub current_revenue_cents: i64, | |
| 46 | + | pub previous_revenue_cents: i64, | |
| 47 | + | pub current_sales: i64, | |
| 48 | + | pub previous_sales: i64, | |
| 49 | + | pub current_followers: i64, | |
| 50 | + | pub previous_followers: i64, | |
| 51 | + | pub total_projects: i64, | |
| 52 | + | pub total_items: i64, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Response from the create-item internal endpoint. | |
| 56 | + | #[derive(Debug, Deserialize)] | |
| 57 | + | #[allow(dead_code)] | |
| 58 | + | pub struct ItemCreated { | |
| 59 | + | pub item_id: String, | |
| 60 | + | pub project_id: String, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Response from the presign-upload internal endpoint. | |
| 64 | + | #[derive(Debug, Deserialize)] | |
| 65 | + | #[allow(dead_code)] | |
| 66 | + | pub struct PresignResponse { | |
| 67 | + | pub upload_url: String, | |
| 68 | + | pub s3_key: String, | |
| 69 | + | pub expires_in: u64, | |
| 70 | + | pub cache_control: Option<String>, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Full item detail returned from the get/update endpoints. | |
| 74 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 75 | + | #[allow(dead_code)] | |
| 76 | + | pub struct ItemDetail { | |
| 77 | + | pub id: String, | |
| 78 | + | pub title: String, | |
| 79 | + | pub description: Option<String>, | |
| 80 | + | pub price_cents: i32, | |
| 81 | + | pub item_type: String, | |
| 82 | + | pub is_public: bool, | |
| 83 | + | pub slug: String, | |
| 84 | + | pub sort_order: i32, | |
| 85 | + | pub sales_count: i32, | |
| 86 | + | pub download_count: i32, | |
| 87 | + | pub play_count: i32, | |
| 88 | + | pub pwyw_enabled: bool, | |
| 89 | + | pub pwyw_min_cents: Option<i32>, | |
| 90 | + | pub has_audio: bool, | |
| 91 | + | pub has_cover: bool, | |
| 92 | + | pub created_at: String, | |
| 93 | + | pub updated_at: String, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// A version of an item. | |
| 97 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 98 | + | #[allow(dead_code)] | |
| 99 | + | pub struct Version { | |
| 100 | + | pub id: String, | |
| 101 | + | pub version_number: String, | |
| 102 | + | pub changelog: Option<String>, | |
| 103 | + | pub file_name: Option<String>, | |
| 104 | + | pub file_size_bytes: Option<i64>, | |
| 105 | + | pub download_count: i32, | |
| 106 | + | pub is_current: bool, | |
| 107 | + | pub created_at: String, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// A blog post summary. | |
| 111 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 112 | + | #[allow(dead_code)] | |
| 113 | + | pub struct BlogPost { | |
| 114 | + | pub id: String, | |
| 115 | + | pub title: String, | |
| 116 | + | pub slug: String, | |
| 117 | + | pub is_published: bool, | |
| 118 | + | pub created_at: String, | |
| 119 | + | pub updated_at: String, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// A promo code. | |
| 123 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 124 | + | #[allow(dead_code)] | |
| 125 | + | pub struct PromoCode { | |
| 126 | + | pub id: String, | |
| 127 | + | pub code: String, | |
| 128 | + | pub code_purpose: String, | |
| 129 | + | pub discount_type: Option<String>, | |
| 130 | + | pub discount_value: Option<i32>, | |
| 131 | + | pub item_title: Option<String>, | |
| 132 | + | pub project_title: Option<String>, | |
| 133 | + | pub max_uses: Option<i32>, | |
| 134 | + | pub use_count: i32, | |
| 135 | + | pub created_at: String, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// A license key. | |
| 139 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 140 | + | #[allow(dead_code)] | |
| 141 | + | pub struct LicenseKey { | |
| 142 | + | pub id: String, | |
| 143 | + | pub key_code: String, | |
| 144 | + | pub activation_count: i32, | |
| 145 | + | pub max_activations: Option<i32>, | |
| 146 | + | pub is_revoked: bool, | |
| 147 | + | pub created_at: String, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Response from the storage-info internal endpoint. | |
| 151 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 152 | + | pub struct StorageInfo { | |
| 153 | + | pub storage_used_bytes: i64, | |
| 154 | + | pub max_storage_bytes: i64, | |
| 155 | + | pub allows_file_uploads: bool, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// A revenue bucket for analytics timeseries. | |
| 159 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 160 | + | #[allow(dead_code)] | |
| 161 | + | pub struct AnalyticsBucket { | |
| 162 | + | pub label: String, | |
| 163 | + | pub revenue_cents: i64, | |
| 164 | + | pub sales_count: i64, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Per-project revenue summary. | |
| 168 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 169 | + | #[allow(dead_code)] | |
| 170 | + | pub struct ProjectRevenue { | |
| 171 | + | pub id: String, | |
| 172 | + | pub title: String, | |
| 173 | + | pub revenue_cents: i64, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// Analytics response with timeseries, comparison, and top projects. | |
| 177 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 178 | + | pub struct AnalyticsData { | |
| 179 | + | pub buckets: Vec<AnalyticsBucket>, | |
| 180 | + | pub current_revenue_cents: i64, | |
| 181 | + | pub previous_revenue_cents: i64, | |
| 182 | + | pub current_sales: i64, | |
| 183 | + | pub previous_sales: i64, | |
| 184 | + | pub current_followers: i64, | |
| 185 | + | pub previous_followers: i64, | |
| 186 | + | pub top_projects: Vec<ProjectRevenue>, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// A seller transaction. | |
| 190 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 191 | + | #[allow(dead_code)] | |
| 192 | + | pub struct Transaction { | |
| 193 | + | pub id: String, | |
| 194 | + | pub item_title: Option<String>, | |
| 195 | + | pub amount_cents: i32, | |
| 196 | + | pub status: String, | |
| 197 | + | pub created_at: String, | |
| 198 | + | pub completed_at: Option<String>, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// CSV export result. | |
| 202 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 203 | + | pub struct ExportResult { | |
| 204 | + | pub csv: String, | |
| 205 | + | pub row_count: usize, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// A registered SSH key. | |
| 209 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 210 | + | #[allow(dead_code)] | |
| 211 | + | pub struct SshKeyInfo { | |
| 212 | + | pub id: String, | |
| 213 | + | pub label: String, | |
| 214 | + | pub fingerprint: String, | |
| 215 | + | pub created_at: String, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Client for calling MNW internal API endpoints. | |
| 219 | + | #[derive(Clone)] | |
| 220 | + | pub struct MnwApiClient { | |
| 221 | + | http: reqwest::Client, | |
| 222 | + | base_url: String, | |
| 223 | + | service_token: String, | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | impl MnwApiClient { | |
| 227 | + | pub fn new(base_url: String, service_token: String) -> Self { | |
| 228 | + | let http = reqwest::Client::builder() | |
| 229 | + | .timeout(std::time::Duration::from_secs(5)) | |
| 230 | + | .build() | |
| 231 | + | .expect("failed to build HTTP client"); | |
| 232 | + | ||
| 233 | + | Self { | |
| 234 | + | http, | |
| 235 | + | base_url, | |
| 236 | + | service_token, | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Look up a user by SSH key fingerprint. | |
| 241 | + | /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. | |
| 242 | + | pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> { | |
| 243 | + | let url = format!("{}/api/internal/ssh-key-lookup", self.base_url); | |
| 244 | + | let resp = self | |
| 245 | + | .http | |
| 246 | + | .get(&url) | |
| 247 | + | .bearer_auth(&self.service_token) | |
| 248 | + | .query(&[("fingerprint", fingerprint)]) | |
| 249 | + | .send() | |
| 250 | + | .await?; | |
| 251 | + | ||
| 252 | + | if resp.status() == reqwest::StatusCode::NOT_FOUND { | |
| 253 | + | return Ok(None); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | if !resp.status().is_success() { | |
| 257 | + | anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status()); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | let info: UserInfo = resp.json().await?; | |
| 261 | + | Ok(Some(info)) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// Fetch all projects for a creator with item counts and revenue. | |
| 265 | + | pub async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> { | |
| 266 | + | let url = format!("{}/api/internal/creator/projects", self.base_url); | |
| 267 | + | let resp = self | |
| 268 | + | .http | |
| 269 | + | .get(&url) | |
| 270 | + | .bearer_auth(&self.service_token) | |
| 271 | + | .query(&[("user_id", user_id)]) | |
| 272 | + | .send() | |
| 273 | + | .await?; | |
| 274 | + | ||
| 275 | + | if !resp.status().is_success() { | |
| 276 | + | anyhow::bail!("get_projects failed: HTTP {}", resp.status()); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | Ok(resp.json().await?) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// Fetch items in a project. | |
| 283 | + | pub async fn get_project_items( | |
| 284 | + | &self, | |
| 285 | + | project_id: &str, | |
| 286 | + | user_id: &str, | |
| 287 | + | ) -> anyhow::Result<Vec<Item>> { | |
| 288 | + | let url = format!( | |
| 289 | + | "{}/api/internal/creator/projects/{}/items", | |
| 290 | + | self.base_url, project_id | |
| 291 | + | ); | |
| 292 | + | let resp = self | |
| 293 | + | .http | |
| 294 | + | .get(&url) | |
| 295 | + | .bearer_auth(&self.service_token) | |
| 296 | + | .query(&[("user_id", user_id)]) | |
| 297 | + | .send() | |
| 298 | + | .await?; | |
| 299 | + | ||
| 300 | + | if !resp.status().is_success() { | |
| 301 | + | anyhow::bail!("get_project_items failed: HTTP {}", resp.status()); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | Ok(resp.json().await?) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /// Fetch period comparison stats for a creator. | |
| 308 | + | pub async fn get_stats(&self, user_id: &str, range: &str) -> anyhow::Result<CreatorStats> { | |
| 309 | + | let url = format!("{}/api/internal/creator/stats", self.base_url); | |
| 310 | + | let resp = self | |
| 311 | + | .http | |
| 312 | + | .get(&url) | |
| 313 | + | .bearer_auth(&self.service_token) | |
| 314 | + | .query(&[("user_id", user_id), ("range", range)]) | |
| 315 | + | .send() | |
| 316 | + | .await?; | |
| 317 | + | ||
| 318 | + | if !resp.status().is_success() { | |
| 319 | + | anyhow::bail!("get_stats failed: HTTP {}", resp.status()); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | Ok(resp.json().await?) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// Fetch storage usage and limits for a creator. | |
| 326 | + | pub async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> { | |
| 327 | + | let url = format!("{}/api/internal/creator/storage", self.base_url); | |
| 328 | + | let resp = self | |
| 329 | + | .http | |
| 330 | + | .get(&url) | |
| 331 | + | .bearer_auth(&self.service_token) | |
| 332 | + | .query(&[("user_id", user_id)]) | |
| 333 | + | .send() | |
| 334 | + | .await?; | |
| 335 | + | ||
| 336 | + | if !resp.status().is_success() { | |
| 337 | + | anyhow::bail!("get_storage_info failed: HTTP {}", resp.status()); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | Ok(resp.json().await?) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// Create an item in a project. | |
| 344 | + | pub async fn create_item( | |
| 345 | + | &self, | |
| 346 | + | user_id: &str, | |
| 347 | + | project_id: &str, | |
| 348 | + | title: &str, | |
| 349 | + | item_type: &str, | |
| 350 | + | price_cents: i32, | |
| 351 | + | ) -> anyhow::Result<ItemCreated> { | |
| 352 | + | let url = format!("{}/api/internal/creator/items", self.base_url); | |
| 353 | + | let resp = self | |
| 354 | + | .http | |
| 355 | + | .post(&url) | |
| 356 | + | .bearer_auth(&self.service_token) | |
| 357 | + | .json(&serde_json::json!({ | |
| 358 | + | "user_id": user_id, | |
| 359 | + | "project_id": project_id, | |
| 360 | + | "title": title, | |
| 361 | + | "item_type": item_type, | |
| 362 | + | "price_cents": price_cents, | |
| 363 | + | })) | |
| 364 | + | .send() | |
| 365 | + | .await?; | |
| 366 | + | ||
| 367 | + | if !resp.status().is_success() { | |
| 368 | + | let status = resp.status(); | |
| 369 | + | let body = resp.text().await.unwrap_or_default(); | |
| 370 | + | anyhow::bail!("create_item failed: HTTP {} — {}", status, body); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | Ok(resp.json().await?) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | /// Get a presigned S3 upload URL. | |
| 377 | + | pub async fn presign_upload( | |
| 378 | + | &self, | |
| 379 | + | user_id: &str, | |
| 380 | + | item_id: &str, | |
| 381 | + | file_type: &str, | |
| 382 | + | file_name: &str, | |
| 383 | + | content_type: &str, | |
| 384 | + | ) -> anyhow::Result<PresignResponse> { | |
| 385 | + | let url = format!("{}/api/internal/upload/presign", self.base_url); | |
| 386 | + | let resp = self | |
| 387 | + | .http | |
| 388 | + | .post(&url) | |
| 389 | + | .bearer_auth(&self.service_token) | |
| 390 | + | .json(&serde_json::json!({ | |
| 391 | + | "user_id": user_id, | |
| 392 | + | "item_id": item_id, | |
| 393 | + | "file_type": file_type, | |
| 394 | + | "file_name": file_name, | |
| 395 | + | "content_type": content_type, | |
| 396 | + | })) | |
| 397 | + | .send() | |
| 398 | + | .await?; | |
| 399 | + | ||
| 400 | + | if !resp.status().is_success() { | |
| 401 | + | let status = resp.status(); | |
| 402 | + | let body = resp.text().await.unwrap_or_default(); | |
| 403 | + | anyhow::bail!("presign_upload failed: HTTP {} — {}", status, body); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | Ok(resp.json().await?) | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// Confirm a completed S3 upload. | |
| 410 | + | pub async fn confirm_upload( | |
| 411 | + | &self, | |
| 412 | + | user_id: &str, | |
| 413 | + | item_id: &str, | |
| 414 | + | file_type: &str, | |
| 415 | + | s3_key: &str, | |
| 416 | + | ) -> anyhow::Result<bool> { | |
| 417 | + | let url = format!("{}/api/internal/upload/confirm", self.base_url); | |
| 418 | + | let resp = self | |
| 419 | + | .http | |
| 420 | + | .post(&url) | |
| 421 | + | .bearer_auth(&self.service_token) | |
| 422 | + | .json(&serde_json::json!({ | |
| 423 | + | "user_id": user_id, | |
| 424 | + | "item_id": item_id, | |
| 425 | + | "file_type": file_type, | |
| 426 | + | "s3_key": s3_key, | |
| 427 | + | })) | |
| 428 | + | .send() | |
| 429 | + | .await?; | |
| 430 | + | ||
| 431 | + | if !resp.status().is_success() { | |
| 432 | + | let status = resp.status(); | |
| 433 | + | let body = resp.text().await.unwrap_or_default(); | |
| 434 | + | anyhow::bail!("confirm_upload failed: HTTP {} — {}", status, body); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | #[derive(Deserialize)] | |
| 438 | + | struct Resp { | |
| 439 | + | success: bool, | |
| 440 | + | } | |
| 441 | + | let r: Resp = resp.json().await?; | |
| 442 | + | Ok(r.success) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | /// Fetch full item detail. | |
| 446 | + | pub async fn get_item_detail( | |
| 447 | + | &self, | |
| 448 | + | user_id: &str, | |
| 449 | + | item_id: &str, | |
| 450 | + | ) -> anyhow::Result<ItemDetail> { | |
| 451 | + | let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); | |
| 452 | + | let resp = self | |
| 453 | + | .http | |
| 454 | + | .get(&url) | |
| 455 | + | .bearer_auth(&self.service_token) | |
| 456 | + | .query(&[("user_id", user_id)]) | |
| 457 | + | .send() | |
| 458 | + | .await?; | |
| 459 | + | ||
| 460 | + | if !resp.status().is_success() { | |
| 461 | + | let status = resp.status(); | |
| 462 | + | let body = resp.text().await.unwrap_or_default(); | |
| 463 | + | anyhow::bail!("get_item_detail failed: HTTP {} — {}", status, body); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | Ok(resp.json().await?) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | /// Update item fields. Only non-None fields are changed. | |
| 470 | + | pub async fn update_item( | |
| 471 | + | &self, | |
| 472 | + | user_id: &str, | |
| 473 | + | item_id: &str, | |
| 474 | + | title: Option<&str>, | |
| 475 | + | description: Option<&str>, | |
| 476 | + | price_cents: Option<i32>, | |
| 477 | + | is_public: Option<bool>, | |
| 478 | + | ) -> anyhow::Result<ItemDetail> { | |
| 479 | + | let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); | |
| 480 | + | let mut body = serde_json::json!({ "user_id": user_id }); | |
| 481 | + | if let Some(t) = title { | |
| 482 | + | body["title"] = serde_json::Value::String(t.to_string()); | |
| 483 | + | } | |
| 484 | + | if let Some(d) = description { | |
| 485 | + | body["description"] = serde_json::Value::String(d.to_string()); | |
| 486 | + | } | |
| 487 | + | if let Some(p) = price_cents { | |
| 488 | + | body["price_cents"] = serde_json::json!(p); | |
| 489 | + | } | |
| 490 | + | if let Some(v) = is_public { | |
| 491 | + | body["is_public"] = serde_json::json!(v); | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | let resp = self | |
| 495 | + | .http | |
| 496 | + | .put(&url) | |
| 497 | + | .bearer_auth(&self.service_token) | |
| 498 | + | .json(&body) | |
| 499 | + | .send() | |
| 500 | + | .await?; |
Lines truncated
| @@ -0,0 +1,317 @@ | |||
| 1 | + | //! Non-interactive SSH command handlers. | |
| 2 | + | //! | |
| 3 | + | //! When a user runs `ssh cli.makenot.work <command>`, the exec_request handler | |
| 4 | + | //! dispatches to this module. All commands write output to a byte buffer and | |
| 5 | + | //! return it for the SSH channel. | |
| 6 | + | ||
| 7 | + | use crate::api::{MnwApiClient, UserInfo}; | |
| 8 | + | ||
| 9 | + | /// Execute a non-interactive command and return the output bytes. | |
| 10 | + | pub async fn execute( | |
| 11 | + | command_line: &str, | |
| 12 | + | user: &UserInfo, | |
| 13 | + | api: &MnwApiClient, | |
| 14 | + | ) -> Vec<u8> { | |
| 15 | + | let parts: Vec<&str> = command_line.trim().split_whitespace().collect(); | |
| 16 | + | if parts.is_empty() { | |
| 17 | + | return help_text(); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | let json = parts.contains(&"--json"); | |
| 21 | + | let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect(); | |
| 22 | + | ||
| 23 | + | match parts[0] { | |
| 24 | + | "projects" => cmd_projects(user, api, json).await, | |
| 25 | + | "analytics" => { | |
| 26 | + | let range = parts | |
| 27 | + | .iter() | |
| 28 | + | .find_map(|p| p.strip_prefix("--range=")) | |
| 29 | + | .unwrap_or("30d"); | |
| 30 | + | cmd_analytics(user, api, range, json).await | |
| 31 | + | } | |
| 32 | + | "transactions" => cmd_transactions(user, api, json).await, | |
| 33 | + | "export" if parts.get(1) == Some(&"sales") => cmd_export_sales(user, api).await, | |
| 34 | + | "promo" => match parts.get(1).copied() { | |
| 35 | + | Some("list") => cmd_promo_list(user, api, json).await, | |
| 36 | + | Some("create") => { | |
| 37 | + | let code = parts.get(2).unwrap_or(&""); | |
| 38 | + | let pct = parts.get(3).unwrap_or(&"0"); | |
| 39 | + | cmd_promo_create(user, api, code, pct).await | |
| 40 | + | } | |
| 41 | + | _ => b"Usage: promo list | promo create CODE DISCOUNT_PCT\r\n".to_vec(), | |
| 42 | + | }, | |
| 43 | + | "blog" => match parts.get(1).copied() { | |
| 44 | + | Some("list") => { | |
| 45 | + | let slug = parts.get(2).unwrap_or(&""); | |
| 46 | + | cmd_blog_list(user, api, slug, json).await | |
| 47 | + | } | |
| 48 | + | _ => b"Usage: blog list <project-slug>\r\n".to_vec(), | |
| 49 | + | }, | |
| 50 | + | "help" | "--help" | "-h" => help_text(), | |
| 51 | + | other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n") | |
| 52 | + | .into_bytes(), | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 57 | + | match api.get_projects(&user.user_id).await { | |
| 58 | + | Ok(projects) => { | |
| 59 | + | if json { | |
| 60 | + | return serde_json::to_vec_pretty(&projects).unwrap_or_default(); | |
| 61 | + | } | |
| 62 | + | if projects.is_empty() { | |
| 63 | + | return b"No projects.\r\n".to_vec(); | |
| 64 | + | } | |
| 65 | + | let mut out = format!( | |
| 66 | + | "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", | |
| 67 | + | "Title", "Type", "Status", "Items", "Revenue" | |
| 68 | + | ); | |
| 69 | + | out.push_str(&"-".repeat(70)); | |
| 70 | + | out.push_str("\r\n"); | |
| 71 | + | for p in &projects { | |
| 72 | + | let status = if p.is_public { "public" } else { "draft" }; | |
| 73 | + | let revenue = format_cents(p.revenue_cents); | |
| 74 | + | out.push_str(&format!( | |
| 75 | + | "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", | |
| 76 | + | truncate(&p.title, 29), | |
| 77 | + | p.project_type, | |
| 78 | + | status, | |
| 79 | + | p.item_count, | |
| 80 | + | revenue, | |
| 81 | + | )); | |
| 82 | + | } | |
| 83 | + | out.into_bytes() | |
| 84 | + | } | |
| 85 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | async fn cmd_analytics( | |
| 90 | + | user: &UserInfo, | |
| 91 | + | api: &MnwApiClient, | |
| 92 | + | range: &str, | |
| 93 | + | json: bool, | |
| 94 | + | ) -> Vec<u8> { | |
| 95 | + | match api.get_analytics(&user.user_id, range).await { | |
| 96 | + | Ok(data) => { | |
| 97 | + | if json { | |
| 98 | + | return serde_json::to_vec_pretty(&data).unwrap_or_default(); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | let mut out = String::new(); | |
| 102 | + | out.push_str(&format!("Analytics ({range})\r\n\r\n")); | |
| 103 | + | ||
| 104 | + | let rev = format_cents(data.current_revenue_cents); | |
| 105 | + | let prev_rev = format_cents(data.previous_revenue_cents); | |
| 106 | + | out.push_str(&format!("Revenue: {rev} (prev: {prev_rev})\r\n")); | |
| 107 | + | out.push_str(&format!( | |
| 108 | + | "Sales: {} (prev: {})\r\n", | |
| 109 | + | data.current_sales, data.previous_sales | |
| 110 | + | )); | |
| 111 | + | out.push_str(&format!( | |
| 112 | + | "Followers: {} (prev: {})\r\n", | |
| 113 | + | data.current_followers, data.previous_followers | |
| 114 | + | )); | |
| 115 | + | ||
| 116 | + | if !data.top_projects.is_empty() { | |
| 117 | + | out.push_str("\r\nTop Projects:\r\n"); | |
| 118 | + | for p in &data.top_projects { | |
| 119 | + | out.push_str(&format!( | |
| 120 | + | " {:<30} {}\r\n", | |
| 121 | + | truncate(&p.title, 29), | |
| 122 | + | format_cents(p.revenue_cents) | |
| 123 | + | )); | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | out.into_bytes() | |
| 128 | + | } | |
| 129 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 134 | + | match api.get_transactions(&user.user_id).await { | |
| 135 | + | Ok(txs) => { | |
| 136 | + | if json { | |
| 137 | + | return serde_json::to_vec_pretty(&txs).unwrap_or_default(); | |
| 138 | + | } | |
| 139 | + | if txs.is_empty() { | |
| 140 | + | return b"No transactions.\r\n".to_vec(); | |
| 141 | + | } | |
| 142 | + | let mut out = format!( | |
| 143 | + | "{:<30} {:<10} {:<12} {:<12}\r\n", | |
| 144 | + | "Item", "Amount", "Status", "Date" | |
| 145 | + | ); | |
| 146 | + | out.push_str(&"-".repeat(66)); | |
| 147 | + | out.push_str("\r\n"); | |
| 148 | + | for tx in &txs { | |
| 149 | + | let title = tx.item_title.as_deref().unwrap_or("--"); | |
| 150 | + | let amount = format_cents(tx.amount_cents as i64); | |
| 151 | + | let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); | |
| 152 | + | out.push_str(&format!( | |
| 153 | + | "{:<30} {:<10} {:<12} {:<12}\r\n", | |
| 154 | + | truncate(title, 29), | |
| 155 | + | amount, | |
| 156 | + | tx.status, | |
| 157 | + | date, | |
| 158 | + | )); | |
| 159 | + | } | |
| 160 | + | out.into_bytes() | |
| 161 | + | } | |
| 162 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 167 | + | match api.export_sales_csv(&user.user_id).await { | |
| 168 | + | Ok(result) => result.csv.into_bytes(), | |
| 169 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 174 | + | match api.list_promo_codes(&user.user_id).await { | |
| 175 | + | Ok(codes) => { | |
| 176 | + | if json { | |
| 177 | + | return serde_json::to_vec_pretty(&codes).unwrap_or_default(); | |
| 178 | + | } | |
| 179 | + | if codes.is_empty() { | |
| 180 | + | return b"No promo codes.\r\n".to_vec(); | |
| 181 | + | } | |
| 182 | + | let mut out = format!( | |
| 183 | + | "{:<20} {:<12} {:<20} {:<10}\r\n", | |
| 184 | + | "Code", "Discount", "Scope", "Uses" | |
| 185 | + | ); | |
| 186 | + | out.push_str(&"-".repeat(64)); | |
| 187 | + | out.push_str("\r\n"); | |
| 188 | + | for c in &codes { | |
| 189 | + | let discount = match (c.discount_type.as_deref(), c.discount_value) { | |
| 190 | + | (Some("percentage"), Some(v)) => format!("{}% off", v), | |
| 191 | + | (Some("fixed"), Some(v)) => format!("${}.{:02} off", v / 100, v % 100), | |
| 192 | + | _ => "Free".to_string(), | |
| 193 | + | }; | |
| 194 | + | let scope = c | |
| 195 | + | .item_title | |
| 196 | + | .as_deref() | |
| 197 | + | .or(c.project_title.as_deref()) | |
| 198 | + | .unwrap_or("All items"); | |
| 199 | + | let uses = match c.max_uses { | |
| 200 | + | Some(max) => format!("{}/{}", c.use_count, max), | |
| 201 | + | None => c.use_count.to_string(), | |
| 202 | + | }; | |
| 203 | + | out.push_str(&format!( | |
| 204 | + | "{:<20} {:<12} {:<20} {:<10}\r\n", | |
| 205 | + | truncate(&c.code, 19), | |
| 206 | + | discount, | |
| 207 | + | truncate(scope, 19), | |
| 208 | + | uses, | |
| 209 | + | )); | |
| 210 | + | } | |
| 211 | + | out.into_bytes() | |
| 212 | + | } | |
| 213 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | async fn cmd_promo_create( | |
| 218 | + | user: &UserInfo, | |
| 219 | + | api: &MnwApiClient, | |
| 220 | + | code: &str, | |
| 221 | + | pct: &str, | |
| 222 | + | ) -> Vec<u8> { | |
| 223 | + | if code.is_empty() { | |
| 224 | + | return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec(); | |
| 225 | + | } | |
| 226 | + | let discount: i32 = pct.parse().unwrap_or(0); | |
| 227 | + | match api | |
| 228 | + | .create_promo_code(&user.user_id, code, "percentage", discount, None, None) | |
| 229 | + | .await | |
| 230 | + | { | |
| 231 | + | Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(), | |
| 232 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | async fn cmd_blog_list( | |
| 237 | + | user: &UserInfo, | |
| 238 | + | api: &MnwApiClient, | |
| 239 | + | slug: &str, | |
| 240 | + | json: bool, | |
| 241 | + | ) -> Vec<u8> { | |
| 242 | + | if slug.is_empty() { | |
| 243 | + | return b"Usage: blog list <project-slug>\r\n".to_vec(); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Find the project by slug | |
| 247 | + | let projects = match api.get_projects(&user.user_id).await { | |
| 248 | + | Ok(p) => p, | |
| 249 | + | Err(e) => return format!("Error: {e}\r\n").into_bytes(), | |
| 250 | + | }; | |
| 251 | + | ||
| 252 | + | let Some(project) = projects.iter().find(|p| p.slug == slug) else { | |
| 253 | + | return format!("Project not found: {slug}\r\n").into_bytes(); | |
| 254 | + | }; | |
| 255 | + | ||
| 256 | + | match api.list_blog_posts(&user.user_id, &project.id).await { | |
| 257 | + | Ok(posts) => { | |
| 258 | + | if json { | |
| 259 | + | return serde_json::to_vec_pretty(&posts).unwrap_or_default(); | |
| 260 | + | } | |
| 261 | + | if posts.is_empty() { | |
| 262 | + | return b"No blog posts.\r\n".to_vec(); | |
| 263 | + | } | |
| 264 | + | let mut out = format!( | |
| 265 | + | "{:<30} {:<20} {:<10} {:<12}\r\n", | |
| 266 | + | "Title", "Slug", "Status", "Created" | |
| 267 | + | ); | |
| 268 | + | out.push_str(&"-".repeat(74)); | |
| 269 | + | out.push_str("\r\n"); | |
| 270 | + | for p in &posts { | |
| 271 | + | let status = if p.is_published { "published" } else { "draft" }; | |
| 272 | + | let date = p.created_at.get(..10).unwrap_or(&p.created_at); | |
| 273 | + | out.push_str(&format!( | |
| 274 | + | "{:<30} {:<20} {:<10} {:<12}\r\n", | |
| 275 | + | truncate(&p.title, 29), | |
| 276 | + | truncate(&p.slug, 19), | |
| 277 | + | status, | |
| 278 | + | date, | |
| 279 | + | )); | |
| 280 | + | } | |
| 281 | + | out.into_bytes() | |
| 282 | + | } | |
| 283 | + | Err(e) => format!("Error: {e}\r\n").into_bytes(), | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fn format_cents(cents: i64) -> String { | |
| 288 | + | if cents == 0 { | |
| 289 | + | "$0".to_string() | |
| 290 | + | } else { | |
| 291 | + | format!("${}.{:02}", cents / 100, cents.abs() % 100) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn help_text() -> Vec<u8> { | |
| 296 | + | b"Usage: ssh cli.makenot.work <command>\r\n\ | |
| 297 | + | \r\n\ | |
| 298 | + | Commands:\r\n\ | |
| 299 | + | \x20 projects List your projects\r\n\ | |
| 300 | + | \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\ | |
| 301 | + | \x20 transactions Recent transactions\r\n\ | |
| 302 | + | \x20 export sales Export sales as CSV\r\n\ | |
| 303 | + | \x20 promo list List promo codes\r\n\ | |
| 304 | + | \x20 promo create CODE PCT Create a promo code\r\n\ | |
| 305 | + | \x20 blog list SLUG List blog posts for project\r\n\ | |
| 306 | + | \r\n\ | |
| 307 | + | Add --json to any command for machine-readable output.\r\n" | |
| 308 | + | .to_vec() | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | fn truncate(s: &str, max_len: usize) -> &str { | |
| 312 | + | if s.len() <= max_len { | |
| 313 | + | s | |
| 314 | + | } else { | |
| 315 | + | &s[..max_len] | |
| 316 | + | } | |
| 317 | + | } |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | //! Configuration loaded from environment variables. | |
| 2 | + | ||
| 3 | + | use std::path::PathBuf; | |
| 4 | + | ||
| 5 | + | /// Application configuration. | |
| 6 | + | pub struct Config { | |
| 7 | + | /// Port to listen on for SSH connections. | |
| 8 | + | pub port: u16, | |
| 9 | + | /// MNW API base URL (e.g., "http://localhost:3000"). | |
| 10 | + | pub api_url: String, | |
| 11 | + | /// Service token for authenticating with MNW internal API. | |
| 12 | + | pub service_token: String, | |
| 13 | + | /// Path to the ed25519 host key file. | |
| 14 | + | pub host_key_path: PathBuf, | |
| 15 | + | /// Base directory for staging uploaded files. | |
| 16 | + | pub staging_dir: PathBuf, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | impl Config { | |
| 20 | + | /// Load configuration from environment variables. | |
| 21 | + | pub fn from_env() -> anyhow::Result<Self> { | |
| 22 | + | let port: u16 = std::env::var("SSH_PORT") | |
| 23 | + | .unwrap_or_else(|_| "2222".to_string()) | |
| 24 | + | .parse() | |
| 25 | + | .map_err(|_| anyhow::anyhow!("Invalid SSH_PORT"))?; | |
| 26 | + | ||
| 27 | + | let api_url = std::env::var("MNW_API_URL") | |
| 28 | + | .unwrap_or_else(|_| "http://localhost:3000".to_string()); | |
| 29 | + | ||
| 30 | + | let service_token = std::env::var("MNW_SERVICE_TOKEN") | |
| 31 | + | .map_err(|_| anyhow::anyhow!("MNW_SERVICE_TOKEN is required"))?; | |
| 32 | + | ||
| 33 | + | let host_key_path = std::env::var("SSH_HOST_KEY") | |
| 34 | + | .unwrap_or_else(|_| "host_ed25519".to_string()) | |
| 35 | + | .into(); | |
| 36 | + | ||
| 37 | + | let staging_dir = std::env::var("STAGING_DIR") | |
| 38 | + | .unwrap_or_else(|_| "/var/lib/mnw-cli/staging".to_string()) | |
| 39 | + | .into(); | |
| 40 | + | ||
| 41 | + | Ok(Config { | |
| 42 | + | port, | |
| 43 | + | api_url, | |
| 44 | + | service_token, | |
| 45 | + | host_key_path, | |
| 46 | + | staging_dir, | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } |
| @@ -0,0 +1,129 @@ | |||
| 1 | + | //! MNW CLI — SSH-based TUI for the Makenot.work creator platform. | |
| 2 | + | //! | |
| 3 | + | //! Runs a russh SSH server that authenticates creators by their registered | |
| 4 | + | //! SSH public keys (via the MNW internal API) and presents a ratatui TUI | |
| 5 | + | //! for managing projects, items, uploads, and analytics. | |
| 6 | + | ||
| 7 | + | mod api; | |
| 8 | + | mod commands; | |
| 9 | + | mod config; | |
| 10 | + | mod ssh; | |
| 11 | + | mod staging; | |
| 12 | + | mod tui; | |
| 13 | + | ||
| 14 | + | use std::sync::Arc; | |
| 15 | + | ||
| 16 | + | use russh::keys::{self, Algorithm, PrivateKey, ssh_key}; | |
| 17 | + | use russh::server::Server as _; | |
| 18 | + | use russh::MethodKind; | |
| 19 | + | use tokio::signal; | |
| 20 | + | use tracing_subscriber::EnvFilter; | |
| 21 | + | ||
| 22 | + | #[tokio::main] | |
| 23 | + | async fn main() -> anyhow::Result<()> { | |
| 24 | + | tracing_subscriber::fmt() | |
| 25 | + | .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?)) | |
| 26 | + | .init(); | |
| 27 | + | ||
| 28 | + | let config = config::Config::from_env()?; | |
| 29 | + | ||
| 30 | + | // Ensure staging base directory exists | |
| 31 | + | std::fs::create_dir_all(&config.staging_dir)?; | |
| 32 | + | tracing::info!(staging_dir = %config.staging_dir.display(), "staging directory ready"); | |
| 33 | + | ||
| 34 | + | // Spawn hourly cleanup task for stale staging files (24h TTL) | |
| 35 | + | let cleanup_dir = config.staging_dir.clone(); | |
| 36 | + | tokio::spawn(async move { | |
| 37 | + | let ttl = std::time::Duration::from_secs(24 * 60 * 60); | |
| 38 | + | let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); | |
| 39 | + | loop { | |
| 40 | + | interval.tick().await; | |
| 41 | + | staging::cleanup_stale(&cleanup_dir, ttl).await; | |
| 42 | + | } | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | // Load or generate host key | |
| 46 | + | let host_key = load_or_generate_host_key(&config.host_key_path)?; | |
| 47 | + | ||
| 48 | + | tracing::info!( | |
| 49 | + | port = config.port, | |
| 50 | + | api_url = %config.api_url, | |
| 51 | + | host_key = %config.host_key_path.display(), | |
| 52 | + | "starting MNW CLI SSH server" | |
| 53 | + | ); | |
| 54 | + | ||
| 55 | + | let mut methods = russh::MethodSet::empty(); | |
| 56 | + | methods.push(MethodKind::PublicKey); | |
| 57 | + | ||
| 58 | + | let ssh_config = russh::server::Config { | |
| 59 | + | methods, | |
| 60 | + | keys: vec![host_key], | |
| 61 | + | auth_rejection_time: std::time::Duration::from_secs(1), | |
| 62 | + | auth_rejection_time_initial: Some(std::time::Duration::from_millis(0)), | |
| 63 | + | ..Default::default() | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | let staging_dir = Arc::new(config.staging_dir); | |
| 67 | + | let api_client = api::MnwApiClient::new(config.api_url, config.service_token); | |
| 68 | + | let mut server = ssh::MnwServer::new(api_client, staging_dir); | |
| 69 | + | ||
| 70 | + | let addr = format!("0.0.0.0:{}", config.port); | |
| 71 | + | tracing::info!(%addr, "listening for SSH connections"); | |
| 72 | + | ||
| 73 | + | // Run SSH server with graceful shutdown on SIGTERM/SIGINT | |
| 74 | + | tokio::select! { | |
| 75 | + | result = server.run_on_address(Arc::new(ssh_config), addr) => { | |
| 76 | + | result?; | |
| 77 | + | } | |
| 78 | + | _ = shutdown_signal() => { | |
| 79 | + | tracing::info!("shutdown signal received, stopping"); | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | tracing::info!("MNW CLI server stopped"); | |
| 84 | + | Ok(()) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | async fn shutdown_signal() { | |
| 88 | + | let ctrl_c = signal::ctrl_c(); | |
| 89 | + | #[cfg(unix)] | |
| 90 | + | let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) | |
| 91 | + | .expect("failed to register SIGTERM handler"); | |
| 92 | + | #[cfg(unix)] | |
| 93 | + | tokio::select! { | |
| 94 | + | _ = ctrl_c => {} | |
| 95 | + | _ = sigterm.recv() => {} | |
| 96 | + | } | |
| 97 | + | #[cfg(not(unix))] | |
| 98 | + | ctrl_c.await.ok(); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Load an ed25519 host key from disk, or generate and save one if it doesn't exist. | |
| 102 | + | fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKey> { | |
| 103 | + | if path.exists() { | |
| 104 | + | tracing::info!(path = %path.display(), "loading host key"); | |
| 105 | + | let key = keys::load_secret_key(path, None)?; | |
| 106 | + | Ok(key) | |
| 107 | + | } else { | |
| 108 | + | tracing::info!(path = %path.display(), "generating new ed25519 host key"); | |
| 109 | + | // Use the rand_core OsRng re-exported through russh's ssh_key dependency | |
| 110 | + | // to avoid version conflicts with the standalone rand crate. | |
| 111 | + | let key = ssh_key::private::PrivateKey::random( | |
| 112 | + | &mut keys::signature::rand_core::OsRng, | |
| 113 | + | Algorithm::Ed25519, | |
| 114 | + | )?; | |
| 115 | + | // Save to disk in OpenSSH format | |
| 116 | + | let pem = key.to_openssh(ssh_key::LineEnding::LF)?; | |
| 117 | + | if let Some(parent) = path.parent() { | |
| 118 | + | std::fs::create_dir_all(parent)?; | |
| 119 | + | } | |
| 120 | + | std::fs::write(path, pem.as_bytes())?; | |
| 121 | + | #[cfg(unix)] | |
| 122 | + | { | |
| 123 | + | use std::os::unix::fs::PermissionsExt; | |
| 124 | + | std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; | |
| 125 | + | } | |
| 126 | + | tracing::info!(path = %path.display(), "host key saved"); | |
| 127 | + | Ok(key.into()) | |
| 128 | + | } | |
| 129 | + | } |
| @@ -0,0 +1,290 @@ | |||
| 1 | + | //! SSH session handler: authentication, PTY, shell, SFTP, and input dispatch. | |
| 2 | + | ||
| 3 | + | use std::collections::HashMap; | |
| 4 | + | use std::net::SocketAddr; | |
| 5 | + | use std::path::PathBuf; | |
| 6 | + | use std::sync::Arc; | |
| 7 | + | ||
| 8 | + | use russh::keys::{HashAlg, PublicKey}; | |
| 9 | + | use russh::server::{Auth, Msg, Session}; | |
| 10 | + | use russh::{Channel, ChannelId}; | |
| 11 | + | use tokio::sync::Mutex; | |
| 12 | + | ||
| 13 | + | use crate::api::{MnwApiClient, UserInfo}; | |
| 14 | + | use crate::ssh::sftp::SftpSession; | |
| 15 | + | use crate::ssh::terminal::TerminalHandle; | |
| 16 | + | use crate::staging; | |
| 17 | + | use crate::tui; | |
| 18 | + | ||
| 19 | + | /// Per-connection handler. Created by `MnwServer::new_client()`. | |
| 20 | + | pub struct MnwHandler { | |
| 21 | + | api: MnwApiClient, | |
| 22 | + | peer_addr: Option<SocketAddr>, | |
| 23 | + | staging_dir: Arc<PathBuf>, | |
| 24 | + | /// Populated after successful auth. | |
| 25 | + | user: Option<UserInfo>, | |
| 26 | + | /// Terminal dimensions (cols, rows). | |
| 27 | + | term_size: (u16, u16), | |
| 28 | + | /// TUI application handle for forwarding keypresses. | |
| 29 | + | app: Option<tui::AppHandle>, | |
| 30 | + | /// Channels stored between open and shell/subsystem request. | |
| 31 | + | channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl MnwHandler { | |
| 35 | + | pub fn new(api: MnwApiClient, peer_addr: Option<SocketAddr>, staging_dir: Arc<PathBuf>) -> Self { | |
| 36 | + | Self { | |
| 37 | + | api, | |
| 38 | + | peer_addr, | |
| 39 | + | staging_dir, | |
| 40 | + | user: None, | |
| 41 | + | term_size: (80, 24), | |
| 42 | + | app: None, | |
| 43 | + | channels: Arc::new(Mutex::new(HashMap::new())), | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl russh::server::Handler for MnwHandler { | |
| 49 | + | type Error = anyhow::Error; | |
| 50 | + | ||
| 51 | + | async fn auth_publickey_offered( | |
| 52 | + | &mut self, | |
| 53 | + | _user: &str, | |
| 54 | + | key: &PublicKey, | |
| 55 | + | ) -> Result<Auth, Self::Error> { | |
| 56 | + | let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 57 | + | tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered"); | |
| 58 | + | ||
| 59 | + | match self.api.lookup_ssh_key(&fingerprint).await { | |
| 60 | + | Ok(Some(info)) => { | |
| 61 | + | if info.suspended { | |
| 62 | + | tracing::warn!(user = %info.username, "suspended user attempted SSH login"); | |
| 63 | + | return Ok(Auth::Reject { | |
| 64 | + | proceed_with_methods: None, | |
| 65 | + | partial_success: false, | |
| 66 | + | }); | |
| 67 | + | } | |
| 68 | + | self.user = Some(info); | |
| 69 | + | Ok(Auth::Accept) | |
| 70 | + | } | |
| 71 | + | Ok(None) => { | |
| 72 | + | tracing::debug!(%fingerprint, "key not found"); | |
| 73 | + | Ok(Auth::Reject { | |
| 74 | + | proceed_with_methods: None, | |
| 75 | + | partial_success: false, | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | Err(e) => { | |
| 79 | + | tracing::error!(error = ?e, "SSH key lookup failed"); | |
| 80 | + | Ok(Auth::Reject { | |
| 81 | + | proceed_with_methods: None, | |
| 82 | + | partial_success: false, | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | async fn auth_publickey( | |
| 89 | + | &mut self, | |
| 90 | + | _user: &str, | |
| 91 | + | _key: &PublicKey, | |
| 92 | + | ) -> Result<Auth, Self::Error> { | |
| 93 | + | // If auth_publickey_offered accepted, the user is already stored. | |
| 94 | + | if self.user.is_some() { | |
| 95 | + | Ok(Auth::Accept) | |
| 96 | + | } else { | |
| 97 | + | Ok(Auth::Reject { | |
| 98 | + | proceed_with_methods: None, | |
| 99 | + | partial_success: false, | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | async fn channel_open_session( | |
| 105 | + | &mut self, | |
| 106 | + | channel: Channel<Msg>, | |
| 107 | + | _session: &mut Session, | |
| 108 | + | ) -> Result<bool, Self::Error> { | |
| 109 | + | let channel_id = channel.id(); | |
| 110 | + | tracing::debug!(channel = %channel_id, "session channel opened"); | |
| 111 | + | // Store channel for later consumption by shell_request or subsystem_request | |
| 112 | + | self.channels.lock().await.insert(channel_id, channel); | |
| 113 | + | Ok(true) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | async fn pty_request( | |
| 117 | + | &mut self, | |
| 118 | + | _channel: ChannelId, | |
| 119 | + | _term: &str, | |
| 120 | + | col_width: u32, | |
| 121 | + | row_height: u32, | |
| 122 | + | _pix_width: u32, | |
| 123 | + | _pix_height: u32, | |
| 124 | + | _modes: &[(russh::Pty, u32)], | |
| 125 | + | _session: &mut Session, | |
| 126 | + | ) -> Result<(), Self::Error> { | |
| 127 | + | self.term_size = (col_width as u16, row_height as u16); | |
| 128 | + | tracing::debug!(cols = col_width, rows = row_height, "PTY requested"); | |
| 129 | + | Ok(()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | async fn shell_request( | |
| 133 | + | &mut self, | |
| 134 | + | channel: ChannelId, | |
| 135 | + | session: &mut Session, | |
| 136 | + | ) -> Result<(), Self::Error> { | |
| 137 | + | let Some(ref user) = self.user else { | |
| 138 | + | tracing::warn!("shell_request without authenticated user"); | |
| 139 | + | session.close(channel)?; | |
| 140 | + | return Ok(()); | |
| 141 | + | }; | |
| 142 | + | ||
| 143 | + | tracing::info!(user = %user.username, "launching TUI"); | |
| 144 | + | ||
| 145 | + | let handle = session.handle(); | |
| 146 | + | let terminal_handle = TerminalHandle::new(handle.clone(), channel); | |
| 147 | + | let (cols, rows) = self.term_size; | |
| 148 | + | ||
| 149 | + | let user_clone = user.clone(); | |
| 150 | + | let staging_dir = staging::user_staging_dir(&self.staging_dir, &user.user_id); | |
| 151 | + | let app_handle = tui::launch( | |
| 152 | + | terminal_handle, | |
| 153 | + | user_clone, | |
| 154 | + | cols, | |
| 155 | + | rows, | |
| 156 | + | handle, | |
| 157 | + | channel, | |
| 158 | + | self.api.clone(), | |
| 159 | + | staging_dir, | |
| 160 | + | )?; | |
| 161 | + | self.app = Some(app_handle); | |
| 162 | + | ||
| 163 | + | Ok(()) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | async fn subsystem_request( | |
| 167 | + | &mut self, | |
| 168 | + | channel_id: ChannelId, | |
| 169 | + | name: &str, | |
| 170 | + | session: &mut Session, | |
| 171 | + | ) -> Result<(), Self::Error> { | |
| 172 | + | if name != "sftp" { | |
| 173 | + | tracing::debug!(subsystem = name, "unsupported subsystem requested"); | |
| 174 | + | session.close(channel_id)?; | |
| 175 | + | return Ok(()); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | let Some(ref user) = self.user else { | |
| 179 | + | tracing::warn!("subsystem_request without authenticated user"); | |
| 180 | + | session.close(channel_id)?; | |
| 181 | + | return Ok(()); | |
| 182 | + | }; | |
| 183 | + | ||
| 184 | + | // Take the stored channel for this ID | |
| 185 | + | let channel = self | |
| 186 | + | .channels | |
| 187 | + | .lock() | |
| 188 | + | .await | |
| 189 | + | .remove(&channel_id); | |
| 190 | + | ||
| 191 | + | let Some(channel) = channel else { | |
| 192 | + | tracing::error!("no stored channel for SFTP subsystem"); | |
| 193 | + | session.close(channel_id)?; | |
| 194 | + | return Ok(()); | |
| 195 | + | }; | |
| 196 | + | ||
| 197 | + | let user_staging = staging::user_staging_dir(&self.staging_dir, &user.user_id); | |
| 198 | + | let sftp_session = SftpSession::new( | |
| 199 | + | user.user_id.clone(), | |
| 200 | + | user.creator_tier.clone(), | |
| 201 | + | user_staging, | |
| 202 | + | ); | |
| 203 | + | ||
| 204 | + | tracing::info!(user = %user.username, "starting SFTP session"); | |
| 205 | + | ||
| 206 | + | let stream = channel.into_stream(); | |
| 207 | + | tokio::spawn(async move { | |
| 208 | + | russh_sftp::server::run(stream, sftp_session).await; | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | Ok(()) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | async fn exec_request( | |
| 215 | + | &mut self, | |
| 216 | + | channel: ChannelId, | |
| 217 | + | data: &[u8], | |
| 218 | + | session: &mut Session, | |
| 219 | + | ) -> Result<(), Self::Error> { | |
| 220 | + | let command_line = String::from_utf8_lossy(data); | |
| 221 | + | let handle = session.handle(); | |
| 222 | + | ||
| 223 | + | // Check if this looks like a legacy SCP transfer | |
| 224 | + | if command_line.starts_with("scp ") { | |
| 225 | + | let msg: &[u8] = | |
| 226 | + | b"Use scp (not scp -O) or sftp to upload files to cli.makenot.work\r\n"; | |
| 227 | + | let bytes = bytes::Bytes::copy_from_slice(msg); | |
| 228 | + | tokio::spawn(async move { | |
| 229 | + | let _ = handle.data(channel, bytes).await; | |
| 230 | + | let _ = handle.close(channel).await; | |
| 231 | + | }); | |
| 232 | + | return Ok(()); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // Execute the command | |
| 236 | + | let Some(ref user) = self.user else { | |
| 237 | + | let _ = handle.close(channel).await; | |
| 238 | + | return Ok(()); | |
| 239 | + | }; | |
| 240 | + | ||
| 241 | + | let user = user.clone(); | |
| 242 | + | let api = self.api.clone(); | |
| 243 | + | let cmd = command_line.to_string(); | |
| 244 | + | tracing::info!(user = %user.username, command = %cmd, "exec command"); | |
| 245 | + | ||
| 246 | + | tokio::spawn(async move { | |
| 247 | + | let output = crate::commands::execute(&cmd, &user, &api).await; | |
| 248 | + | let bytes = bytes::Bytes::from(output); | |
| 249 | + | let _ = handle.data(channel, bytes).await; | |
| 250 | + | let _ = handle.close(channel).await; | |
| 251 | + | }); | |
| 252 | + | ||
| 253 | + | Ok(()) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | async fn data( | |
| 257 | + | &mut self, | |
| 258 | + | _channel: ChannelId, | |
| 259 | + | data: &[u8], | |
| 260 | + | _session: &mut Session, | |
| 261 | + | ) -> Result<(), Self::Error> { | |
| 262 | + | if let Some(ref app) = self.app { | |
| 263 | + | app.send_input(data).await; | |
| 264 | + | } | |
| 265 | + | Ok(()) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | async fn window_change_request( | |
| 269 | + | &mut self, | |
| 270 | + | _channel: ChannelId, | |
| 271 | + | col_width: u32, | |
| 272 | + | row_height: u32, | |
| 273 | + | _pix_width: u32, | |
| 274 | + | _pix_height: u32, | |
| 275 | + | _session: &mut Session, | |
| 276 | + | ) -> Result<(), Self::Error> { | |
| 277 | + | self.term_size = (col_width as u16, row_height as u16); | |
| 278 | + | if let Some(ref app) = self.app { | |
| 279 | + | app.send_resize(col_width as u16, row_height as u16).await; | |
| 280 | + | } | |
| 281 | + | Ok(()) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | async fn auth_succeeded(&mut self, _session: &mut Session) -> Result<(), Self::Error> { | |
| 285 | + | if let Some(ref user) = self.user { | |
| 286 | + | tracing::info!(user = %user.username, peer = ?self.peer_addr, "authenticated"); | |
| 287 | + | } | |
| 288 | + | Ok(()) | |
| 289 | + | } | |
| 290 | + | } |
| @@ -0,0 +1,32 @@ | |||
| 1 | + | //! SSH server implementation using russh. | |
| 2 | + | ||
| 3 | + | pub mod handler; | |
| 4 | + | pub mod sftp; | |
| 5 | + | pub mod terminal; | |
| 6 | + | ||
| 7 | + | use std::net::SocketAddr; | |
| 8 | + | use std::path::PathBuf; | |
| 9 | + | use std::sync::Arc; | |
| 10 | + | ||
| 11 | + | use crate::api::MnwApiClient; | |
| 12 | + | ||
| 13 | + | /// SSH server that spawns a new handler per connection. | |
| 14 | + | pub struct MnwServer { | |
| 15 | + | api: MnwApiClient, | |
| 16 | + | staging_dir: Arc<PathBuf>, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | impl MnwServer { | |
| 20 | + | pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>) -> Self { | |
| 21 | + | Self { api, staging_dir } | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | impl russh::server::Server for MnwServer { | |
| 26 | + | type Handler = handler::MnwHandler; | |
| 27 | + | ||
| 28 | + | fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler { | |
| 29 | + | tracing::info!(?peer_addr, "new SSH connection"); | |
| 30 | + | handler::MnwHandler::new(self.api.clone(), peer_addr, Arc::clone(&self.staging_dir)) | |
| 31 | + | } | |
| 32 | + | } |
| @@ -0,0 +1,426 @@ | |||
| 1 | + | //! SFTP subsystem handler for file uploads. | |
| 2 | + | //! | |
| 3 | + | //! Presents a virtual filesystem with a single `/upload/` directory. | |
| 4 | + | //! Files written here land in the staging directory on disk, from where | |
| 5 | + | //! the TUI publish flow sends them to S3. | |
| 6 | + | ||
| 7 | + | use std::collections::HashMap; | |
| 8 | + | use std::path::PathBuf; | |
| 9 | + | ||
| 10 | + | use russh_sftp::protocol::{ | |
| 11 | + | Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | use crate::staging::{self, is_allowed_extension, sanitize_filename, STAGING_QUOTA_BYTES}; | |
| 15 | + | ||
| 16 | + | /// SFTP session handler for a single authenticated user. | |
| 17 | + | pub struct SftpSession { | |
| 18 | + | user_id: String, | |
| 19 | + | creator_tier: Option<String>, | |
| 20 | + | staging_dir: PathBuf, | |
| 21 | + | open_files: HashMap<String, OpenFile>, | |
| 22 | + | dir_handles: HashMap<String, bool>, // handle -> already_read | |
| 23 | + | next_handle: u64, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | struct OpenFile { | |
| 27 | + | path: PathBuf, | |
| 28 | + | file: tokio::fs::File, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl SftpSession { | |
| 32 | + | pub fn new(user_id: String, creator_tier: Option<String>, staging_dir: PathBuf) -> Self { | |
| 33 | + | Self { | |
| 34 | + | user_id, | |
| 35 | + | creator_tier, | |
| 36 | + | staging_dir, | |
| 37 | + | open_files: HashMap::new(), | |
| 38 | + | dir_handles: HashMap::new(), | |
| 39 | + | next_handle: 1, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | fn alloc_handle(&mut self) -> String { | |
| 44 | + | let h = self.next_handle; | |
| 45 | + | self.next_handle += 1; | |
| 46 | + | format!("h{}", h) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | fn ok_status(&self, id: u32) -> Status { | |
| 50 | + | Status { | |
| 51 | + | id, | |
| 52 | + | status_code: StatusCode::Ok, | |
| 53 | + | error_message: String::new(), | |
| 54 | + | language_tag: String::new(), | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn is_basic_tier(&self) -> bool { | |
| 59 | + | self.creator_tier.as_deref() == Some("basic") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn is_upload_path(path: &str) -> bool { | |
| 63 | + | let normalized = path.trim_matches('/'); | |
| 64 | + | normalized == "upload" || normalized.is_empty() || normalized == "." | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | fn extract_filename(path: &str) -> Option<&str> { | |
| 68 | + | let normalized = path.trim_start_matches('/'); | |
| 69 | + | normalized.strip_prefix("upload/").or_else(|| { | |
| 70 | + | // Direct filename without upload/ prefix | |
| 71 | + | if !normalized.contains('/') && normalized != "upload" && !normalized.is_empty() { | |
| 72 | + | Some(normalized) | |
| 73 | + | } else { | |
| 74 | + | None | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | impl russh_sftp::server::Handler for SftpSession { | |
| 81 | + | type Error = StatusCode; | |
| 82 | + | ||
| 83 | + | fn unimplemented(&self) -> Self::Error { | |
| 84 | + | StatusCode::OpUnsupported | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | async fn init( | |
| 88 | + | &mut self, | |
| 89 | + | version: u32, | |
| 90 | + | _extensions: HashMap<String, String>, | |
| 91 | + | ) -> Result<Version, Self::Error> { | |
| 92 | + | tracing::debug!(user = %self.user_id, sftp_version = version, "SFTP session initialized"); | |
| 93 | + | ||
| 94 | + | // Ensure staging dir exists | |
| 95 | + | if let Err(e) = tokio::fs::create_dir_all(&self.staging_dir).await { | |
| 96 | + | tracing::error!(error = ?e, "failed to create staging dir"); | |
| 97 | + | return Err(StatusCode::Failure); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | Ok(Version::new()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | async fn realpath(&mut self, id: u32, _path: String) -> Result<Name, Self::Error> { | |
| 104 | + | Ok(Name { | |
| 105 | + | id, | |
| 106 | + | files: vec![File::dummy("/upload")], | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> { | |
| 111 | + | if !Self::is_upload_path(&path) { | |
| 112 | + | return Err(StatusCode::NoSuchFile); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | let handle = self.alloc_handle(); | |
| 116 | + | self.dir_handles.insert(handle.clone(), false); | |
| 117 | + | ||
| 118 | + | Ok(Handle { id, handle }) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> { | |
| 122 | + | let already_read = self | |
| 123 | + | .dir_handles | |
| 124 | + | .get_mut(&handle) | |
| 125 | + | .ok_or(StatusCode::Failure)?; | |
| 126 | + | ||
| 127 | + | if *already_read { | |
| 128 | + | return Err(StatusCode::Eof); | |
| 129 | + | } | |
| 130 | + | *already_read = true; | |
| 131 | + | ||
| 132 | + | let staged = staging::list_staged_files(&self.staging_dir).await; | |
| 133 | + | ||
| 134 | + | let files: Vec<File> = staged | |
| 135 | + | .into_iter() | |
| 136 | + | .map(|sf| { | |
| 137 | + | let mut attrs = FileAttributes::empty(); | |
| 138 | + | attrs.set_regular(true); | |
| 139 | + | attrs.size = Some(sf.size); | |
| 140 | + | if let Ok(dur) = sf | |
| 141 | + | .modified | |
| 142 | + | .duration_since(std::time::SystemTime::UNIX_EPOCH) | |
| 143 | + | { | |
| 144 | + | attrs.mtime = Some(dur.as_secs() as u32); | |
| 145 | + | } | |
| 146 | + | File::new(sf.filename, attrs) | |
| 147 | + | }) | |
| 148 | + | .collect(); | |
| 149 | + | ||
| 150 | + | Ok(Name { id, files }) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> { | |
| 154 | + | if Self::is_upload_path(&path) { | |
| 155 | + | let mut attrs = FileAttributes::empty(); | |
| 156 | + | attrs.set_dir(true); | |
| 157 | + | attrs.permissions = Some(0o755); | |
| 158 | + | return Ok(Attrs { id, attrs }); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | if let Some(filename) = Self::extract_filename(&path) { | |
| 162 | + | let file_path = self.staging_dir.join(sanitize_filename(filename)); | |
| 163 | + | if let Ok(metadata) = tokio::fs::metadata(&file_path).await { | |
| 164 | + | let attrs = FileAttributes::from(&metadata); | |
| 165 | + | return Ok(Attrs { id, attrs }); | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | Err(StatusCode::NoSuchFile) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> { | |
| 173 | + | self.stat(id, path).await | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> { | |
| 177 | + | if self.dir_handles.contains_key(&handle) { | |
| 178 | + | let mut attrs = FileAttributes::empty(); | |
| 179 | + | attrs.set_dir(true); | |
| 180 | + | attrs.permissions = Some(0o755); | |
| 181 | + | return Ok(Attrs { id, attrs }); | |
| 182 | + | } | |
| 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 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | Err(StatusCode::Failure) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | async fn open( | |
| 195 | + | &mut self, | |
| 196 | + | id: u32, | |
| 197 | + | filename: String, | |
| 198 | + | pflags: OpenFlags, | |
| 199 | + | _attrs: FileAttributes, | |
| 200 | + | ) -> Result<Handle, Self::Error> { | |
| 201 | + | // Only allow writing to /upload/<filename> | |
| 202 | + | let raw_name = Self::extract_filename(&filename).ok_or(StatusCode::PermissionDenied)?; | |
| 203 | + | let safe_name = sanitize_filename(raw_name); | |
| 204 | + | ||
| 205 | + | if safe_name.is_empty() { | |
| 206 | + | return Err(StatusCode::NoSuchFile); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // Check tier — Basic is text-only, no file uploads | |
| 210 | + | if self.is_basic_tier() { | |
| 211 | + | tracing::warn!(user = %self.user_id, "Basic tier user attempted SFTP upload"); | |
| 212 | + | return Err(StatusCode::PermissionDenied); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // Check extension | |
| 216 | + | let ext = safe_name.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| 217 | + | if !is_allowed_extension(&ext) { | |
| 218 | + | tracing::warn!(user = %self.user_id, ext, "unsupported file extension"); | |
| 219 | + | return Err(StatusCode::PermissionDenied); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Check staging quota before opening | |
| 223 | + | let current_usage = staging::staging_usage(&self.staging_dir).await; | |
| 224 | + | if current_usage >= STAGING_QUOTA_BYTES { | |
| 225 | + | tracing::warn!(user = %self.user_id, usage = current_usage, "staging quota exceeded"); | |
| 226 | + | return Err(StatusCode::Failure); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | let file_path = self.staging_dir.join(&safe_name); | |
| 230 | + | ||
| 231 | + | if pflags.contains(OpenFlags::WRITE) || pflags.contains(OpenFlags::CREATE) { | |
| 232 | + | // Ensure staging dir exists | |
| 233 | + | if let Err(e) = tokio::fs::create_dir_all(&self.staging_dir).await { | |
| 234 | + | tracing::error!(error = ?e, "failed to create staging dir"); | |
| 235 | + | return Err(StatusCode::Failure); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | let file = tokio::fs::OpenOptions::new() | |
| 239 | + | .write(true) | |
| 240 | + | .create(true) | |
| 241 | + | .truncate(pflags.contains(OpenFlags::TRUNCATE)) | |
| 242 | + | .open(&file_path) | |
| 243 | + | .await | |
| 244 | + | .map_err(|e| { | |
| 245 | + | tracing::error!(error = ?e, "failed to open staging file for write"); | |
| 246 | + | StatusCode::Failure | |
| 247 | + | })?; | |
| 248 | + | ||
| 249 | + | let handle = self.alloc_handle(); | |
| 250 | + | self.open_files.insert( | |
| 251 | + | handle.clone(), | |
| 252 | + | OpenFile { | |
| 253 | + | path: file_path, | |
| 254 | + | file, | |
| 255 | + | }, | |
| 256 | + | ); | |
| 257 | + | ||
| 258 | + | tracing::info!(user = %self.user_id, file = %safe_name, "staging file opened for write"); | |
| 259 | + | return Ok(Handle { id, handle }); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | if pflags.contains(OpenFlags::READ) { | |
| 263 | + | let file = tokio::fs::File::open(&file_path).await.map_err(|_| StatusCode::NoSuchFile)?; | |
| 264 | + | let handle = self.alloc_handle(); | |
| 265 | + | self.open_files.insert( | |
| 266 | + | handle.clone(), | |
| 267 | + | OpenFile { | |
| 268 | + | path: file_path, | |
| 269 | + | file, | |
| 270 | + | }, | |
| 271 | + | ); | |
| 272 | + | return Ok(Handle { id, handle }); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | Err(StatusCode::PermissionDenied) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | async fn write( | |
| 279 | + | &mut self, | |
| 280 | + | id: u32, | |
| 281 | + | handle: String, | |
| 282 | + | offset: u64, | |
| 283 | + | data: Vec<u8>, | |
| 284 | + | ) -> Result<Status, Self::Error> { | |
| 285 | + | use tokio::io::{AsyncSeekExt, AsyncWriteExt}; | |
| 286 | + | ||
| 287 | + | let of = self | |
| 288 | + | .open_files | |
| 289 | + | .get_mut(&handle) | |
| 290 | + | .ok_or(StatusCode::Failure)?; | |
| 291 | + | ||
| 292 | + | // Check staging quota (approximate — race-free enforcement at close time) | |
| 293 | + | let current_usage = staging::staging_usage(&self.staging_dir).await; | |
| 294 | + | if current_usage + data.len() as u64 > STAGING_QUOTA_BYTES { | |
| 295 | + | return Err(StatusCode::Failure); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | of.file | |
| 299 | + | .seek(std::io::SeekFrom::Start(offset)) | |
| 300 | + | .await | |
| 301 | + | .map_err(|_| StatusCode::Failure)?; | |
| 302 | + | ||
| 303 | + | of.file | |
| 304 | + | .write_all(&data) | |
| 305 | + | .await | |
| 306 | + | .map_err(|_| StatusCode::Failure)?; | |
| 307 | + | ||
| 308 | + | Ok(self.ok_status(id)) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | async fn read( | |
| 312 | + | &mut self, | |
| 313 | + | id: u32, | |
| 314 | + | handle: String, | |
| 315 | + | offset: u64, | |
| 316 | + | len: u32, | |
| 317 | + | ) -> Result<Data, Self::Error> { | |
| 318 | + | use tokio::io::{AsyncReadExt, AsyncSeekExt}; | |
| 319 | + | ||
| 320 | + | let of = self | |
| 321 | + | .open_files | |
| 322 | + | .get_mut(&handle) | |
| 323 | + | .ok_or(StatusCode::Failure)?; | |
| 324 | + | ||
| 325 | + | of.file | |
| 326 | + | .seek(std::io::SeekFrom::Start(offset)) | |
| 327 | + | .await | |
| 328 | + | .map_err(|_| StatusCode::Failure)?; | |
| 329 | + | ||
| 330 | + | let mut buf = vec![0u8; len as usize]; | |
| 331 | + | let n = of | |
| 332 | + | .file | |
| 333 | + | .read(&mut buf) | |
| 334 | + | .await | |
| 335 | + | .map_err(|_| StatusCode::Failure)?; | |
| 336 | + | ||
| 337 | + | if n == 0 { | |
| 338 | + | return Err(StatusCode::Eof); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | buf.truncate(n); | |
| 342 | + | Ok(Data { id, data: buf }) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> { | |
| 346 | + | if self.dir_handles.remove(&handle).is_some() { | |
| 347 | + | return Ok(self.ok_status(id)); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | if let Some(of) = self.open_files.remove(&handle) { | |
| 351 | + | drop(of.file); | |
| 352 | + | tracing::debug!(user = %self.user_id, path = %of.path.display(), "file handle closed"); | |
| 353 | + | return Ok(self.ok_status(id)); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | Err(StatusCode::Failure) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | async fn remove(&mut self, id: u32, filename: String) -> Result<Status, Self::Error> { | |
| 360 | + | let raw_name = Self::extract_filename(&filename).ok_or(StatusCode::NoSuchFile)?; | |
| 361 | + | let safe_name = sanitize_filename(raw_name); | |
| 362 | + | let file_path = self.staging_dir.join(&safe_name); | |
| 363 | + | ||
| 364 | + | tokio::fs::remove_file(&file_path) | |
| 365 | + | .await | |
| 366 | + | .map_err(|_| StatusCode::NoSuchFile)?; | |
| 367 | + | ||
| 368 | + | tracing::info!(user = %self.user_id, file = %safe_name, "staging file removed"); | |
| 369 | + | Ok(self.ok_status(id)) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | async fn mkdir( | |
| 373 | + | &mut self, | |
| 374 | + | _id: u32, | |
| 375 | + | _path: String, | |
| 376 | + | _attrs: FileAttributes, | |
| 377 | + | ) -> Result<Status, Self::Error> { | |
| 378 | + | Err(StatusCode::PermissionDenied) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | async fn rmdir(&mut self, _id: u32, _path: String) -> Result<Status, Self::Error> { | |
| 382 | + | Err(StatusCode::PermissionDenied) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | async fn rename( | |
| 386 | + | &mut self, | |
| 387 | + | _id: u32, | |
| 388 | + | _oldpath: String, | |
| 389 | + | _newpath: String, | |
| 390 | + | ) -> Result<Status, Self::Error> { | |
| 391 | + | Err(StatusCode::PermissionDenied) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | async fn symlink( | |
| 395 | + | &mut self, | |
| 396 | + | _id: u32, | |
| 397 | + | _linkpath: String, | |
| 398 | + | _targetpath: String, | |
| 399 | + | ) -> Result<Status, Self::Error> { | |
| 400 | + | Err(StatusCode::OpUnsupported) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | async fn readlink(&mut self, _id: u32, _path: String) -> Result<Name, Self::Error> { | |
| 404 | + | Err(StatusCode::OpUnsupported) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | async fn setstat( | |
| 408 | + | &mut self, | |
| 409 | + | id: u32, | |
| 410 | + | _path: String, | |
| 411 | + | _attrs: FileAttributes, | |
| 412 | + | ) -> Result<Status, Self::Error> { | |
| 413 | + | // Silently accept — some SFTP clients send setstat after upload | |
| 414 | + | Ok(self.ok_status(id)) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | async fn fsetstat( | |
| 418 | + | &mut self, | |
| 419 | + | id: u32, | |
| 420 | + | _handle: String, | |
| 421 | + | _attrs: FileAttributes, | |
| 422 | + | ) -> Result<Status, Self::Error> { | |
| 423 | + | // Silently accept | |
| 424 | + | Ok(self.ok_status(id)) | |
| 425 | + | } | |
| 426 | + | } |
| @@ -0,0 +1,61 @@ | |||
| 1 | + | //! TerminalHandle: bridges ratatui's Write trait to an SSH channel. | |
| 2 | + | //! | |
| 3 | + | //! Buffers writes in a Vec<u8>, then on flush() sends the buffer | |
| 4 | + | //! contents via an mpsc channel to a spawned task that relays data | |
| 5 | + | //! to the SSH session. | |
| 6 | + | ||
| 7 | + | use std::io; | |
| 8 | + | ||
| 9 | + | use tokio::sync::mpsc; | |
| 10 | + | ||
| 11 | + | /// A `Write` sink that buffers output and flushes it to an SSH channel | |
| 12 | + | /// via an async mpsc sender. | |
| 13 | + | pub struct TerminalHandle { | |
| 14 | + | sink: Vec<u8>, | |
| 15 | + | tx: mpsc::Sender<Vec<u8>>, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl TerminalHandle { | |
| 19 | + | /// Create a new TerminalHandle and spawn the relay task. | |
| 20 | + | /// | |
| 21 | + | /// The relay task forwards buffered data to the SSH session's | |
| 22 | + | /// channel via `session_handle.data()`. | |
| 23 | + | pub fn new( | |
| 24 | + | session_handle: russh::server::Handle, | |
| 25 | + | channel_id: russh::ChannelId, | |
| 26 | + | ) -> Self { | |
| 27 | + | let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64); | |
| 28 | + | ||
| 29 | + | tokio::spawn(async move { | |
| 30 | + | while let Some(data) = rx.recv().await { | |
| 31 | + | // Handle::data() accepts impl Into<Bytes>; Vec<u8> converts directly. | |
| 32 | + | if session_handle.data(channel_id, data).await.is_err() { | |
| 33 | + | break; | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | Self { | |
| 39 | + | sink: Vec::with_capacity(4096), | |
| 40 | + | tx, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl io::Write for TerminalHandle { | |
| 46 | + | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | |
| 47 | + | self.sink.extend_from_slice(buf); | |
| 48 | + | Ok(buf.len()) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | fn flush(&mut self) -> io::Result<()> { | |
| 52 | + | if self.sink.is_empty() { | |
| 53 | + | return Ok(()); | |
| 54 | + | } | |
| 55 | + | let data = std::mem::take(&mut self.sink); | |
| 56 | + | self.tx | |
| 57 | + | .try_send(data) | |
| 58 | + | .map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?; | |
| 59 | + | Ok(()) | |
| 60 | + | } | |
| 61 | + | } |
| @@ -0,0 +1,283 @@ | |||
| 1 | + | //! Staging directory management for SFTP uploads. | |
| 2 | + | //! | |
| 3 | + | //! Files uploaded via SFTP land in a per-user staging directory on disk. | |
| 4 | + | //! From there, the TUI publish flow uploads them to S3 via MNW internal API. | |
| 5 | + | ||
| 6 | + | use std::path::{Path, PathBuf}; | |
| 7 | + | ||
| 8 | + | use tokio::fs; | |
| 9 | + | ||
| 10 | + | /// A file in the staging directory waiting to be published. | |
| 11 | + | #[derive(Debug, Clone)] | |
| 12 | + | pub struct StagedFile { | |
| 13 | + | pub filename: String, | |
| 14 | + | pub size: u64, | |
| 15 | + | pub modified: std::time::SystemTime, | |
| 16 | + | /// Derived from extension. | |
| 17 | + | pub classification: Option<FileClassification>, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// Classification of a staged file based on its extension. | |
| 21 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | + | pub struct FileClassification { | |
| 23 | + | pub item_type: &'static str, | |
| 24 | + | pub file_type: &'static str, | |
| 25 | + | pub content_type: &'static str, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// 1 GB staging quota per user. | |
| 29 | + | pub const STAGING_QUOTA_BYTES: u64 = 1024 * 1024 * 1024; | |
| 30 | + | ||
| 31 | + | /// Get the staging directory path for a user. | |
| 32 | + | pub fn user_staging_dir(base: &Path, user_id: &str) -> PathBuf { | |
| 33 | + | base.join(user_id) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// List all staged files in a user's staging directory. | |
| 37 | + | pub async fn list_staged_files(dir: &Path) -> Vec<StagedFile> { | |
| 38 | + | let mut files = Vec::new(); | |
| 39 | + | let Ok(mut entries) = fs::read_dir(dir).await else { | |
| 40 | + | return files; | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | while let Ok(Some(entry)) = entries.next_entry().await { | |
| 44 | + | let Ok(metadata) = entry.metadata().await else { | |
| 45 | + | continue; | |
| 46 | + | }; | |
| 47 | + | if !metadata.is_file() { | |
| 48 | + | continue; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | let filename = entry.file_name().to_string_lossy().to_string(); | |
| 52 | + | let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| 53 | + | let classification = classify_extension(&ext); | |
| 54 | + | ||
| 55 | + | files.push(StagedFile { | |
| 56 | + | filename, | |
| 57 | + | size: metadata.len(), | |
| 58 | + | modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH), | |
| 59 | + | classification, | |
| 60 | + | }); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | files.sort_by(|a, b| b.modified.cmp(&a.modified)); | |
| 64 | + | files | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Calculate total bytes used in a staging directory. | |
| 68 | + | pub async fn staging_usage(dir: &Path) -> u64 { | |
| 69 | + | let mut total = 0u64; | |
| 70 | + | let Ok(mut entries) = fs::read_dir(dir).await else { | |
| 71 | + | return 0; | |
| 72 | + | }; | |
| 73 | + | ||
| 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 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | total | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Remove staged files older than `ttl` across all user directories. | |
| 86 | + | pub async fn cleanup_stale(base: &Path, ttl: std::time::Duration) { | |
| 87 | + | let Ok(mut users) = fs::read_dir(base).await else { | |
| 88 | + | return; | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | let now = std::time::SystemTime::now(); | |
| 92 | + | let mut removed = 0u64; | |
| 93 | + | ||
| 94 | + | while let Ok(Some(user_dir)) = users.next_entry().await { | |
| 95 | + | let Ok(metadata) = user_dir.metadata().await else { | |
| 96 | + | continue; | |
| 97 | + | }; | |
| 98 | + | if !metadata.is_dir() { | |
| 99 | + | continue; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | let Ok(mut files) = fs::read_dir(user_dir.path()).await else { | |
| 103 | + | continue; | |
| 104 | + | }; | |
| 105 | + | ||
| 106 | + | let mut dir_empty = true; | |
| 107 | + | while let Ok(Some(file)) = files.next_entry().await { | |
| 108 | + | let Ok(fmeta) = file.metadata().await else { | |
| 109 | + | dir_empty = false; | |
| 110 | + | continue; | |
| 111 | + | }; | |
| 112 | + | ||
| 113 | + | let age = fmeta | |
| 114 | + | .modified() | |
| 115 | + | .ok() | |
| 116 | + | .and_then(|m| now.duration_since(m).ok()) | |
| 117 | + | .unwrap_or_default(); | |
| 118 | + | ||
| 119 | + | if age > ttl { | |
| 120 | + | if fs::remove_file(file.path()).await.is_ok() { | |
| 121 | + | removed += 1; | |
| 122 | + | } | |
| 123 | + | } else { | |
| 124 | + | dir_empty = false; | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Remove empty user dirs | |
| 129 | + | if dir_empty { | |
| 130 | + | let _ = fs::remove_dir(user_dir.path()).await; | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | if removed > 0 { | |
| 135 | + | tracing::info!(removed, "cleaned up stale staging files"); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Sanitize a filename: keep only safe characters, prevent path traversal. | |
| 140 | + | pub fn sanitize_filename(name: &str) -> String { | |
| 141 | + | // Take only the final path component | |
| 142 | + | let base = name.rsplit('/').next().unwrap_or(name); | |
| 143 | + | let base = base.rsplit('\\').next().unwrap_or(base); | |
| 144 | + | ||
| 145 | + | let sanitized: String = base | |
| 146 | + | .chars() | |
| 147 | + | .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_' || *c == ' ') | |
| 148 | + | .collect(); | |
| 149 | + | ||
| 150 | + | let sanitized = sanitized.trim().to_string(); | |
| 151 | + | ||
| 152 | + | if sanitized.is_empty() || sanitized == "." || sanitized == ".." { | |
| 153 | + | return "upload".to_string(); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Limit length | |
| 157 | + | if sanitized.len() > 200 { | |
| 158 | + | sanitized[..200].to_string() | |
| 159 | + | } else { | |
| 160 | + | sanitized | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Classify a file extension into item type, file type, and content type. | |
| 165 | + | pub fn classify_extension(ext: &str) -> Option<FileClassification> { | |
| 166 | + | match ext { | |
| 167 | + | // Audio | |
| 168 | + | "mp3" => Some(FileClassification { | |
| 169 | + | item_type: "audio", | |
| 170 | + | file_type: "audio", | |
| 171 | + | content_type: "audio/mpeg", | |
| 172 | + | }), | |
| 173 | + | "wav" => Some(FileClassification { | |
| 174 | + | item_type: "audio", | |
| 175 | + | file_type: "audio", | |
| 176 | + | content_type: "audio/wav", | |
| 177 | + | }), | |
| 178 | + | "flac" => Some(FileClassification { | |
| 179 | + | item_type: "audio", | |
| 180 | + | file_type: "audio", | |
| 181 | + | content_type: "audio/flac", | |
| 182 | + | }), | |
| 183 | + | "ogg" => Some(FileClassification { | |
| 184 | + | item_type: "audio", | |
| 185 | + | file_type: "audio", | |
| 186 | + | content_type: "audio/ogg", | |
| 187 | + | }), | |
| 188 | + | "m4a" => Some(FileClassification { | |
| 189 | + | item_type: "audio", | |
| 190 | + | file_type: "audio", | |
| 191 | + | content_type: "audio/mp4", | |
| 192 | + | }), | |
| 193 | + | "aac" => Some(FileClassification { | |
| 194 | + | item_type: "audio", | |
| 195 | + | file_type: "audio", | |
| 196 | + | content_type: "audio/aac", | |
| 197 | + | }), | |
| 198 | + | // Digital downloads | |
| 199 | + | "zip" => Some(FileClassification { | |
| 200 | + | item_type: "digital", | |
| 201 | + | file_type: "download", | |
| 202 | + | content_type: "application/zip", | |
| 203 | + | }), | |
| 204 | + | "dmg" => Some(FileClassification { | |
| 205 | + | item_type: "digital", | |
| 206 | + | file_type: "download", | |
| 207 | + | content_type: "application/x-apple-diskimage", | |
| 208 | + | }), | |
| 209 | + | "exe" => Some(FileClassification { | |
| 210 | + | item_type: "digital", | |
| 211 | + | file_type: "download", | |
| 212 | + | content_type: "application/vnd.microsoft.portable-executable", | |
| 213 | + | }), | |
| 214 | + | "appimage" => Some(FileClassification { | |
| 215 | + | item_type: "digital", | |
| 216 | + | file_type: "download", | |
| 217 | + | content_type: "application/octet-stream", | |
| 218 | + | }), | |
| 219 | + | "deb" => Some(FileClassification { | |
| 220 | + | item_type: "digital", | |
| 221 | + | file_type: "download", | |
| 222 | + | content_type: "application/vnd.debian.binary-package", | |
| 223 | + | }), | |
| 224 | + | // Plugins | |
| 225 | + | "clap" => Some(FileClassification { | |
| 226 | + | item_type: "plugin", | |
| 227 | + | file_type: "download", | |
| 228 | + | content_type: "application/octet-stream", | |
| 229 | + | }), | |
| 230 | + | "vst3" => Some(FileClassification { | |
| 231 | + | item_type: "plugin", | |
| 232 | + | file_type: "download", | |
| 233 | + | content_type: "application/octet-stream", | |
| 234 | + | }), | |
| 235 | + | _ => None, | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Derive a human-readable title from a filename. | |
| 240 | + | /// Strips extension, replaces `-` and `_` with spaces, title-cases words. | |
| 241 | + | pub fn derive_title(filename: &str) -> String { | |
| 242 | + | let without_ext = match filename.rfind('.') { | |
| 243 | + | Some(pos) if pos > 0 => &filename[..pos], | |
| 244 | + | _ => filename, | |
| 245 | + | }; | |
| 246 | + | ||
| 247 | + | without_ext | |
| 248 | + | .replace(['-', '_'], " ") | |
| 249 | + | .split_whitespace() | |
| 250 | + | .map(title_case_word) | |
| 251 | + | .collect::<Vec<_>>() | |
| 252 | + | .join(" ") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | fn title_case_word(word: &str) -> String { | |
| 256 | + | let mut chars = word.chars(); | |
| 257 | + | match chars.next() { | |
| 258 | + | None => String::new(), | |
| 259 | + | Some(first) => { | |
| 260 | + | let mut s = first.to_uppercase().to_string(); | |
| 261 | + | s.extend(chars.map(|c| c.to_ascii_lowercase())); | |
| 262 | + | s | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// Check if an extension is allowed for upload. | |
| 268 | + | pub fn is_allowed_extension(ext: &str) -> bool { | |
| 269 | + | classify_extension(&ext.to_lowercase()).is_some() | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Format bytes into a human-readable string. | |
| 273 | + | pub fn format_bytes(bytes: u64) -> String { | |
| 274 | + | if bytes < 1024 { | |
| 275 | + | format!("{} B", bytes) | |
| 276 | + | } else if bytes < 1024 * 1024 { | |
| 277 | + | format!("{:.1} KB", bytes as f64 / 1024.0) | |
| 278 | + | } else if bytes < 1024 * 1024 * 1024 { | |
| 279 | + | format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) | |
| 280 | + | } else { | |
| 281 | + | format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) | |
| 282 | + | } | |
| 283 | + | } |
| @@ -0,0 +1,343 @@ | |||
| 1 | + | //! Analytics dashboard — revenue chart, stats, top projects, transactions, export. | |
| 2 | + | ||
| 3 | + | use ratatui::Frame; | |
| 4 | + | use ratatui::layout::{Constraint, Layout}; | |
| 5 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 6 | + | use ratatui::text::{Line, Span}; | |
| 7 | + | use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row, Table}; | |
| 8 | + | ||
| 9 | + | use super::App; | |
| 10 | + | ||
| 11 | + | pub fn render(frame: &mut Frame, app: &App) { | |
| 12 | + | let area = frame.area(); | |
| 13 | + | ||
| 14 | + | let range_label = match app.analytics_range.as_str() { | |
| 15 | + | "7d" => "7 days", | |
| 16 | + | "30d" => "30 days", | |
| 17 | + | "90d" => "90 days", | |
| 18 | + | "all" => "All time", | |
| 19 | + | _ => &app.analytics_range, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | let title = Line::from(vec![ | |
| 23 | + | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 24 | + | Span::raw(" -- "), | |
| 25 | + | Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)), | |
| 26 | + | Span::raw(format!(" ({}) ", range_label)), | |
| 27 | + | ]); | |
| 28 | + | ||
| 29 | + | let block = Block::default() | |
| 30 | + | .title(title) | |
| 31 | + | .borders(Borders::ALL) | |
| 32 | + | .border_style(Style::default().fg(Color::Gray)); | |
| 33 | + | ||
| 34 | + | let inner = block.inner(area); | |
| 35 | + | frame.render_widget(block, area); | |
| 36 | + | ||
| 37 | + | let chunks = Layout::vertical([ | |
| 38 | + | Constraint::Length(1), // spacer | |
| 39 | + | Constraint::Length(3), // stat cards | |
| 40 | + | Constraint::Length(1), // spacer | |
| 41 | + | Constraint::Min(6), // chart or transactions | |
| 42 | + | Constraint::Length(1), // status | |
| 43 | + | Constraint::Length(1), // keybindings | |
| 44 | + | ]) | |
| 45 | + | .split(inner); | |
| 46 | + | ||
| 47 | + | // Stat cards | |
| 48 | + | render_stat_cards(frame, app, chunks[1]); | |
| 49 | + | ||
| 50 | + | // Main content area | |
| 51 | + | if app.loading { | |
| 52 | + | let loading = Paragraph::new(" Loading..."); | |
| 53 | + | frame.render_widget(loading, chunks[3]); | |
| 54 | + | } else if app.analytics_show_transactions { | |
| 55 | + | render_transactions(frame, app, chunks[3]); | |
| 56 | + | } else { | |
| 57 | + | render_chart_and_projects(frame, app, chunks[3]); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Status line | |
| 61 | + | if let Some(ref status) = app.analytics_status { | |
| 62 | + | let style = if status.starts_with("Error") { | |
| 63 | + | Style::default().fg(Color::Red) | |
| 64 | + | } else { | |
| 65 | + | Style::default().fg(Color::Green) | |
| 66 | + | }; | |
| 67 | + | let status_line = Paragraph::new(Line::from(vec![ | |
| 68 | + | Span::raw(" "), | |
| 69 | + | Span::styled(status.as_str(), style), | |
| 70 | + | ])); | |
| 71 | + | frame.render_widget(status_line, chunks[4]); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Keybindings | |
| 75 | + | let key_spans = vec![ | |
| 76 | + | Span::raw(" "), | |
| 77 | + | Span::styled("[1-4]", Style::default().add_modifier(Modifier::BOLD)), | |
| 78 | + | Span::raw(" Range "), | |
| 79 | + | Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)), | |
| 80 | + | Span::raw(" Transactions "), | |
| 81 | + | Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)), | |
| 82 | + | Span::raw(" Export CSV "), | |
| 83 | + | Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), | |
| 84 | + | Span::raw(" Refresh "), | |
| 85 | + | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 86 | + | Span::raw(" Back"), | |
| 87 | + | ]; | |
| 88 | + | ||
| 89 | + | let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray)); | |
| 90 | + | frame.render_widget(keys, chunks[5]); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 94 | + | let stat_chunks = Layout::horizontal([ | |
| 95 | + | Constraint::Ratio(1, 3), | |
| 96 | + | Constraint::Ratio(1, 3), | |
| 97 | + | Constraint::Ratio(1, 3), | |
| 98 | + | ]) | |
| 99 | + | .split(area); | |
| 100 | + | ||
| 101 | + | let data = &app.analytics_data; | |
| 102 | + | ||
| 103 | + | let stats = [ | |
| 104 | + | ( | |
| 105 | + | "Revenue", | |
| 106 | + | format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)), | |
| 107 | + | data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), | |
| 108 | + | ), | |
| 109 | + | ( | |
| 110 | + | "Sales", | |
| 111 | + | data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()), | |
| 112 | + | data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)), | |
| 113 | + | ), | |
| 114 | + | ( | |
| 115 | + | "Followers", | |
| 116 | + | data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()), | |
| 117 | + | data.as_ref().map(|d| pct_change(d.current_followers, d.previous_followers)), | |
| 118 | + | ), | |
| 119 | + | ]; | |
| 120 | + | ||
| 121 | + | for (i, (label, value, change)) in stats.iter().enumerate() { | |
| 122 | + | let block = Block::default() | |
| 123 | + | .borders(Borders::ALL) | |
| 124 | + | .border_style(Style::default().fg(Color::DarkGray)); | |
| 125 | + | let inner = block.inner(stat_chunks[i]); | |
| 126 | + | frame.render_widget(block, stat_chunks[i]); | |
| 127 | + | ||
| 128 | + | let mut spans = vec![ | |
| 129 | + | Span::styled( | |
| 130 | + | format!(" {value}"), | |
| 131 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 132 | + | ), | |
| 133 | + | Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)), | |
| 134 | + | ]; | |
| 135 | + | ||
| 136 | + | if let Some(Some((text, positive))) = change { | |
| 137 | + | let color = if *positive { Color::Green } else { Color::Red }; | |
| 138 | + | spans.push(Span::styled(format!(" {text}"), Style::default().fg(color))); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | let text = Paragraph::new(Line::from(spans)); | |
| 142 | + | frame.render_widget(text, inner); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 147 | + | let chunks = Layout::vertical([ | |
| 148 | + | Constraint::Length(1), // chart header | |
| 149 | + | Constraint::Min(4), // chart | |
| 150 | + | Constraint::Length(1), // spacer | |
| 151 | + | Constraint::Length(1), // projects header | |
| 152 | + | Constraint::Min(2), // projects table | |
| 153 | + | ]) | |
| 154 | + | .split(area); | |
| 155 | + | ||
| 156 | + | // Chart header | |
| 157 | + | let header = Paragraph::new(Line::from(vec![ | |
| 158 | + | Span::raw(" "), | |
| 159 | + | Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)), | |
| 160 | + | ])); | |
| 161 | + | frame.render_widget(header, chunks[0]); | |
| 162 | + | ||
| 163 | + | // Revenue bar chart | |
| 164 | + | if let Some(ref data) = app.analytics_data { | |
| 165 | + | if data.buckets.is_empty() { | |
| 166 | + | let empty = Paragraph::new(" No revenue data for this period."); | |
| 167 | + | frame.render_widget(empty, chunks[1]); | |
| 168 | + | } else { | |
| 169 | + | let max_val = data.buckets.iter().map(|b| b.revenue_cents).max().unwrap_or(1).max(1); | |
| 170 | + | ||
| 171 | + | let bars: Vec<Bar> = data | |
| 172 | + | .buckets | |
| 173 | + | .iter() | |
| 174 | + | .map(|b| { | |
| 175 | + | Bar::default() | |
| 176 | + | .value(b.revenue_cents as u64) | |
| 177 | + | .label(Line::from(b.label.clone())) | |
| 178 | + | .text_value(if b.revenue_cents > 0 { | |
| 179 | + | format_cents(b.revenue_cents) | |
| 180 | + | } else { | |
| 181 | + | String::new() | |
| 182 | + | }) | |
| 183 | + | .style(Style::default().fg(Color::Cyan)) | |
| 184 | + | }) | |
| 185 | + | .collect(); | |
| 186 | + | ||
| 187 | + | let chart = BarChart::default() | |
| 188 | + | .data(BarGroup::default().bars(&bars)) | |
| 189 | + | .bar_width( | |
| 190 | + | (chunks[1].width as usize) | |
| 191 | + | .checked_div(bars.len().max(1)) | |
| 192 | + | .unwrap_or(3) | |
| 193 | + | .min(8) | |
| 194 | + | .max(1) as u16, | |
| 195 | + | ) | |
| 196 | + | .bar_gap(1) | |
| 197 | + | .max(max_val as u64); | |
| 198 | + | ||
| 199 | + | frame.render_widget(chart, chunks[1]); | |
| 200 | + | } | |
| 201 | + | } else { | |
| 202 | + | let empty = Paragraph::new(" No data."); | |
| 203 | + | frame.render_widget(empty, chunks[1]); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // Projects header | |
| 207 | + | let proj_header = Paragraph::new(Line::from(vec![ | |
| 208 | + | Span::raw(" "), | |
| 209 | + | Span::styled("Top Projects", Style::default().add_modifier(Modifier::BOLD)), | |
| 210 | + | ])); | |
| 211 | + | frame.render_widget(proj_header, chunks[3]); | |
| 212 | + | ||
| 213 | + | // Top projects | |
| 214 | + | if let Some(ref data) = app.analytics_data { | |
| 215 | + | if data.top_projects.is_empty() { | |
| 216 | + | let empty = Paragraph::new(" No project revenue yet."); | |
| 217 | + | frame.render_widget(empty, chunks[4]); | |
| 218 | + | } else { | |
| 219 | + | let rows: Vec<Row> = data | |
| 220 | + | .top_projects | |
| 221 | + | .iter() | |
| 222 | + | .map(|p| { | |
| 223 | + | Row::new(vec![ | |
| 224 | + | format!(" {}", p.title), | |
| 225 | + | format_cents(p.revenue_cents), | |
| 226 | + | ]) | |
| 227 | + | }) | |
| 228 | + | .collect(); | |
| 229 | + | ||
| 230 | + | let header = Row::new(vec![" Project", "Revenue"]) | |
| 231 | + | .style( | |
| 232 | + | Style::default() | |
| 233 | + | .fg(Color::DarkGray) | |
| 234 | + | .add_modifier(Modifier::BOLD), | |
| 235 | + | ) | |
| 236 | + | .bottom_margin(0); | |
| 237 | + | ||
| 238 | + | let widths = [Constraint::Min(20), Constraint::Length(12)]; | |
| 239 | + | let table = Table::new(rows, widths).header(header); | |
| 240 | + | frame.render_widget(table, chunks[4]); | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 246 | + | let chunks = Layout::vertical([ | |
| 247 | + | Constraint::Length(1), // header | |
| 248 | + | Constraint::Min(3), // table | |
| 249 | + | ]) | |
| 250 | + | .split(area); | |
| 251 | + | ||
| 252 | + | let count = app.transactions.len(); | |
| 253 | + | let header = Paragraph::new(Line::from(vec![ | |
| 254 | + | Span::raw(" "), | |
| 255 | + | Span::styled("Transactions", Style::default().add_modifier(Modifier::BOLD)), | |
| 256 | + | if count == 0 { | |
| 257 | + | Span::raw("") | |
| 258 | + | } else { | |
| 259 | + | Span::raw(format!(" ({})", count)) | |
| 260 | + | }, | |
| 261 | + | ])); | |
| 262 | + | frame.render_widget(header, chunks[0]); | |
| 263 | + | ||
| 264 | + | if app.transactions.is_empty() { | |
| 265 | + | let empty = Paragraph::new(" No transactions."); | |
| 266 | + | frame.render_widget(empty, chunks[1]); | |
| 267 | + | } else { | |
| 268 | + | let selected = app.selected_index; | |
| 269 | + | let rows: Vec<Row> = app | |
| 270 | + | .transactions | |
| 271 | + | .iter() | |
| 272 | + | .enumerate() | |
| 273 | + | .map(|(i, tx)| { | |
| 274 | + | let style = if i == selected { | |
| 275 | + | Style::default() | |
| 276 | + | .bg(Color::DarkGray) | |
| 277 | + | .fg(Color::White) | |
| 278 | + | .add_modifier(Modifier::BOLD) | |
| 279 | + | } else { | |
| 280 | + | Style::default() | |
| 281 | + | }; | |
| 282 | + | ||
| 283 | + | let title = tx.item_title.as_deref().unwrap_or("--"); | |
| 284 | + | let amount = format_cents(tx.amount_cents as i64); | |
| 285 | + | let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); | |
| 286 | + | ||
| 287 | + | Row::new(vec![ | |
| 288 | + | format!(" {}", title), | |
| 289 | + | amount, | |
| 290 | + | tx.status.clone(), | |
| 291 | + | date.to_string(), | |
| 292 | + | ]) | |
| 293 | + | .style(style) | |
| 294 | + | }) | |
| 295 | + | .collect(); | |
| 296 | + | ||
| 297 | + | let table_header = Row::new(vec![" Item", "Amount", "Status", "Date"]) | |
| 298 | + | .style( | |
| 299 | + | Style::default() | |
| 300 | + | .fg(Color::DarkGray) | |
| 301 | + | .add_modifier(Modifier::BOLD), | |
| 302 | + | ) | |
| 303 | + | .bottom_margin(0); | |
| 304 | + | ||
| 305 | + | let widths = [ | |
| 306 | + | Constraint::Min(20), | |
| 307 | + | Constraint::Length(12), | |
| 308 | + | Constraint::Length(10), | |
| 309 | + | Constraint::Length(12), | |
| 310 | + | ]; | |
| 311 | + | ||
| 312 | + | let table = Table::new(rows, widths).header(table_header); | |
| 313 | + | frame.render_widget(table, chunks[1]); | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | fn format_cents(cents: i64) -> String { | |
| 318 | + | if cents == 0 { | |
| 319 | + | "$0".to_string() | |
| 320 | + | } else { | |
| 321 | + | format!("${}.{:02}", cents / 100, cents.abs() % 100) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> { | |
| 326 | + | if previous == 0 { | |
| 327 | + | if current > 0 { | |
| 328 | + | return Some(("+inf%".to_string(), true)); | |
| 329 | + | } | |
| 330 | + | return None; | |
| 331 | + | } | |
| 332 | + | let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64; | |
| 333 | + | if change == 0 { | |
| 334 | + | None | |
| 335 | + | } else { | |
| 336 | + | let text = if change > 0 { | |
| 337 | + | format!("+{}%", change) | |
| 338 | + | } else { | |
| 339 | + | format!("{}%", change) | |
| 340 | + | }; | |
| 341 | + | Some((text, change > 0)) | |
| 342 | + | } | |
| 343 | + | } |
| @@ -0,0 +1,158 @@ | |||
| 1 | + | //! Blog post management screen — list, create, delete posts. | |
| 2 | + | ||
| 3 | + | use ratatui::Frame; | |
| 4 | + | use ratatui::layout::{Constraint, Layout}; | |
| 5 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 6 | + | use ratatui::text::{Line, Span}; | |
| 7 | + | use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; | |
| 8 | + | ||
| 9 | + | use super::App; | |
| 10 | + | ||
| 11 | + | pub fn render(frame: &mut Frame, app: &App) { | |
| 12 | + | let area = frame.area(); | |
| 13 | + | ||
| 14 | + | let project_title = app | |
| 15 | + | .blog_project_title | |
| 16 | + | .as_deref() | |
| 17 | + | .unwrap_or("Blog"); | |
| 18 | + | ||
| 19 | + | let title = Line::from(vec![ | |
| 20 | + | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 21 | + | Span::raw(" -- "), | |
| 22 | + | Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)), | |
| 23 | + | Span::raw(" -- Blog "), | |
| 24 | + | ]); | |
| 25 | + | ||
| 26 | + | let block = Block::default() | |
| 27 | + | .title(title) | |
| 28 | + | .borders(Borders::ALL) | |
| 29 | + | .border_style(Style::default().fg(Color::Gray)); | |
| 30 | + | ||
| 31 | + | let inner = block.inner(area); | |
| 32 | + | frame.render_widget(block, area); | |
| 33 | + | ||
| 34 | + | let chunks = Layout::vertical([ | |
| 35 | + | Constraint::Length(1), // spacer | |
| 36 | + | Constraint::Length(1), // section header | |
| 37 | + | Constraint::Min(3), // post list | |
| 38 | + | Constraint::Length(1), // status line | |
| 39 | + | Constraint::Length(1), // keybindings | |
| 40 | + | ]) | |
| 41 | + | .split(inner); | |
| 42 | + | ||
| 43 | + | // Section header | |
| 44 | + | let count = app.blog_posts.len(); | |
| 45 | + | let header = Paragraph::new(Line::from(vec![ | |
| 46 | + | Span::raw(" "), | |
| 47 | + | Span::styled("Posts", Style::default().add_modifier(Modifier::BOLD)), | |
| 48 | + | if count == 0 { | |
| 49 | + | Span::raw("") | |
| 50 | + | } else { | |
| 51 | + | Span::raw(format!(" ({})", count)) | |
| 52 | + | }, | |
| 53 | + | ])); | |
| 54 | + | frame.render_widget(header, chunks[1]); | |
| 55 | + | ||
| 56 | + | // Post list | |
| 57 | + | if app.loading { | |
| 58 | + | let loading = Paragraph::new(" Loading..."); | |
| 59 | + | frame.render_widget(loading, chunks[2]); | |
| 60 | + | } else if app.blog_posts.is_empty() { | |
| 61 | + | let empty = Paragraph::new(" No blog posts. Press [n] to create one."); | |
| 62 | + | frame.render_widget(empty, chunks[2]); | |
| 63 | + | } else { | |
| 64 | + | render_post_table(frame, app, chunks[2]); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Status line | |
| 68 | + | if let Some(ref status) = app.blog_status { | |
| 69 | + | let style = if status.starts_with("Error") { | |
| 70 | + | Style::default().fg(Color::Red) | |
| 71 | + | } else { | |
| 72 | + | Style::default().fg(Color::Green) | |
| 73 | + | }; | |
| 74 | + | let status_line = Paragraph::new(Line::from(vec![ | |
| 75 | + | Span::raw(" "), | |
| 76 | + | Span::styled(status.as_str(), style), | |
| 77 | + | ])); | |
| 78 | + | frame.render_widget(status_line, chunks[3]); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Keybindings | |
| 82 | + | let mut key_spans = vec![ | |
| 83 | + | Span::raw(" "), | |
| 84 | + | Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)), | |
| 85 | + | Span::raw(" Nav "), | |
| 86 | + | Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)), | |
| 87 | + | Span::raw(" New "), | |
| 88 | + | ]; | |
| 89 | + | ||
| 90 | + | if !app.blog_posts.is_empty() { | |
| 91 | + | key_spans.extend([ | |
| 92 | + | Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)), | |
| 93 | + | Span::raw(" Delete "), | |
| 94 | + | ]); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | key_spans.extend([ | |
| 98 | + | Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), | |
| 99 | + | Span::raw(" Refresh "), | |
| 100 | + | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 101 | + | Span::raw(" Back"), | |
| 102 | + | ]); | |
| 103 | + | ||
| 104 | + | let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray)); | |
| 105 | + | frame.render_widget(keys, chunks[4]); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 109 | + | let selected = app.selected_index; | |
| 110 | + | let rows: Vec<Row> = app | |
| 111 | + | .blog_posts | |
| 112 | + | .iter() | |
| 113 | + | .enumerate() | |
| 114 | + | .map(|(i, post)| { | |
| 115 | + | let style = if i == selected { | |
| 116 | + | Style::default() | |
| 117 | + | .bg(Color::DarkGray) | |
| 118 | + | .fg(Color::White) | |
| 119 | + | .add_modifier(Modifier::BOLD) | |
| 120 | + | } else { | |
| 121 | + | Style::default() | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | let status = if post.is_published { | |
| 125 | + | "published" | |
| 126 | + | } else { | |
| 127 | + | "draft" | |
| 128 | + | }; | |
| 129 | + | let date = post.created_at.get(..10).unwrap_or(&post.created_at); | |
| 130 | + | ||
| 131 | + | Row::new(vec![ | |
| 132 | + | format!(" {}", post.title), | |
| 133 | + | post.slug.clone(), | |
| 134 | + | status.to_string(), | |
| 135 | + | date.to_string(), | |
| 136 | + | ]) | |
| 137 | + | .style(style) | |
| 138 | + | }) | |
| 139 | + | .collect(); | |
| 140 | + | ||
| 141 | + | let header = Row::new(vec![" Title", "Slug", "Status", "Created"]) | |
| 142 | + | .style( | |
| 143 | + | Style::default() | |
| 144 | + | .fg(Color::DarkGray) | |
| 145 | + | .add_modifier(Modifier::BOLD), | |
| 146 | + | ) | |
| 147 | + | .bottom_margin(0); | |
| 148 | + | ||
| 149 | + | let widths = [ | |
| 150 | + | Constraint::Min(20), | |
| 151 | + | Constraint::Length(20), | |
| 152 | + | Constraint::Length(10), | |
| 153 | + | Constraint::Length(12), | |
| 154 | + | ]; | |
| 155 | + | ||
| 156 | + | let table = Table::new(rows, widths).header(header); | |
| 157 | + | frame.render_widget(table, area); | |
| 158 | + | } |
| @@ -0,0 +1,226 @@ | |||
| 1 | + | //! Home screen — project list with stats overview. | |
| 2 | + | ||
| 3 | + | use ratatui::Frame; | |
| 4 | + | use ratatui::layout::{Constraint, Layout}; | |
| 5 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 6 | + | use ratatui::text::{Line, Span}; | |
| 7 | + | use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; | |
| 8 | + | ||
| 9 | + | use super::App; | |
| 10 | + | ||
| 11 | + | pub fn render(frame: &mut Frame, app: &App) { | |
| 12 | + | let area = frame.area(); | |
| 13 | + | ||
| 14 | + | let tier_label = app | |
| 15 | + | .user | |
| 16 | + | .creator_tier | |
| 17 | + | .as_deref() | |
| 18 | + | .map(format_tier) | |
| 19 | + | .unwrap_or("No tier"); | |
| 20 | + | ||
| 21 | + | let title = Line::from(vec![ | |
| 22 | + | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 23 | + | Span::raw(" ── "), | |
| 24 | + | Span::styled( | |
| 25 | + | &app.user.username, | |
| 26 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 27 | + | ), | |
| 28 | + | Span::raw(" ── "), | |
| 29 | + | Span::raw(tier_label), | |
| 30 | + | Span::raw(" "), | |
| 31 | + | ]); | |
| 32 | + | ||
| 33 | + | let block = Block::default() | |
| 34 | + | .title(title) | |
| 35 | + | .borders(Borders::ALL) | |
| 36 | + | .border_style(Style::default().fg(Color::Gray)); | |
| 37 | + | ||
| 38 | + | let inner = block.inner(area); | |
| 39 | + | frame.render_widget(block, area); | |
| 40 | + | ||
| 41 | + | let chunks = Layout::vertical([ | |
| 42 | + | Constraint::Length(1), // spacer | |
| 43 | + | Constraint::Length(3), // stats bar | |
| 44 | + | Constraint::Length(1), // spacer | |
| 45 | + | Constraint::Length(1), // section header | |
| 46 | + | Constraint::Min(3), // project list | |
| 47 | + | Constraint::Length(1), // keybindings | |
| 48 | + | ]) | |
| 49 | + | .split(inner); | |
| 50 | + | ||
| 51 | + | // Stats bar | |
| 52 | + | render_stats(frame, app, chunks[1]); | |
| 53 | + | ||
| 54 | + | // Section header | |
| 55 | + | let header = Paragraph::new(Line::from(vec![ | |
| 56 | + | Span::raw(" "), | |
| 57 | + | Span::styled("Projects", Style::default().add_modifier(Modifier::BOLD)), | |
| 58 | + | if app.projects.is_empty() { | |
| 59 | + | Span::raw("") | |
| 60 | + | } else { | |
| 61 | + | Span::raw(format!(" ({})", app.projects.len())) | |
| 62 | + | }, | |
| 63 | + | ])); | |
| 64 | + | frame.render_widget(header, chunks[3]); | |
| 65 | + | ||
| 66 | + | // Project list | |
| 67 | + | if app.loading { | |
| 68 | + | let loading = Paragraph::new(" Loading..."); | |
| 69 | + | frame.render_widget(loading, chunks[4]); | |
| 70 | + | } else if app.projects.is_empty() { | |
| 71 | + | let empty = Paragraph::new(" No projects yet."); | |
| 72 | + | frame.render_widget(empty, chunks[4]); | |
| 73 | + | } else { | |
| 74 | + | render_project_table(frame, app, chunks[4]); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Keybindings | |
| 78 | + | let keys = Paragraph::new(Line::from(vec![ | |
| 79 | + | Span::raw(" "), | |
| 80 | + | Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)), | |
| 81 | + | Span::raw(" Navigate "), | |
| 82 | + | Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), | |
| 83 | + | Span::raw(" Open "), | |
| 84 | + | Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)), | |
| 85 | + | Span::raw(" Upload "), | |
| 86 | + | Span::styled("[a]", Style::default().add_modifier(Modifier::BOLD)), | |
| 87 | + | Span::raw(" Analytics "), | |
| 88 | + | Span::styled("[c]", Style::default().add_modifier(Modifier::BOLD)), | |
| 89 | + | Span::raw(" Promo "), | |
| 90 | + | Span::styled("[s]", Style::default().add_modifier(Modifier::BOLD)), | |
| 91 | + | Span::raw(" Settings "), | |
| 92 | + | Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), | |
| 93 | + | Span::raw(" Refresh "), | |
| 94 | + | Span::styled("[q]", Style::default().add_modifier(Modifier::BOLD)), | |
| 95 | + | Span::raw(" Quit"), | |
| 96 | + | ])) | |
| 97 | + | .style(Style::default().fg(Color::DarkGray)); | |
| 98 | + | frame.render_widget(keys, chunks[5]); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | fn render_stats(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 102 | + | let stats_chunks = Layout::horizontal([ | |
| 103 | + | Constraint::Ratio(1, 4), | |
| 104 | + | Constraint::Ratio(1, 4), | |
| 105 | + | Constraint::Ratio(1, 4), | |
| 106 | + | Constraint::Ratio(1, 4), | |
| 107 | + | ]) | |
| 108 | + | .split(area); | |
| 109 | + | ||
| 110 | + | let (revenue, sales, followers, items) = if let Some(ref s) = app.stats { | |
| 111 | + | ( | |
| 112 | + | format_cents(s.current_revenue_cents), | |
| 113 | + | s.current_sales.to_string(), | |
| 114 | + | s.current_followers.to_string(), | |
| 115 | + | s.total_items.to_string(), | |
| 116 | + | ) | |
| 117 | + | } else { | |
| 118 | + | ("--".into(), "--".into(), "--".into(), "--".into()) | |
| 119 | + | }; | |
| 120 | + | ||
| 121 | + | let stat_items = [ | |
| 122 | + | ("Revenue", &revenue), | |
| 123 | + | ("Sales", &sales), | |
| 124 | + | ("Followers", &followers), | |
| 125 | + | ("Items", &items), | |
| 126 | + | ]; | |
| 127 | + | ||
| 128 | + | for (i, (label, value)) in stat_items.iter().enumerate() { | |
| 129 | + | let block = Block::default() | |
| 130 | + | .borders(Borders::ALL) | |
| 131 | + | .border_style(Style::default().fg(Color::DarkGray)); | |
| 132 | + | let inner = block.inner(stats_chunks[i]); | |
| 133 | + | frame.render_widget(block, stats_chunks[i]); | |
| 134 | + | ||
| 135 | + | let text = Paragraph::new(Line::from(vec![ | |
| 136 | + | Span::styled( | |
| 137 | + | format!(" {value}"), | |
| 138 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 139 | + | ), | |
| 140 | + | Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)), | |
| 141 | + | ])); | |
| 142 | + | frame.render_widget(text, inner); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 147 | + | let selected = app.selected_index; | |
| 148 | + | let rows: Vec<Row> = app | |
| 149 | + | .projects | |
| 150 | + | .iter() | |
| 151 | + | .enumerate() | |
| 152 | + | .map(|(i, p)| { | |
| 153 | + | let style = if i == selected { | |
| 154 | + | Style::default() | |
| 155 | + | .bg(Color::DarkGray) | |
| 156 | + | .fg(Color::White) | |
| 157 | + | .add_modifier(Modifier::BOLD) | |
| 158 | + | } else { | |
| 159 | + | Style::default() | |
| 160 | + | }; | |
| 161 | + | ||
| 162 | + | let visibility = if p.is_public { "public" } else { "draft" }; | |
| 163 | + | ||
| 164 | + | Row::new(vec![ | |
| 165 | + | format!(" {}", p.title), | |
| 166 | + | format_project_type(&p.project_type), | |
| 167 | + | visibility.to_string(), | |
| 168 | + | p.item_count.to_string(), | |
| 169 | + | format_cents(p.revenue_cents), | |
| 170 | + | ]) | |
| 171 | + | .style(style) | |
| 172 | + | }) | |
| 173 | + | .collect(); | |
| 174 | + | ||
| 175 | + | let header = Row::new(vec![" Title", "Type", "Status", "Items", "Revenue"]) | |
| 176 | + | .style( | |
| 177 | + | Style::default() | |
| 178 | + | .fg(Color::DarkGray) | |
| 179 | + | .add_modifier(Modifier::BOLD), | |
| 180 | + | ) | |
| 181 | + | .bottom_margin(0); | |
| 182 | + | ||
| 183 | + | let widths = [ | |
| 184 | + | Constraint::Min(20), | |
| 185 | + | Constraint::Length(12), | |
| 186 | + | Constraint::Length(8), | |
| 187 | + | Constraint::Length(7), | |
| 188 | + | Constraint::Length(12), | |
| 189 | + | ]; | |
| 190 | + | ||
| 191 | + | let table = Table::new(rows, widths).header(header); | |
| 192 | + | frame.render_widget(table, area); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn format_cents(cents: i64) -> String { | |
| 196 | + | if cents == 0 { | |
| 197 | + | "$0".to_string() | |
| 198 | + | } else { | |
| 199 | + | format!("${}.{:02}", cents / 100, cents % 100) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | fn format_tier(tier: &str) -> &str { | |
| 204 | + | match tier { | |
| 205 | + | "basic" => "Basic", | |
| 206 | + | "small_files" => "Small Files", | |
| 207 | + | "big_files" => "Big Files", | |
| 208 | + | "streaming" => "Streaming", | |
| 209 | + | _ => tier, | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | fn format_project_type(pt: &str) -> String { | |
| 214 | + | match pt { | |
| 215 | + | "software" => "Software", | |
| 216 | + | "music" => "Music", | |
| 217 | + | "blog" => "Blog", | |
| 218 | + | "video" => "Video", | |
| 219 | + | "audio" => "Audio", | |
| 220 | + | "art" => "Art", | |
| 221 | + | "writing" => "Writing", | |
| 222 | + | "education" => "Education", | |
| 223 | + | other => other, | |
| 224 | + | } | |
| 225 | + | .to_string() | |
| 226 | + | } |
| @@ -0,0 +1,306 @@ | |||
| 1 | + | //! Item detail screen — view/edit item fields, versions, publish status. | |
| 2 | + | ||
| 3 | + | use ratatui::Frame; | |
| 4 | + | use ratatui::layout::{Constraint, Layout}; | |
| 5 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 6 | + | use ratatui::text::{Line, Span}; | |
| 7 | + | use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; | |
| 8 | + | ||
| 9 | + | use crate::api::ItemDetail; | |
| 10 | + | use crate::staging; | |
| 11 | + | ||
| 12 | + | use super::App; | |
| 13 | + | ||
| 14 | + | pub fn render(frame: &mut Frame, app: &App) { | |
| 15 | + | let area = frame.area(); | |
| 16 | + | ||
| 17 | + | let item = match &app.item_detail { | |
| 18 | + | Some(d) => d, | |
| 19 | + | None => { | |
| 20 | + | let loading = Paragraph::new(" Loading..."); | |
| 21 | + | frame.render_widget(loading, area); | |
| 22 | + | return; | |
| 23 | + | } | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | let status_label = if item.is_public { "Published" } else { "Draft" }; | |
| 27 | + | ||
| 28 | + | let title = Line::from(vec![ | |
| 29 | + | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 30 | + | Span::raw(" -- "), | |
| 31 | + | Span::styled(&item.title, Style::default().add_modifier(Modifier::BOLD)), | |
| 32 | + | Span::raw(format!(" -- {} -- {} ", format_item_type(&item.item_type), status_label)), | |
| 33 | + | ]); | |
| 34 | + | ||
| 35 | + | let block = Block::default() | |
| 36 | + | .title(title) | |
| 37 | + | .borders(Borders::ALL) | |
| 38 | + | .border_style(Style::default().fg(Color::Gray)); | |
| 39 | + | ||
| 40 | + | let inner = block.inner(area); | |
| 41 | + | frame.render_widget(block, area); | |
| 42 | + | ||
| 43 | + | let chunks = Layout::vertical([ | |
| 44 | + | Constraint::Length(1), // spacer | |
| 45 | + | Constraint::Length(6), // item info | |
| 46 | + | Constraint::Length(1), // spacer | |
| 47 | + | Constraint::Length(1), // versions header | |
| 48 | + | Constraint::Min(3), // versions list | |
| 49 | + | Constraint::Length(1), // status line | |
| 50 | + | Constraint::Length(1), // keybindings | |
| 51 | + | ]) | |
| 52 | + | .split(inner); | |
| 53 | + | ||
| 54 | + | // Item info | |
| 55 | + | render_item_info(frame, app, item, chunks[1]); | |
| 56 | + | ||
| 57 | + | // Versions header | |
| 58 | + | let version_count = app.item_versions.len(); | |
| 59 | + | let version_header = Paragraph::new(Line::from(vec![ | |
| 60 | + | Span::raw(" "), | |
| 61 | + | Span::styled("Versions", Style::default().add_modifier(Modifier::BOLD)), | |
| 62 | + | if version_count == 0 { | |
| 63 | + | Span::raw("") | |
| 64 | + | } else { | |
| 65 | + | Span::raw(format!(" ({})", version_count)) | |
| 66 | + | }, | |
| 67 | + | ])); | |
| 68 | + | frame.render_widget(version_header, chunks[3]); | |
| 69 | + | ||
| 70 | + | // Versions list | |
| 71 | + | if app.loading { | |
| 72 | + | let loading = Paragraph::new(" Loading..."); | |
| 73 | + | frame.render_widget(loading, chunks[4]); | |
| 74 | + | } else if app.item_versions.is_empty() { | |
| 75 | + | let empty = Paragraph::new(" No versions."); | |
| 76 | + | frame.render_widget(empty, chunks[4]); | |
| 77 | + | } else { | |
| 78 | + | render_versions_table(frame, app, chunks[4]); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Status line | |
| 82 | + | if let Some(ref status) = app.item_status { | |
| 83 | + | let style = if status.starts_with("Error") { | |
| 84 | + | Style::default().fg(Color::Red) | |
| 85 | + | } else { | |
| 86 | + | Style::default().fg(Color::Green) | |
| 87 | + | }; | |
| 88 | + | let status_line = Paragraph::new(Line::from(vec![ | |
| 89 | + | Span::raw(" "), | |
| 90 | + | Span::styled(status.as_str(), style), | |
| 91 | + | ])); | |
| 92 | + | frame.render_widget(status_line, chunks[5]); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Keybindings | |
| 96 | + | let mut key_spans = vec![Span::raw(" ")]; | |
| 97 | + | ||
| 98 | + | if app.item_editing.is_some() { | |
| 99 | + | key_spans.extend([ | |
| 100 | + | Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), | |
| 101 | + | Span::raw(" Confirm "), | |
| 102 | + | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 103 | + | Span::raw(" Cancel"), | |
| 104 | + | ]); | |
| 105 | + | } else { | |
| 106 | + | key_spans.extend([ | |
| 107 | + | Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)), | |
| 108 | + | Span::raw(" Edit "), | |
| 109 | + | ]); | |
| 110 | + | if item.is_public { | |
| 111 | + | key_spans.extend([ | |
| 112 | + | Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)), | |
| 113 | + | Span::raw(" Unpublish "), | |
| 114 | + | ]); | |
| 115 | + | } else { | |
| 116 | + | key_spans.extend([ | |
| 117 | + | Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)), | |
| 118 | + | Span::raw(" Publish "), | |
| 119 | + | ]); | |
| 120 | + | } | |
| 121 | + | key_spans.extend([ | |
| 122 | + | Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)), | |
| 123 | + | Span::raw(" Delete "), | |
| 124 | + | Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), | |
| 125 | + | Span::raw(" Refresh "), | |
| 126 | + | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 127 | + | Span::raw(" Back"), | |
| 128 | + | ]); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray)); | |
| 132 | + | frame.render_widget(keys, chunks[6]); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn render_item_info( | |
| 136 | + | frame: &mut Frame, | |
| 137 | + | app: &App, | |
| 138 | + | item: &ItemDetail, | |
| 139 | + | area: ratatui::layout::Rect, | |
| 140 | + | ) { | |
| 141 | + | let price = format_price(item.price_cents); | |
| 142 | + | let desc_preview = item | |
| 143 | + | .description | |
| 144 | + | .as_deref() | |
| 145 | + | .unwrap_or("(no description)") | |
| 146 | + | .chars() | |
| 147 | + | .take(60) | |
| 148 | + | .collect::<String>(); | |
| 149 | + | ||
| 150 | + | // Show edit indicator for editing field | |
| 151 | + | let editing = app.item_editing; | |
| 152 | + | let edit_marker = |field: ItemEditField| -> &str { | |
| 153 | + | if editing == Some(field) { | |
| 154 | + | "> " | |
| 155 | + | } else { | |
| 156 | + | " " | |
| 157 | + | } | |
| 158 | + | }; | |
| 159 | + | ||
| 160 | + | let lines = vec![ | |
| 161 | + | Line::from(vec![ | |
| 162 | + | Span::raw(edit_marker(ItemEditField::Title)), | |
| 163 | + | Span::styled("Title: ", Style::default().fg(Color::DarkGray)), | |
| 164 | + | if editing == Some(ItemEditField::Title) { | |
| 165 | + | Span::styled( | |
| 166 | + | format!("{}_", app.edit_buffer), | |
| 167 | + | Style::default().add_modifier(Modifier::UNDERLINED), | |
| 168 | + | ) | |
| 169 | + | } else { | |
| 170 | + | Span::raw(item.title.clone()) | |
| 171 | + | }, | |
| 172 | + | ]), | |
| 173 | + | Line::from(vec![ | |
| 174 | + | Span::raw(edit_marker(ItemEditField::Description)), | |
| 175 | + | Span::styled("Description: ", Style::default().fg(Color::DarkGray)), | |
| 176 | + | if editing == Some(ItemEditField::Description) { | |
| 177 | + | Span::styled( | |
| 178 | + | format!("{}_", app.edit_buffer), | |
| 179 | + | Style::default().add_modifier(Modifier::UNDERLINED), | |
| 180 | + | ) | |
| 181 | + | } else { | |
| 182 | + | Span::raw(desc_preview) | |
| 183 | + | }, | |
| 184 | + | ]), | |
| 185 | + | Line::from(vec![ | |
| 186 | + | Span::raw(edit_marker(ItemEditField::Price)), | |
| 187 | + | Span::styled("Price: ", Style::default().fg(Color::DarkGray)), | |
| 188 | + | if editing == Some(ItemEditField::Price) { | |
| 189 | + | Span::styled( | |
| 190 | + | format!("${}_", app.edit_buffer), | |
| 191 | + | Style::default().add_modifier(Modifier::UNDERLINED), | |
| 192 | + | ) | |
| 193 | + | } else { | |
| 194 | + | Span::raw(price) | |
| 195 | + | }, | |
| 196 | + | ]), | |
| 197 | + | Line::from(vec![ | |
| 198 | + | Span::raw(" "), | |
| 199 | + | Span::styled("Type: ", Style::default().fg(Color::DarkGray)), | |
| 200 | + | Span::raw(format_item_type(&item.item_type)), | |
| 201 | + | Span::raw(" "), | |
| 202 | + | Span::styled("Slug: ", Style::default().fg(Color::DarkGray)), | |
| 203 | + | Span::raw(item.slug.clone()), | |
| 204 | + | ]), | |
| 205 | + | Line::from(vec![ | |
| 206 | + | Span::raw(" "), | |
| 207 | + | Span::styled("Sales: ", Style::default().fg(Color::DarkGray)), | |
| 208 | + | Span::raw(item.sales_count.to_string()), | |
| 209 | + | Span::raw(" "), | |
| 210 | + | Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)), | |
| 211 | + | Span::raw(item.download_count.to_string()), | |
| 212 | + | if item.play_count > 0 { | |
| 213 | + | Span::raw(format!(" Plays: {}", item.play_count)) | |
| 214 | + | } else { | |
| 215 | + | Span::raw("") | |
| 216 | + | }, | |
| 217 | + | ]), | |
| 218 | + | Line::from(vec![ | |
| 219 | + | Span::raw(" "), | |
| 220 | + | Span::styled("Audio: ", Style::default().fg(Color::DarkGray)), | |
| 221 | + | Span::raw(if item.has_audio { "yes" } else { "no" }), | |
| 222 | + | Span::raw(" "), | |
| 223 | + | Span::styled("Cover: ", Style::default().fg(Color::DarkGray)), | |
| 224 | + | Span::raw(if item.has_cover { "yes" } else { "no" }), | |
| 225 | + | ]), | |
| 226 | + | ]; | |
| 227 | + | ||
| 228 | + | let info = Paragraph::new(lines); | |
| 229 | + | frame.render_widget(info, area); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 233 | + | let rows: Vec<Row> = app | |
| 234 | + | .item_versions | |
| 235 | + | .iter() | |
| 236 | + | .map(|v| { | |
| 237 | + | let current = if v.is_current { "*" } else { " " }; | |
| 238 | + | let size = v | |
| 239 | + | .file_size_bytes | |
| 240 | + | .map(|b| staging::format_bytes(b as u64)) | |
| 241 | + | .unwrap_or_else(|| "--".to_string()); | |
| 242 | + | let name = v.file_name.as_deref().unwrap_or("--"); | |
| 243 | + | let date = v.created_at.get(..10).unwrap_or(&v.created_at); | |
| 244 | + | ||
| 245 | + | Row::new(vec![ | |
| 246 | + | format!(" {}{}", current, v.version_number), | |
| 247 | + | name.to_string(), | |
| 248 | + | size, | |
| 249 | + | v.download_count.to_string(), | |
| 250 | + | date.to_string(), | |
| 251 | + | ]) | |
| 252 | + | }) | |
| 253 | + | .collect(); | |
| 254 | + | ||
| 255 | + | let header = Row::new(vec![" Version", "File", "Size", "Downloads", "Date"]) | |
| 256 | + | .style( | |
| 257 | + | Style::default() | |
| 258 | + | .fg(Color::DarkGray) | |
| 259 | + | .add_modifier(Modifier::BOLD), | |
| 260 | + | ) | |
| 261 | + | .bottom_margin(0); | |
| 262 | + | ||
| 263 | + | let widths = [ | |
| 264 | + | Constraint::Length(12), | |
| 265 | + | Constraint::Min(16), | |
| 266 | + | Constraint::Length(10), | |
| 267 | + | Constraint::Length(10), | |
| 268 | + | Constraint::Length(12), | |
| 269 | + | ]; | |
| 270 | + | ||
| 271 | + | let table = Table::new(rows, widths).header(header); | |
| 272 | + | frame.render_widget(table, area); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// Which field is being edited on the item detail screen. | |
| 276 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 277 | + | pub enum ItemEditField { | |
| 278 | + | Title, | |
| 279 | + | Description, | |
| 280 | + | Price, | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | fn format_price(cents: i32) -> String { | |
| 284 | + | if cents == 0 { | |
| 285 | + | "Free".to_string() | |
| 286 | + | } else { | |
| 287 | + | format!("${}.{:02}", cents / 100, cents % 100) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | fn format_item_type(it: &str) -> &str { | |
| 292 | + | match it { | |
| 293 | + | "audio" => "Audio", | |
| 294 | + | "text" => "Text", | |
| 295 | + | "video" => "Video", | |
| 296 | + | "image" => "Image", | |
| 297 | + | "plugin" => "Plugin", | |
| 298 | + | "preset" => "Preset", | |
| 299 | + | "sample" => "Sample", | |
| 300 | + | "course" => "Course", | |
| 301 | + | "template" => "Template", | |
| 302 | + | "digital" => "Digital", | |
| 303 | + | "bundle" => "Bundle", | |
| 304 | + | other => other, | |
| 305 | + | } | |
| 306 | + | } |
| @@ -0,0 +1,162 @@ | |||
| 1 | + | //! License key management screen — list, generate, revoke keys. | |
| 2 | + | ||
| 3 | + | use ratatui::Frame; | |
| 4 | + | use ratatui::layout::{Constraint, Layout}; | |
| 5 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 6 | + | use ratatui::text::{Line, Span}; | |
| 7 | + | use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; | |
| 8 | + | ||
| 9 | + | use super::App; | |
| 10 | + | ||
| 11 | + | pub fn render(frame: &mut Frame, app: &App) { | |
| 12 | + | let area = frame.area(); | |
| 13 | + | ||
| 14 | + | let item_title = app | |
| 15 | + | .keys_item_title | |
| 16 | + | .as_deref() | |
| 17 | + | .unwrap_or("License Keys"); | |
| 18 | + | ||
| 19 | + | let title = Line::from(vec![ | |
| 20 | + | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 21 | + | Span::raw(" -- "), | |
| 22 | + | Span::styled(item_title, Style::default().add_modifier(Modifier::BOLD)), | |
| 23 | + | Span::raw(" -- Keys "), | |
| 24 | + | ]); | |
| 25 | + | ||
| 26 | + | let block = Block::default() | |
| 27 | + | .title(title) | |
| 28 | + | .borders(Borders::ALL) | |
| 29 | + | .border_style(Style::default().fg(Color::Gray)); | |
| 30 | + | ||
| 31 | + | let inner = block.inner(area); | |
| 32 | + | frame.render_widget(block, area); | |
| 33 | + | ||
| 34 | + | let chunks = Layout::vertical([ | |
| 35 | + | Constraint::Length(1), // spacer | |
| 36 | + | Constraint::Length(1), // section header | |
| 37 | + | Constraint::Min(3), // key list | |
| 38 | + | Constraint::Length(1), // status line | |
| 39 | + | Constraint::Length(1), // keybindings | |
| 40 | + | ]) | |
| 41 | + | .split(inner); | |
| 42 | + | ||
| 43 | + | // Section header | |
| 44 | + | let count = app.license_keys.len(); | |
| 45 | + | let header = Paragraph::new(Line::from(vec![ | |
| 46 | + | Span::raw(" "), | |
| 47 | + | Span::styled("Keys", Style::default().add_modifier(Modifier::BOLD)), | |
| 48 | + | if count == 0 { | |
| 49 | + | Span::raw("") | |
| 50 | + | } else { | |
| 51 | + | Span::raw(format!(" ({})", count)) | |
| 52 | + | }, | |
| 53 | + | ])); | |
| 54 | + | frame.render_widget(header, chunks[1]); | |
| 55 | + | ||
| 56 | + | // Key list | |
| 57 | + | if app.loading { | |
| 58 | + | let loading = Paragraph::new(" Loading..."); | |
| 59 | + | frame.render_widget(loading, chunks[2]); | |
| 60 | + | } else if app.license_keys.is_empty() { | |
| 61 | + | let empty = Paragraph::new(" No license keys. Press [g] to generate one."); | |
| 62 | + | frame.render_widget(empty, chunks[2]); | |
| 63 | + | } else { | |
| 64 | + | render_key_table(frame, app, chunks[2]); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Status line | |
| 68 | + | if let Some(ref status) = app.keys_status { | |
| 69 | + | let style = if status.starts_with("Error") { | |
| 70 | + | Style::default().fg(Color::Red) | |
| 71 | + | } else { | |
| 72 | + | Style::default().fg(Color::Green) | |
| 73 | + | }; | |
| 74 | + | let status_line = Paragraph::new(Line::from(vec![ | |
| 75 | + | Span::raw(" "), | |
| 76 | + | Span::styled(status.as_str(), style), | |
| 77 | + | ])); | |
| 78 | + | frame.render_widget(status_line, chunks[3]); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Keybindings | |
| 82 | + | let mut key_spans = vec![ | |
| 83 | + | Span::raw(" "), | |
| 84 | + | Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)), | |
| 85 | + | Span::raw(" Nav "), | |
| 86 | + | Span::styled("[g]", Style::default().add_modifier(Modifier::BOLD)), | |
| 87 | + | Span::raw(" Generate "), | |
| 88 | + | ]; | |
| 89 | + | ||
| 90 | + | if !app.license_keys.is_empty() { | |
| 91 | + | key_spans.extend([ | |
| 92 | + | Span::styled("[x]", Style::default().add_modifier(Modifier::BOLD)), | |
| 93 | + | Span::raw(" Revoke "), | |
| 94 | + | ]); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | key_spans.extend([ | |
| 98 | + | Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), | |
| 99 | + | Span::raw(" Refresh "), | |
| 100 | + | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 101 | + | Span::raw(" Back"), | |
| 102 | + | ]); | |
| 103 | + | ||
| 104 | + | let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray)); | |
| 105 | + | frame.render_widget(keys, chunks[4]); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 109 | + | let selected = app.selected_index; | |
| 110 | + | let rows: Vec<Row> = app | |
| 111 | + | .license_keys | |
| 112 | + | .iter() | |
| 113 | + | .enumerate() | |
| 114 | + | .map(|(i, key)| { | |
| 115 | + | let style = if i == selected { | |
| 116 | + | Style::default() | |
| 117 | + | .bg(Color::DarkGray) | |
| 118 | + | .fg(Color::White) | |
| 119 | + | .add_modifier(Modifier::BOLD) | |
| 120 | + | } else { | |
| 121 | + | Style::default() | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | let status = if key.is_revoked { | |
| 125 | + | "Revoked" | |
| 126 | + | } else { | |
| 127 | + | "Active" | |
| 128 | + | }; | |
| 129 | + | let activations = match key.max_activations { | |
| 130 | + | Some(max) => format!("{}/{}", key.activation_count, max), | |
| 131 | + | None => key.activation_count.to_string(), | |
| 132 | + | }; | |
| 133 | + | let date = key.created_at.get(..10).unwrap_or(&key.created_at); | |
| 134 | + | ||
| 135 | + | Row::new(vec![ | |
| 136 | + | format!(" {}", key.key_code), | |
| 137 | + | status.to_string(), | |
| 138 | + | activations, | |
| 139 | + | date.to_string(), | |
| 140 | + | ]) | |
| 141 | + | .style(style) | |
| 142 | + | }) | |
| 143 | + | .collect(); | |
| 144 | + | ||
| 145 | + | let header = Row::new(vec![" Key", "Status", "Activations", "Created"]) | |
| 146 | + | .style( | |
| 147 | + | Style::default() | |
| 148 | + | .fg(Color::DarkGray) | |
| 149 | + | .add_modifier(Modifier::BOLD), | |
| 150 | + | ) | |
| 151 | + | .bottom_margin(0); | |
| 152 | + | ||
| 153 | + | let widths = [ | |
| 154 | + | Constraint::Min(24), | |
| 155 | + | Constraint::Length(10), | |
| 156 | + | Constraint::Length(12), | |
| 157 | + | Constraint::Length(12), | |
| 158 | + | ]; | |
| 159 | + | ||
| 160 | + | let table = Table::new(rows, widths).header(header); | |
| 161 | + | frame.render_widget(table, area); | |
| 162 | + | } |
| @@ -0,0 +1,2172 @@ | |||
| 1 | + | //! TUI application state and event loop. | |
| 2 | + | ||
| 3 | + | pub mod analytics; | |
| 4 | + | pub mod blog; | |
| 5 | + | pub mod home; | |
| 6 | + | pub mod item; | |
| 7 | + | pub mod keys; | |
| 8 | + | pub mod project; | |
| 9 | + | pub mod promo; | |
| 10 | + | pub mod settings; | |
| 11 | + | pub mod upload; | |
| 12 | + | ||
| 13 | + | use std::path::PathBuf; | |
| 14 | + | ||
| 15 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 16 | + | use ratatui::Terminal; | |
| 17 | + | use ratatui::backend::CrosstermBackend; | |
| 18 | + | use tokio::sync::mpsc; | |
| 19 | + | ||
| 20 | + | use crate::api::{ | |
| 21 | + | AnalyticsData, BlogPost, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project, | |
| 22 | + | PromoCode, SshKeyInfo, StorageInfo, Transaction, UserInfo, Version, | |
| 23 | + | }; | |
| 24 | + | use crate::ssh::terminal::TerminalHandle; | |
| 25 | + | use crate::staging::{self, StagedFile}; | |
| 26 | + | ||
| 27 | + | /// Events sent to the TUI event loop. | |
| 28 | + | pub enum AppEvent { | |
| 29 | + | /// Raw input bytes from the SSH channel. | |
| 30 | + | Input(Vec<u8>), | |
| 31 | + | /// Terminal resize. | |
| 32 | + | Resize(u16, u16), | |
| 33 | + | /// Data loaded from the API. | |
| 34 | + | DataLoaded(DataPayload), | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Payload variants for async data loading. | |
| 38 | + | pub enum DataPayload { | |
| 39 | + | Home { | |
| 40 | + | projects: Vec<Project>, | |
| 41 | + | stats: CreatorStats, | |
| 42 | + | }, | |
| 43 | + | ProjectItems { | |
| 44 | + | items: Vec<Item>, | |
| 45 | + | }, | |
| 46 | + | StagedFiles { | |
| 47 | + | files: Vec<StagedFile>, | |
| 48 | + | storage: Option<StorageInfo>, | |
| 49 | + | }, | |
| 50 | + | PublishResult { | |
| 51 | + | filename: String, | |
| 52 | + | success: bool, | |
| 53 | + | error: Option<String>, | |
| 54 | + | }, | |
| 55 | + | ItemDetail { | |
| 56 | + | detail: ItemDetail, | |
| 57 | + | versions: Vec<Version>, | |
| 58 | + | }, | |
| 59 | + | ItemUpdated { | |
| 60 | + | detail: ItemDetail, | |
| 61 | + | }, | |
| 62 | + | ItemDeleted, | |
| 63 | + | ItemActionError { | |
| 64 | + | error: String, | |
| 65 | + | }, | |
| 66 | + | /// Signal to reload the project items list after a mutation. | |
| 67 | + | ProjectReload { | |
| 68 | + | project_idx: usize, | |
| 69 | + | }, | |
| 70 | + | BlogPosts { | |
| 71 | + | posts: Vec<BlogPost>, | |
| 72 | + | }, | |
| 73 | + | BlogCreated, | |
| 74 | + | PromoCodes { | |
| 75 | + | codes: Vec<PromoCode>, | |
| 76 | + | }, | |
| 77 | + | LicenseKeys { | |
| 78 | + | keys: Vec<LicenseKey>, | |
| 79 | + | }, | |
| 80 | + | GenericSuccess { | |
| 81 | + | message: String, | |
| 82 | + | }, | |
| 83 | + | GenericError { | |
| 84 | + | error: String, | |
| 85 | + | }, | |
| 86 | + | Analytics { | |
| 87 | + | data: AnalyticsData, | |
| 88 | + | }, | |
| 89 | + | Transactions { | |
| 90 | + | txs: Vec<Transaction>, | |
| 91 | + | }, | |
| 92 | + | ExportCsv { | |
| 93 | + | csv: String, | |
| 94 | + | row_count: usize, | |
| 95 | + | }, | |
| 96 | + | Settings { | |
| 97 | + | keys: Vec<SshKeyInfo>, | |
| 98 | + | storage: Option<StorageInfo>, | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Handle for sending events to a running TUI session. | |
| 103 | + | #[derive(Clone)] | |
| 104 | + | pub struct AppHandle { | |
| 105 | + | tx: mpsc::Sender<AppEvent>, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | impl AppHandle { | |
| 109 | + | pub async fn send_input(&self, data: &[u8]) { | |
| 110 | + | let _ = self.tx.send(AppEvent::Input(data.to_vec())).await; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | pub async fn send_resize(&self, cols: u16, rows: u16) { | |
| 114 | + | let _ = self.tx.send(AppEvent::Resize(cols, rows)).await; | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Active screen in the TUI. | |
| 119 | + | enum Screen { | |
| 120 | + | Home, | |
| 121 | + | /// Project detail view. Index is into `app.projects`. | |
| 122 | + | Project(usize), | |
| 123 | + | /// Upload management screen. | |
| 124 | + | Upload, | |
| 125 | + | /// Item detail view. Stores (project_index, item_id). | |
| 126 | + | Item(usize, String), | |
| 127 | + | /// Blog post list for a project. Stores (project_index, project_id). | |
| 128 | + | Blog(usize, String), | |
| 129 | + | /// Promo code management. | |
| 130 | + | Promo, | |
| 131 | + | /// License key management for an item. Stores (project_index, item_id). | |
| 132 | + | Keys(usize, String), | |
| 133 | + | /// Analytics dashboard. | |
| 134 | + | Analytics, | |
| 135 | + | /// Settings screen (profile, storage, SSH keys). | |
| 136 | + | Settings, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// User-editable metadata for a staged file. | |
| 140 | + | #[derive(Debug, Clone, Default)] | |
| 141 | + | pub struct FileMetadata { | |
| 142 | + | pub title: Option<String>, | |
| 143 | + | pub project_idx: Option<usize>, | |
| 144 | + | pub project_name: Option<String>, | |
| 145 | + | pub price_cents: i32, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Which field is being edited on the upload screen. | |
| 149 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 150 | + | pub(crate) enum EditField { | |
| 151 | + | Title, | |
| 152 | + | Project, | |
| 153 | + | Price, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Steps for creating a blog post. | |
| 157 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 158 | + | pub(crate) enum BlogCreateStep { | |
| 159 | + | Title, | |
| 160 | + | Body, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Steps for creating a promo code. | |
| 164 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 165 | + | pub(crate) enum PromoCreateStep { | |
| 166 | + | Code, | |
| 167 | + | Discount, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Application state shared across screens. | |
| 171 | + | pub struct App { | |
| 172 | + | pub user: UserInfo, | |
| 173 | + | pub projects: Vec<Project>, | |
| 174 | + | pub stats: Option<CreatorStats>, | |
| 175 | + | pub items: Vec<Item>, | |
| 176 | + | pub selected_index: usize, | |
| 177 | + | pub loading: bool, | |
| 178 | + | pub staged_files: Vec<StagedFile>, | |
| 179 | + | pub storage_info: Option<StorageInfo>, | |
| 180 | + | pub file_metadata: Vec<FileMetadata>, | |
| 181 | + | pub upload_status: Option<String>, | |
| 182 | + | pub editing_field: Option<EditField>, | |
| 183 | + | pub edit_buffer: String, | |
| 184 | + | pub publishing: bool, | |
| 185 | + | pub item_detail: Option<ItemDetail>, | |
| 186 | + | pub item_versions: Vec<Version>, | |
| 187 | + | pub item_status: Option<String>, | |
| 188 | + | pub item_editing: Option<item::ItemEditField>, | |
| 189 | + | // Blog | |
| 190 | + | pub blog_posts: Vec<BlogPost>, | |
| 191 | + | pub blog_project_title: Option<String>, | |
| 192 | + | pub blog_status: Option<String>, | |
| 193 | + | pub blog_creating: bool, | |
| 194 | + | pub blog_create_step: Option<BlogCreateStep>, | |
| 195 | + | pub blog_create_title: String, | |
| 196 | + | // Promo codes | |
| 197 | + | pub promo_codes: Vec<PromoCode>, | |
| 198 | + | pub promo_status: Option<String>, | |
| 199 | + | pub promo_editing_step: Option<PromoCreateStep>, | |
| 200 | + | pub promo_create_code: String, | |
| 201 | + | pub promo_create_discount: String, | |
| 202 | + | // License keys | |
| 203 | + | pub license_keys: Vec<LicenseKey>, | |
| 204 | + | pub keys_item_title: Option<String>, | |
| 205 | + | pub keys_status: Option<String>, | |
| 206 | + | // Analytics | |
| 207 | + | pub analytics_data: Option<AnalyticsData>, | |
| 208 | + | pub analytics_range: String, | |
| 209 | + | pub analytics_status: Option<String>, | |
| 210 | + | pub analytics_show_transactions: bool, | |
| 211 | + | pub transactions: Vec<Transaction>, | |
| 212 | + | // Settings | |
| 213 | + | pub ssh_keys: Vec<SshKeyInfo>, | |
| 214 | + | pub settings_status: Option<String>, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | impl App { | |
| 218 | + | fn new(user: UserInfo) -> Self { | |
| 219 | + | Self { | |
| 220 | + | user, | |
| 221 | + | projects: Vec::new(), | |
| 222 | + | stats: None, | |
| 223 | + | items: Vec::new(), | |
| 224 | + | selected_index: 0, | |
| 225 | + | loading: true, | |
| 226 | + | staged_files: Vec::new(), | |
| 227 | + | storage_info: None, | |
| 228 | + | file_metadata: Vec::new(), | |
| 229 | + | upload_status: None, | |
| 230 | + | editing_field: None, | |
| 231 | + | edit_buffer: String::new(), | |
| 232 | + | publishing: false, | |
| 233 | + | item_detail: None, | |
| 234 | + | item_versions: Vec::new(), | |
| 235 | + | item_status: None, | |
| 236 | + | item_editing: None, | |
| 237 | + | blog_posts: Vec::new(), | |
| 238 | + | blog_project_title: None, | |
| 239 | + | blog_status: None, | |
| 240 | + | blog_creating: false, | |
| 241 | + | blog_create_step: None, | |
| 242 | + | blog_create_title: String::new(), | |
| 243 | + | promo_codes: Vec::new(), | |
| 244 | + | promo_status: None, | |
| 245 | + | promo_editing_step: None, | |
| 246 | + | promo_create_code: String::new(), | |
| 247 | + | promo_create_discount: String::new(), | |
| 248 | + | license_keys: Vec::new(), | |
| 249 | + | keys_item_title: None, | |
| 250 | + | keys_status: None, | |
| 251 | + | analytics_data: None, | |
| 252 | + | analytics_range: "30d".to_string(), | |
| 253 | + | analytics_status: None, | |
| 254 | + | analytics_show_transactions: false, | |
| 255 | + | transactions: Vec::new(), | |
| 256 | + | ssh_keys: Vec::new(), | |
| 257 | + | settings_status: None, | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | fn list_len(&self, screen: &Screen) -> usize { | |
| 262 | + | match screen { | |
| 263 | + | Screen::Home => self.projects.len(), | |
| 264 | + | Screen::Project(_) => self.items.len(), | |
| 265 | + | Screen::Upload => self.staged_files.len(), | |
| 266 | + | Screen::Item(..) => self.item_versions.len(), | |
| 267 | + | Screen::Blog(..) => self.blog_posts.len(), | |
| 268 | + | Screen::Promo => self.promo_codes.len(), | |
| 269 | + | Screen::Keys(..) => self.license_keys.len(), | |
| 270 | + | Screen::Analytics => self.transactions.len(), | |
| 271 | + | Screen::Settings => self.ssh_keys.len(), | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | fn move_up(&mut self, screen: &Screen) { | |
| 276 | + | if self.selected_index > 0 { | |
| 277 | + | self.selected_index -= 1; | |
| 278 | + | } else { | |
| 279 | + | // Wrap to bottom | |
| 280 | + | let len = self.list_len(screen); | |
| 281 | + | if len > 0 { | |
| 282 | + | self.selected_index = len - 1; | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fn move_down(&mut self, screen: &Screen) { | |
| 288 | + | let len = self.list_len(screen); | |
| 289 | + | if len > 0 { | |
| 290 | + | if self.selected_index < len - 1 { | |
| 291 | + | self.selected_index += 1; | |
| 292 | + | } else { | |
| 293 | + | // Wrap to top | |
| 294 | + | self.selected_index = 0; | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Ensure file_metadata vec matches staged_files length. | |
| 300 | + | fn sync_metadata(&mut self) { | |
| 301 | + | while self.file_metadata.len() < self.staged_files.len() { | |
| 302 | + | let idx = self.file_metadata.len(); | |
| 303 | + | let title = staging::derive_title(&self.staged_files[idx].filename); | |
| 304 | + | self.file_metadata.push(FileMetadata { | |
| 305 | + | title: Some(title), | |
| 306 | + | ..Default::default() | |
| 307 | + | }); | |
| 308 | + | } | |
| 309 | + | self.file_metadata.truncate(self.staged_files.len()); | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Launch the TUI event loop in a background task. | |
| 314 | + | pub fn launch( | |
| 315 | + | writer: TerminalHandle, | |
| 316 | + | user: UserInfo, | |
| 317 | + | cols: u16, | |
| 318 | + | rows: u16, | |
| 319 | + | session_handle: russh::server::Handle, | |
| 320 | + | channel_id: russh::ChannelId, | |
| 321 | + | api: MnwApiClient, | |
| 322 | + | staging_dir: PathBuf, | |
| 323 | + | ) -> anyhow::Result<AppHandle> { | |
| 324 | + | let backend = CrosstermBackend::new(writer); | |
| 325 | + | let mut terminal = Terminal::new(backend)?; | |
| 326 | + | terminal.resize(ratatui::layout::Rect::new(0, 0, cols, rows))?; | |
| 327 | + | ||
| 328 | + | let (tx, mut rx) = mpsc::channel::<AppEvent>(64); | |
| 329 | + | let handle = AppHandle { tx: tx.clone() }; | |
| 330 | + | ||
| 331 | + | // Kick off initial data load | |
| 332 | + | let user_id = user.user_id.clone(); | |
| 333 | + | let api_clone = api.clone(); | |
| 334 | + | let tx_clone = tx.clone(); | |
| 335 | + | tokio::spawn(async move { | |
| 336 | + | load_home_data(&api_clone, &user_id, &tx_clone).await; | |
| 337 | + | }); | |
| 338 | + | ||
| 339 | + | tokio::spawn(async move { | |
| 340 | + | let mut app = App::new(user); | |
| 341 | + | let mut screen = Screen::Home; | |
| 342 | + | let staging_dir = staging_dir; | |
| 343 | + | ||
| 344 | + | // Initial render (loading state) | |
| 345 | + | if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) { | |
| 346 | + | tracing::error!(error = ?e, "initial render failed"); | |
| 347 | + | return; | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | while let Some(event) = rx.recv().await { | |
| 351 | + | match event { | |
| 352 | + | AppEvent::Input(data) => { | |
| 353 | + | if let Some(key) = parse_key(&data) { | |
| 354 | + | // Global quit | |
| 355 | + | if (key.modifiers.contains(KeyModifiers::CONTROL) | |
| 356 | + | && key.code == KeyCode::Char('c')) | |
| 357 | + | || matches!( | |
| 358 | + | (&screen, key.code), | |
| 359 | + | (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q')) | |
| 360 | + | ) | |
| 361 | + | { | |
| 362 | + | tracing::info!(user = %app.user.username, "user quit"); | |
| 363 | + | let _ = session_handle.close(channel_id).await; | |
| 364 | + | return; | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | match screen { | |
| 368 | + | Screen::Home => { | |
| 369 | + | handle_home_input( | |
| 370 | + | key, &mut app, &mut screen, &api, &tx, &staging_dir, | |
| 371 | + | ) | |
| 372 | + | .await; | |
| 373 | + | } | |
| 374 | + | Screen::Project(_) => { | |
| 375 | + | handle_project_input( | |
| 376 | + | key, &mut app, &mut screen, &api, &tx, | |
| 377 | + | ) | |
| 378 | + | .await; | |
| 379 | + | } | |
| 380 | + | Screen::Upload => { | |
| 381 | + | handle_upload_input( | |
| 382 | + | key, &mut app, &mut screen, &api, &tx, &staging_dir, | |
| 383 | + | ) | |
| 384 | + | .await; | |
| 385 | + | } | |
| 386 | + | Screen::Item(..) => { | |
| 387 | + | handle_item_input( | |
| 388 | + | key, &mut app, &mut screen, &api, &tx, | |
| 389 | + | ) | |
| 390 | + | .await; | |
| 391 | + | } | |
| 392 | + | Screen::Blog(..) => { | |
| 393 | + | handle_blog_input( | |
| 394 | + | key, &mut app, &mut screen, &api, &tx, | |
| 395 | + | ) | |
| 396 | + | .await; | |
| 397 | + | } | |
| 398 | + | Screen::Promo => { | |
| 399 | + | handle_promo_input( | |
| 400 | + | key, &mut app, &mut screen, &api, &tx, | |
| 401 | + | ) | |
| 402 | + | .await; | |
| 403 | + | } | |
| 404 | + | Screen::Keys(..) => { | |
| 405 | + | handle_keys_input( | |
| 406 | + | key, &mut app, &mut screen, &api, &tx, | |
| 407 | + | ) | |
| 408 | + | .await; | |
| 409 | + | } | |
| 410 | + | Screen::Analytics => { | |
| 411 | + | handle_analytics_input( | |
| 412 | + | key, &mut app, &mut screen, &api, &tx, | |
| 413 | + | ) | |
| 414 | + | .await; | |
| 415 | + | } | |
| 416 | + | Screen::Settings => { | |
| 417 | + | handle_settings_input( | |
| 418 | + | key, &mut app, &mut screen, &api, &tx, | |
| 419 | + | ) | |
| 420 | + | .await; | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | AppEvent::Resize(cols, rows) => { | |
| 426 | + | let rect = ratatui::layout::Rect::new(0, 0, cols, rows); | |
| 427 | + | let _ = terminal.resize(rect); | |
| 428 | + | } | |
| 429 | + | AppEvent::DataLoaded(payload) => match payload { | |
| 430 | + | DataPayload::Home { projects, stats } => { | |
| 431 | + | app.projects = projects; | |
| 432 | + | app.stats = Some(stats); | |
| 433 | + | app.loading = false; | |
| 434 | + | app.selected_index = 0; | |
| 435 | + | } | |
| 436 | + | DataPayload::ProjectItems { items } => { | |
| 437 | + | app.items = items; | |
| 438 | + | app.loading = false; | |
| 439 | + | app.selected_index = 0; | |
| 440 | + | } | |
| 441 | + | DataPayload::StagedFiles { files, storage } => { | |
| 442 | + | app.staged_files = files; | |
| 443 | + | if let Some(s) = storage { | |
| 444 | + | app.storage_info = Some(s); | |
| 445 | + | } | |
| 446 | + | app.sync_metadata(); | |
| 447 | + | app.loading = false; | |
| 448 | + | if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() { | |
| 449 | + | app.selected_index = app.staged_files.len() - 1; | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | DataPayload::ItemDetail { detail, versions } => { | |
| 453 | + | app.item_detail = Some(detail); | |
| 454 | + | app.item_versions = versions; | |
| 455 | + | app.loading = false; | |
| 456 | + | app.selected_index = 0; | |
| 457 | + | } | |
| 458 | + | DataPayload::ItemUpdated { detail } => { | |
| 459 | + | app.item_detail = Some(detail); | |
| 460 | + | app.item_status = Some("Updated".to_string()); | |
| 461 | + | app.item_editing = None; | |
| 462 | + | app.edit_buffer.clear(); | |
| 463 | + | } | |
| 464 | + | DataPayload::ItemDeleted => { | |
| 465 | + | app.item_status = Some("Deleted".to_string()); | |
| 466 | + | // Navigate back to project view | |
| 467 | + | if let Screen::Item(project_idx, _) = &screen { | |
| 468 | + | let pidx = *project_idx; | |
| 469 | + | screen = Screen::Project(pidx); | |
| 470 | + | app.item_detail = None; | |
| 471 | + | app.item_versions.clear(); | |
| 472 | + | app.item_status = None; | |
| 473 | + | app.selected_index = 0; | |
| 474 | + | app.loading = true; | |
| 475 | + | ||
| 476 | + | if let Some(p) = app.projects.get(pidx) { | |
| 477 | + | let api = api.clone(); | |
| 478 | + | let project_id = p.id.clone(); | |
| 479 | + | let user_id = app.user.user_id.clone(); | |
| 480 | + | let tx = tx.clone(); | |
| 481 | + | tokio::spawn(async move { | |
| 482 | + | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 483 | + | }); | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | DataPayload::ItemActionError { error } => { | |
| 488 | + | app.item_status = Some(format!("Error: {}", error)); | |
| 489 | + | } | |
| 490 | + | DataPayload::BlogPosts { posts } => { | |
| 491 | + | app.blog_posts = posts; | |
| 492 | + | app.loading = false; | |
| 493 | + | app.selected_index = 0; | |
| 494 | + | } | |
| 495 | + | DataPayload::BlogCreated => { | |
| 496 | + | app.blog_creating = false; | |
| 497 | + | app.blog_create_step = None; | |
| 498 | + | app.blog_create_title.clear(); | |
| 499 | + | app.edit_buffer.clear(); | |
| 500 | + | app.blog_status = Some("Post created".to_string()); |
Lines truncated