From 6d435a8fed2dae11b241a3d5bebe4c9690d66ae9 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Tue, 28 Nov 2023 12:24:52 -0800 Subject: [PATCH 01/16] Add api_key to RobloxApiClient Use AssetCreator for RobloxSyncBackend.upload_to_group_id + rename --- Cargo.lock | 810 ++++++++++++++++++++++++++++--- Cargo.toml | 6 +- src/commands/create_cache_map.rs | 9 +- src/commands/sync.rs | 14 +- src/commands/upload_image.rs | 17 +- src/data/config.rs | 4 + src/main.rs | 2 +- src/options.rs | 3 + src/roblox_web_api.rs | 101 +++- src/sync_backend.rs | 9 +- 10 files changed, 859 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16b4618..11b7764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + [[package]] name = "bitflags" version = "1.3.2" @@ -150,7 +156,16 @@ dependencies = [ "cfg-if 0.1.10", "constant_time_eq", "crypto-mac", - "digest", + "digest 0.8.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", ] [[package]] @@ -197,6 +212,12 @@ dependencies = [ "iovec", ] +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + [[package]] name = "cc" version = "1.0.73" @@ -224,12 +245,51 @@ dependencies = [ "ansi_term", "atty", "bitflags", - "strsim", - "textwrap", + "strsim 0.8.0", + "textwrap 0.11.0", "unicode-width", "vec_map", ] +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "clap_lex", + "indexmap 1.9.1", + "once_cell", + "strsim 0.10.0", + "termcolor", + "textwrap 0.16.0", +] + +[[package]] +name = "clap_derive" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.99", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "clicolors-control" version = "1.0.1" @@ -440,13 +500,23 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array 0.14.7", + "typenum", +] + [[package]] name = "crypto-mac" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" dependencies = [ - "generic-array", + "generic-array 0.12.4", "subtle", ] @@ -482,7 +552,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" dependencies = [ - "generic-array", + "generic-array 0.12.4", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -576,6 +656,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "failure" version = "0.1.8" @@ -594,7 +680,7 @@ checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", "synstructure", ] @@ -681,6 +767,21 @@ version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" +[[package]] +name = "futures-channel" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" + [[package]] name = "futures-cpupool" version = "0.1.8" @@ -691,6 +792,30 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "futures-sink" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" + +[[package]] +name = "futures-task" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" + +[[package]] +name = "futures-util" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -700,6 +825,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -758,23 +893,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5b34c246847f938a410a03c5458c7fee2274436675e76d8b903c08efc29c462" dependencies = [ "byteorder 1.4.3", - "bytes", + "bytes 0.4.12", "fnv", "futures", - "http", - "indexmap", + "http 0.1.21", + "indexmap 1.9.1", "log", "slab", "string", "tokio-io", ] +[[package]] +name = "h2" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +dependencies = [ + "bytes 1.5.0", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.11", + "indexmap 2.1.0", + "slab", + "tokio 1.34.0", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "heck" version = "0.3.3" @@ -784,6 +944,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -799,29 +965,57 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6ccf5ede3a895d8856620237b2f02972c1bbc78d2965ad7fe8838d4a0ed41f0" dependencies = [ - "bytes", + "bytes 0.4.12", "fnv", "itoa 0.4.8", ] +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes 1.5.0", + "fnv", + "itoa 1.0.3", +] + [[package]] name = "http-body" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6741c859c1b2463a423a1dbce98d418e6c3c3fc720fb0d45528657320920292d" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", - "http", + "http 0.1.21", "tokio-buf", ] +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes 1.5.0", + "http 0.2.11", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "1.3.0" @@ -843,12 +1037,12 @@ version = "0.12.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c843caf6296fc1f93444735205af9ed4e109a539005abb2564ae1d6fad34c52" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", "futures-cpupool", - "h2", - "http", - "http-body", + "h2 0.1.26", + "http 0.1.21", + "http-body 0.1.0", "httparse", "iovec", "itoa 0.4.8", @@ -856,7 +1050,7 @@ dependencies = [ "net2", "rustc_version", "time 0.1.44", - "tokio", + "tokio 0.1.22", "tokio-buf", "tokio-executor", "tokio-io", @@ -864,7 +1058,31 @@ dependencies = [ "tokio-tcp", "tokio-threadpool", "tokio-timer", - "want", + "want 0.2.0", +] + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes 1.5.0", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.22", + "http 0.2.11", + "http-body 0.4.5", + "httparse", + "httpdate", + "itoa 1.0.3", + "pin-project-lite", + "socket2 0.4.10", + "tokio 1.34.0", + "tower-service", + "tracing", + "want 0.3.1", ] [[package]] @@ -873,13 +1091,26 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a800d6aa50af4b5850b2b0f659625ce9504df908e9733b635720483be26174f" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", - "hyper", + "hyper 0.12.36", "native-tls", "tokio-io", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes 1.5.0", + "hyper 0.14.27", + "native-tls", + "tokio 1.34.0", + "tokio-native-tls", +] + [[package]] name = "idna" version = "0.1.5" @@ -938,7 +1169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg 1.1.0", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", ] [[package]] @@ -982,6 +1223,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "itoa" version = "0.4.8" @@ -1003,6 +1250,15 @@ dependencies = [ "rayon", ] +[[package]] +name = "js-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -1021,9 +1277,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.132" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "line-wrap" @@ -1049,6 +1305,16 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg 1.1.0", + "scopeguard", +] + [[package]] name = "log" version = "0.4.17" @@ -1070,6 +1336,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if 1.0.0", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.5.0" @@ -1157,6 +1433,17 @@ dependencies = [ "winapi 0.2.8", ] +[[package]] +name = "mio" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "miow" version = "0.2.2" @@ -1296,7 +1583,7 @@ checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -1318,6 +1605,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + [[package]] name = "packos" version = "0.1.0" @@ -1333,11 +1626,21 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.3.4", + "parking_lot_core 0.6.2", "rustc_version", ] +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api 0.4.11", + "parking_lot_core 0.9.9", +] + [[package]] name = "parking_lot_core" version = "0.6.2" @@ -1349,10 +1652,23 @@ dependencies = [ "libc", "redox_syscall 0.1.57", "rustc_version", - "smallvec", + "smallvec 0.6.14", "winapi 0.3.9", ] +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall 0.4.1", + "smallvec 1.11.2", + "windows-targets", +] + [[package]] name = "path-slash" version = "0.1.5" @@ -1371,6 +1687,18 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.25" @@ -1384,7 +1712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225" dependencies = [ "base64 0.13.0", - "indexmap", + "indexmap 1.9.1", "line-wrap", "serde", "time 0.3.14", @@ -1424,7 +1752,7 @@ dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", - "syn", + "syn 1.0.99", "version_check", ] @@ -1447,9 +1775,9 @@ checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" dependencies = [ "unicode-ident", ] @@ -1472,9 +1800,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.21" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] @@ -1616,7 +1944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8a61b073240f4c13b1e780a8999a113dfa28bc93f2cf9fc41c6f36e7aceb5bf" dependencies = [ "byteorder 0.5.3", - "clap", + "clap 2.34.0", "cookie 0.15.1", "dirs 1.0.5", "env_logger 0.9.0", @@ -1626,6 +1954,22 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "rbxcloud" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88cfd4a751daf5d48401657a03cbaca3e7564a995fb87f73a80248f09c424580" +dependencies = [ + "anyhow", + "base64 0.13.0", + "clap 3.2.25", + "md-5", + "reqwest 0.11.22", + "serde", + "serde_json", + "tokio 1.34.0", +] + [[package]] name = "rdrand" version = "0.4.0" @@ -1650,6 +1994,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.3.5" @@ -1705,24 +2058,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f88643aea3c1343c804950d7bf983bd2067f5ab59db6d613a08e05572f2714ab" dependencies = [ "base64 0.10.1", - "bytes", + "bytes 0.4.12", "cookie 0.12.0", "cookie_store", "encoding_rs", "flate2", "futures", - "http", - "hyper", - "hyper-tls", + "http 0.1.21", + "hyper 0.12.36", + "hyper-tls 0.3.2", "log", "mime", "mime_guess", "native-tls", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.5.5", "time 0.1.44", - "tokio", + "tokio 0.1.22", "tokio-executor", "tokio-io", "tokio-threadpool", @@ -1732,6 +2085,45 @@ dependencies = [ "winreg 0.6.2", ] +[[package]] +name = "reqwest" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +dependencies = [ + "base64 0.21.5", + "bytes 1.5.0", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.22", + "http 0.2.11", + "http-body 0.4.5", + "hyper 0.14.27", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding 2.2.0", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded 0.7.1", + "system-configuration", + "tokio 1.34.0", + "tokio-native-tls", + "tower-service", + "url 2.3.1", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "roblox_install" version = "0.3.0" @@ -1797,7 +2189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" dependencies = [ "lazy_static", - "windows-sys", + "windows-sys 0.36.1", ] [[package]] @@ -1876,7 +2268,7 @@ checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -1902,13 +2294,25 @@ dependencies = [ "url 1.7.2", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.3", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ - "indexmap", + "indexmap 1.9.1", "ryu", "serde", "yaml-rust", @@ -1929,6 +2333,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.7" @@ -1947,6 +2360,32 @@ dependencies = [ "maybe-uninit", ] +[[package]] +name = "smallvec" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "standback" version = "0.2.17" @@ -1980,7 +2419,7 @@ dependencies = [ "quote", "serde", "serde_derive", - "syn", + "syn 1.0.99", ] [[package]] @@ -1996,7 +2435,7 @@ dependencies = [ "serde_derive", "serde_json", "sha1", - "syn", + "syn 1.0.99", ] [[package]] @@ -2011,7 +2450,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" dependencies = [ - "bytes", + "bytes 0.4.12", ] [[package]] @@ -2020,13 +2459,19 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "structopt" version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" dependencies = [ - "clap", + "clap 2.34.0", "lazy_static", "structopt-derive", ] @@ -2037,11 +2482,11 @@ version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" dependencies = [ - "heck", + "heck 0.3.3", "proc-macro-error", "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -2061,6 +2506,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.12.6" @@ -2069,10 +2525,31 @@ checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", "unicode-xid", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tarmac" version = "0.7.4" @@ -2090,8 +2567,9 @@ dependencies = [ "path-slash", "png 0.15.3", "rbx_cookie", + "rbxcloud", "regex", - "reqwest", + "reqwest 0.9.24", "roblox_install", "secrecy", "serde", @@ -2143,6 +2621,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "textwrap" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" + [[package]] name = "thiserror" version = "1.0.34" @@ -2160,7 +2644,7 @@ checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -2231,7 +2715,7 @@ dependencies = [ "proc-macro2", "quote", "standback", - "syn", + "syn 1.0.99", ] [[package]] @@ -2255,9 +2739,9 @@ version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a09c0b5bb588872ab2f09afa13ee6e9dac11e10a0ec9e8e3ba39a5a5d530af6" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", - "mio", + "mio 0.6.23", "num_cpus", "tokio-current-thread", "tokio-executor", @@ -2268,13 +2752,32 @@ dependencies = [ "tokio-timer", ] +[[package]] +name = "tokio" +version = "1.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +dependencies = [ + "backtrace", + "bytes 1.5.0", + "libc", + "mio 0.8.9", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.48.0", +] + [[package]] name = "tokio-buf" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fb220f46c53859a4b7ec083e41dec9778ff0b1851c0942b211edb89e0ccdc46" dependencies = [ - "bytes", + "bytes 0.4.12", "either", "futures", ] @@ -2305,11 +2808,32 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", "log", ] +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio 1.34.0", +] + [[package]] name = "tokio-reactor" version = "0.1.12" @@ -2320,9 +2844,9 @@ dependencies = [ "futures", "lazy_static", "log", - "mio", + "mio 0.6.23", "num_cpus", - "parking_lot", + "parking_lot 0.9.0", "slab", "tokio-executor", "tokio-io", @@ -2345,10 +2869,10 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98df18ed66e3b72e742f185882a9e201892407957e45fbff8da17ae7a7c51f72" dependencies = [ - "bytes", + "bytes 0.4.12", "futures", "iovec", - "mio", + "mio 0.6.23", "tokio-io", "tokio-reactor", ] @@ -2382,6 +2906,20 @@ dependencies = [ "tokio-executor", ] +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes 1.5.0", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio 1.34.0", + "tracing", +] + [[package]] name = "toml" version = "0.5.9" @@ -2391,11 +2929,36 @@ dependencies = [ "serde", ] +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + [[package]] name = "try-lock" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" [[package]] name = "try_from" @@ -2531,6 +3094,15 @@ dependencies = [ "try-lock", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -2570,10 +3142,22 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 1.0.99", "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa76fb221a1f8acddf5b54ace85912606980ad661ac7a503b4570ffd3a624dad" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.82" @@ -2592,7 +3176,7 @@ checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2603,6 +3187,16 @@ version = "0.2.82" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" +[[package]] +name = "web-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed055ab27f941423197eb86b2035720b1a3ce40504df082cac2ecc6ed73335a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.7" @@ -2658,43 +3252,109 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_i686_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winreg" version = "0.6.2" @@ -2713,6 +3373,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if 1.0.0", + "windows-sys 0.48.0", +] + [[package]] name = "ws2_32-sys" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 9fcacc7..a8eea7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,6 @@ default-members = [ [dependencies] packos = { path = "packos", version = "0.1.0" } -secrecy = "0.8.0" -rbx_cookie = "0.1.4" - anyhow = "1.0.27" backtrace = "0.3.46" blake3 = "0.1.3" @@ -46,9 +43,12 @@ lazy_static = "1.4.0" log = "0.4.8" path-slash = "0.1.3" png = "0.15.3" +rbxcloud = "0.6.0" +rbx_cookie = "0.1.4" regex = "1.3.3" reqwest = "0.9.24" roblox_install = "0.3.0" +secrecy = "0.8.0" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" structopt = { version = "0.3", default-features = false } diff --git a/src/commands/create_cache_map.rs b/src/commands/create_cache_map.rs index ff68c52..67f0c86 100644 --- a/src/commands/create_cache_map.rs +++ b/src/commands/create_cache_map.rs @@ -7,13 +7,18 @@ use fs_err as fs; use crate::asset_name::AssetName; use crate::data::Manifest; use crate::options::{CreateCacheMapOptions, GlobalOptions}; -use crate::roblox_web_api::RobloxApiClient; +use crate::roblox_web_api::{RobloxApiClient, RobloxCredentials}; pub fn create_cache_map( global: GlobalOptions, options: CreateCacheMapOptions, ) -> anyhow::Result<()> { - let mut api_client = RobloxApiClient::new(global.auth); + let mut api_client = RobloxApiClient::new(RobloxCredentials { + token: global.auth, + api_key: None, + user_id: None, + group_id: None, + })?; let project_path = match options.project_path { Some(path) => path, diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 7261e2c..220db4e 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -22,7 +22,7 @@ use crate::{ }, dpi_scale, options::{GlobalOptions, SyncOptions, SyncTarget}, - roblox_web_api::{RobloxApiClient, RobloxApiError}, + roblox_web_api::{RobloxApiClient, RobloxApiError, RobloxCredentials}, sync_backend::{ DebugSyncBackend, Error as SyncBackendError, LocalSyncBackend, NoneSyncBackend, RetryBackend, RobloxSyncBackend, SyncBackend, UploadInfo, @@ -45,21 +45,25 @@ pub fn sync(global: GlobalOptions, options: SyncOptions) -> Result<(), SyncError None => env::current_dir()?, }; - let mut api_client = RobloxApiClient::new(global.auth.or_else(get_auth_cookie)); - let mut session = SyncSession::new(&fuzzy_config_path)?; + let mut api_client = RobloxApiClient::new(RobloxCredentials { + token: global.auth.or_else(get_auth_cookie), + api_key: global.api_key, + group_id: session.root_config().upload_to_group_id, + user_id: session.root_config().upload_to_user_id, + })?; + let project_name = session.root_config().name.to_string(); session.discover_configs()?; session.discover_inputs()?; match &options.target { SyncTarget::Roblox => { - let group_id = session.root_config().upload_to_group_id; sync_session( &mut session, &options, - RobloxSyncBackend::new(&mut api_client, group_id), + RobloxSyncBackend::new(&mut api_client), ); } SyncTarget::Local => { diff --git a/src/commands/upload_image.rs b/src/commands/upload_image.rs index cb9a32b..cfd8886 100644 --- a/src/commands/upload_image.rs +++ b/src/commands/upload_image.rs @@ -8,10 +8,13 @@ use crate::{ alpha_bleed::alpha_bleed, auth_cookie::get_auth_cookie, options::{GlobalOptions, UploadImageOptions}, - roblox_web_api::{ImageUploadData, RobloxApiClient}, + roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials}, }; -pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) { +pub fn upload_image( + global: GlobalOptions, + options: UploadImageOptions, +) -> Result<(), RobloxApiError> { let auth = global .auth .or_else(get_auth_cookie) @@ -30,13 +33,17 @@ pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) { .encode(&img.to_bytes(), width, height, img.color()) .unwrap(); - let mut client = RobloxApiClient::new(Some(auth)); + let mut client = RobloxApiClient::new(RobloxCredentials { + token: Some(auth), + api_key: global.api_key, + user_id: None, + group_id: None, + })?; let upload_data = ImageUploadData { image_data: Cow::Owned(encoded_image.to_vec()), name: &options.name, description: &options.description, - group_id: None, }; let response = client @@ -45,4 +52,6 @@ pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) { eprintln!("Image uploaded successfully!"); println!("{}", response.backing_asset_id); + + Ok(()) } diff --git a/src/data/config.rs b/src/data/config.rs index cdfe38e..8f5b0a1 100644 --- a/src/data/config.rs +++ b/src/data/config.rs @@ -39,6 +39,10 @@ pub struct Config { /// not have access to create assets on the group. pub upload_to_group_id: Option, + /// Upload to the given user. This field only has effect when using the Open + /// Cloud API. + pub upload_to_user_id: Option, + /// A list of paths that Tarmac should search in to find other Tarmac /// projects. /// diff --git a/src/main.rs b/src/main.rs index f84fade..ebaff87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,7 @@ use crate::options::{Options, Subcommand}; fn run(options: Options) -> Result<(), anyhow::Error> { match options.command { Subcommand::UploadImage(upload_options) => { - commands::upload_image(options.global, upload_options) + commands::upload_image(options.global, upload_options)? } Subcommand::Sync(sync_options) => commands::sync(options.global, sync_options)?, Subcommand::CreateCacheMap(sub_options) => { diff --git a/src/options.rs b/src/options.rs index ca6b5ce..4028259 100644 --- a/src/options.rs +++ b/src/options.rs @@ -21,6 +21,9 @@ pub struct GlobalOptions { #[structopt(long, global(true))] pub auth: Option, + #[structopt(long, global(true), env = "TARMAC_API_KEY")] + pub api_key: Option, + /// Sets verbosity level. Can be specified multiple times. #[structopt(long = "verbose", short, global(true), parse(from_occurrences))] pub verbosity: u8, diff --git a/src/roblox_web_api.rs b/src/roblox_web_api.rs index 55227c3..bfadaa5 100644 --- a/src/roblox_web_api.rs +++ b/src/roblox_web_api.rs @@ -3,6 +3,7 @@ use std::{ fmt::{self, Write}, }; +use rbxcloud::rbx::assets::{AssetCreator, AssetGroupCreator, AssetUserCreator}; use reqwest::{ header::{HeaderValue, COOKIE}, Client, Request, Response, StatusCode, @@ -18,7 +19,6 @@ pub struct ImageUploadData<'a> { pub image_data: Cow<'a, [u8]>, pub name: &'a str, pub description: &'a str, - pub group_id: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -40,11 +40,21 @@ struct RawUploadResponse { } pub struct RobloxApiClient { - auth_token: Option, + pub creator: Option, + csrf_token: Option, + credentials: RobloxCredentials, client: Client, } +#[derive(Debug)] +pub struct RobloxCredentials { + pub token: Option, + pub api_key: Option, + pub user_id: Option, + pub group_id: Option, +} + impl fmt::Debug for RobloxApiClient { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "RobloxApiClient") @@ -52,29 +62,60 @@ impl fmt::Debug for RobloxApiClient { } impl RobloxApiClient { - pub fn new(auth_token: Option) -> Self { - match auth_token { - Some(token) => { - let csrf_token = match get_csrf_token(&token) { - Ok(value) => Some(value), - Err(err) => { - log::error!("Was unable to fetch CSRF token: {}", err.to_string()); - None - } - }; + pub fn new(credentials: RobloxCredentials) -> Result { + if credentials.api_key.is_none() && credentials.token.is_none() { + return Err(RobloxApiError::MissingAuth); + } - Self { - auth_token: Some(token), - csrf_token, - client: Client::new(), + let csrf_token = if let Some(token) = &credentials.token { + match get_csrf_token(&token) { + Ok(value) => Some(value), + Err(err) => { + log::error!("Was unable to fetch CSRF token: {}", err.to_string()); + None } } - _ => Self { - auth_token, - csrf_token: None, - client: Client::new(), - }, - } + } else { + None + }; + + let creator = match ( + &credentials.api_key, + credentials.group_id, + credentials.user_id, + ) { + (_, Some(id), None) => Some(AssetCreator::Group(AssetGroupCreator { + group_id: id.to_string(), + })), + + (api_key, None, Some(id)) => { + if api_key.is_none() { + log::warn!("{}", "A user ID was specified, but no API key was specified. + +Tarmac will attempt to upload to the currently logged-in user or to the user associated with the token given in --auth. + +If you mean to use the Open Cloud API, make sure to provide an API key! +") + } + + Some(AssetCreator::User(AssetUserCreator { + user_id: id.to_string(), + })) + } + + (Some(_), None, None) => return Err(RobloxApiError::ApiKeyNeedsCreatorId), + + (_, Some(_), Some(_)) => return Err(RobloxApiError::AmbiguousCreatorType), + + (None, None, None) => None, + }; + + Ok(Self { + csrf_token, + creator, + credentials, + client: Client::new(), + }) } pub fn download_image(&mut self, id: u64) -> Result, RobloxApiError> { @@ -167,7 +208,7 @@ impl RobloxApiClient { ) -> Result { let mut url = "https://data.roblox.com/data/upload/json?assetTypeId=13".to_owned(); - if let Some(group_id) = data.group_id { + if let Some(AssetCreator::Group(AssetGroupCreator { group_id })) = &self.creator { write!(url, "&groupId={}", group_id).unwrap(); } @@ -232,7 +273,7 @@ impl RobloxApiClient { /// Attach required headers to a request object before sending it to a /// Roblox API, like authentication and CSRF protection. fn attach_headers(&self, request: &mut Request) { - if let Some(auth_token) = &self.auth_token { + if let Some(auth_token) = &self.credentials.token { let cookie_value = format!(".ROBLOSECURITY={}", auth_token.expose_secret()); request.headers_mut().insert( @@ -269,4 +310,16 @@ pub enum RobloxApiError { #[error("Request for CSRF token did not return an X-CSRF-Token header.")] MissingCsrfToken, + + #[error("Failed to retrieve asset ID from Roblox cloud")] + AssetGetFailed, + + #[error("Either a group or a user ID must be specified when using an API key")] + ApiKeyNeedsCreatorId, + + #[error("Tarmac is unable to locate an authentication method")] + MissingAuth, + + #[error("Group ID and user ID cannot both be specified")] + AmbiguousCreatorType, } diff --git a/src/sync_backend.rs b/src/sync_backend.rs index ec6c329..43e221a 100644 --- a/src/sync_backend.rs +++ b/src/sync_backend.rs @@ -32,15 +32,11 @@ pub struct UploadInfo { pub struct RobloxSyncBackend<'a> { api_client: &'a mut RobloxApiClient, - upload_to_group_id: Option, } impl<'a> RobloxSyncBackend<'a> { - pub fn new(api_client: &'a mut RobloxApiClient, upload_to_group_id: Option) -> Self { - Self { - api_client, - upload_to_group_id, - } + pub fn new(api_client: &'a mut RobloxApiClient) -> Self { + Self { api_client } } } @@ -54,7 +50,6 @@ impl<'a> SyncBackend for RobloxSyncBackend<'a> { image_data: Cow::Owned(data.contents), name: &data.name, description: "Uploaded by Tarmac.", - group_id: self.upload_to_group_id, }); match result { From fbf9e66595b6dd98a70e2853b8cc5e56307c447b Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Tue, 28 Nov 2023 15:09:21 -0800 Subject: [PATCH 02/16] Add open cloud implementation --- Cargo.lock | 1 + Cargo.toml | 1 + src/commands/upload_image.rs | 19 +--- src/roblox_web_api.rs | 179 ++++++++++++++++++++++++++++------- 4 files changed, 149 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11b7764..de9ee71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2576,6 +2576,7 @@ dependencies = [ "serde_json", "structopt", "thiserror", + "tokio 1.34.0", "toml", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index a8eea7c..796acec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,4 +54,5 @@ serde_json = "1.0" structopt = { version = "0.3", default-features = false } thiserror = "1.0.13" toml = "0.5.3" +tokio = "1.20.1" walkdir = "2.2.9" \ No newline at end of file diff --git a/src/commands/upload_image.rs b/src/commands/upload_image.rs index cfd8886..c20c77d 100644 --- a/src/commands/upload_image.rs +++ b/src/commands/upload_image.rs @@ -6,20 +6,11 @@ use std::borrow::Cow; use crate::{ alpha_bleed::alpha_bleed, - auth_cookie::get_auth_cookie, options::{GlobalOptions, UploadImageOptions}, - roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials}, + roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxCredentials}, }; -pub fn upload_image( - global: GlobalOptions, - options: UploadImageOptions, -) -> Result<(), RobloxApiError> { - let auth = global - .auth - .or_else(get_auth_cookie) - .expect("no auth cookie found"); - +pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyhow::Result<()> { let image_data = fs::read(options.path).expect("couldn't read input file"); let mut img = image::load_from_memory(&image_data).expect("couldn't load image"); @@ -34,7 +25,7 @@ pub fn upload_image( .unwrap(); let mut client = RobloxApiClient::new(RobloxCredentials { - token: Some(auth), + token: global.auth, api_key: global.api_key, user_id: None, group_id: None, @@ -46,9 +37,7 @@ pub fn upload_image( description: &options.description, }; - let response = client - .upload_image(upload_data) - .expect("Roblox API request failed"); + let response = client.upload_image(&upload_data)?; eprintln!("Image uploaded successfully!"); println!("{}", response.backing_asset_id); diff --git a/src/roblox_web_api.rs b/src/roblox_web_api.rs index bfadaa5..09563c1 100644 --- a/src/roblox_web_api.rs +++ b/src/roblox_web_api.rs @@ -1,9 +1,17 @@ use std::{ borrow::Cow, fmt::{self, Write}, + time::Duration, }; -use rbxcloud::rbx::assets::{AssetCreator, AssetGroupCreator, AssetUserCreator}; +use rbxcloud::rbx::{ + assets::{ + AssetCreation, AssetCreationContext, AssetCreator, AssetGroupCreator, AssetType, + AssetUserCreator, + }, + error::Error as RbxCloudError, + CreateAssetWithContents, GetAsset, RbxCloud, +}; use reqwest::{ header::{HeaderValue, COOKIE}, Client, Request, Response, StatusCode, @@ -11,6 +19,7 @@ use reqwest::{ use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::runtime::Runtime; use crate::auth_cookie::get_csrf_token; @@ -45,6 +54,7 @@ pub struct RobloxApiClient { csrf_token: Option, credentials: RobloxCredentials, client: Client, + runtime: Runtime, } #[derive(Debug)] @@ -114,6 +124,7 @@ If you mean to use the Open Cloud API, make sure to provide an API key! csrf_token, creator, credentials, + runtime: Runtime::new().unwrap(), client: Client::new(), }) } @@ -137,51 +148,39 @@ If you mean to use the Open Cloud API, make sure to provide an API key! &mut self, data: ImageUploadData, ) -> Result { - let response = self.upload_image_raw(&data)?; + let name = "image"; + let warn = || { + log::warn!( + "Image name '{}' was moderated, retrying with different name...", + data.name + ); + }; - // Some other errors will be reported inside the response, even - // though we received a successful HTTP response. - if response.success { - let asset_id = response.asset_id.unwrap(); - let backing_asset_id = response.backing_asset_id.unwrap(); + match self.upload_image(&data) { + Ok(response) => Ok(response), - Ok(UploadResponse { - asset_id, - backing_asset_id, - }) - } else { - let message = response.message.unwrap(); + Err(RobloxApiError::ApiError { message }) if message.contains("inappropriate") => { + warn(); + self.upload_image(&ImageUploadData { name, ..data }) + } - // There are no status codes for this API, so we pattern match - // on the returned error message. - // - // If the error message text mentions something being - // inappropriate, we assume the title was problematic and - // attempt to re-upload. - if message.contains("inappropriate") { - log::warn!( - "Image name '{}' was moderated, retrying with different name...", - data.name - ); - - let new_data = ImageUploadData { - name: "image", - ..data - }; - - self.upload_image(new_data) - } else { - Err(RobloxApiError::ApiError { message }) + Err(RobloxApiError::ResponseError { status, body }) + if status == 400 && body.contains("moderated") => + { + warn(); + self.upload_image(&ImageUploadData { name, ..data }) } + + Err(e) => Err(e), } } /// Upload an image, returning an error if anything goes wrong. pub fn upload_image( &mut self, - data: ImageUploadData, + data: &ImageUploadData, ) -> Result { - let response = self.upload_image_raw(&data)?; + let response = self.upload_image_with_preferred_api(&data)?; // Some other errors will be reported inside the response, even // though we received a successful HTTP response. @@ -200,9 +199,105 @@ If you mean to use the Open Cloud API, make sure to provide an API key! } } + fn upload_image_with_preferred_api( + &mut self, + data: &ImageUploadData, + ) -> Result { + match &self.credentials.api_key { + Some(_) => { + let api_key = self + .credentials + .api_key + .as_ref() + .ok_or(RobloxApiError::MissingAuth)?; + + let creator = self + .creator + .as_ref() + .ok_or(RobloxApiError::ApiKeyNeedsCreatorId)?; + + self.upload_image_open_cloud(api_key, creator, data) + } + None => self.upload_image_legacy(data), + } + } + + fn upload_image_open_cloud( + &self, + api_key: &SecretString, + creator: &AssetCreator, + data: &ImageUploadData, + ) -> Result { + let assets = RbxCloud::new(api_key.expose_secret()).assets(); + + let map_response_error = |e| match e { + RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { + status: StatusCode::from_u16(code).unwrap_or_default(), + body: msg, + }, + _ => RobloxApiError::RbxCloud(e), + }; + + let asset_info = CreateAssetWithContents { + asset: AssetCreation { + asset_type: AssetType::DecalPng, + display_name: data.name.to_string(), + description: data.description.to_string(), + creation_context: AssetCreationContext { + creator: creator.clone(), + expected_price: None, + }, + }, + contents: &data.image_data, + }; + + let operation_id = self + .runtime + .block_on(async { assets.create_with_contents(&asset_info).await }) + .map_err(map_response_error) + .map(|response| response.path)? + .ok_or(RobloxApiError::MissingOperationPath)? + .strip_prefix("operations/") + .ok_or(RobloxApiError::MalformedOperationPath)? + .to_string(); + + const MAX_RETRIES: u32 = 5; + const INITIAL_SLEEP_DURATION: Duration = Duration::from_millis(50); + const BACKOFF: u32 = 2; + + let mut retry_count = 0; + let asset_id = loop { + let operation_id = operation_id.clone(); + let maybe_asset_id = self + .runtime + .block_on(async { assets.get(&GetAsset { operation_id }).await }) + .map_err(map_response_error)? + .response + .map(|response| response.asset_id) + .map(|id| id.parse::().map_err(RobloxApiError::MalformedAssetId)); + + match maybe_asset_id { + Some(id) => break id, + None if retry_count > MAX_RETRIES => break Err(RobloxApiError::AssetGetFailed), + + _ => { + retry_count += 1; + std::thread::sleep(INITIAL_SLEEP_DURATION * retry_count.pow(BACKOFF)); + } + } + }?; + + Ok(RawUploadResponse { + success: true, + message: None, + asset_id: Some(asset_id), + backing_asset_id: Some(asset_id), + }) + } + /// Upload an image, returning the raw response returned by the endpoint, /// which may have further failures to handle. - fn upload_image_raw( + fn upload_image_legacy( &mut self, data: &ImageUploadData, ) -> Result { @@ -322,4 +417,16 @@ pub enum RobloxApiError { #[error("Group ID and user ID cannot both be specified")] AmbiguousCreatorType, + + #[error("Operation path is missing")] + MissingOperationPath, + + #[error("Operation path is malformed")] + MalformedOperationPath, + + #[error("Open Cloud API error")] + RbxCloud(#[from] RbxCloudError), + + #[error("Failed to parse asset ID from asset get response")] + MalformedAssetId(#[from] std::num::ParseIntError), } From eb679ddab3d6133a8828c9264e66f297e1025d79 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 14:13:11 -0800 Subject: [PATCH 03/16] Add user ID and group ID options to upload-image command --- src/commands/upload_image.rs | 4 ++-- src/options.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/upload_image.rs b/src/commands/upload_image.rs index c20c77d..654f92a 100644 --- a/src/commands/upload_image.rs +++ b/src/commands/upload_image.rs @@ -27,8 +27,8 @@ pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyho let mut client = RobloxApiClient::new(RobloxCredentials { token: global.auth, api_key: global.api_key, - user_id: None, - group_id: None, + user_id: options.user_id, + group_id: options.group_id, })?; let upload_data = ImageUploadData { diff --git a/src/options.rs b/src/options.rs index 4028259..22e1bc7 100644 --- a/src/options.rs +++ b/src/options.rs @@ -58,6 +58,15 @@ pub struct UploadImageOptions { /// The description to give to the resulting Decal asset. #[structopt(long, default_value = "Uploaded by Tarmac.")] pub description: String, + + /// The ID of the user to upload to. This option only has effect when using + /// an API key. + #[structopt(long)] + pub user_id: Option, + + /// The ID of the group to upload to. + #[structopt(long)] + pub group_id: Option, } #[derive(Debug, StructOpt)] From 55c5d6bf59eb5a2c55abf5c4f159c31cfbb977df Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 14:34:45 -0800 Subject: [PATCH 04/16] Mention that create-cache-map only works with --auth --- src/options.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/options.rs b/src/options.rs index 22e1bc7..f642d65 100644 --- a/src/options.rs +++ b/src/options.rs @@ -39,7 +39,8 @@ pub enum Subcommand { Sync(SyncOptions), /// Downloads any packed spritesheets, then generates a file mapping asset - /// IDs to file paths. + /// IDs to file paths. This command only works when logged into Roblox + /// Studio or when a .ROBLOSECURITY token is passed via --auth. CreateCacheMap(CreateCacheMapOptions), /// Creates a file that lists all assets required by the project. From be0042777d7e20cedde2d70bdb00e3facdea6156 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 17:23:49 -0800 Subject: [PATCH 05/16] Pull out API implementations into their own modules + trait --- src/auth_cookie.rs | 2 +- src/commands/create_cache_map.rs | 4 +- src/commands/sync.rs | 10 +- src/commands/upload_image.rs | 4 +- src/main.rs | 2 +- src/roblox_api/legacy.rs | 230 ++++++++++++++++ src/roblox_api/mod.rs | 135 ++++++++++ src/roblox_api/open_cloud.rs | 149 +++++++++++ src/roblox_web_api.rs | 432 ------------------------------- src/sync_backend.rs | 12 +- 10 files changed, 532 insertions(+), 448 deletions(-) create mode 100644 src/roblox_api/legacy.rs create mode 100644 src/roblox_api/mod.rs create mode 100644 src/roblox_api/open_cloud.rs delete mode 100644 src/roblox_web_api.rs diff --git a/src/auth_cookie.rs b/src/auth_cookie.rs index 316caf8..81fbaad 100644 --- a/src/auth_cookie.rs +++ b/src/auth_cookie.rs @@ -7,7 +7,7 @@ use reqwest::{ }; use secrecy::{ExposeSecret, SecretString}; -use crate::roblox_web_api::RobloxApiError; +use crate::roblox_api::RobloxApiError; pub fn get_auth_cookie() -> Option { rbx_cookie::get_value().map(SecretString::new) diff --git a/src/commands/create_cache_map.rs b/src/commands/create_cache_map.rs index 67f0c86..bd784bc 100644 --- a/src/commands/create_cache_map.rs +++ b/src/commands/create_cache_map.rs @@ -7,13 +7,13 @@ use fs_err as fs; use crate::asset_name::AssetName; use crate::data::Manifest; use crate::options::{CreateCacheMapOptions, GlobalOptions}; -use crate::roblox_web_api::{RobloxApiClient, RobloxCredentials}; +use crate::roblox_api::{get_preferred_client, RobloxCredentials}; pub fn create_cache_map( global: GlobalOptions, options: CreateCacheMapOptions, ) -> anyhow::Result<()> { - let mut api_client = RobloxApiClient::new(RobloxCredentials { + let mut api_client = get_preferred_client(RobloxCredentials { token: global.auth, api_key: None, user_id: None, diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 220db4e..dedb603 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -22,7 +22,7 @@ use crate::{ }, dpi_scale, options::{GlobalOptions, SyncOptions, SyncTarget}, - roblox_web_api::{RobloxApiClient, RobloxApiError, RobloxCredentials}, + roblox_api::{get_preferred_client, RobloxApiClient, RobloxApiError, RobloxCredentials}, sync_backend::{ DebugSyncBackend, Error as SyncBackendError, LocalSyncBackend, NoneSyncBackend, RetryBackend, RobloxSyncBackend, SyncBackend, UploadInfo, @@ -47,7 +47,7 @@ pub fn sync(global: GlobalOptions, options: SyncOptions) -> Result<(), SyncError let mut session = SyncSession::new(&fuzzy_config_path)?; - let mut api_client = RobloxApiClient::new(RobloxCredentials { + let mut api_client = get_preferred_client(RobloxCredentials { token: global.auth.or_else(get_auth_cookie), api_key: global.api_key, group_id: session.root_config().upload_to_group_id, @@ -63,7 +63,7 @@ pub fn sync(global: GlobalOptions, options: SyncOptions) -> Result<(), SyncError sync_session( &mut session, &options, - RobloxSyncBackend::new(&mut api_client), + RobloxSyncBackend::new(api_client.as_mut()), ); } SyncTarget::Local => { @@ -84,7 +84,7 @@ pub fn sync(global: GlobalOptions, options: SyncOptions) -> Result<(), SyncError session.write_manifest()?; session.codegen()?; session.write_asset_list()?; - session.populate_asset_cache(&mut api_client)?; + session.populate_asset_cache(api_client.as_mut())?; if session.sync_errors.is_empty() { Ok(()) @@ -664,7 +664,7 @@ impl SyncSession { Ok(()) } - fn populate_asset_cache(&self, api_client: &mut RobloxApiClient) -> Result<(), SyncError> { + fn populate_asset_cache(&self, api_client: &mut dyn RobloxApiClient) -> Result<(), SyncError> { let cache_path = match &self.root_config().asset_cache_path { Some(path) => path, None => return Ok(()), diff --git a/src/commands/upload_image.rs b/src/commands/upload_image.rs index 654f92a..88db85b 100644 --- a/src/commands/upload_image.rs +++ b/src/commands/upload_image.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; use crate::{ alpha_bleed::alpha_bleed, options::{GlobalOptions, UploadImageOptions}, - roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxCredentials}, + roblox_api::{get_preferred_client, ImageUploadData, RobloxCredentials}, }; pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyhow::Result<()> { @@ -24,7 +24,7 @@ pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyho .encode(&img.to_bytes(), width, height, img.color()) .unwrap(); - let mut client = RobloxApiClient::new(RobloxCredentials { + let mut client = get_preferred_client(RobloxCredentials { token: global.auth, api_key: global.api_key, user_id: options.user_id, diff --git a/src/main.rs b/src/main.rs index ebaff87..e768775 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod dpi_scale; mod glob; mod lua_ast; mod options; -mod roblox_web_api; +mod roblox_api; mod sync_backend; use std::{env, panic, process}; diff --git a/src/roblox_api/legacy.rs b/src/roblox_api/legacy.rs new file mode 100644 index 0000000..f8c0480 --- /dev/null +++ b/src/roblox_api/legacy.rs @@ -0,0 +1,230 @@ +use std::fmt::{self, Write}; + +use reqwest::{ + header::{HeaderValue, COOKIE}, + Client, Request, Response, StatusCode, +}; +use secrecy::ExposeSecret; +use serde::Deserialize; + +use crate::auth_cookie::get_csrf_token; + +use super::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials, UploadResponse}; + +/// Internal representation of what the asset upload endpoint returns, before +/// we've handled any errors. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RawUploadResponse { + success: bool, + message: Option, + asset_id: Option, + backing_asset_id: Option, +} + +pub struct LegacyClient { + credentials: RobloxCredentials, + csrf_token: Option, + client: Client, +} + +impl fmt::Debug for LegacyClient { + fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "RobloxApiClient") + } +} + +impl RobloxApiClient for LegacyClient { + fn new(credentials: RobloxCredentials) -> Result { + match &credentials.token { + Some(token) => { + let csrf_token = match get_csrf_token(token) { + Ok(value) => Some(value), + Err(err) => { + log::error!("Was unable to fetch CSRF token: {}", err.to_string()); + None + } + }; + + Ok(Self { + credentials, + csrf_token, + client: Client::new(), + }) + } + _ => Ok(Self { + credentials, + csrf_token: None, + client: Client::new(), + }), + } + } + + fn download_image(&mut self, id: u64) -> Result, RobloxApiError> { + let url = format!("https://roblox.com/asset?id={}", id); + + let mut response = + self.execute_with_csrf_retry(|client| Ok(client.get(&url).build()?))?; + + let mut buffer = Vec::new(); + response.copy_to(&mut buffer)?; + + Ok(buffer) + } + + /// Upload an image, retrying if the asset endpoint determines that the + /// asset's name is inappropriate. The asset's name will be replaced with a + /// generic known-good string. + fn upload_image_with_moderation_retry( + &mut self, + data: &ImageUploadData, + ) -> Result { + let response = self.upload_image_raw(data)?; + + // Some other errors will be reported inside the response, even + // though we received a successful HTTP response. + if response.success { + let asset_id = response.asset_id.unwrap(); + let backing_asset_id = response.backing_asset_id.unwrap(); + + Ok(UploadResponse { + asset_id, + backing_asset_id, + }) + } else { + let message = response.message.unwrap(); + + // There are no status codes for this API, so we pattern match + // on the returned error message. + // + // If the error message text mentions something being + // inappropriate, we assume the title was problematic and + // attempt to re-upload. + if message.contains("inappropriate") { + log::warn!( + "Image name '{}' was moderated, retrying with different name...", + data.name + ); + + let new_data = ImageUploadData { + name: "image", + ..data.to_owned() + }; + + self.upload_image(&new_data) + } else { + Err(RobloxApiError::ApiError { message }) + } + } + } + + /// Upload an image, returning an error if anything goes wrong. + fn upload_image(&mut self, data: &ImageUploadData) -> Result { + let response = self.upload_image_raw(data)?; + + // Some other errors will be reported inside the response, even + // though we received a successful HTTP response. + if response.success { + let asset_id = response.asset_id.unwrap(); + let backing_asset_id = response.backing_asset_id.unwrap(); + + Ok(UploadResponse { + asset_id, + backing_asset_id, + }) + } else { + let message = response.message.unwrap(); + + Err(RobloxApiError::ApiError { message }) + } + } +} + +impl LegacyClient { + /// Upload an image, returning the raw response returned by the endpoint, + /// which may have further failures to handle. + fn upload_image_raw( + &mut self, + data: &ImageUploadData, + ) -> Result { + let mut url = "https://data.roblox.com/data/upload/json?assetTypeId=13".to_owned(); + + if let Some(id) = &self.credentials.group_id { + write!(url, "&groupId={}", id).unwrap(); + } + + let mut response = self.execute_with_csrf_retry(|client| { + Ok(client + .post(&url) + .query(&[("name", data.name), ("description", data.description)]) + .body(data.image_data.clone().into_owned()) + .build()?) + })?; + + let body = response.text()?; + + // Some errors will be reported through HTTP status codes, handled here. + if response.status().is_success() { + match serde_json::from_str(&body) { + Ok(response) => Ok(response), + Err(source) => Err(RobloxApiError::BadResponseJson { body, source }), + } + } else { + Err(RobloxApiError::ResponseError { + status: response.status(), + body, + }) + } + } + + /// Execute a request generated by the given function, retrying if the + /// endpoint requests that the user refreshes their CSRF token. + fn execute_with_csrf_retry(&mut self, make_request: F) -> Result + where + F: Fn(&Client) -> Result, + { + let mut request = make_request(&self.client)?; + self.attach_headers(&mut request); + + let response = self.client.execute(request)?; + + match response.status() { + StatusCode::FORBIDDEN => { + if let Some(csrf) = response.headers().get("X-CSRF-Token") { + log::debug!("Retrying request with X-CSRF-Token..."); + + self.csrf_token = Some(csrf.clone()); + + let mut new_request = make_request(&self.client)?; + self.attach_headers(&mut new_request); + + Ok(self.client.execute(new_request)?) + } else { + // If the response did not return a CSRF token for us to + // retry with, this request was likely forbidden for other + // reasons. + + Ok(response) + } + } + _ => Ok(response), + } + } + + /// Attach required headers to a request object before sending it to a + /// Roblox API, like authentication and CSRF protection. + fn attach_headers(&self, request: &mut Request) { + if let Some(auth_token) = &self.credentials.token { + let cookie_value = format!(".ROBLOSECURITY={}", auth_token.expose_secret()); + + request.headers_mut().insert( + COOKIE, + HeaderValue::from_bytes(cookie_value.as_bytes()).unwrap(), + ); + } + + if let Some(csrf) = &self.csrf_token { + request.headers_mut().insert("X-CSRF-Token", csrf.clone()); + } + } +} diff --git a/src/roblox_api/mod.rs b/src/roblox_api/mod.rs new file mode 100644 index 0000000..42343b6 --- /dev/null +++ b/src/roblox_api/mod.rs @@ -0,0 +1,135 @@ +mod legacy; +mod open_cloud; + +use std::borrow::Cow; + +use rbxcloud::rbx::error::Error as RbxCloudError; +use reqwest::StatusCode; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use self::{legacy::LegacyClient, open_cloud::OpenCloudClient}; + +#[derive(Debug, Clone)] +pub struct ImageUploadData<'a> { + pub image_data: Cow<'a, [u8]>, + pub name: &'a str, + pub description: &'a str, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct UploadResponse { + pub asset_id: u64, + pub backing_asset_id: u64, +} + +#[derive(Debug)] +pub struct RobloxCredentials { + pub token: Option, + pub api_key: Option, + pub user_id: Option, + pub group_id: Option, +} + +pub trait RobloxApiClient { + fn new(credentials: RobloxCredentials) -> Result + where + Self: Sized; + + fn upload_image_with_moderation_retry( + &mut self, + data: &ImageUploadData, + ) -> Result; + + fn upload_image(&mut self, data: &ImageUploadData) -> Result; + + fn download_image(&mut self, id: u64) -> Result, RobloxApiError>; +} + +#[derive(Debug, Error)] +pub enum RobloxApiError { + #[error("Roblox API HTTP error")] + Http { + #[from] + source: reqwest::Error, + }, + + #[error("Roblox API error: {message}")] + ApiError { message: String }, + + #[error("Roblox API returned success, but had malformed JSON response: {body}")] + BadResponseJson { + body: String, + source: serde_json::Error, + }, + + #[error("Roblox API returned HTTP {status} with body: {body}")] + ResponseError { status: StatusCode, body: String }, + + #[error("Request for CSRF token did not return an X-CSRF-Token header.")] + MissingCsrfToken, + + #[error("Failed to retrieve asset ID from Roblox cloud")] + AssetGetFailed, + + #[error("Either a group or a user ID must be specified when using an API key")] + ApiKeyNeedsCreatorId, + + #[error("Tarmac is unable to locate an authentication method")] + MissingAuth, + + #[error("Group ID and user ID cannot both be specified")] + AmbiguousCreatorType, + + #[error("Operation path is missing")] + MissingOperationPath, + + #[error("Operation path is malformed")] + MalformedOperationPath, + + #[error("Open Cloud API error")] + RbxCloud(#[from] RbxCloudError), + + #[error("Failed to parse asset ID from asset get response")] + MalformedAssetId(#[from] std::num::ParseIntError), +} + +pub fn get_preferred_client( + credentials: RobloxCredentials, +) -> Result, RobloxApiError> { + match &credentials { + RobloxCredentials { + token: None, + api_key: None, + .. + } => Err(RobloxApiError::MissingAuth), + + RobloxCredentials { + group_id: Some(_), + user_id: Some(_), + .. + } => Err(RobloxApiError::AmbiguousCreatorType), + + RobloxCredentials { + api_key: Some(_), .. + } => Ok(Box::new(OpenCloudClient::new(credentials)?)), + + RobloxCredentials { + token: Some(_), + user_id, + .. + } => { + if user_id.is_some() { + log::warn!("A user ID was specified, but no API key was specified. + +Tarmac will attempt to upload to the user currently logged into Roblox Studio, or to the user associated with the token given in --auth. + +If you mean to use the Open Cloud API, make sure to provide an API key!") + }; + + Ok(Box::new(LegacyClient::new(credentials)?)) + } + } +} diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs new file mode 100644 index 0000000..08d4ec8 --- /dev/null +++ b/src/roblox_api/open_cloud.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +use rbxcloud::rbx::{ + assets::{ + AssetCreation, AssetCreationContext, AssetCreator, AssetGroupCreator, AssetType, + AssetUserCreator, + }, + error::Error as RbxCloudError, + CreateAssetWithContents, GetAsset, RbxCloud, +}; +use reqwest::StatusCode; +use secrecy::ExposeSecret; +use tokio::runtime::Runtime; + +use super::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials, UploadResponse}; + +pub struct OpenCloudClient { + credentials: RobloxCredentials, + creator: AssetCreator, + runtime: Runtime, +} + +impl RobloxApiClient for OpenCloudClient { + fn new(credentials: RobloxCredentials) -> Result { + let creator = match (credentials.group_id, credentials.user_id) { + (Some(id), None) => Ok(AssetCreator::Group(AssetGroupCreator { + group_id: id.to_string(), + })), + (None, Some(id)) => Ok(AssetCreator::User(AssetUserCreator { + user_id: id.to_string(), + })), + (None, None) => Err(RobloxApiError::ApiKeyNeedsCreatorId), + (Some(_), Some(_)) => Err(RobloxApiError::AmbiguousCreatorType), + }?; + + Ok(Self { + creator, + credentials, + runtime: Runtime::new().unwrap(), + }) + } + + fn upload_image_with_moderation_retry( + &mut self, + data: &ImageUploadData, + ) -> Result { + match self.upload_image(data) { + Ok(response) => Ok(response), + + Err(RobloxApiError::ResponseError { status, body }) + if status == 400 && body.contains("moderated") => + { + log::warn!( + "Image name '{}' was moderated, retrying with different name...", + data.name + ); + self.upload_image(&ImageUploadData { + name: "image", + ..data.to_owned() + }) + } + + Err(e) => Err(e), + } + } + + fn upload_image(&mut self, data: &ImageUploadData) -> Result { + self.upload_image_inner(data) + } + + fn download_image(&mut self, _id: u64) -> Result, RobloxApiError> { + unimplemented!() + } +} + +impl OpenCloudClient { + fn upload_image_inner(&self, data: &ImageUploadData) -> Result { + let assets = RbxCloud::new( + self.credentials + .api_key + .as_ref() + .ok_or(RobloxApiError::MissingAuth)? + .expose_secret(), + ) + .assets(); + + let map_response_error = |e| match e { + RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { + status: StatusCode::from_u16(code).unwrap_or_default(), + body: msg, + }, + _ => RobloxApiError::RbxCloud(e), + }; + + let asset_info = CreateAssetWithContents { + asset: AssetCreation { + asset_type: AssetType::DecalPng, + display_name: data.name.to_string(), + description: data.description.to_string(), + creation_context: AssetCreationContext { + creator: self.creator.clone(), + expected_price: None, + }, + }, + contents: &data.image_data, + }; + + let operation_id = self + .runtime + .block_on(async { assets.create_with_contents(&asset_info).await }) + .map_err(map_response_error) + .map(|response| response.path)? + .ok_or(RobloxApiError::MissingOperationPath)? + .strip_prefix("operations/") + .ok_or(RobloxApiError::MalformedOperationPath)? + .to_string(); + + const MAX_RETRIES: u32 = 5; + const INITIAL_SLEEP_DURATION: Duration = Duration::from_millis(50); + const BACKOFF: u32 = 2; + + let mut retry_count = 0; + let asset_id = loop { + let operation_id = operation_id.clone(); + let maybe_asset_id = self + .runtime + .block_on(async { assets.get(&GetAsset { operation_id }).await }) + .map_err(map_response_error)? + .response + .map(|response| response.asset_id) + .map(|id| id.parse::().map_err(RobloxApiError::MalformedAssetId)); + + match maybe_asset_id { + Some(id) => break id, + None if retry_count > MAX_RETRIES => break Err(RobloxApiError::AssetGetFailed), + + _ => { + retry_count += 1; + std::thread::sleep(INITIAL_SLEEP_DURATION * retry_count.pow(BACKOFF)); + } + } + }?; + + Ok(UploadResponse { + asset_id, + backing_asset_id: asset_id, + }) + } +} diff --git a/src/roblox_web_api.rs b/src/roblox_web_api.rs deleted file mode 100644 index 09563c1..0000000 --- a/src/roblox_web_api.rs +++ /dev/null @@ -1,432 +0,0 @@ -use std::{ - borrow::Cow, - fmt::{self, Write}, - time::Duration, -}; - -use rbxcloud::rbx::{ - assets::{ - AssetCreation, AssetCreationContext, AssetCreator, AssetGroupCreator, AssetType, - AssetUserCreator, - }, - error::Error as RbxCloudError, - CreateAssetWithContents, GetAsset, RbxCloud, -}; -use reqwest::{ - header::{HeaderValue, COOKIE}, - Client, Request, Response, StatusCode, -}; -use secrecy::{ExposeSecret, SecretString}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::runtime::Runtime; - -use crate::auth_cookie::get_csrf_token; - -#[derive(Debug, Clone)] -pub struct ImageUploadData<'a> { - pub image_data: Cow<'a, [u8]>, - pub name: &'a str, - pub description: &'a str, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub struct UploadResponse { - pub asset_id: u64, - pub backing_asset_id: u64, -} - -/// Internal representation of what the asset upload endpoint returns, before -/// we've handled any errors. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct RawUploadResponse { - success: bool, - message: Option, - asset_id: Option, - backing_asset_id: Option, -} - -pub struct RobloxApiClient { - pub creator: Option, - - csrf_token: Option, - credentials: RobloxCredentials, - client: Client, - runtime: Runtime, -} - -#[derive(Debug)] -pub struct RobloxCredentials { - pub token: Option, - pub api_key: Option, - pub user_id: Option, - pub group_id: Option, -} - -impl fmt::Debug for RobloxApiClient { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "RobloxApiClient") - } -} - -impl RobloxApiClient { - pub fn new(credentials: RobloxCredentials) -> Result { - if credentials.api_key.is_none() && credentials.token.is_none() { - return Err(RobloxApiError::MissingAuth); - } - - let csrf_token = if let Some(token) = &credentials.token { - match get_csrf_token(&token) { - Ok(value) => Some(value), - Err(err) => { - log::error!("Was unable to fetch CSRF token: {}", err.to_string()); - None - } - } - } else { - None - }; - - let creator = match ( - &credentials.api_key, - credentials.group_id, - credentials.user_id, - ) { - (_, Some(id), None) => Some(AssetCreator::Group(AssetGroupCreator { - group_id: id.to_string(), - })), - - (api_key, None, Some(id)) => { - if api_key.is_none() { - log::warn!("{}", "A user ID was specified, but no API key was specified. - -Tarmac will attempt to upload to the currently logged-in user or to the user associated with the token given in --auth. - -If you mean to use the Open Cloud API, make sure to provide an API key! -") - } - - Some(AssetCreator::User(AssetUserCreator { - user_id: id.to_string(), - })) - } - - (Some(_), None, None) => return Err(RobloxApiError::ApiKeyNeedsCreatorId), - - (_, Some(_), Some(_)) => return Err(RobloxApiError::AmbiguousCreatorType), - - (None, None, None) => None, - }; - - Ok(Self { - csrf_token, - creator, - credentials, - runtime: Runtime::new().unwrap(), - client: Client::new(), - }) - } - - pub fn download_image(&mut self, id: u64) -> Result, RobloxApiError> { - let url = format!("https://roblox.com/asset?id={}", id); - - let mut response = - self.execute_with_csrf_retry(|client| Ok(client.get(&url).build()?))?; - - let mut buffer = Vec::new(); - response.copy_to(&mut buffer)?; - - Ok(buffer) - } - - /// Upload an image, retrying if the asset endpoint determines that the - /// asset's name is inappropriate. The asset's name will be replaced with a - /// generic known-good string. - pub fn upload_image_with_moderation_retry( - &mut self, - data: ImageUploadData, - ) -> Result { - let name = "image"; - let warn = || { - log::warn!( - "Image name '{}' was moderated, retrying with different name...", - data.name - ); - }; - - match self.upload_image(&data) { - Ok(response) => Ok(response), - - Err(RobloxApiError::ApiError { message }) if message.contains("inappropriate") => { - warn(); - self.upload_image(&ImageUploadData { name, ..data }) - } - - Err(RobloxApiError::ResponseError { status, body }) - if status == 400 && body.contains("moderated") => - { - warn(); - self.upload_image(&ImageUploadData { name, ..data }) - } - - Err(e) => Err(e), - } - } - - /// Upload an image, returning an error if anything goes wrong. - pub fn upload_image( - &mut self, - data: &ImageUploadData, - ) -> Result { - let response = self.upload_image_with_preferred_api(&data)?; - - // Some other errors will be reported inside the response, even - // though we received a successful HTTP response. - if response.success { - let asset_id = response.asset_id.unwrap(); - let backing_asset_id = response.backing_asset_id.unwrap(); - - Ok(UploadResponse { - asset_id, - backing_asset_id, - }) - } else { - let message = response.message.unwrap(); - - Err(RobloxApiError::ApiError { message }) - } - } - - fn upload_image_with_preferred_api( - &mut self, - data: &ImageUploadData, - ) -> Result { - match &self.credentials.api_key { - Some(_) => { - let api_key = self - .credentials - .api_key - .as_ref() - .ok_or(RobloxApiError::MissingAuth)?; - - let creator = self - .creator - .as_ref() - .ok_or(RobloxApiError::ApiKeyNeedsCreatorId)?; - - self.upload_image_open_cloud(api_key, creator, data) - } - None => self.upload_image_legacy(data), - } - } - - fn upload_image_open_cloud( - &self, - api_key: &SecretString, - creator: &AssetCreator, - data: &ImageUploadData, - ) -> Result { - let assets = RbxCloud::new(api_key.expose_secret()).assets(); - - let map_response_error = |e| match e { - RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { - status: StatusCode::from_u16(code).unwrap_or_default(), - body: msg, - }, - _ => RobloxApiError::RbxCloud(e), - }; - - let asset_info = CreateAssetWithContents { - asset: AssetCreation { - asset_type: AssetType::DecalPng, - display_name: data.name.to_string(), - description: data.description.to_string(), - creation_context: AssetCreationContext { - creator: creator.clone(), - expected_price: None, - }, - }, - contents: &data.image_data, - }; - - let operation_id = self - .runtime - .block_on(async { assets.create_with_contents(&asset_info).await }) - .map_err(map_response_error) - .map(|response| response.path)? - .ok_or(RobloxApiError::MissingOperationPath)? - .strip_prefix("operations/") - .ok_or(RobloxApiError::MalformedOperationPath)? - .to_string(); - - const MAX_RETRIES: u32 = 5; - const INITIAL_SLEEP_DURATION: Duration = Duration::from_millis(50); - const BACKOFF: u32 = 2; - - let mut retry_count = 0; - let asset_id = loop { - let operation_id = operation_id.clone(); - let maybe_asset_id = self - .runtime - .block_on(async { assets.get(&GetAsset { operation_id }).await }) - .map_err(map_response_error)? - .response - .map(|response| response.asset_id) - .map(|id| id.parse::().map_err(RobloxApiError::MalformedAssetId)); - - match maybe_asset_id { - Some(id) => break id, - None if retry_count > MAX_RETRIES => break Err(RobloxApiError::AssetGetFailed), - - _ => { - retry_count += 1; - std::thread::sleep(INITIAL_SLEEP_DURATION * retry_count.pow(BACKOFF)); - } - } - }?; - - Ok(RawUploadResponse { - success: true, - message: None, - asset_id: Some(asset_id), - backing_asset_id: Some(asset_id), - }) - } - - /// Upload an image, returning the raw response returned by the endpoint, - /// which may have further failures to handle. - fn upload_image_legacy( - &mut self, - data: &ImageUploadData, - ) -> Result { - let mut url = "https://data.roblox.com/data/upload/json?assetTypeId=13".to_owned(); - - if let Some(AssetCreator::Group(AssetGroupCreator { group_id })) = &self.creator { - write!(url, "&groupId={}", group_id).unwrap(); - } - - let mut response = self.execute_with_csrf_retry(|client| { - Ok(client - .post(&url) - .query(&[("name", data.name), ("description", data.description)]) - .body(data.image_data.clone().into_owned()) - .build()?) - })?; - - let body = response.text()?; - - // Some errors will be reported through HTTP status codes, handled here. - if response.status().is_success() { - match serde_json::from_str(&body) { - Ok(response) => Ok(response), - Err(source) => Err(RobloxApiError::BadResponseJson { body, source }), - } - } else { - Err(RobloxApiError::ResponseError { - status: response.status(), - body, - }) - } - } - - /// Execute a request generated by the given function, retrying if the - /// endpoint requests that the user refreshes their CSRF token. - fn execute_with_csrf_retry(&mut self, make_request: F) -> Result - where - F: Fn(&Client) -> Result, - { - let mut request = make_request(&self.client)?; - self.attach_headers(&mut request); - - let response = self.client.execute(request)?; - - match response.status() { - StatusCode::FORBIDDEN => { - if let Some(csrf) = response.headers().get("X-CSRF-Token") { - log::debug!("Retrying request with X-CSRF-Token..."); - - self.csrf_token = Some(csrf.clone()); - - let mut new_request = make_request(&self.client)?; - self.attach_headers(&mut new_request); - - Ok(self.client.execute(new_request)?) - } else { - // If the response did not return a CSRF token for us to - // retry with, this request was likely forbidden for other - // reasons. - - Ok(response) - } - } - _ => Ok(response), - } - } - - /// Attach required headers to a request object before sending it to a - /// Roblox API, like authentication and CSRF protection. - fn attach_headers(&self, request: &mut Request) { - if let Some(auth_token) = &self.credentials.token { - let cookie_value = format!(".ROBLOSECURITY={}", auth_token.expose_secret()); - - request.headers_mut().insert( - COOKIE, - HeaderValue::from_bytes(cookie_value.as_bytes()).unwrap(), - ); - } - - if let Some(csrf) = &self.csrf_token { - request.headers_mut().insert("X-CSRF-Token", csrf.clone()); - } - } -} - -#[derive(Debug, Error)] -pub enum RobloxApiError { - #[error("Roblox API HTTP error")] - Http { - #[from] - source: reqwest::Error, - }, - - #[error("Roblox API error: {message}")] - ApiError { message: String }, - - #[error("Roblox API returned success, but had malformed JSON response: {body}")] - BadResponseJson { - body: String, - source: serde_json::Error, - }, - - #[error("Roblox API returned HTTP {status} with body: {body}")] - ResponseError { status: StatusCode, body: String }, - - #[error("Request for CSRF token did not return an X-CSRF-Token header.")] - MissingCsrfToken, - - #[error("Failed to retrieve asset ID from Roblox cloud")] - AssetGetFailed, - - #[error("Either a group or a user ID must be specified when using an API key")] - ApiKeyNeedsCreatorId, - - #[error("Tarmac is unable to locate an authentication method")] - MissingAuth, - - #[error("Group ID and user ID cannot both be specified")] - AmbiguousCreatorType, - - #[error("Operation path is missing")] - MissingOperationPath, - - #[error("Operation path is malformed")] - MalformedOperationPath, - - #[error("Open Cloud API error")] - RbxCloud(#[from] RbxCloudError), - - #[error("Failed to parse asset ID from asset get response")] - MalformedAssetId(#[from] std::num::ParseIntError), -} diff --git a/src/sync_backend.rs b/src/sync_backend.rs index 43e221a..f2a621c 100644 --- a/src/sync_backend.rs +++ b/src/sync_backend.rs @@ -11,8 +11,10 @@ use reqwest::StatusCode; use roblox_install::RobloxStudio; use thiserror::Error; -use crate::data::AssetId; -use crate::roblox_web_api::{ImageUploadData, RobloxApiClient, RobloxApiError}; +use crate::{ + data::AssetId, + roblox_api::{ImageUploadData, RobloxApiClient, RobloxApiError}, +}; pub trait SyncBackend { fn upload(&mut self, data: UploadInfo) -> Result; @@ -31,11 +33,11 @@ pub struct UploadInfo { } pub struct RobloxSyncBackend<'a> { - api_client: &'a mut RobloxApiClient, + api_client: &'a mut dyn RobloxApiClient, } impl<'a> RobloxSyncBackend<'a> { - pub fn new(api_client: &'a mut RobloxApiClient) -> Self { + pub fn new(api_client: &'a mut dyn RobloxApiClient) -> Self { Self { api_client } } } @@ -46,7 +48,7 @@ impl<'a> SyncBackend for RobloxSyncBackend<'a> { let result = self .api_client - .upload_image_with_moderation_retry(ImageUploadData { + .upload_image_with_moderation_retry(&ImageUploadData { image_data: Cow::Owned(data.contents), name: &data.name, description: "Uploaded by Tarmac.", From 07bc9b6f12198aea1a06306f9b023c1aca6e20ac Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 17:35:52 -0800 Subject: [PATCH 06/16] Add fallback to legacy when attempting to download with Open Cloud --- src/roblox_api/mod.rs | 2 +- src/roblox_api/open_cloud.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/roblox_api/mod.rs b/src/roblox_api/mod.rs index 42343b6..d31819a 100644 --- a/src/roblox_api/mod.rs +++ b/src/roblox_api/mod.rs @@ -25,7 +25,7 @@ pub struct UploadResponse { pub backing_asset_id: u64, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct RobloxCredentials { pub token: Option, pub api_key: Option, diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs index 08d4ec8..c4dbf50 100644 --- a/src/roblox_api/open_cloud.rs +++ b/src/roblox_api/open_cloud.rs @@ -12,7 +12,10 @@ use reqwest::StatusCode; use secrecy::ExposeSecret; use tokio::runtime::Runtime; -use super::{ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials, UploadResponse}; +use super::{ + legacy::LegacyClient, ImageUploadData, RobloxApiClient, RobloxApiError, RobloxCredentials, + UploadResponse, +}; pub struct OpenCloudClient { credentials: RobloxCredentials, @@ -68,8 +71,8 @@ impl RobloxApiClient for OpenCloudClient { self.upload_image_inner(data) } - fn download_image(&mut self, _id: u64) -> Result, RobloxApiError> { - unimplemented!() + fn download_image(&mut self, id: u64) -> Result, RobloxApiError> { + LegacyClient::new(self.credentials.clone())?.download_image(id) } } From cbd9be06ee1476c4d921acd21f5bc6a11513ac15 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 19:34:50 -0800 Subject: [PATCH 07/16] Add upload-to-user-id to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0a01dbc..68a254e 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ tarmac help [] * If defined, Tarmac will write a list of asset URLs used by the project to the given file. One URL is printed per line. * `upload-to-group-id`, int, **optional** * If defined, Tarmac will attempt to upload all assets to the given Roblox Group. If unable, syncing will fail. +* `upload-to-user-id`, int, **optional** + * If defined, Tarmac will attempt to upload assets to the given Roblox user. This option is required when using the Open Cloud API via `--api-key`, but has no effect when using cookie authentication. * `inputs`, list\, **optional** * A list of inputs that Tarmac will process. * `includes`, list\, **optional** From fce30149e39c496076f162bcae6630a89d10dc13 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 19:48:08 -0800 Subject: [PATCH 08/16] Add api-key to readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 68a254e..4cdf7f0 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ These options can be specified alongside any subcommands and are all optional. * Prints help information about Tarmac and exits. * `--version`, `-V` * Prints version information about Tarmac and exits. +* `--api-key ` + * Defines the API key Tarmac will use to authenticate with Open Cloud. + * If not specified, Tarmac will fall back to the cookie authentication method. * `--auth ` * Explicitly defines the authentication cookie Tarmac should use to communicate with Roblox. * If not specified, Tarmac will attempt to locate one from the local system. From d5dfd68b121f98fdcdcf247a8823eeaacac732dc Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 19:59:33 -0800 Subject: [PATCH 09/16] More readme --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4cdf7f0..7034cd8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,11 @@ codegen-path = "src/assets.lua" codegen-base-path = "assets" ``` -Run `tarmac sync --target roblox` to have Tarmac upload any new or updated assets that your project depends on. You may need to pass a `.ROBLOSECURITY` cookie explicitly via the `--auth` argument. +Run `tarmac sync --target roblox` to have Tarmac upload any new or updated assets that your project depends on. + +To use Roblox Open Cloud, provide a valid [API key with asset read and write permissions](https://create.roblox.com/docs/cloud/open-cloud/api-keys) to the `--api-key` option (or store it in an environment variable called `TARMAC_API_KEY`), and specify the user or group ID the API key belongs to in `upload-to-user-id` or `upload-to-group-id` in your project. + +Otherwise, you may need to pass a `.ROBLOSECURITY` cookie explicitly via the `--auth` argument. Tarmac will generate Lua code in `src/assets.lua` that looks something like this: @@ -89,7 +93,7 @@ These options can be specified alongside any subcommands and are all optional. * Prints version information about Tarmac and exits. * `--api-key ` * Defines the API key Tarmac will use to authenticate with Open Cloud. - * If not specified, Tarmac will fall back to the cookie authentication method. + * If not specified, Tarmac will attempt to read a key from the `TARMAC_API_KEY` environment variable, or else fall back to the cookie authentication method. * `--auth ` * Explicitly defines the authentication cookie Tarmac should use to communicate with Roblox. * If not specified, Tarmac will attempt to locate one from the local system. From 17c34099603af5c5210af1c4d2cc1b5a89e0197f Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 20:32:38 -0800 Subject: [PATCH 10/16] Retrieve Roblox Studio's auth cookie in upload-image command --- src/commands/upload_image.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/upload_image.rs b/src/commands/upload_image.rs index 88db85b..928e6d9 100644 --- a/src/commands/upload_image.rs +++ b/src/commands/upload_image.rs @@ -6,6 +6,7 @@ use std::borrow::Cow; use crate::{ alpha_bleed::alpha_bleed, + auth_cookie::get_auth_cookie, options::{GlobalOptions, UploadImageOptions}, roblox_api::{get_preferred_client, ImageUploadData, RobloxCredentials}, }; @@ -25,7 +26,7 @@ pub fn upload_image(global: GlobalOptions, options: UploadImageOptions) -> anyho .unwrap(); let mut client = get_preferred_client(RobloxCredentials { - token: global.auth, + token: global.auth.or_else(get_auth_cookie), api_key: global.api_key, user_id: options.user_id, group_id: options.group_id, From 064e636366cde7133e235a5f29200d3c52d4eecf Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 21:48:05 -0800 Subject: [PATCH 11/16] Retrieve Roblox Studio's auth cookie in create_cache_map command --- src/commands/create_cache_map.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/create_cache_map.rs b/src/commands/create_cache_map.rs index bd784bc..d5a32a7 100644 --- a/src/commands/create_cache_map.rs +++ b/src/commands/create_cache_map.rs @@ -5,6 +5,7 @@ use std::io::{BufWriter, Write}; use fs_err as fs; use crate::asset_name::AssetName; +use crate::auth_cookie::get_auth_cookie; use crate::data::Manifest; use crate::options::{CreateCacheMapOptions, GlobalOptions}; use crate::roblox_api::{get_preferred_client, RobloxCredentials}; @@ -14,7 +15,7 @@ pub fn create_cache_map( options: CreateCacheMapOptions, ) -> anyhow::Result<()> { let mut api_client = get_preferred_client(RobloxCredentials { - token: global.auth, + token: global.auth.or_else(get_auth_cookie), api_key: None, user_id: None, group_id: None, From 8c7edfa45aa17f565a826969333f6b9f1d3c10fa Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Wed, 29 Nov 2023 22:35:30 -0800 Subject: [PATCH 12/16] Hide api-key's env value in help output --- src/options.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/options.rs b/src/options.rs index f642d65..0b1146f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -21,7 +21,7 @@ pub struct GlobalOptions { #[structopt(long, global(true))] pub auth: Option, - #[structopt(long, global(true), env = "TARMAC_API_KEY")] + #[structopt(long, global(true), env = "TARMAC_API_KEY", hide_env_values = true)] pub api_key: Option, /// Sets verbosity level. Can be specified multiple times. From d6713c12392b1283444a1e712870fd5200a646f6 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Fri, 1 Dec 2023 13:51:55 -0800 Subject: [PATCH 13/16] Only need one pattern here --- src/roblox_api/open_cloud.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs index c4dbf50..13546cf 100644 --- a/src/roblox_api/open_cloud.rs +++ b/src/roblox_api/open_cloud.rs @@ -48,8 +48,6 @@ impl RobloxApiClient for OpenCloudClient { data: &ImageUploadData, ) -> Result { match self.upload_image(data) { - Ok(response) => Ok(response), - Err(RobloxApiError::ResponseError { status, body }) if status == 400 && body.contains("moderated") => { @@ -63,7 +61,7 @@ impl RobloxApiClient for OpenCloudClient { }) } - Err(e) => Err(e), + result => result, } } From ac3c7ab59762c4d987f74a0d9b1f7ee0f537b890 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Fri, 1 Dec 2023 13:55:27 -0800 Subject: [PATCH 14/16] Reuse GetAsset --- src/roblox_api/open_cloud.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs index 13546cf..6d20b5c 100644 --- a/src/roblox_api/open_cloud.rs +++ b/src/roblox_api/open_cloud.rs @@ -121,11 +121,11 @@ impl OpenCloudClient { const BACKOFF: u32 = 2; let mut retry_count = 0; + let operation = GetAsset { operation_id }; let asset_id = loop { - let operation_id = operation_id.clone(); let maybe_asset_id = self .runtime - .block_on(async { assets.get(&GetAsset { operation_id }).await }) + .block_on(async { assets.get(&operation).await }) .map_err(map_response_error)? .response .map(|response| response.asset_id) From ffdf850496e0ef7cef21ad4cfd2730d79a1ccfda Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Fri, 1 Dec 2023 14:08:30 -0800 Subject: [PATCH 15/16] Create RbxCloud once per session --- src/roblox_api/open_cloud.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs index 6d20b5c..8d77e7d 100644 --- a/src/roblox_api/open_cloud.rs +++ b/src/roblox_api/open_cloud.rs @@ -6,7 +6,7 @@ use rbxcloud::rbx::{ AssetUserCreator, }, error::Error as RbxCloudError, - CreateAssetWithContents, GetAsset, RbxCloud, + CreateAssetWithContents, GetAsset, RbxAssets, RbxCloud, }; use reqwest::StatusCode; use secrecy::ExposeSecret; @@ -20,6 +20,7 @@ use super::{ pub struct OpenCloudClient { credentials: RobloxCredentials, creator: AssetCreator, + assets: RbxAssets, runtime: Runtime, } @@ -36,8 +37,18 @@ impl RobloxApiClient for OpenCloudClient { (Some(_), Some(_)) => Err(RobloxApiError::AmbiguousCreatorType), }?; + let assets = RbxCloud::new( + credentials + .api_key + .as_ref() + .ok_or(RobloxApiError::MissingAuth)? + .expose_secret(), + ) + .assets(); + Ok(Self { creator, + assets, credentials, runtime: Runtime::new().unwrap(), }) @@ -76,15 +87,6 @@ impl RobloxApiClient for OpenCloudClient { impl OpenCloudClient { fn upload_image_inner(&self, data: &ImageUploadData) -> Result { - let assets = RbxCloud::new( - self.credentials - .api_key - .as_ref() - .ok_or(RobloxApiError::MissingAuth)? - .expose_secret(), - ) - .assets(); - let map_response_error = |e| match e { RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { status: StatusCode::from_u16(code).unwrap_or_default(), @@ -108,7 +110,7 @@ impl OpenCloudClient { let operation_id = self .runtime - .block_on(async { assets.create_with_contents(&asset_info).await }) + .block_on(async { self.assets.create_with_contents(&asset_info).await }) .map_err(map_response_error) .map(|response| response.path)? .ok_or(RobloxApiError::MissingOperationPath)? @@ -125,7 +127,7 @@ impl OpenCloudClient { let asset_id = loop { let maybe_asset_id = self .runtime - .block_on(async { assets.get(&operation).await }) + .block_on(async { self.assets.get(&operation).await }) .map_err(map_response_error)? .response .map(|response| response.asset_id) From b1914809f2ccad6d3dbd49a795c78854f52bb195 Mon Sep 17 00:00:00 2001 From: Kenneth Loeffler Date: Fri, 1 Dec 2023 14:22:53 -0800 Subject: [PATCH 16/16] Map from HttpStatusError to ResponseError in From impl --- src/roblox_api/mod.rs | 2 +- src/roblox_api/open_cloud.rs | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/roblox_api/mod.rs b/src/roblox_api/mod.rs index d31819a..85b848e 100644 --- a/src/roblox_api/mod.rs +++ b/src/roblox_api/mod.rs @@ -90,7 +90,7 @@ pub enum RobloxApiError { MalformedOperationPath, #[error("Open Cloud API error")] - RbxCloud(#[from] RbxCloudError), + RbxCloud(RbxCloudError), #[error("Failed to parse asset ID from asset get response")] MalformedAssetId(#[from] std::num::ParseIntError), diff --git a/src/roblox_api/open_cloud.rs b/src/roblox_api/open_cloud.rs index 8d77e7d..bd613fa 100644 --- a/src/roblox_api/open_cloud.rs +++ b/src/roblox_api/open_cloud.rs @@ -87,14 +87,6 @@ impl RobloxApiClient for OpenCloudClient { impl OpenCloudClient { fn upload_image_inner(&self, data: &ImageUploadData) -> Result { - let map_response_error = |e| match e { - RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { - status: StatusCode::from_u16(code).unwrap_or_default(), - body: msg, - }, - _ => RobloxApiError::RbxCloud(e), - }; - let asset_info = CreateAssetWithContents { asset: AssetCreation { asset_type: AssetType::DecalPng, @@ -111,7 +103,6 @@ impl OpenCloudClient { let operation_id = self .runtime .block_on(async { self.assets.create_with_contents(&asset_info).await }) - .map_err(map_response_error) .map(|response| response.path)? .ok_or(RobloxApiError::MissingOperationPath)? .strip_prefix("operations/") @@ -127,8 +118,7 @@ impl OpenCloudClient { let asset_id = loop { let maybe_asset_id = self .runtime - .block_on(async { self.assets.get(&operation).await }) - .map_err(map_response_error)? + .block_on(async { self.assets.get(&operation).await })? .response .map(|response| response.asset_id) .map(|id| id.parse::().map_err(RobloxApiError::MalformedAssetId)); @@ -150,3 +140,15 @@ impl OpenCloudClient { }) } } + +impl From for RobloxApiError { + fn from(value: RbxCloudError) -> Self { + match value { + RbxCloudError::HttpStatusError { code, msg } => RobloxApiError::ResponseError { + status: StatusCode::from_u16(code).unwrap_or_default(), + body: msg, + }, + _ => RobloxApiError::RbxCloud(value), + } + } +}