mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-22 07:08:53 +02:00
feat: own sync server
This commit is contained in:
1 parent
d6c21de169
commit
8f64745b1a
42 files changed
+1965
-136
No files matched your search
@@ -3746,6 +3746,9 @@ fn sync_mode_error_response(err: String) -> (StatusCode, String) {
|
||||
pub async fn remote_launch_precondition(
|
||||
profile: &crate::profile::types::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
|
||||
return Err(refusal);
|
||||
}
|
||||
remote_launch_profile_rules(profile)?;
|
||||
|
||||
// The manifest is written last, so a host pulling mid-upload gets files
|
||||
@@ -4219,7 +4222,7 @@ fn cookie_bot_eligible_profile(
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
crate::cookie_bot::run_precondition(&profile)
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
+133
-3
@@ -27,6 +27,13 @@ const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
static ACCESS_TOKEN_REFRESH: Streak = Streak::new(module_path!(), "Access token refresh");
|
||||
static SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Sync token fetch");
|
||||
static SERVER_SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Own server sync token fetch");
|
||||
|
||||
/// Tokens for the customer's own sync server, by address. In memory only:
|
||||
/// they last minutes and cost one request to replace.
|
||||
static SERVER_SYNC_TOKENS: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
> = std::sync::LazyLock::new(Default::default);
|
||||
static PROFILE_REFRESH: Streak = Streak::new(module_path!(), "Cloud profile refresh");
|
||||
static PROXY_CONFIG_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy config fetch");
|
||||
static PROXY_USAGE_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy usage fetch");
|
||||
@@ -557,7 +564,8 @@ impl CloudAuthManager {
|
||||
|
||||
// --- JWT expiry check ---
|
||||
|
||||
fn jwt_expiry(token: &str) -> Option<i64> {
|
||||
/// A JWT's claims, unverified.
|
||||
fn jwt_claims(token: &str) -> Option<serde_json::Value> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
@@ -572,8 +580,21 @@ impl CloudAuthManager {
|
||||
}
|
||||
};
|
||||
|
||||
let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
|
||||
json.get("exp").and_then(|v| v.as_i64())
|
||||
serde_json::from_slice(&payload).ok()
|
||||
}
|
||||
|
||||
fn jwt_expiry(token: &str) -> Option<i64> {
|
||||
Self::jwt_claims(token)?.get("exp").and_then(|v| v.as_i64())
|
||||
}
|
||||
|
||||
/// Whether `token` is one the Donut API bound to `server`. Anything else,
|
||||
/// such as a token for Donut cloud from an API that does not know the
|
||||
/// `server` field yet, must never be sent to a third party's server.
|
||||
fn is_server_sync_token_for(token: &str, server: &str) -> bool {
|
||||
Self::jwt_claims(token).is_some_and(|claims| {
|
||||
claims.get("scope").and_then(|v| v.as_str()) == Some("server-sync")
|
||||
&& claims.get("aud").and_then(|v| v.as_str()) == Some(server)
|
||||
})
|
||||
}
|
||||
|
||||
fn jwt_expires_within(token: &str, margin_secs: i64, now: i64) -> bool {
|
||||
@@ -1148,7 +1169,86 @@ impl CloudAuthManager {
|
||||
Ok(Some(sync_token))
|
||||
}
|
||||
|
||||
/// A token for the customer's own donut-sync at `server`, which the Donut
|
||||
/// API binds to that one address (`sync::account_server`).
|
||||
pub async fn server_sync_token(&self, server: &str) -> Result<String, String> {
|
||||
if !self.is_logged_in().await {
|
||||
return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED"));
|
||||
}
|
||||
let cached = SERVER_SYNC_TOKENS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|tokens| tokens.get(server).cloned());
|
||||
if let Some(token) = cached.filter(|token| !Self::is_jwt_expiring_soon(token)) {
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let fetched = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/sync-token");
|
||||
let client = self.client.clone();
|
||||
let body = serde_json::json!({ "server": server });
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get sync token: {e}"))?;
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED"));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Sync token request failed ({status}): {body}"));
|
||||
}
|
||||
let result: SyncTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse sync token response: {e}"))?;
|
||||
Ok(result.sync_token)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let fetched = fetched.and_then(|token| {
|
||||
if Self::is_server_sync_token_for(&token, server) {
|
||||
Ok(token)
|
||||
} else {
|
||||
Err(crate::backend_error("ACCOUNT_SYNC_UNAVAILABLE"))
|
||||
}
|
||||
});
|
||||
match fetched {
|
||||
Ok(token) => {
|
||||
SERVER_SYNC_TOKEN_FETCH.succeeded();
|
||||
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
|
||||
tokens.insert(server.to_string(), token.clone());
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
Err(e) => {
|
||||
SERVER_SYNC_TOKEN_FETCH.failed(&e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a token the own server refused, so the next request fetches one.
|
||||
pub(crate) fn discard_server_sync_token(refused: &str) {
|
||||
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
|
||||
tokens.retain(|_, token| token != refused);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<(), String> {
|
||||
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
|
||||
tokens.clear();
|
||||
}
|
||||
// The own server is this account's choice; the next account starts on
|
||||
// Donut cloud.
|
||||
crate::sync::account_server::clear();
|
||||
|
||||
// Clear wayfern token
|
||||
self.clear_wayfern_token().await;
|
||||
self.set_wayfern_device_refusal(None);
|
||||
@@ -2150,6 +2250,36 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn jwt_with(claims: serde_json::Value) -> String {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let encode =
|
||||
|value: &serde_json::Value| general_purpose::URL_SAFE_NO_PAD.encode(value.to_string());
|
||||
format!(
|
||||
"{}.{}.signature",
|
||||
encode(&serde_json::json!({ "alg": "RS256" })),
|
||||
encode(&claims)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_token_bound_to_the_server_goes_to_it() {
|
||||
let server = "https://sync.example.com";
|
||||
let bound = jwt_with(serde_json::json!({ "scope": "server-sync", "aud": server }));
|
||||
assert!(CloudAuthManager::is_server_sync_token_for(&bound, server));
|
||||
// An API that ignores `server` answers with a token for Donut cloud, which
|
||||
// must never reach someone else's server.
|
||||
let cloud = jwt_with(serde_json::json!({ "scope": "sync", "prefix": "users/u1/" }));
|
||||
assert!(!CloudAuthManager::is_server_sync_token_for(&cloud, server));
|
||||
assert!(!CloudAuthManager::is_server_sync_token_for(
|
||||
&bound,
|
||||
"https://other.example.com"
|
||||
));
|
||||
assert!(!CloudAuthManager::is_server_sync_token_for(
|
||||
"not-a-jwt",
|
||||
server
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_token_request_yields_the_servers_message() {
|
||||
let refused = r#"Wayfern token request failed (403 Forbidden): {"message":"Browser automation is restricted to your primary device. Log out other devices to use it here.","error":"Forbidden","statusCode":403}"#;
|
||||
|
||||
@@ -603,6 +603,15 @@ pub struct CookieBotUsage {
|
||||
|
||||
// --- Client-side preconditions ---------------------------------------------
|
||||
|
||||
/// [`bot_precondition`] for this device as it is now: also refused while the
|
||||
/// account syncs with its own server, which the bot's hosts cannot reach.
|
||||
pub fn run_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
|
||||
return Err(refusal);
|
||||
}
|
||||
bot_precondition(profile, &exit_reachability(profile))
|
||||
}
|
||||
|
||||
/// Whether this profile could ever be warmed by the bot.
|
||||
///
|
||||
/// The server is authoritative — it re-checks all of this and owns the parts
|
||||
|
||||
@@ -2117,7 +2117,7 @@ async fn save_cookie_bot_schedule(
|
||||
// Refused here rather than when the run is due: a profile that can never be
|
||||
// warmed should never reach an enrolment, an hour of quota or a leased host.
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
cookie_bot::run_precondition(&profile)?;
|
||||
// The frontend sends the user's choices; the profile facts the server refuses
|
||||
// a run on are stamped here, from the profile itself, so a caller cannot
|
||||
// assert them.
|
||||
@@ -2178,7 +2178,7 @@ async fn run_cookie_bot_now(
|
||||
max_minutes: Option<u32>,
|
||||
) -> Result<cookie_bot::CookieBotRunStarted, String> {
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
cookie_bot::run_precondition(&profile)?;
|
||||
cookie_bot::run_now(&profile_id, max_minutes)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run start", e))
|
||||
@@ -3541,6 +3541,8 @@ pub fn run_with_builder(
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
check_sync_server_connection,
|
||||
sync::account_server::get_account_sync_server,
|
||||
sync::account_server::set_account_sync_server,
|
||||
set_profile_sync_mode,
|
||||
cancel_profile_sync,
|
||||
request_profile_sync,
|
||||
|
||||
@@ -10878,12 +10878,11 @@ impl McpServer {
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
.map_err(|message| McpError {
|
||||
code: -32000,
|
||||
message,
|
||||
data: None,
|
||||
})?;
|
||||
crate::cookie_bot::run_precondition(&profile).map_err(|message| McpError {
|
||||
code: -32000,
|
||||
message,
|
||||
data: None,
|
||||
})?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,9 @@ pub async fn start_remote_session(
|
||||
.to_string();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
|
||||
return Err(RemoteSessionError::Other(refusal));
|
||||
}
|
||||
if let Some(refusal) = local_launch_refusal(profile, &platform) {
|
||||
return Err(refusal);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,11 @@ pub struct AppSettings {
|
||||
pub api_token: Option<String>, // Displayed token for user to copy
|
||||
#[serde(default)]
|
||||
pub sync_server_url: Option<String>, // URL of the sync server
|
||||
/// The customer's own donut-sync a signed-in paid account syncs with,
|
||||
/// instead of Donut cloud (`sync::account_server`). Owned by
|
||||
/// `set_account_sync_server`, never by a settings save.
|
||||
#[serde(default)]
|
||||
pub account_sync_server_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub first_launch_timestamp: Option<u64>, // Unix epoch seconds when app was first launched
|
||||
#[serde(default)]
|
||||
@@ -302,6 +307,7 @@ impl Default for AppSettings {
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
sync_server_url: None,
|
||||
account_sync_server_url: None,
|
||||
first_launch_timestamp: None,
|
||||
commercial_trial_acknowledged: false,
|
||||
mcp_remote_enabled: false,
|
||||
@@ -711,10 +717,12 @@ pub async fn save_app_settings(
|
||||
settings.mcp_remote_enabled = current.mcp_remote_enabled;
|
||||
settings.mcp_remote_key_id = current.mcp_remote_key_id;
|
||||
settings.feedback_sent_at = current.feedback_sent_at;
|
||||
settings.account_sync_server_url = current.account_sync_server_url;
|
||||
}
|
||||
} else {
|
||||
settings.mcp_remote_enabled = false;
|
||||
settings.mcp_remote_key_id = None;
|
||||
settings.account_sync_server_url = None;
|
||||
}
|
||||
|
||||
let mut persist_settings = settings.clone();
|
||||
@@ -893,6 +901,16 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
#[tauri::command]
|
||||
pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result<SyncSettings, String> {
|
||||
// Cloud auth takes priority over self-hosted settings
|
||||
if let Some(server) = crate::sync::account_server::configured().await {
|
||||
let sync_token = crate::cloud_auth::CLOUD_AUTH
|
||||
.server_sync_token(&server)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(SyncSettings {
|
||||
sync_server_url: Some(server),
|
||||
sync_token,
|
||||
});
|
||||
}
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let sync_token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
@@ -1449,6 +1467,7 @@ mod tests {
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
sync_server_url: None,
|
||||
account_sync_server_url: None,
|
||||
first_launch_timestamp: None,
|
||||
commercial_trial_acknowledged: false,
|
||||
mcp_remote_enabled: false,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A donut-sync server the customer runs, used while signed in to a paid
|
||||
//! Donut account.
|
||||
//!
|
||||
//! The app signs in to that server with a short token the Donut API issues for
|
||||
//! its one address, and the server asks the API whose the token is. The plan's
|
||||
//! profile limit and the team's namespace, roles and group access thus apply
|
||||
//! there as on Donut cloud, and every person who syncs needs their own seat. A
|
||||
//! shared `SYNC_TOKEN` opens the whole bucket to anyone who has it, so it stays
|
||||
//! the signed-out way to use a server.
|
||||
|
||||
use crate::settings_manager::SettingsManager;
|
||||
|
||||
/// The address in the one form the Donut API and donut-sync also write: scheme,
|
||||
/// host and port as the URL parser writes them, then the path without trailing
|
||||
/// slashes. A token is bound to this exact string.
|
||||
pub fn normalize_server_url(raw: &str) -> Option<String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let url = url::Url::parse(raw).ok()?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let host = url.host_str().filter(|host| !host.is_empty())?;
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
if url.query().is_some_and(|q| !q.is_empty()) || url.fragment().is_some_and(|f| !f.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let port = url
|
||||
.port()
|
||||
.map(|port| format!(":{port}"))
|
||||
.unwrap_or_default();
|
||||
Some(format!(
|
||||
"{}://{host}{port}{}",
|
||||
url.scheme(),
|
||||
url.path().trim_end_matches('/')
|
||||
))
|
||||
}
|
||||
|
||||
/// The own server set on this device, signed in or not.
|
||||
fn stored() -> Option<String> {
|
||||
SettingsManager::instance()
|
||||
.load_settings()
|
||||
.ok()?
|
||||
.account_sync_server_url
|
||||
}
|
||||
|
||||
/// The own server this signed-in account syncs with, or `None` for Donut
|
||||
/// cloud.
|
||||
pub async fn configured() -> Option<String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
return None;
|
||||
}
|
||||
stored()
|
||||
}
|
||||
|
||||
/// The refusal for work that runs a profile on Donut's hosts. They take the
|
||||
/// profile from Donut cloud and cannot reach the customer's server.
|
||||
pub fn cloud_run_refusal() -> Option<String> {
|
||||
stored().map(|_| crate::backend_error("REMOTE_NEEDS_DONUT_SYNC"))
|
||||
}
|
||||
|
||||
pub(crate) fn clear() {
|
||||
let manager = SettingsManager::instance();
|
||||
if let Ok(mut settings) = manager.load_settings() {
|
||||
if settings.account_sync_server_url.take().is_some() {
|
||||
let _ = manager.save_settings(&settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that `raw` is a server this account can sync with, and return its
|
||||
/// address.
|
||||
async fn check(raw: &str) -> Result<String, String> {
|
||||
let auth = &crate::cloud_auth::CLOUD_AUTH;
|
||||
if !auth.is_logged_in().await {
|
||||
return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED"));
|
||||
}
|
||||
if !auth.can_use_cloud_backup().await {
|
||||
return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED"));
|
||||
}
|
||||
let server =
|
||||
normalize_server_url(raw).ok_or_else(|| crate::backend_error("SYNC_SERVER_URL_INVALID"))?;
|
||||
|
||||
let probe = super::preflight::check_sync_server(&server).await;
|
||||
if !probe.server_reachable || probe.storage_ready == Some(false) {
|
||||
return Err(crate::backend_error("SYNC_SERVER_UNREACHABLE"));
|
||||
}
|
||||
if probe.storage_reachable == Some(false) {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "SYNC_SERVER_STORAGE_UNREACHABLE",
|
||||
"params": { "endpoint": probe.storage_endpoint.unwrap_or_default() },
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
match probe.account_url.as_deref().and_then(normalize_server_url) {
|
||||
None => return Err(crate::backend_error("SYNC_SERVER_NO_ACCOUNTS")),
|
||||
Some(address) if address != server => {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "SYNC_SERVER_ADDRESS_MISMATCH",
|
||||
"params": { "address": address },
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
|
||||
// The API decides who may sync. Asking now names a refusal here, not at the
|
||||
// first sync.
|
||||
auth.server_sync_token(&server).await?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account_sync_server() -> Result<Option<String>, String> {
|
||||
Ok(stored())
|
||||
}
|
||||
|
||||
/// Sync this signed-in account with its own server, or with Donut cloud again
|
||||
/// when `server_url` is empty.
|
||||
#[tauri::command]
|
||||
pub async fn set_account_sync_server(server_url: Option<String>) -> Result<Option<String>, String> {
|
||||
let server = match server_url.as_deref().map(str::trim) {
|
||||
Some(raw) if !raw.is_empty() => Some(check(raw).await?),
|
||||
_ => None,
|
||||
};
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
settings.account_sync_server_url = server.clone();
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
log::info!("Account sync server set own_server={}", server.is_some());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_one_form_for_every_way_to_type_the_same_address() {
|
||||
// The same cases as the API and donut-sync: a token only matches when all
|
||||
// three write the address alike.
|
||||
for typed in [
|
||||
"https://Sync.Example.com",
|
||||
"https://sync.example.com/",
|
||||
" https://sync.example.com:443// ",
|
||||
"https://sync.example.com/?",
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize_server_url(typed).as_deref(),
|
||||
Some("https://sync.example.com"),
|
||||
"{typed}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
normalize_server_url("http://192.168.1.5:3929/donut/").as_deref(),
|
||||
Some("http://192.168.1.5:3929/donut")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_server_url("http://[::1]:3929").as_deref(),
|
||||
Some("http://[::1]:3929")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_cannot_be_a_server_address() {
|
||||
let long = format!("https://{}.com", "a".repeat(2100));
|
||||
for bad in [
|
||||
"",
|
||||
"sync.example.com",
|
||||
"ftp://sync.example.com",
|
||||
"https://user:pass@sync.example.com",
|
||||
"https://sync.example.com/?a=1",
|
||||
"https://sync.example.com/#x",
|
||||
long.as_str(),
|
||||
] {
|
||||
assert_eq!(normalize_server_url(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,12 +574,23 @@ impl SyncEngine {
|
||||
pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result<Self, String> {
|
||||
// Cloud auth takes priority
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
|
||||
.ok_or_else(|| "Cloud sync token not available".to_string())?;
|
||||
let (url, token) = match super::account_server::configured().await {
|
||||
Some(server) => {
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.server_sync_token(&server)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get own server sync token: {e}"))?;
|
||||
(server, token)
|
||||
}
|
||||
None => {
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
|
||||
.ok_or_else(|| "Cloud sync token not available".to_string())?;
|
||||
(crate::cloud_auth::CLOUD_SYNC_URL.to_string(), token)
|
||||
}
|
||||
};
|
||||
let mut engine = Self::new(url, token);
|
||||
engine.read_only = crate::team_roles::is_read_only();
|
||||
return Ok(engine);
|
||||
@@ -3923,7 +3934,10 @@ async fn apply_profile_sync_mode(
|
||||
);
|
||||
}
|
||||
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
// The cloud counts what it stores; an own server counts for itself.
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
|
||||
&& super::account_server::configured().await.is_none()
|
||||
{
|
||||
let sync_count = profile_manager
|
||||
.list_profiles()
|
||||
.map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod account_server;
|
||||
mod client;
|
||||
pub mod config_entities;
|
||||
pub mod encryption;
|
||||
|
||||
@@ -41,6 +41,10 @@ pub struct SyncServerCheck {
|
||||
pub storage_reachable: Option<bool>,
|
||||
/// Why the storage probe failed, for the log and the error surface.
|
||||
pub storage_error: Option<String>,
|
||||
/// The address Donut accounts sign in to the server with, when it lets
|
||||
/// them (`SYNC_PUBLIC_URL`).
|
||||
#[serde(default)]
|
||||
pub account_url: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncServerCheck {
|
||||
@@ -62,6 +66,8 @@ struct ReadyzBody {
|
||||
s3: Option<bool>,
|
||||
#[serde(default, rename = "storageEndpoint")]
|
||||
storage_endpoint: Option<String>,
|
||||
#[serde(default, rename = "accountUrl")]
|
||||
account_url: Option<String>,
|
||||
}
|
||||
|
||||
fn probe_client() -> reqwest::Client {
|
||||
@@ -113,6 +119,7 @@ pub async fn check_sync_server(server_url: &str) -> SyncServerCheck {
|
||||
|
||||
let body = readyz.json::<ReadyzBody>().await.ok();
|
||||
check.storage_ready = body.as_ref().and_then(|b| b.s3);
|
||||
check.account_url = body.as_ref().and_then(|b| b.account_url.clone());
|
||||
check.storage_endpoint = body.and_then(|b| b.storage_endpoint);
|
||||
|
||||
if let Some(endpoint) = check.storage_endpoint.clone() {
|
||||
@@ -226,6 +233,7 @@ mod tests {
|
||||
storage_endpoint: Some("http://minio:9000".to_string()),
|
||||
storage_reachable: Some(false),
|
||||
storage_error: Some("connection failed: dns error".to_string()),
|
||||
account_url: None,
|
||||
};
|
||||
assert!(!check.is_usable());
|
||||
}
|
||||
@@ -238,6 +246,7 @@ mod tests {
|
||||
storage_endpoint: Some("http://localhost:9101".to_string()),
|
||||
storage_reachable: Some(true),
|
||||
storage_error: None,
|
||||
account_url: None,
|
||||
};
|
||||
assert!(check.is_usable());
|
||||
}
|
||||
@@ -253,6 +262,7 @@ mod tests {
|
||||
storage_endpoint: None,
|
||||
storage_reachable: None,
|
||||
storage_error: None,
|
||||
account_url: None,
|
||||
};
|
||||
assert!(check.is_usable());
|
||||
}
|
||||
@@ -267,6 +277,20 @@ mod tests {
|
||||
assert!(!check.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readyz_names_the_account_address() {
|
||||
let body: ReadyzBody = serde_json::from_str(
|
||||
r#"{"status":"ready","s3":true,"accountUrl":"https://sync.example.com"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
body.account_url.as_deref(),
|
||||
Some("https://sync.example.com")
|
||||
);
|
||||
let older: ReadyzBody = serde_json::from_str(r#"{"status":"ready","s3":true}"#).unwrap();
|
||||
assert_eq!(older.account_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_server_is_not_usable() {
|
||||
assert!(!SyncServerCheck::default().is_usable());
|
||||
|
||||
@@ -36,6 +36,8 @@ pub enum SyncWorkItem {
|
||||
#[derive(Clone, Copy)]
|
||||
enum TokenSource {
|
||||
Cloud,
|
||||
/// The customer's own server, with a token the Donut API binds to it.
|
||||
Account,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
@@ -134,6 +136,18 @@ impl SyncSubscription {
|
||||
app_handle: &tauri::AppHandle,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Result<Option<Self>, String> {
|
||||
if let Some(server) = super::account_server::configured().await {
|
||||
let token = crate::cloud_auth::CLOUD_AUTH
|
||||
.server_sync_token(&server)
|
||||
.await?;
|
||||
return Ok(Some(Self::new(
|
||||
server,
|
||||
token,
|
||||
TokenSource::Account,
|
||||
work_tx,
|
||||
)));
|
||||
}
|
||||
|
||||
// Cloud auth takes priority
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
|
||||
@@ -221,8 +235,14 @@ impl SyncSubscription {
|
||||
}
|
||||
Err(StreamFailure::Refused(status)) => {
|
||||
log::warn!("Sync subscription refused status={status}");
|
||||
if matches!(source, TokenSource::Cloud) {
|
||||
crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(¤t);
|
||||
match source {
|
||||
TokenSource::Cloud => {
|
||||
crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(¤t)
|
||||
}
|
||||
TokenSource::Account => {
|
||||
crate::cloud_auth::CloudAuthManager::discard_server_sync_token(¤t)
|
||||
}
|
||||
TokenSource::SelfHosted => {}
|
||||
}
|
||||
refused = Some(current);
|
||||
token = None;
|
||||
@@ -289,6 +309,13 @@ impl SyncSubscription {
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
}
|
||||
TokenSource::Account => match super::account_server::configured().await {
|
||||
Some(server) => crate::cloud_auth::CLOUD_AUTH
|
||||
.server_sync_token(&server)
|
||||
.await
|
||||
.map(Some),
|
||||
None => Ok(None),
|
||||
},
|
||||
TokenSource::SelfHosted => SettingsManager::instance()
|
||||
.get_sync_token(app_handle)
|
||||
.await
|
||||
|
||||
Reference in new issue
Block a user