CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
The client half of task 1942's server work. Metadata already rides the delta feed; this fetches the payload so a synced image exists on the device. Blobs are filed under their own sha256, so the same image attached to five notes is stored once and re-downloading it is free — the dedupe the task asks for falls out of content addressing rather than needing bookkeeping. The hash is also the integrity check, applied on the way IN. Bytes that don't hash to what the server advertised are refused rather than filed under a name that lies about them — and because the blob then still counts as missing, the next sync simply tries again. SECURITY: the hash arrives in a server response and becomes a FILENAME, so it is validated as 64 hex characters before touching the filesystem. Without that, a hostile or buggy server could send "../../..." and steer a write outside the blob directory. Tested. A failed attachment never fails the sync. Notes are the primary data and have already landed; aborting here would let one unreachable file block every future sync. Counted, logged, surfaced in the UI as "they'll retry on the next sync", and retried because the blob is still absent. sha2 is pure Rust, so the Windows cross-compile lane pays nothing for it — the constraint recorded in ci-requirements.md. SPLIT, deliberately: this stores the bytes but does NOT yet render them in the webview. That half needs a custom URI scheme or the asset protocol, whose URL form differs by platform (Windows uses http://scheme.localhost/, others scheme://localhost/) — and CI cannot verify webview rendering at all, being headless with no webview. Guessing at it here would ship an unverifiable change on the most fragile lane. Follow-up filed; synced images will show as broken until it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
354 lines
12 KiB
Rust
354 lines
12 KiB
Rust
//! HTTP transport to a ThoughtSync server.
|
|
//!
|
|
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
|
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
|
//! which is why the timeout, identity headers and error vocabulary live here rather
|
|
//! than inline at each call site.
|
|
//!
|
|
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
|
//! fully usable with no network at all.
|
|
|
|
use std::time::Duration;
|
|
|
|
use reqwest::{RequestBuilder, StatusCode};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::compat::{self, Compatibility, ServerInfo};
|
|
use super::wire;
|
|
|
|
/// Timeout for the short request/response calls in this module. Kept tight because a
|
|
/// user is watching a button while they run, and the most common mistake — a wrong
|
|
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
|
|
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
|
/// failing one at ten seconds would make a large store impossible to ever pull.
|
|
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
|
|
|
|
/// Shared by every call that presents a token, so a revoked one reads the same way
|
|
/// wherever it surfaces.
|
|
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
|
|
revoked. Unlink and link again to issue a new one.";
|
|
|
|
/// What the link UI needs after a handshake: where we ended up (the normalized URL,
|
|
/// which may differ from what was typed), who answered, and whether we can work
|
|
/// with them.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProbeResult {
|
|
pub base_url: String,
|
|
pub server: ServerInfo,
|
|
pub compatibility: Compatibility,
|
|
}
|
|
|
|
/// The account a device token belongs to. Surfaced after linking so the user can
|
|
/// confirm they linked the account they meant to — easy to get wrong on a server
|
|
/// hosting more than one.
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Identity {
|
|
pub id: String,
|
|
pub email: String,
|
|
#[serde(default)]
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DeviceLoginResponse {
|
|
token: String,
|
|
user: Identity,
|
|
}
|
|
|
|
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
|
|
reqwest::Client::builder()
|
|
.timeout(timeout)
|
|
.build()
|
|
.map_err(|e| format!("Could not start the network client: {e}"))
|
|
}
|
|
|
|
fn http() -> Result<reqwest::Client, String> {
|
|
http_with(REQUEST_TIMEOUT)
|
|
}
|
|
|
|
/// Attach the client-identity headers every request carries, plus a bearer token
|
|
/// when we hold one.
|
|
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
|
|
let mut builder = builder;
|
|
for (name, value) in compat::client_headers() {
|
|
builder = builder.header(name, value);
|
|
}
|
|
match token {
|
|
Some(t) => builder.bearer_auth(t),
|
|
None => builder,
|
|
}
|
|
}
|
|
|
|
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
|
format!(
|
|
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
|
|
different site may be answering there.",
|
|
status.as_u16()
|
|
)
|
|
}
|
|
|
|
/// Ask a server who it is and whether we can sync with it.
|
|
///
|
|
/// `Err` means we never got a usable answer (bad address, unreachable, not a
|
|
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
|
|
/// with a verdict — that distinction matters, because the two need very different
|
|
/// messages: one is "check what you typed", the other is "update something".
|
|
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
|
let base_url = compat::normalize_base_url(raw_url)
|
|
.ok_or("Enter a server address, like https://notes.example.com")?;
|
|
|
|
let request = prepare(http()?.get(config_url(&base_url)), None);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(&base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(&base_url, status));
|
|
}
|
|
|
|
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
|
|
// captive portal). Report the address, not the parse error, which would mean
|
|
// nothing to the person reading it.
|
|
let server: ServerInfo = response.json().await.map_err(|_| {
|
|
format!(
|
|
"{base_url} responded, but not with ThoughtSync's configuration. \
|
|
Is that the right address?"
|
|
)
|
|
})?;
|
|
|
|
let compatibility = compat::evaluate(&server);
|
|
Ok(ProbeResult {
|
|
base_url,
|
|
server,
|
|
compatibility,
|
|
})
|
|
}
|
|
|
|
/// Exchange email + password for a device bearer token.
|
|
///
|
|
/// The fresh-install path: it needs no existing session, which is what lets a brand
|
|
/// new desktop install link without visiting the web app first.
|
|
pub async fn device_login(
|
|
base_url: &str,
|
|
email: &str,
|
|
password: &str,
|
|
device_name: &str,
|
|
) -> Result<(String, Identity), String> {
|
|
let body = serde_json::json!({
|
|
"email": email,
|
|
"password": password,
|
|
"name": device_name,
|
|
});
|
|
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err("That email and password didn't match an account on this server.".to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
let parsed: DeviceLoginResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
|
|
Ok((parsed.token, parsed.user))
|
|
}
|
|
|
|
/// Validate a token by asking whom it belongs to.
|
|
///
|
|
/// Used when the user pastes a token issued from the web app. Storing it unverified
|
|
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
|
|
/// far from the thing that caused it.
|
|
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
|
|
let request = prepare(http()?.get(me_url(base_url)), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
let message = "That token isn't valid on this server — it may have been revoked. \
|
|
Issue a new one from the web app under Account → Linked devices.";
|
|
return Err(message.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
|
|
}
|
|
|
|
/// Fetch one page of the change feed, starting after `since`.
|
|
///
|
|
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
|
|
/// rather than here so the transport stays a single request/response.
|
|
pub async fn fetch_changes(
|
|
base_url: &str,
|
|
token: &str,
|
|
since: i64,
|
|
) -> Result<wire::ChangesPage, String> {
|
|
let url = format!("{base_url}/api/sync/changes?since={since}");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Download one attachment's bytes.
|
|
///
|
|
/// Metadata already arrived on the delta feed; this is only the payload, fetched
|
|
/// over the same route the web app uses (owner/shared scoped server-side).
|
|
pub async fn fetch_attachment(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
attachment_id: &str,
|
|
) -> Result<Vec<u8>, String> {
|
|
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.bytes()
|
|
.await
|
|
.map(|b| b.to_vec())
|
|
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Send a batch of changes and hand back the raw reply.
|
|
///
|
|
/// Returns text rather than parsed results so this module stays pure transport —
|
|
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
|
|
/// the parsing be unit-tested without a server.
|
|
pub async fn push_changes<T: Serialize>(
|
|
base_url: &str,
|
|
token: &str,
|
|
changes: &[T],
|
|
) -> Result<String, String> {
|
|
let body = serde_json::json!({ "changes": changes });
|
|
let url = format!("{base_url}/api/sync/push");
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(TOKEN_REJECTED.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
|
}
|
|
|
|
/// The public, unauthenticated endpoint carrying the handshake.
|
|
fn config_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/config")
|
|
}
|
|
|
|
fn device_login_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/device-login")
|
|
}
|
|
|
|
fn me_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/me")
|
|
}
|
|
|
|
/// Turn a transport failure into something a person can act on. reqwest's own
|
|
/// Display is accurate but reads like a stack trace.
|
|
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
|
|
if err.is_timeout() {
|
|
// No specific duration here: these calls run under two different budgets
|
|
// (interactive vs bulk sync), and naming the wrong one is worse than naming
|
|
// none.
|
|
format!(
|
|
"{base_url} didn't respond in time. It may be offline, or unreachable \
|
|
from this network."
|
|
)
|
|
} else if err.is_connect() {
|
|
format!(
|
|
"Couldn't reach {base_url}. Check the address and that the server is \
|
|
running. If it uses plain HTTP, include http:// explicitly."
|
|
)
|
|
} else {
|
|
format!("Couldn't reach {base_url}: {err}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn urls_join_without_doubling_slashes() {
|
|
// normalize_base_url has already stripped any trailing slash, so plain
|
|
// concatenation is correct — this pins that assumption.
|
|
assert_eq!(
|
|
config_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/config"
|
|
);
|
|
assert_eq!(
|
|
device_login_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/device-login"
|
|
);
|
|
assert_eq!(
|
|
me_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/me"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn urls_preserve_a_port_and_subpath() {
|
|
assert_eq!(
|
|
config_url("http://192.168.1.10:8000/thoughtsync"),
|
|
"http://192.168.1.10:8000/thoughtsync/api/config"
|
|
);
|
|
}
|
|
}
|