M12 — the Android client, end to end #2
@@ -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