desktop: render synced attachments instead of broken images (task 2114)
A synced note carried the SERVER's relative attachment path (/api/notes/<id>/attachments/<aid>). In the webview that resolves against the app origin and 404s, so every synced image rendered broken even though the bytes were already on disk from M10.7d. The absolute server URL wouldn't have worked either: that route wants a bearer token the webview never sends, and it would put an offline app on the network to show a file it already has. The bytes now come off disk over a custom URI scheme, served straight from the content-addressed blob store. The webview caches and range-requests them like any other resource — which a data: URI would have thrown away — and the URL is immutable-cacheable because a content address can never describe different bytes. Two things worth knowing about the shape of this: The URL is rewritten in `load_attachments`, the single place the desktop builds an attachment for the UI. NoteCard and NoteEditor are untouched, so there's no second render site to drift. The scheme's URL form is NOT the same on every platform: `scheme://localhost/` on Linux and macOS, `http://scheme.localhost/` on Windows and Android. Getting it wrong breaks exactly one channel, silently, and a headless CI runner can never tell you. The mime rides in the URL, and this scheme is an origin of its own, so an attachment claiming to be text/html would run as a document there. Only media families are echoed back; everything else is served as an opaque download, which is the right treatment for an arbitrary file anyway. Path safety is inherited rather than re-implemented — the handler reads through BlobStore, which already refuses anything that isn't a bare sha256. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
@@ -33,6 +33,27 @@ pub fn run() {
|
||||
])
|
||||
.build(),
|
||||
)
|
||||
// Attachment bytes are served to the webview from the local blob store
|
||||
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
|
||||
// the webview is created; the directory it reads from arrives later, in
|
||||
// `setup`, via `blobs::publish_root`.
|
||||
.register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| {
|
||||
let (status, content_type, body) =
|
||||
sync::blobs::serve(request.uri().path(), request.uri().query());
|
||||
tauri::http::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", content_type)
|
||||
// The bytes are content-addressed: a given URL can never describe
|
||||
// different bytes, so the webview may keep them indefinitely.
|
||||
.header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| {
|
||||
tauri::http::Response::builder()
|
||||
.status(500)
|
||||
.body(Vec::new())
|
||||
.expect("a bodiless 500 always builds")
|
||||
})
|
||||
})
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
log_environment(app);
|
||||
@@ -51,6 +72,9 @@ pub fn run() {
|
||||
// synced image is readable with no network (M10.7d).
|
||||
let blobs = sync::blobs::BlobStore::new(dir.join("blobs"))?;
|
||||
log::info!("attachment store ready: {}", blobs.root().display());
|
||||
// Hand the directory to the URI-scheme handler registered below, which
|
||||
// was built before this path could be resolved.
|
||||
sync::blobs::publish_root(blobs.root().to_path_buf());
|
||||
app.manage(blobs);
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -92,13 +92,28 @@ fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<At
|
||||
"SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
let server_url: String = r.get(1)?;
|
||||
let mime: String = r.get(3)?;
|
||||
let sha256: Option<String> = r.get(5)?;
|
||||
Ok(Attachment {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
// Point at the LOCAL bytes, not the server's route. The stored url is the
|
||||
// server's relative path, which resolves against the app origin in the
|
||||
// webview and 404s — and even absolute it would need a bearer token the
|
||||
// webview never sends. Rewriting here rather than at each render site
|
||||
// means NoteCard and NoteEditor stay untouched and can't drift.
|
||||
//
|
||||
// Without a hash there's nothing to address the blob by (an older server
|
||||
// that predates the sha256 column), so the original url is left alone:
|
||||
// still broken, but no more broken than it already was.
|
||||
url: match sha256.as_deref() {
|
||||
Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime),
|
||||
_ => server_url,
|
||||
},
|
||||
filename: r.get(2)?,
|
||||
mime: r.get(3)?,
|
||||
mime,
|
||||
size: r.get(4)?,
|
||||
sha256: r.get(5)?,
|
||||
sha256,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A sha256 in lowercase hex, and nothing else.
|
||||
///
|
||||
@@ -82,6 +83,124 @@ impl BlobStore {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Serving blobs to the webview (M10.7f) -----------------------------------
|
||||
//
|
||||
// A synced note's attachment `url` is the SERVER's relative path
|
||||
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
|
||||
// against the app origin and 404s, and swapping in the absolute server URL wouldn't
|
||||
// help either — that route needs a bearer token the webview won't send, and it would
|
||||
// make an offline app fetch over the network to show a file it already has on disk.
|
||||
//
|
||||
// So the bytes are served locally, over a custom URI scheme, straight out of this
|
||||
// store. The webview then caches and range-requests them like any other resource,
|
||||
// which a `data:` URI would have thrown away.
|
||||
|
||||
/// The scheme the webview fetches attachment bytes over.
|
||||
pub const BLOB_SCHEME: &str = "tsblob";
|
||||
|
||||
/// The blob directory, published once the app has resolved its data dir.
|
||||
///
|
||||
/// A `OnceLock` rather than Tauri's managed state because the scheme handler is
|
||||
/// registered on the BUILDER, before `setup` has computed that path — and because
|
||||
/// reading it this way keeps the handler independent of which Tauri 2.x minor
|
||||
/// changed the handler's context argument.
|
||||
static SERVE_ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn publish_root(root: PathBuf) {
|
||||
let _ = SERVE_ROOT.set(root);
|
||||
}
|
||||
|
||||
/// The URL an `<img>`/`<audio>`/`<a href>` should point at for these bytes.
|
||||
///
|
||||
/// **The two forms are not interchangeable.** A custom scheme is reachable as
|
||||
/// `scheme://localhost/<path>` on Linux and macOS, but Windows and Android map it
|
||||
/// onto `http://scheme.localhost/<path>`. Getting this wrong breaks exactly one
|
||||
/// platform, silently, and CI cannot catch it — the runner is headless.
|
||||
pub fn url_for(sha256: &str, mime: &str) -> String {
|
||||
let query = urlencode(mime);
|
||||
if cfg!(any(windows, target_os = "android")) {
|
||||
format!("http://{BLOB_SCHEME}.localhost/{sha256}?mime={query}")
|
||||
} else {
|
||||
format!("{BLOB_SCHEME}://localhost/{sha256}?mime={query}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode the few characters a mime type can contain that don't belong in a
|
||||
/// query value. Hand-rolled rather than adding a dependency for `/` and `+`.
|
||||
fn urlencode(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
for b in value.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 urldecode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
|
||||
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The Content-Type to serve for a claimed mime.
|
||||
///
|
||||
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
|
||||
/// arbitrary type would let an attachment claiming `text/html` run as a document
|
||||
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
|
||||
/// `image/` can name a scriptable type. Everything else is served as an opaque
|
||||
/// download — the right treatment for an arbitrary file regardless.
|
||||
fn content_type_for(mime: &str) -> String {
|
||||
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
||||
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
|
||||
// A header value can't carry control characters, and a mime type has no business
|
||||
// being long — both would only arrive from a malformed or hostile feed.
|
||||
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
|
||||
if familiar && printable {
|
||||
mime.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request from the blob store. `path` is the URI path, `query` its query.
|
||||
pub fn serve(path: &str, query: Option<&str>) -> (u16, String, Vec<u8>) {
|
||||
let requested = path.trim_start_matches('/');
|
||||
let Some(root) = SERVE_ROOT.get() else {
|
||||
// A request before the store was published — nothing to serve yet.
|
||||
return (503, "text/plain".into(), Vec::new());
|
||||
};
|
||||
let store = BlobStore {
|
||||
root: root.clone(),
|
||||
};
|
||||
// `read` goes through `path`, which rejects anything that isn't a bare sha256 —
|
||||
// so this handler inherits the traversal guard rather than re-implementing it.
|
||||
let Some(bytes) = store.read(requested) else {
|
||||
return (404, "text/plain".into(), Vec::new());
|
||||
};
|
||||
let claimed = query
|
||||
.and_then(|q| q.split('&').find_map(|p| p.strip_prefix("mime=")))
|
||||
.map(urldecode)
|
||||
.unwrap_or_default();
|
||||
(200, content_type_for(&claimed), bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -141,6 +260,57 @@ mod tests {
|
||||
assert!(store.has(HELLO), "should be found under the lowercase name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blob_url_carries_the_hash_and_the_mime() {
|
||||
let url = url_for(HELLO, "image/png");
|
||||
assert!(url.contains(HELLO), "the hash addresses the bytes: {url}");
|
||||
assert!(url.contains("mime=image%2Fpng"), "mime encoded: {url}");
|
||||
// The platform split is the whole risk of this feature, and CI is headless,
|
||||
// so at least pin that the right branch was taken for THIS build.
|
||||
if cfg!(any(windows, target_os = "android")) {
|
||||
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
|
||||
} else {
|
||||
assert!(url.starts_with("tsblob://localhost/"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encoding_round_trips_a_mime() {
|
||||
assert_eq!(urldecode(&urlencode("image/svg+xml")), "image/svg+xml");
|
||||
assert_eq!(urldecode(&urlencode("audio/mpeg")), "audio/mpeg");
|
||||
// A malformed escape is left alone rather than eaten — the value still has to
|
||||
// survive intact enough for `content_type_for` to reject it.
|
||||
assert_eq!(urldecode("not-an-escape%ZZ"), "not-an-escape%ZZ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_types_are_echoed_back() {
|
||||
assert_eq!(content_type_for("image/png"), "image/png");
|
||||
assert_eq!(content_type_for("audio/mpeg"), "audio/mpeg");
|
||||
assert_eq!(content_type_for("application/pdf"), "application/pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scriptable_type_is_served_as_a_download() {
|
||||
// This scheme is an origin of its own. An attachment claiming to be HTML
|
||||
// must not be handed back as a document that can run there.
|
||||
let opaque = "application/octet-stream";
|
||||
assert_eq!(content_type_for("text/html"), opaque);
|
||||
assert_eq!(content_type_for("application/javascript"), opaque);
|
||||
assert_eq!(content_type_for(""), opaque);
|
||||
// A control character can't reach a header value even under a safe family.
|
||||
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_refuses_a_path_that_isnt_a_hash() {
|
||||
// Delegated to `path`, so the traversal guard is the same one `store` uses.
|
||||
publish_root(std::env::temp_dir().join("ts-blobs-serve-guard"));
|
||||
let (status, _, body) = serve("/../../etc/passwd", None);
|
||||
assert_eq!(status, 404);
|
||||
assert!(body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_blob_reads_as_none() {
|
||||
let store = store("missing");
|
||||
|
||||
Reference in New Issue
Block a user