sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL; client.rs had items after its test module; the ffi's sync_now doc had fused into client_update's; complete_reminder (ffi and EditorAction) still said recurrence advancement was to come, though the core does it; NoteQuery.view listed views the core never matched and claimed it validated. Test scratch dirs drop the old ts-/iw- prefixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
785 lines
28 KiB
Rust
785 lines
28 KiB
Rust
//! HTTP transport to an Inkwell 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::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use reqwest::{RequestBuilder, Response, 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);
|
|
|
|
/// One file going up can be far larger than a page of notes, and a timeout shorter
|
|
/// than the transfer is not a failure that retrying ever fixes — the same upload
|
|
/// would time out again every cycle.
|
|
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// What became of this device's token on the SERVER when unlinking.
|
|
///
|
|
/// Not a bool, and not an error: unlinking must never be blocked by the network —
|
|
/// wanting to stop syncing is a local decision — so the remote half reports back
|
|
/// instead of failing the call, and each outcome needs different advice.
|
|
///
|
|
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(tag = "status", rename_all = "snake_case")]
|
|
pub enum RevokeOutcome {
|
|
/// The server confirmed it: this token authenticates nothing now.
|
|
Revoked,
|
|
/// This server has no self-revoke route — it predates one. The token is still
|
|
/// live, and only the web app can retire it.
|
|
Unsupported,
|
|
/// We couldn't reach the server, or it refused. The token is still live.
|
|
Failed { reason: String },
|
|
/// Nothing to revoke; the app wasn't linked.
|
|
Skipped,
|
|
}
|
|
|
|
/// Retire the device token we authenticate with, server-side.
|
|
///
|
|
/// Identified by the token itself rather than a device id, because a token pasted
|
|
/// from the web app never carried one — a route keyed on the id would work for
|
|
/// exactly one of the two ways this app can be linked.
|
|
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
|
|
let client = match http() {
|
|
Ok(client) => client,
|
|
Err(reason) => return RevokeOutcome::Failed { reason },
|
|
};
|
|
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
|
|
let response = match request.send().await {
|
|
Ok(response) => response,
|
|
Err(e) => {
|
|
return RevokeOutcome::Failed {
|
|
reason: describe_transport_error(base_url, &e),
|
|
}
|
|
}
|
|
};
|
|
|
|
let status = response.status();
|
|
// 401 counts as revoked: the token already authenticates nothing — retired by
|
|
// another device, or purged server-side — which is the state we were asking for.
|
|
if status.is_success() || status == StatusCode::UNAUTHORIZED {
|
|
return RevokeOutcome::Revoked;
|
|
}
|
|
match status {
|
|
// No such route: a server older than self-revoke. Any other shape of 404
|
|
// (a proxy, a stale base URL) leaves the token live too, so the advice the
|
|
// user needs is the same either way.
|
|
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
|
|
other => RevokeOutcome::Failed {
|
|
reason: unexpected_status(base_url, other),
|
|
},
|
|
}
|
|
}
|
|
|
|
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()
|
|
)
|
|
}
|
|
|
|
/// Send `request`, failing the way every call to the server fails alike: a
|
|
/// transport error, described for a person, and a 401, as `unauthorized` (what a
|
|
/// rejected credential means depends on the call). Any other status is the
|
|
/// caller's to read; [`send`] is this plus "anything but success is unexpected".
|
|
async fn send_raw(
|
|
base_url: &str,
|
|
request: RequestBuilder,
|
|
unauthorized: &str,
|
|
) -> Result<Response, String> {
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
if response.status() == StatusCode::UNAUTHORIZED {
|
|
return Err(unauthorized.to_string());
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
/// [`send_raw`], and anything but a success is [`unexpected_status`].
|
|
async fn send(
|
|
base_url: &str,
|
|
request: RequestBuilder,
|
|
unauthorized: &str,
|
|
) -> Result<Response, String> {
|
|
let response = send_raw(base_url, request, unauthorized).await?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
/// The server's own words for a refusal, when it gave some (`{"error": "…"}`).
|
|
/// "file is too large (max 25 MB)" is what a person can act on; a status is not.
|
|
async fn server_reason(response: Response) -> Option<String> {
|
|
let body = response.json::<serde_json::Value>().await.ok()?;
|
|
body.get("error")?.as_str().map(String::from)
|
|
}
|
|
|
|
/// 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 an
|
|
/// Inkwell 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")?;
|
|
// Before anything is sent, and in particular before a password or token is.
|
|
compat::refuse_public_cleartext(&base_url)?;
|
|
|
|
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 an Inkwell 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 Inkwell'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 refused = "That email and password didn't match an account on this server.";
|
|
let response = send(base_url, request, refused).await?;
|
|
|
|
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 refused = "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.";
|
|
let response = send(base_url, request, refused).await?;
|
|
|
|
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,
|
|
shares: bool,
|
|
) -> Result<wire::ChangesPage, String> {
|
|
// `shares=1` only to a server advertising `shares`: it asks for the notes shared
|
|
// with this account and the `revoked` list, which an older server would ignore.
|
|
let url = if shares {
|
|
format!("{base_url}/api/sync/changes?since={since}&shares=1")
|
|
} else {
|
|
format!("{base_url}/api/sync/changes?since={since}")
|
|
};
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let response = send(base_url, request, TOKEN_REJECTED).await?;
|
|
|
|
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 = send(base_url, request, TOKEN_REJECTED).await?;
|
|
|
|
response
|
|
.bytes()
|
|
.await
|
|
.map(|b| b.to_vec())
|
|
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Why an upload didn't land, split by whether trying again could help.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum UploadError {
|
|
/// Worth another attempt next cycle: the network, a server error, or a note the
|
|
/// server hasn't got yet.
|
|
Retry(String),
|
|
/// The server refused this file and will refuse it the same way every time —
|
|
/// too large, an id in use, bytes that don't match their hash.
|
|
Refused(String),
|
|
}
|
|
|
|
/// Upload one file attached on this device, under the id it was given here.
|
|
///
|
|
/// `PUT /api/sync/attachments/<id>` takes the raw bytes; idempotent, so a retry of
|
|
/// an upload whose reply was lost answers 200 and stores nothing twice.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn upload_attachment(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
attachment_id: &str,
|
|
filename: &str,
|
|
mime: &str,
|
|
sha256: &str,
|
|
bytes: Vec<u8>,
|
|
) -> Result<(), UploadError> {
|
|
let url = format!("{base_url}/api/sync/attachments/{attachment_id}");
|
|
let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?;
|
|
let request = prepare(client.put(url), Some(token))
|
|
.query(&[
|
|
("note_id", note_id),
|
|
("filename", filename),
|
|
("sha256", sha256),
|
|
])
|
|
.header(reqwest::header::CONTENT_TYPE, mime)
|
|
.body(bytes);
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?;
|
|
|
|
let status = response.status();
|
|
if status.is_success() {
|
|
return Ok(());
|
|
}
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
return Err(UploadError::Retry(TOKEN_REJECTED.to_string()));
|
|
}
|
|
// The server's own words, when it gave some: "file is too large (max 25 MB)" is
|
|
// what a person can act on, and a bare status code is not.
|
|
let reason = server_reason(response)
|
|
.await
|
|
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
|
if status.is_client_error() && status != StatusCode::NOT_FOUND {
|
|
Err(UploadError::Refused(reason))
|
|
} else {
|
|
// 404: the note isn't on the server yet (its push was rejected, say). 5xx:
|
|
// the server's problem, and likely a passing one.
|
|
Err(UploadError::Retry(reason))
|
|
}
|
|
}
|
|
|
|
/// 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 = send(base_url, request, TOKEN_REJECTED).await?;
|
|
|
|
response
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
|
}
|
|
|
|
// --- sharing (#5175) -----------------------------------------------------------
|
|
//
|
|
// The Share dialog on a linked device asks the server directly, over the device
|
|
// token: who is on the instance, and who a note is shared with. Shares are the
|
|
// server's, so there is nothing to keep offline and nothing to queue.
|
|
|
|
/// Someone on the instance a note can be shared with.
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub struct Member {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub display_name: String,
|
|
#[serde(default)]
|
|
pub email: String,
|
|
}
|
|
|
|
/// A group the admin made, which a note can be shared with (#5177). Sharing with it
|
|
/// reaches whoever is in it at the time.
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub struct ShareGroup {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub member_count: u32,
|
|
}
|
|
|
|
/// Everyone a note can be shared with: the other people on the instance, and every
|
|
/// group. `groups` defaults empty for a server from before groups.
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub struct Directory {
|
|
#[serde(default)]
|
|
pub members: Vec<Member>,
|
|
#[serde(default)]
|
|
pub groups: Vec<ShareGroup>,
|
|
}
|
|
|
|
/// One share of a note, at `view` or `edit`: to a person (`member`) or to a group
|
|
/// (`group`), never both.
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
pub struct NoteShare {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub member: Option<Member>,
|
|
#[serde(default)]
|
|
pub group: Option<ShareGroup>,
|
|
pub permission: String,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
}
|
|
|
|
/// Who a new share goes to.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ShareTarget {
|
|
Member(String),
|
|
Group(String),
|
|
}
|
|
|
|
impl ShareTarget {
|
|
fn body(&self, permission: &str) -> serde_json::Value {
|
|
match self {
|
|
ShareTarget::Member(id) => {
|
|
serde_json::json!({ "user_id": id, "permission": permission })
|
|
}
|
|
ShareTarget::Group(id) => {
|
|
serde_json::json!({ "group_id": id, "permission": permission })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SharesReply {
|
|
shares: Vec<NoteShare>,
|
|
}
|
|
|
|
/// A note made on this device that hasn't been pushed yet is a 404 to the server,
|
|
/// and so is one this account doesn't own.
|
|
const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it.";
|
|
|
|
async fn read_reply<T: serde::de::DeserializeOwned>(
|
|
base_url: &str,
|
|
request: RequestBuilder,
|
|
) -> Result<T, String> {
|
|
let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
|
|
let status = response.status();
|
|
if status == StatusCode::NOT_FOUND {
|
|
return Err(SHARE_NOT_FOUND.to_string());
|
|
}
|
|
if !status.is_success() {
|
|
// The server's own words when it gave some ("choose someone to share with").
|
|
let reason = server_reason(response).await;
|
|
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
|
|
}
|
|
response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
|
|
}
|
|
|
|
/// Everyone on the instance but this account, and every group.
|
|
pub async fn directory(base_url: &str, token: &str) -> Result<Directory, String> {
|
|
let url = format!("{base_url}/api/users/directory");
|
|
read_reply(base_url, prepare(http()?.get(url), Some(token))).await
|
|
}
|
|
|
|
pub async fn list_shares(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
) -> Result<Vec<NoteShare>, String> {
|
|
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
|
let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
|
Ok(reply.shares)
|
|
}
|
|
|
|
/// Share with a person or a group, or change their permission. Answers the note's
|
|
/// shares.
|
|
pub async fn share_note(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
target: &ShareTarget,
|
|
permission: &str,
|
|
) -> Result<Vec<NoteShare>, String> {
|
|
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
|
let body = target.body(permission);
|
|
let reply: SharesReply = read_reply(
|
|
base_url,
|
|
prepare(http()?.post(url), Some(token)).json(&body),
|
|
)
|
|
.await?;
|
|
Ok(reply.shares)
|
|
}
|
|
|
|
pub async fn unshare_note(
|
|
base_url: &str,
|
|
token: &str,
|
|
note_id: &str,
|
|
share_id: &str,
|
|
) -> Result<Vec<NoteShare>, String> {
|
|
let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}");
|
|
let reply: SharesReply =
|
|
read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?;
|
|
Ok(reply.shares)
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// `self` rather than a device id: see `revoke_self`.
|
|
fn revoke_self_url(base_url: &str) -> String {
|
|
format!("{base_url}/api/auth/devices/self")
|
|
}
|
|
|
|
/// 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}")
|
|
}
|
|
}
|
|
|
|
/// The Android client a linked server can hand out.
|
|
///
|
|
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
|
|
/// means the server has no client to offer, which is an ordinary state and not an
|
|
/// error — a self-hoster who never touches Android has one.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ClientRelease {
|
|
pub version: String,
|
|
/// What decides "is this newer". The name is for people and sorts like a string.
|
|
pub version_code: i64,
|
|
pub size: i64,
|
|
pub sha256: String,
|
|
/// Path on the same server, not an absolute URL — the client joins it to the
|
|
/// base it is already linked to, so a compromised or misconfigured server
|
|
/// cannot redirect the download somewhere else.
|
|
pub url: String,
|
|
}
|
|
|
|
/// What Android client the linked server has, if any.
|
|
///
|
|
/// `Ok(None)` for a server that simply has none — that is the answer to the
|
|
/// question, not a failure to answer it.
|
|
pub async fn fetch_client_release(
|
|
base_url: &str,
|
|
token: &str,
|
|
) -> Result<Option<ClientRelease>, String> {
|
|
let url = format!("{base_url}/api/client/android");
|
|
let request = prepare(http()?.get(url), Some(token));
|
|
let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
|
|
|
|
let status = response.status();
|
|
if status == StatusCode::NOT_FOUND {
|
|
return Ok(None);
|
|
}
|
|
if !status.is_success() {
|
|
return Err(unexpected_status(base_url, status));
|
|
}
|
|
|
|
response
|
|
.json::<ClientRelease>()
|
|
.await
|
|
.map(Some)
|
|
.map_err(|e| {
|
|
format!("{base_url} described its Android client in a way this app could not read: {e}")
|
|
})
|
|
}
|
|
|
|
/// Download the client to `dest`, verifying it on the way in.
|
|
///
|
|
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
|
|
/// a phone, on top of whatever the app is already using, is how an update gets
|
|
/// killed by the low-memory killer half way through.
|
|
///
|
|
/// Written to `dest.part` and renamed only once the digest matches, so an
|
|
/// interrupted download can never be mistaken for a finished one. The digest is
|
|
/// not a trust anchor — the APK signature is, and Android checks that at install —
|
|
/// but it catches a truncated or corrupted transfer before the installer is
|
|
/// bothered with it.
|
|
pub async fn download_client(
|
|
base_url: &str,
|
|
token: &str,
|
|
release: &ClientRelease,
|
|
dest: &Path,
|
|
) -> Result<(), String> {
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::Write;
|
|
|
|
// The advertised path is joined to the base we are LINKED to. Taking an
|
|
// absolute URL from the response would let a server point the download at a
|
|
// host the user never agreed to.
|
|
let path = release.url.trim_start_matches('/');
|
|
let url = format!("{base_url}/{path}");
|
|
|
|
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
|
let mut response = send(base_url, request, TOKEN_REJECTED).await?;
|
|
|
|
let partial = dest.with_extension("part");
|
|
if let Some(parent) = partial.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
|
|
}
|
|
let mut file = std::fs::File::create(&partial)
|
|
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
let mut written: i64 = 0;
|
|
loop {
|
|
let chunk = response
|
|
.chunk()
|
|
.await
|
|
.map_err(|e| describe_transport_error(base_url, &e))?;
|
|
let Some(chunk) = chunk else { break };
|
|
hasher.update(&chunk);
|
|
written += chunk.len() as i64;
|
|
file.write_all(&chunk)
|
|
.map_err(|e| format!("Couldn't write the download: {e}"))?;
|
|
}
|
|
file.flush()
|
|
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
|
|
drop(file);
|
|
|
|
let digest = format!("{:x}", hasher.finalize());
|
|
let mismatch = if written != release.size {
|
|
Some(format!("expected {} bytes, got {written}", release.size))
|
|
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
|
|
Some("the contents did not match the checksum the server published".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(why) = mismatch {
|
|
// The half-file is removed rather than left: a later run finding it would
|
|
// have no way to tell it from a good one.
|
|
let _ = std::fs::remove_file(&partial);
|
|
return Err(format!("The download from {base_url} was damaged — {why}."));
|
|
}
|
|
|
|
std::fs::rename(&partial, dest)
|
|
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_share_names_a_person_or_a_group() {
|
|
assert_eq!(
|
|
ShareTarget::Member("u1".into()).body("view"),
|
|
serde_json::json!({ "user_id": "u1", "permission": "view" })
|
|
);
|
|
assert_eq!(
|
|
ShareTarget::Group("g1".into()).body("edit"),
|
|
serde_json::json!({ "group_id": "g1", "permission": "edit" })
|
|
);
|
|
let raw = r#"{"id":"s2","member":null,
|
|
"group":{"id":"g1","name":"Family","member_count":3},
|
|
"permission":"edit"}"#;
|
|
let share: NoteShare = serde_json::from_str(raw).expect("a group share reads");
|
|
assert!(share.member.is_none());
|
|
assert_eq!(share.group.expect("group").member_count, 3);
|
|
// A server from before groups sends no `groups` at all.
|
|
let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory");
|
|
assert!(older.groups.is_empty());
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
assert_eq!(
|
|
revoke_self_url("https://notes.example.com"),
|
|
"https://notes.example.com/api/auth/devices/self"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn revoke_outcome_serializes_tagged_for_the_frontend() {
|
|
// The UI decides between "signed out on the server" and "still valid, go
|
|
// revoke it" by reading this tag, so its shape is part of the contract.
|
|
let json = serde_json::to_string(&RevokeOutcome::Failed {
|
|
reason: "offline".into(),
|
|
})
|
|
.expect("outcome serializes");
|
|
assert!(json.contains("\"status\":\"failed\""), "got {json}");
|
|
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
|
|
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
|
|
}
|
|
|
|
#[test]
|
|
fn urls_preserve_a_port_and_subpath() {
|
|
assert_eq!(
|
|
config_url("http://192.168.1.10:8000/inkwell"),
|
|
"http://192.168.1.10:8000/inkwell/api/config"
|
|
);
|
|
}
|
|
}
|