attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or dismissed preview came back on the next pull. Now: - core: add_attachment keeps the bytes in the blob store and queues the row (schema v10: attachments.uploaded / upload_error). Push uploads it once its note has landed. A refusal that retrying won't fix (too large, id clash, hash mismatch) is recorded on the file and not re-sent every cycle; the editor shows it. - core: removing a synced attachment or dismissing a preview leaves a tombstone in pending_deletes; push sends it as an `attachment`/`preview` delete, and a pull while it waits doesn't put the row back. A pull also keeps files still waiting to upload instead of replacing them wholesale. - server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent, size-capped) and child deletes in push, which apply regardless of LWW and answer noop for rows the caller can't see. One store_attachment helper for the upload route, the importer and sync. Protocol 5, feature attachment_sync; the client sends neither to a server without it. - server: migration 0031 makes a link preview's insert/delete bump its note, so background-fetched previews and web dismissals reach linked devices. - desktop: Attach and paste-image work offline (raw-bytes IPC command). - SVG is served as a download by the desktop blob scheme too (as #1981 did for the web), and drawn as a file chip on both. - autosync: drop the catch_unwind; release builds abort on panic, so it only ever worked in debug builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,11 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// 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 \
|
||||
@@ -314,6 +319,71 @@ pub async fn fetch_attachment(
|
||||
.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 = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.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 —
|
||||
|
||||
Reference in New Issue
Block a user