refactor: remove S3 sync feature and related settings
This commit is contained in:
Generated
-12
@@ -836,15 +836,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -2299,14 +2290,11 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"hex",
|
|
||||||
"hmac",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
"whoami",
|
"whoami",
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ base64 = "0.22"
|
|||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
hex = "0.4"
|
|
||||||
hmac = "0.12"
|
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
ratatui = { version = "0.30", features = ["crossterm_0_29"] }
|
ratatui = { version = "0.30", features = ["crossterm_0_29"] }
|
||||||
reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] }
|
reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
whoami = "1.6"
|
whoami = "1.6"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A personal SSH and shell connection manager with a terminal user interface (TUI)
|
|||||||
- **Import from `~/.ssh/config`** — One command to import all existing hosts
|
- **Import from `~/.ssh/config`** — One command to import all existing hosts
|
||||||
- **Local shell scan** — Auto-detects available shells from `/etc/shells`
|
- **Local shell scan** — Auto-detects available shells from `/etc/shells`
|
||||||
- **Quick select** — Press `Ctrl+Q` then a number key (1–9) to connect instantly
|
- **Quick select** — Press `Ctrl+Q` then a number key (1–9) to connect instantly
|
||||||
- **Encrypted cloud sync** — Push/pull config via GitHub Gist, WebDAV, or S3 with AES-256-GCM encryption
|
- **Encrypted cloud sync** — Push/pull config via GitHub Gist or WebDAV with AES-256-GCM encryption
|
||||||
- **CLI subcommands** — Connect, import, sync, and diagnostics without launching the TUI
|
- **CLI subcommands** — Connect, import, sync, and diagnostics without launching the TUI
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rust = "latest"
|
rust = "latest"
|
||||||
|
rust-analyzer = "latest"
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ impl App {
|
|||||||
let result = match self.config.settings.backend {
|
let result = match self.config.settings.backend {
|
||||||
SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
|
SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
|
||||||
SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
|
SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
|
||||||
SyncBackend::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()),
|
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(msg) => self.toast(msg, true),
|
Ok(msg) => self.toast(msg, true),
|
||||||
@@ -79,7 +78,6 @@ impl App {
|
|||||||
let result = match self.config.settings.backend {
|
let result = match self.config.settings.backend {
|
||||||
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
||||||
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
||||||
SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
|
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(count) => self.toast(format!("pulled {count} items"), true),
|
Ok(count) => self.toast(format!("pulled {count} items"), true),
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ pub enum SettingsField {
|
|||||||
WebdavUrl,
|
WebdavUrl,
|
||||||
WebdavUser,
|
WebdavUser,
|
||||||
WebdavPassword,
|
WebdavPassword,
|
||||||
S3Endpoint,
|
|
||||||
S3Bucket,
|
|
||||||
S3AccessKey,
|
|
||||||
S3SecretKey,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsField {
|
impl SettingsField {
|
||||||
@@ -27,14 +23,6 @@ impl SettingsField {
|
|||||||
SettingsField::WebdavPassword,
|
SettingsField::WebdavPassword,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
SyncBackend::S3 => {
|
|
||||||
fields.extend_from_slice(&[
|
|
||||||
SettingsField::S3Endpoint,
|
|
||||||
SettingsField::S3Bucket,
|
|
||||||
SettingsField::S3AccessKey,
|
|
||||||
SettingsField::S3SecretKey,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fields
|
fields
|
||||||
}
|
}
|
||||||
@@ -47,10 +35,6 @@ impl SettingsField {
|
|||||||
Self::WebdavUrl => "URL",
|
Self::WebdavUrl => "URL",
|
||||||
Self::WebdavUser => "Username",
|
Self::WebdavUser => "Username",
|
||||||
Self::WebdavPassword => "Password",
|
Self::WebdavPassword => "Password",
|
||||||
Self::S3Endpoint => "Endpoint",
|
|
||||||
Self::S3Bucket => "Bucket",
|
|
||||||
Self::S3AccessKey => "Access Key",
|
|
||||||
Self::S3SecretKey => "Secret Key",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +55,6 @@ pub struct SettingsState {
|
|||||||
pub webdav_url: String,
|
pub webdav_url: String,
|
||||||
pub webdav_user: String,
|
pub webdav_user: String,
|
||||||
pub webdav_password: String,
|
pub webdav_password: String,
|
||||||
pub s3_endpoint: String,
|
|
||||||
pub s3_bucket: String,
|
|
||||||
pub s3_access_key: String,
|
|
||||||
pub s3_secret_key: String,
|
|
||||||
pub active: SettingsField,
|
pub active: SettingsField,
|
||||||
pub cursor: usize,
|
pub cursor: usize,
|
||||||
}
|
}
|
||||||
@@ -87,10 +67,6 @@ impl SettingsState {
|
|||||||
SettingsField::WebdavUrl => &self.webdav_url,
|
SettingsField::WebdavUrl => &self.webdav_url,
|
||||||
SettingsField::WebdavUser => &self.webdav_user,
|
SettingsField::WebdavUser => &self.webdav_user,
|
||||||
SettingsField::WebdavPassword => &self.webdav_password,
|
SettingsField::WebdavPassword => &self.webdav_password,
|
||||||
SettingsField::S3Endpoint => &self.s3_endpoint,
|
|
||||||
SettingsField::S3Bucket => &self.s3_bucket,
|
|
||||||
SettingsField::S3AccessKey => &self.s3_access_key,
|
|
||||||
SettingsField::S3SecretKey => &self.s3_secret_key,
|
|
||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,10 +116,6 @@ impl Default for SettingsState {
|
|||||||
webdav_url: String::new(),
|
webdav_url: String::new(),
|
||||||
webdav_user: String::new(),
|
webdav_user: String::new(),
|
||||||
webdav_password: String::new(),
|
webdav_password: String::new(),
|
||||||
s3_endpoint: String::new(),
|
|
||||||
s3_bucket: String::new(),
|
|
||||||
s3_access_key: String::new(),
|
|
||||||
s3_secret_key: String::new(),
|
|
||||||
active: SettingsField::SyncPassword,
|
active: SettingsField::SyncPassword,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
}
|
}
|
||||||
@@ -158,10 +130,6 @@ impl TextEditing for SettingsState {
|
|||||||
SettingsField::WebdavUrl => &self.webdav_url,
|
SettingsField::WebdavUrl => &self.webdav_url,
|
||||||
SettingsField::WebdavUser => &self.webdav_user,
|
SettingsField::WebdavUser => &self.webdav_user,
|
||||||
SettingsField::WebdavPassword => &self.webdav_password,
|
SettingsField::WebdavPassword => &self.webdav_password,
|
||||||
SettingsField::S3Endpoint => &self.s3_endpoint,
|
|
||||||
SettingsField::S3Bucket => &self.s3_bucket,
|
|
||||||
SettingsField::S3AccessKey => &self.s3_access_key,
|
|
||||||
SettingsField::S3SecretKey => &self.s3_secret_key,
|
|
||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,10 +141,6 @@ impl TextEditing for SettingsState {
|
|||||||
SettingsField::WebdavUrl => Some(&mut self.webdav_url),
|
SettingsField::WebdavUrl => Some(&mut self.webdav_url),
|
||||||
SettingsField::WebdavUser => Some(&mut self.webdav_user),
|
SettingsField::WebdavUser => Some(&mut self.webdav_user),
|
||||||
SettingsField::WebdavPassword => Some(&mut self.webdav_password),
|
SettingsField::WebdavPassword => Some(&mut self.webdav_password),
|
||||||
SettingsField::S3Endpoint => Some(&mut self.s3_endpoint),
|
|
||||||
SettingsField::S3Bucket => Some(&mut self.s3_bucket),
|
|
||||||
SettingsField::S3AccessKey => Some(&mut self.s3_access_key),
|
|
||||||
SettingsField::S3SecretKey => Some(&mut self.s3_secret_key),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,14 +176,6 @@ impl App {
|
|||||||
self.config.settings.webdav_user.clone().unwrap_or_default();
|
self.config.settings.webdav_user.clone().unwrap_or_default();
|
||||||
self.session.settings.webdav_password =
|
self.session.settings.webdav_password =
|
||||||
self.config.settings.webdav_password.clone().unwrap_or_default();
|
self.config.settings.webdav_password.clone().unwrap_or_default();
|
||||||
self.session.settings.s3_endpoint =
|
|
||||||
self.config.settings.s3_endpoint.clone().unwrap_or_default();
|
|
||||||
self.session.settings.s3_bucket =
|
|
||||||
self.config.settings.s3_bucket.clone().unwrap_or_default();
|
|
||||||
self.session.settings.s3_access_key =
|
|
||||||
self.config.settings.s3_access_key.clone().unwrap_or_default();
|
|
||||||
self.session.settings.s3_secret_key =
|
|
||||||
self.config.settings.s3_secret_key.clone().unwrap_or_default();
|
|
||||||
self.session.settings.active = SettingsField::SyncPassword;
|
self.session.settings.active = SettingsField::SyncPassword;
|
||||||
self.session.settings.cursor = char_len(self.session.settings.active_text());
|
self.session.settings.cursor = char_len(self.session.settings.active_text());
|
||||||
self.session.mode = Mode::Settings;
|
self.session.mode = Mode::Settings;
|
||||||
@@ -241,18 +197,6 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
Some(wd_pw)
|
Some(wd_pw)
|
||||||
};
|
};
|
||||||
let s3_ep = self.session.settings.s3_endpoint.trim().to_string();
|
|
||||||
self.config.settings.s3_endpoint = if s3_ep.is_empty() { None } else { Some(s3_ep) };
|
|
||||||
let s3_bk = self.session.settings.s3_bucket.trim().to_string();
|
|
||||||
self.config.settings.s3_bucket = if s3_bk.is_empty() { None } else { Some(s3_bk) };
|
|
||||||
let s3_ak = self.session.settings.s3_access_key.trim().to_string();
|
|
||||||
self.config.settings.s3_access_key = if s3_ak.is_empty() { None } else { Some(s3_ak) };
|
|
||||||
let s3_sk = self.session.settings.s3_secret_key.trim().to_string();
|
|
||||||
self.config.settings.s3_secret_key = if s3_sk.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(s3_sk)
|
|
||||||
};
|
|
||||||
self.config.save()?;
|
self.config.save()?;
|
||||||
self.session.mode = Mode::Home;
|
self.session.mode = Mode::Home;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+1
-7
@@ -1,5 +1,5 @@
|
|||||||
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
|
||||||
use crate::sync::{self, gist, s3, webdav};
|
use crate::sync::{self, gist, webdav};
|
||||||
use crate::{connection, import, ui};
|
use crate::{connection, import, ui};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
@@ -88,16 +88,11 @@ fn run_sync(command: SyncCommand) -> Result<()> {
|
|||||||
webdav::push(&mut cfg)?;
|
webdav::push(&mut cfg)?;
|
||||||
println!("pushed");
|
println!("pushed");
|
||||||
}
|
}
|
||||||
SyncBackend::S3 => {
|
|
||||||
s3::push(&mut cfg)?;
|
|
||||||
println!("pushed");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
SyncCommand::Pull { strategy } => {
|
SyncCommand::Pull { strategy } => {
|
||||||
let count = match cfg.settings.backend {
|
let count = match cfg.settings.backend {
|
||||||
SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?,
|
SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?,
|
||||||
SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?,
|
SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?,
|
||||||
SyncBackend::S3 => s3::pull_with_strategy(&mut cfg, strat(strategy))?,
|
|
||||||
};
|
};
|
||||||
println!("pulled {count} items");
|
println!("pulled {count} items");
|
||||||
}
|
}
|
||||||
@@ -124,7 +119,6 @@ fn doctor(name: Option<String>) -> Result<()> {
|
|||||||
match cfg.settings.backend {
|
match cfg.settings.backend {
|
||||||
SyncBackend::Gist => "gist",
|
SyncBackend::Gist => "gist",
|
||||||
SyncBackend::Webdav => "webdav",
|
SyncBackend::Webdav => "webdav",
|
||||||
SyncBackend::S3 => "s3",
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ pub enum SyncBackend {
|
|||||||
#[default]
|
#[default]
|
||||||
Gist,
|
Gist,
|
||||||
Webdav,
|
Webdav,
|
||||||
S3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -60,10 +59,6 @@ pub struct Settings {
|
|||||||
pub webdav_url: Option<String>,
|
pub webdav_url: Option<String>,
|
||||||
pub webdav_user: Option<String>,
|
pub webdav_user: Option<String>,
|
||||||
pub webdav_password: Option<String>,
|
pub webdav_password: Option<String>,
|
||||||
pub s3_endpoint: Option<String>,
|
|
||||||
pub s3_bucket: Option<String>,
|
|
||||||
pub s3_access_key: Option<String>,
|
|
||||||
pub s3_secret_key: Option<String>,
|
|
||||||
pub sync_password: Option<String>,
|
pub sync_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
mod crypto;
|
mod crypto;
|
||||||
pub mod gist;
|
pub mod gist;
|
||||||
pub mod s3;
|
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
use crate::config::ConnectionType;
|
use crate::config::ConnectionType;
|
||||||
|
|||||||
-313
@@ -1,313 +0,0 @@
|
|||||||
use crate::config::SshellConfig;
|
|
||||||
use super::{PullStrategy, build_sync_payload, merge_remote};
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use reqwest::blocking::Client;
|
|
||||||
use reqwest::header::{CONTENT_TYPE, HOST};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
const FILE_NAME: &str = "sshell-config.toml";
|
|
||||||
const SERVICE: &str = "s3";
|
|
||||||
|
|
||||||
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
|
|
||||||
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
|
|
||||||
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
|
|
||||||
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
|
|
||||||
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
|
|
||||||
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
|
|
||||||
let body = toml::to_string_pretty(&payload)?;
|
|
||||||
let body_bytes = body.as_bytes();
|
|
||||||
|
|
||||||
let path = format!("/{bucket}/{FILE_NAME}");
|
|
||||||
let host = endpoint_host(endpoint);
|
|
||||||
|
|
||||||
let now = chrono_now();
|
|
||||||
let region = region_from_endpoint(endpoint);
|
|
||||||
|
|
||||||
let payload_hash = hex_hash(body_bytes);
|
|
||||||
let (auth_header, amz_date) = sign(
|
|
||||||
access_key,
|
|
||||||
secret_key,
|
|
||||||
®ion,
|
|
||||||
&SigningRequest {
|
|
||||||
method: "PUT",
|
|
||||||
host: &host,
|
|
||||||
path: &path,
|
|
||||||
query: &[],
|
|
||||||
payload_hash: &payload_hash,
|
|
||||||
timestamp: &now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let url = format!("https://{host}{path}");
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client
|
|
||||||
.put(&url)
|
|
||||||
.header(HOST, host.clone())
|
|
||||||
.header(CONTENT_TYPE, "application/octet-stream")
|
|
||||||
.header("x-amz-content-sha256", &payload_hash)
|
|
||||||
.header("x-amz-date", &amz_date)
|
|
||||||
.header("Authorization", &auth_header)
|
|
||||||
.body(body)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().unwrap_or_default();
|
|
||||||
bail!("sync push failed: {status} {body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
|
|
||||||
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
|
|
||||||
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
|
|
||||||
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
|
|
||||||
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
|
|
||||||
|
|
||||||
let path = format!("/{bucket}/{FILE_NAME}");
|
|
||||||
let host = endpoint_host(endpoint);
|
|
||||||
|
|
||||||
let now = chrono_now();
|
|
||||||
let region = region_from_endpoint(endpoint);
|
|
||||||
|
|
||||||
let payload_hash = hex_hash(b"");
|
|
||||||
let (auth_header, amz_date) = sign(
|
|
||||||
access_key,
|
|
||||||
secret_key,
|
|
||||||
®ion,
|
|
||||||
&SigningRequest {
|
|
||||||
method: "GET",
|
|
||||||
host: &host,
|
|
||||||
path: &path,
|
|
||||||
query: &[],
|
|
||||||
payload_hash: &payload_hash,
|
|
||||||
timestamp: &now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let url = format!("https://{host}{path}");
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.header(HOST, host.clone())
|
|
||||||
.header("x-amz-content-sha256", &payload_hash)
|
|
||||||
.header("x-amz-date", &amz_date)
|
|
||||||
.header("Authorization", &auth_header)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
bail!("sync pull failed: remote file not found");
|
|
||||||
}
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().unwrap_or_default();
|
|
||||||
bail!("sync pull failed: {status} {body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = response.text()?;
|
|
||||||
let remote: toml::Value =
|
|
||||||
toml::from_str(&content).with_context(|| "failed to parse remote config")?;
|
|
||||||
|
|
||||||
merge_remote(cfg, remote, strategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AWS Signature V4 ───────────────────────────────────────────
|
|
||||||
|
|
||||||
struct SigningRequest<'a> {
|
|
||||||
method: &'a str,
|
|
||||||
host: &'a str,
|
|
||||||
path: &'a str,
|
|
||||||
query: &'a [(&'a str, &'a str)],
|
|
||||||
payload_hash: &'a str,
|
|
||||||
timestamp: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign(
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
region: &str,
|
|
||||||
req: &SigningRequest<'_>,
|
|
||||||
) -> (String, String) {
|
|
||||||
let date = &req.timestamp[..8];
|
|
||||||
let amz_date = req.timestamp.to_string();
|
|
||||||
|
|
||||||
let content_type_val = if req.method == "PUT" {
|
|
||||||
"application/octet-stream"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
// Canonical headers (sorted by key)
|
|
||||||
let mut headers: Vec<(&str, String)> = vec![
|
|
||||||
("content-type", content_type_val.to_string()),
|
|
||||||
("host", req.host.to_string()),
|
|
||||||
("x-amz-content-sha256", req.payload_hash.to_string()),
|
|
||||||
("x-amz-date", amz_date.clone()),
|
|
||||||
];
|
|
||||||
headers.sort_by_key(|(k, _)| *k);
|
|
||||||
|
|
||||||
let signed_headers: String = headers.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(";");
|
|
||||||
let canonical_headers: String = headers
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}:{v}\n"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let canonical_querystring = req.query
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
let canonical_request = format!(
|
|
||||||
"{method}\n{path}\n{qs}\n{headers}\n{signed}\n{hash}",
|
|
||||||
method = req.method,
|
|
||||||
path = req.path,
|
|
||||||
qs = canonical_querystring,
|
|
||||||
headers = canonical_headers,
|
|
||||||
signed = signed_headers,
|
|
||||||
hash = req.payload_hash,
|
|
||||||
);
|
|
||||||
|
|
||||||
let credential_scope = format!("{date}/{region}/{SERVICE}/aws4_request");
|
|
||||||
let string_to_sign = format!(
|
|
||||||
"AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
|
|
||||||
timestamp = req.timestamp,
|
|
||||||
scope = credential_scope,
|
|
||||||
hash = hex_hash(canonical_request.as_bytes()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let signing_key = derive_signing_key(secret_key, date, region);
|
|
||||||
let signature = hex_hmac(&signing_key, string_to_sign.as_bytes());
|
|
||||||
|
|
||||||
let auth = format!(
|
|
||||||
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
|
||||||
);
|
|
||||||
|
|
||||||
(auth, amz_date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_signing_key(secret_key: &str, date: &str, region: &str) -> Vec<u8> {
|
|
||||||
let k_date = hmac_bytes(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
|
|
||||||
let k_region = hmac_bytes(&k_date, region.as_bytes());
|
|
||||||
let k_service = hmac_bytes(&k_region, SERVICE.as_bytes());
|
|
||||||
hmac_bytes(&k_service, b"aws4_request")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key len");
|
|
||||||
mac.update(data);
|
|
||||||
mac.finalize().into_bytes().to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex_hmac(key: &[u8], data: &[u8]) -> String {
|
|
||||||
hex::encode(hmac_bytes(key, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex_hash(data: &[u8]) -> String {
|
|
||||||
hex::encode(Sha256::digest(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url_encode(s: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for b in s.bytes() {
|
|
||||||
match b {
|
|
||||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => {
|
|
||||||
out.push(b as char)
|
|
||||||
}
|
|
||||||
_ => out.push_str(&format!("%{b:02X}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn endpoint_host(endpoint: &str) -> String {
|
|
||||||
let s = endpoint
|
|
||||||
.strip_prefix("https://")
|
|
||||||
.or_else(|| endpoint.strip_prefix("http://"))
|
|
||||||
.unwrap_or(endpoint);
|
|
||||||
s.trim_end_matches('/').to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn region_from_endpoint(endpoint: &str) -> String {
|
|
||||||
if endpoint.contains("r2.cloudflarestorage.com") {
|
|
||||||
return "auto".to_string();
|
|
||||||
}
|
|
||||||
let host = endpoint_host(endpoint);
|
|
||||||
let parts: Vec<&str> = host.split('.').collect();
|
|
||||||
for (i, part) in parts.iter().enumerate() {
|
|
||||||
if *part == "s3" && i + 1 < parts.len() {
|
|
||||||
return parts[i + 1].to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"us-east-1".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn chrono_now() -> String {
|
|
||||||
let now = std::time::SystemTime::now();
|
|
||||||
let duration = now
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let secs = duration.as_secs();
|
|
||||||
// Simple UTC time formatting without chrono dependency
|
|
||||||
let days = secs / 86400;
|
|
||||||
let time_of_day = secs % 86400;
|
|
||||||
let hours = time_of_day / 3600;
|
|
||||||
let minutes = (time_of_day % 3600) / 60;
|
|
||||||
let seconds = time_of_day % 60;
|
|
||||||
|
|
||||||
// Calculate year/month/day from days since epoch
|
|
||||||
let (year, month, day) = days_to_date(days);
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
|
|
||||||
let mut year = 1970u64;
|
|
||||||
loop {
|
|
||||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
|
||||||
if days < days_in_year {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
days -= days_in_year;
|
|
||||||
year += 1;
|
|
||||||
}
|
|
||||||
let leap = is_leap(year);
|
|
||||||
let month_days = [
|
|
||||||
31,
|
|
||||||
if leap { 29 } else { 28 },
|
|
||||||
31,
|
|
||||||
30,
|
|
||||||
31,
|
|
||||||
30,
|
|
||||||
31,
|
|
||||||
31,
|
|
||||||
30,
|
|
||||||
31,
|
|
||||||
30,
|
|
||||||
31,
|
|
||||||
];
|
|
||||||
let mut month = 0u64;
|
|
||||||
for (i, &md) in month_days.iter().enumerate() {
|
|
||||||
if days < md {
|
|
||||||
month = i as u64 + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
days -= md;
|
|
||||||
}
|
|
||||||
if month == 0 {
|
|
||||||
month = 12;
|
|
||||||
}
|
|
||||||
(year, month, days + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_leap(year: u64) -> bool {
|
|
||||||
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::config::SyncBackend;
|
use crate::config::SyncBackend;
|
||||||
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT};
|
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, TEXT};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
style::{Color, Style, Stylize},
|
style::{Color, Style, Stylize},
|
||||||
@@ -31,13 +31,6 @@ pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, title: &str, area:
|
|||||||
("webdav not set", MUTED)
|
("webdav not set", MUTED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SyncBackend::S3 => {
|
|
||||||
if app.config.settings.s3_endpoint.is_some() {
|
|
||||||
("s3 ready", PURPLE)
|
|
||||||
} else {
|
|
||||||
("s3 not set", MUTED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut spans: Vec<Span<'static>> = vec![
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len};
|
use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len};
|
||||||
use crate::config::SyncBackend;
|
use crate::config::SyncBackend;
|
||||||
use crate::ui::component::{FormRow, badge_span};
|
use crate::ui::component::{FormRow, badge_span};
|
||||||
use crate::ui::{ACCENT, ORANGE, PURPLE};
|
use crate::ui::{ACCENT, ORANGE};
|
||||||
|
|
||||||
use super::{View, handle_form_nav};
|
use super::{View, handle_form_nav};
|
||||||
|
|
||||||
@@ -40,7 +40,6 @@ impl View for SettingsView {
|
|||||||
SettingsField::Backend => match settings.backend {
|
SettingsField::Backend => match settings.backend {
|
||||||
SyncBackend::Gist => badge_span("Gist", ACCENT),
|
SyncBackend::Gist => badge_span("Gist", ACCENT),
|
||||||
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
|
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
|
||||||
SyncBackend::S3 => badge_span("S3", PURPLE),
|
|
||||||
},
|
},
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
@@ -55,7 +54,6 @@ impl View for SettingsView {
|
|||||||
field,
|
field,
|
||||||
SettingsField::SyncPassword
|
SettingsField::SyncPassword
|
||||||
| SettingsField::WebdavPassword
|
| SettingsField::WebdavPassword
|
||||||
| SettingsField::S3SecretKey
|
|
||||||
);
|
);
|
||||||
let (display, secret_cursor) = if is_secret {
|
let (display, secret_cursor) = if is_secret {
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
@@ -119,8 +117,7 @@ fn settings_toggle(settings: &mut SettingsState) {
|
|||||||
SettingsField::Backend => {
|
SettingsField::Backend => {
|
||||||
settings.backend = match settings.backend {
|
settings.backend = match settings.backend {
|
||||||
SyncBackend::Gist => SyncBackend::Webdav,
|
SyncBackend::Gist => SyncBackend::Webdav,
|
||||||
SyncBackend::Webdav => SyncBackend::S3,
|
SyncBackend::Webdav => SyncBackend::Gist,
|
||||||
SyncBackend::S3 => SyncBackend::Gist,
|
|
||||||
};
|
};
|
||||||
settings.ensure_active_visible();
|
settings.ensure_active_visible();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user