Implements cli uploads and link generation.

This commit is contained in:
2026-08-23 17:08:21 -07:00
parent e4ccd62072
commit c6ad520797
8 changed files with 702 additions and 37 deletions

264
Cargo.lock generated
View File

@@ -41,6 +41,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@@ -465,6 +515,52 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -494,6 +590,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -517,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types 0.5.0",
"libc", "libc",
@@ -530,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -661,6 +767,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"dbus",
"zeroize",
]
[[package]] [[package]]
name = "defmt" name = "defmt"
version = "1.1.1" version = "1.1.1"
@@ -1794,6 +1910,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1970,7 +2092,12 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [ dependencies = [
"byteorder",
"dbus-secret-service",
"log", "log",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize", "zeroize",
] ]
@@ -2145,7 +2272,7 @@ dependencies = [
"openssl-probe", "openssl-probe",
"openssl-sys", "openssl-sys",
"schannel", "schannel",
"security-framework", "security-framework 3.7.0",
"security-framework-sys", "security-framework-sys",
"tempfile", "tempfile",
] ]
@@ -2239,6 +2366,16 @@ dependencies = [
[[package]] [[package]]
name = "nut" name = "nut"
version = "0.1.0" version = "0.1.0"
dependencies = [
"clap",
"nextcloud_client",
"open",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]] [[package]]
name = "objc2" name = "objc2"
@@ -2441,6 +2578,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "open" name = "open"
version = "5.4.1" version = "5.4.1"
@@ -3100,6 +3243,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -3107,7 +3263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
"security-framework-sys", "security-framework-sys",
@@ -3552,7 +3708,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3978,7 +4134,9 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -4349,6 +4507,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.25.0" version = "1.25.0"
@@ -4819,6 +4983,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4852,13 +5025,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -4889,6 +5079,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4901,6 +5097,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4913,12 +5115,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4931,6 +5145,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4943,6 +5163,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -4955,6 +5181,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4967,6 +5199,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
@@ -5197,6 +5435,20 @@ name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type | | ID | Title | Status | Type |
|---|---|---|---| |---|---|---|---|
| [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature |
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature | | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature |
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature |
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature |
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature | | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature |
| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | | [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature |
| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
--- ---
@@ -232,21 +232,21 @@ Support multiple Nextcloud accounts in the backend credential system.
- NUT-006 - NUT-006
<a id="nut-008" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-008" class="task" data-status="done" data-task-type="feature"></a>
### Implement CLI Frontend ### Implement CLI Frontend
**ID:** NUT-008 **ID:** NUT-008
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands. Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands.
**Requirements:** **Requirements:**
- [ ] Add `login` command (browser-based Login Flow v2) - [x] Add `login` command (browser-based Login Flow v2)
- [ ] Add `upload` command - [x] Add `upload` command
- [ ] Add `--account` flag - [x] Add `--account` flag
- [ ] Add `--stdin` support - [x] Add `--stdin` support
- [ ] Add error reporting - [x] Add error reporting
**Dependencies:** **Dependencies:**
- NUT-003 - NUT-003

View File

@@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
nextcloud_client = { path = "../nextcloud_client" }
clap = { version = "4.5", features = ["derive", "cargo"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] }
open = "5.3"
url = "2.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -1,3 +1,349 @@
fn main() { use clap::{Args, Parser, Subcommand};
println!("Hello, world!"); use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use nextcloud_client::{
initiate_login_flow, poll_login_flow, ClientConfig, CredentialStore, NextcloudClient,
ProgressCallback, ProgressEvent, Result, UploadOptions,
};
#[derive(Parser, Debug)]
#[command(
name = "nut",
author = "Tom Hicks <headhunter3@gmail.com>",
version,
about = "Nextcloud Upload Tool — Fast, seamless file uploads & share links",
long_about = "Upload files to Nextcloud directly from your terminal, automatically generate public share links, and manage multiple accounts with secure OS keychain storage."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Log in to a Nextcloud server using browser authorization (Login Flow v2)
Login(LoginArgs),
/// Upload a file or stream to Nextcloud
Upload(UploadArgs),
/// Manage configured Nextcloud accounts
#[command(subcommand)]
Account(AccountCommands),
/// Quick alias to list all configured accounts
Accounts,
}
#[derive(Args, Debug)]
struct LoginArgs {
/// Base URL of the Nextcloud instance (e.g. https://cloud.example.com)
server_url: String,
/// Optional friendly label for this account (e.g. "Work", "Home")
#[arg(short, long)]
label: Option<String>,
/// Set this account as the default active account
#[arg(short, long, default_value_t = true)]
default: bool,
}
#[derive(Args, Debug)]
struct UploadArgs {
/// Path to local file(s) to upload
#[arg(value_name = "FILE")]
files: Vec<PathBuf>,
/// Specific account identifier or label to use (defaults to active account)
#[arg(short, long)]
account: Option<String>,
/// Remote destination folder on Nextcloud (defaults to "Uploads")
#[arg(short = 'd', long, default_value = "Uploads")]
remote_dir: String,
/// Automatically generate a public share link after upload
#[arg(short, long)]
share: bool,
/// Optional password to protect the public share link
#[arg(short, long)]
password: Option<String>,
/// Upload content from standard input (stdin)
#[arg(long)]
stdin: bool,
/// Remote filename to use when uploading via stdin
#[arg(long, default_value = "stdin_upload.txt")]
filename: String,
}
#[derive(Subcommand, Debug)]
enum AccountCommands {
/// List all configured Nextcloud accounts
List,
/// Set the active default account
Default {
/// Account ID, label, or username
account: String,
},
/// Remove a configured account and its credentials
Delete {
/// Account ID, label, or username
account: String,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Login(args) => handle_login(args).await,
Commands::Upload(args) => handle_upload(args).await,
Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(),
Commands::Account(AccountCommands::Default { account }) => {
handle_account_set_default(&account)
}
Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account),
};
if let Err(err) = result {
eprintln!("\x1b[1;31mError:\x1b[0m {err}");
std::process::exit(1);
}
}
/// Handle interactive browser login flow.
async fn handle_login(args: LoginArgs) -> Result<()> {
let server_url = ClientConfig::normalize_url(&args.server_url)?;
let http = reqwest::Client::new();
println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url);
let flow = initiate_login_flow(&http, &server_url).await?;
println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:");
println!(" \x1b[1;36m{}\x1b[0m\n", flow.login);
// Attempt to open the default desktop browser
if open::that(&flow.login).is_err() {
println!(" (Could not automatically launch browser. Please copy and open the link above.)");
}
print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization...");
io::Write::flush(&mut io::stdout())?;
let poll_interval = Duration::from_secs(2);
let timeout = Duration::from_secs(300); // 5 minute timeout
let start_time = tokio::time::Instant::now();
loop {
if start_time.elapsed() >= timeout {
println!();
return Err(nextcloud_client::NextcloudError::Other(
"Timed out waiting for authorization in browser".into(),
));
}
tokio::time::sleep(poll_interval).await;
if let Some(creds) = poll_login_flow(&http, &flow.poll.endpoint, &flow.poll.token).await? {
println!("\n\x1b[1;32m==>\x1b[0m Authorization granted for user '\x1b[1m{}\x1b[0m'!", creds.login_name);
// Save credentials to keychain & accounts list
let mut account = CredentialStore::save_account(
&creds.server,
&creds.login_name,
&creds.app_password,
args.default,
)?;
if let Some(lbl) = args.label {
account.label = Some(lbl);
let mut accounts = CredentialStore::list_accounts()?;
if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
a.label = account.label.clone();
}
CredentialStore::save_accounts(&accounts)?;
}
println!("\x1b[1;32m==>\x1b[0m Account '\x1b[1m{}\x1b[0m' saved securely in system keychain.", account.id);
if account.is_default {
println!(" Set as default active account.");
}
return Ok(());
}
}
}
/// Handle file and stdin uploads.
async fn handle_upload(args: UploadArgs) -> Result<()> {
let client = match args.account.as_deref() {
Some(acc) => CredentialStore::create_client_for_account(acc)?,
None => CredentialStore::create_client_for_default()?,
};
// Stdin Upload
if args.stdin {
println!("\x1b[1;34m==>\x1b[0m Reading data from stdin...");
let mut buffer = Vec::new();
io::stdin().read_to_end(&mut buffer)?;
let len = buffer.len() as u64;
let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename);
println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path);
let progress_cb = create_progress_callback(len);
let cursor = io::Cursor::new(buffer);
let bytes = client
.upload_reader(cursor, &remote_path, Some(len), Some(progress_cb))
.await?;
println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes);
if args.share {
create_and_print_share(&client, &remote_path, args.password.as_deref()).await?;
}
return Ok(());
}
if args.files.is_empty() {
return Err(nextcloud_client::NextcloudError::Other(
"No files specified for upload. Usage: nut upload <FILE>... or nut upload --stdin".into(),
));
}
for file_path in &args.files {
upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?;
}
Ok(())
}
async fn upload_single_file(
client: &NextcloudClient,
local_path: &Path,
remote_dir: &str,
create_share: bool,
password: Option<&str>,
) -> Result<()> {
if !local_path.exists() {
return Err(nextcloud_client::NextcloudError::NotFound {
path: local_path.display().to_string(),
});
}
let file_name = local_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let clean_dir = remote_dir.trim_matches('/');
let remote_path = if clean_dir.is_empty() {
file_name.to_string()
} else {
format!("{clean_dir}/{file_name}")
};
let metadata = tokio::fs::metadata(local_path).await?;
let file_size = metadata.len();
println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path);
let progress_cb = create_progress_callback(file_size);
let options = UploadOptions {
remote_path: remote_path.clone(),
create_share,
share_password: password.map(|s| s.to_string()),
overwrite: true,
};
let result = client
.upload_and_share(local_path, &options, Some(progress_cb))
.await?;
println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded);
if let Some(share_url) = result.share_url {
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url);
}
if let Some(direct_url) = result.direct_download_url {
println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
}
Ok(())
}
async fn create_and_print_share(
client: &NextcloudClient,
remote_path: &str,
password: Option<&str>,
) -> Result<()> {
println!("\x1b[1;34m==>\x1b[0m Generating public share link...");
let share = client.create_public_share(remote_path, password).await?;
let direct_url = client.direct_download_url(&share.token)?;
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url);
println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
Ok(())
}
fn create_progress_callback(total_bytes: u64) -> ProgressCallback {
Arc::new(move |event| match event {
ProgressEvent::Progress { bytes_transferred, total_bytes: total } => {
let tot = total.unwrap_or(total_bytes);
if tot > 0 {
let percent = (bytes_transferred as f64 / tot as f64) * 100.0;
print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot);
} else {
print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred);
}
let _ = io::Write::flush(&mut io::stdout());
}
ProgressEvent::Completed { total_bytes } => {
print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes);
let _ = io::Write::flush(&mut io::stdout());
}
_ => {}
})
}
/// List stored accounts.
fn handle_account_list() -> Result<()> {
let accounts = CredentialStore::list_accounts()?;
if accounts.is_empty() {
println!("No Nextcloud accounts configured yet.");
println!("Run \x1b[1;36mnut login <SERVER_URL>\x1b[0m to connect an account.");
return Ok(());
}
println!("\x1b[1mConfigured Nextcloud Accounts:\x1b[0m\n");
for acc in accounts {
let default_mark = if acc.is_default { "\x1b[1;32m* (active default)\x1b[0m" } else { "" };
let label_str = acc.label.as_deref().unwrap_or("—");
println!(" • \x1b[1;36m{}\x1b[0m {}", acc.id, default_mark);
println!(" Username: {}", acc.username);
println!(" Server: {}", acc.server_url);
println!(" Label: {}\n", label_str);
}
Ok(())
}
fn handle_account_set_default(account: &str) -> Result<()> {
CredentialStore::set_default_account(account)?;
println!("\x1b[1;32m✓\x1b[0m Active default account set to '\x1b[1m{}\x1b[0m'.", account);
Ok(())
}
fn handle_account_delete(account: &str) -> Result<()> {
CredentialStore::delete_account(account)?;
println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account);
Ok(())
} }

View File

@@ -14,6 +14,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "2.0" thiserror = "2.0"
url = { version = "2.5", features = ["serde"] } url = { version = "2.5", features = ["serde"] }
keyring = "3" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
dirs = "6.0" dirs = "6.0"
open = "5.3" open = "5.3"

View File

@@ -69,11 +69,8 @@ mod humantime_serde {
} }
impl ClientConfig { impl ClientConfig {
/// Create a new `ClientConfig` by parsing a URL string and optional credentials. /// Normalize a server URL string: validates http/https scheme and ensures a trailing slash.
pub fn new( pub fn normalize_url(server_url_str: &str) -> Result<Url> {
server_url_str: &str,
credentials: Option<AccountCredentials>,
) -> Result<Self> {
let mut server_url = Url::parse(server_url_str)?; let mut server_url = Url::parse(server_url_str)?;
// Ensure scheme is http or https // Ensure scheme is http or https
@@ -90,6 +87,16 @@ impl ClientConfig {
server_url.set_path(&new_path); server_url.set_path(&new_path);
} }
Ok(server_url)
}
/// Create a new `ClientConfig` by parsing a URL string and optional credentials.
pub fn new(
server_url_str: &str,
credentials: Option<AccountCredentials>,
) -> Result<Self> {
let server_url = Self::normalize_url(server_url_str)?;
Ok(Self { Ok(Self {
server_url, server_url,
credentials, credentials,
@@ -127,6 +134,12 @@ mod tests {
assert_eq!(config.credentials.as_ref().unwrap().username, "alice"); assert_eq!(config.credentials.as_ref().unwrap().username, "alice");
} }
#[test]
fn test_subpath_normalization() {
let url = ClientConfig::normalize_url("https://disobedient.cloud/nextcloud").unwrap();
assert_eq!(url.as_str(), "https://disobedient.cloud/nextcloud/");
}
#[test] #[test]
fn test_invalid_scheme() { fn test_invalid_scheme() {
let result = ClientConfig::new("ftp://cloud.example.com", None); let result = ClientConfig::new("ftp://cloud.example.com", None);

View File

@@ -31,7 +31,7 @@ pub struct StoredAccount {
#[serde(default)] #[serde(default)]
pub is_default: bool, pub is_default: bool,
/// Fallback password storage (only populated if system keyring is unavailable). /// Fallback password storage (populated if system keyring is unavailable).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub fallback_password: Option<String>, pub fallback_password: Option<String>,
} }
@@ -137,7 +137,7 @@ impl CredentialStore {
let keyring_key = account.keyring_key(); let keyring_key = account.keyring_key();
// 1. Attempt retrieval from OS Keyring // 1. Attempt retrieval from OS Keyring (using full id username@host)
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) { if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) {
if let Ok(password) = entry.get_password() { if let Ok(password) = entry.get_password() {
let username = account.username.clone(); let username = account.username.clone();
@@ -145,7 +145,7 @@ impl CredentialStore {
} }
} }
// Also try fallback by username alone if migrated // Also try retrieval by username alone (if previously stored with just username)
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) { if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) {
if let Ok(password) = entry.get_password() { if let Ok(password) = entry.get_password() {
let username = account.username.clone(); let username = account.username.clone();
@@ -158,7 +158,7 @@ impl CredentialStore {
let username = account.username.clone(); let username = account.username.clone();
return Ok(Some(( return Ok(Some((
account.clone(), account.clone(),
AccountCredentials::new(username, pass), AccountCredentials::new(username, pass.clone()),
))); )));
} }
@@ -168,14 +168,26 @@ impl CredentialStore {
/// Retrieve the default/active account credentials. /// Retrieve the default/active account credentials.
pub fn get_default_credentials() -> Result<Option<(StoredAccount, AccountCredentials)>> { pub fn get_default_credentials() -> Result<Option<(StoredAccount, AccountCredentials)>> {
let accounts = Self::list_accounts()?; let accounts = Self::list_accounts()?;
let default_id = accounts if accounts.is_empty() {
return Ok(None);
}
let default_account = accounts
.iter() .iter()
.find(|a| a.is_default) .find(|a| a.is_default)
.map(|a| a.id.clone()) .or_else(|| accounts.first());
.or_else(|| accounts.first().map(|a| a.id.clone()));
match default_id { match default_account {
Some(id) => Self::get_credentials(&id), Some(acc) => match Self::get_credentials(&acc.id)? {
Some(res) => Ok(Some(res)),
None => Err(NextcloudError::AuthenticationFailed {
username: acc.username.clone(),
message: format!(
"Account '{}' is registered, but its password was not found in Keychain. Please re-authenticate by running 'nut login {}'.",
acc.id, acc.server_url
),
}),
},
None => Ok(None), None => Ok(None),
} }
} }
@@ -227,10 +239,14 @@ impl CredentialStore {
// Try storing password in OS Keyring // Try storing password in OS Keyring
let mut fallback_password = None; let mut fallback_password = None;
let keyring_result = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key()) let entry_res = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key());
.and_then(|entry| entry.set_password(app_password)); let keyring_saved = match entry_res {
Ok(ref entry) => entry.set_password(app_password).is_ok(),
Err(_) => false,
};
if keyring_result.is_err() { if !keyring_saved {
// Save in fallback password field on failure
fallback_password = Some(app_password.to_string()); fallback_password = Some(app_password.to_string());
} }
new_account.fallback_password = fallback_password; new_account.fallback_password = fallback_password;
@@ -283,7 +299,7 @@ impl CredentialStore {
/// Create an initialized `NextcloudClient` for an account matching `query`. /// Create an initialized `NextcloudClient` for an account matching `query`.
pub fn create_client_for_account(query: &str) -> Result<NextcloudClient> { pub fn create_client_for_account(query: &str) -> Result<NextcloudClient> {
let (account, creds) = Self::get_credentials(query)?.ok_or_else(|| { let (account, creds) = Self::get_credentials(query)?.ok_or_else(|| {
NextcloudError::Other(format!("No credentials found for account '{query}'")) NextcloudError::Other(format!("No credentials found for account '{query}'. Run 'nut login' to authenticate."))
})?; })?;
let config = ClientConfig::new(&account.server_url, Some(creds))?; let config = ClientConfig::new(&account.server_url, Some(creds))?;
@@ -293,7 +309,7 @@ impl CredentialStore {
/// Create an initialized `NextcloudClient` for the default account. /// Create an initialized `NextcloudClient` for the default account.
pub fn create_client_for_default() -> Result<NextcloudClient> { pub fn create_client_for_default() -> Result<NextcloudClient> {
let (account, creds) = Self::get_default_credentials()?.ok_or_else(|| { let (account, creds) = Self::get_default_credentials()?.ok_or_else(|| {
NextcloudError::Other("No default account configured. Please run 'nut login' or configure an account.".into()) NextcloudError::Other("No accounts configured. Please run 'nut login <server_url>' to connect your Nextcloud instance.".into())
})?; })?;
let config = ClientConfig::new(&account.server_url, Some(creds))?; let config = ClientConfig::new(&account.server_url, Some(creds))?;

View File

@@ -15,6 +15,7 @@ pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
impl NextcloudClient { impl NextcloudClient {
/// Upload a stream of bytes to a Nextcloud WebDAV destination with optional progress reporting. /// Upload a stream of bytes to a Nextcloud WebDAV destination with optional progress reporting.
/// Automatically ensures any parent directories exist before uploading.
/// ///
/// # Arguments /// # Arguments
/// * `remote_path` - The destination path on Nextcloud (e.g. `"Uploads/photo.jpg"`). /// * `remote_path` - The destination path on Nextcloud (e.g. `"Uploads/photo.jpg"`).
@@ -32,6 +33,14 @@ impl NextcloudClient {
S: Stream<Item = std::result::Result<Bytes, E>> + Send + Sync + 'static, S: Stream<Item = std::result::Result<Bytes, E>> + Send + Sync + 'static,
E: Into<Box<dyn std::error::Error + Send + Sync>> + 'static, E: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
{ {
// Ensure parent directories exist
let clean_path = remote_path.trim_start_matches('/');
if let Some((parent_dir, _)) = clean_path.rsplit_once('/') {
if !parent_dir.is_empty() {
self.create_folder_all(parent_dir).await?;
}
}
let url = self.webdav_url(remote_path)?; let url = self.webdav_url(remote_path)?;
let progress_stream = ProgressStream::new(stream, content_length, callback); let progress_stream = ProgressStream::new(stream, content_length, callback);
let body = reqwest::Body::wrap_stream(progress_stream); let body = reqwest::Body::wrap_stream(progress_stream);
@@ -58,7 +67,7 @@ impl NextcloudClient {
}) })
} else if status == reqwest::StatusCode::CONFLICT { } else if status == reqwest::StatusCode::CONFLICT {
Err(NextcloudError::Other(format!( Err(NextcloudError::Other(format!(
"WebDAV Conflict (409) at '{remote_path}'. Ensure the parent directory exists on Nextcloud." "WebDAV Conflict (409) at '{remote_path}'. Ensure the destination directory exists."
))) )))
} else { } else {
let error_text = response.text().await.unwrap_or_default(); let error_text = response.text().await.unwrap_or_default();
@@ -149,4 +158,25 @@ impl NextcloudClient {
}) })
} }
} }
/// Recursively ensure all parent and subdirectories exist using WebDAV `MKCOL`.
pub async fn create_folder_all(&self, remote_dir: &str) -> Result<()> {
let clean = remote_dir.trim_matches('/');
if clean.is_empty() {
return Ok(());
}
let mut current_path = String::new();
for segment in clean.split('/') {
if segment.is_empty() {
continue;
}
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(segment);
self.create_folder(&current_path).await?;
}
Ok(())
}
} }