Files
brittle/src-tauri/src/pdf_protocol.rs

273 lines
9.0 KiB
Rust

//! Custom `brittle://` URI scheme handler.
//!
//! Routes:
//! `brittle://app/viewer?ref_id=<uuid>` — the PDF viewer HTML page
//! `brittle://app/pdfjs/<path>` — pdfjs-dist static assets
//! `brittle://app/pdf?ref_id=<uuid>` — raw PDF bytes from the repository
//!
//! The pure routing and path-resolution logic lives in the `routing` sub-module
//! so it can be unit-tested without a running Tauri application.
use std::path::PathBuf;
use tauri::{
http::{header, Request, Response, StatusCode},
AppHandle, Manager, Runtime,
};
use crate::state::AppState;
// ── Embedded assets ───────────────────────────────────────────────────────────
static VIEWER_HTML: &[u8] = include_bytes!("pdf_viewer.html");
static PDFJS_MIN_JS: &[u8] = include_bytes!(
"../pdfjs/node_modules/pdfjs-dist/build/pdf.min.js"
);
static PDFJS_WORKER_JS: &[u8] = include_bytes!(
"../pdfjs/node_modules/pdfjs-dist/build/pdf.worker.min.js"
);
// ── Public API ────────────────────────────────────────────────────────────────
/// Entry point registered with `tauri::Builder::register_uri_scheme_protocol`.
///
/// Generic over the Tauri runtime so the function can be used from a closure
/// in the builder without knowing the concrete runtime at compile time.
pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Response<Vec<u8>> {
let uri = req.uri();
match routing::classify(uri.path(), uri.query()) {
routing::Route::Viewer { ref_id } => serve_viewer(&ref_id),
routing::Route::PdfjsAsset { rel_path } => {
if rel_path.contains("..") {
return response_403();
}
serve_pdfjs_file(&rel_path)
}
routing::Route::Pdf { ref_id } => serve_pdf(app, &ref_id),
routing::Route::NotFound => response_404(),
}
}
// ── Route handlers ────────────────────────────────────────────────────────────
fn serve_viewer(_ref_id: &str) -> Response<Vec<u8>> {
// The viewer reads `ref_id` from its own URL query string via JavaScript
// (`new URLSearchParams(location.search).get("ref_id")`), so no substitution
// into the HTML template is needed.
response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8")
}
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
let bytes: &[u8] = match rel_path {
"build/pdf.min.js" => PDFJS_MIN_JS,
"build/pdf.worker.min.js" => PDFJS_WORKER_JS,
_ => return response_404(),
};
let mut resp = response_ok(bytes.to_vec(), "application/javascript; charset=utf-8");
resp.headers_mut().insert(
header::CACHE_CONTROL,
"public, max-age=3600".parse().unwrap(),
);
resp
}
fn serve_pdf<R: Runtime>(app: &AppHandle<R>, ref_id: &str) -> Response<Vec<u8>> {
use brittle_core::{model::ids::ReferenceId, store::FsStore, Brittle};
use uuid::Uuid;
let uuid = match Uuid::parse_str(ref_id) {
Ok(u) => u,
Err(_) => return response_400("invalid ref_id: not a valid UUID"),
};
let rid = ReferenceId::from(uuid);
let state = app.state::<AppState>();
let pdf_path: Result<PathBuf, String> =
state.with_repo_read(|b: &Brittle<FsStore>| b.get_pdf_path(rid));
match pdf_path {
Err(e) => response_404_msg(&e),
Ok(path) => match std::fs::read(&path) {
Ok(bytes) => {
let mut resp = response_ok(bytes, "application/pdf");
resp.headers_mut()
.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
resp
}
Err(e) => response_500(&e.to_string()),
},
}
}
// ── Response builders ─────────────────────────────────────────────────────────
fn response_ok(body: Vec<u8>, content_type: &str) -> Response<Vec<u8>> {
Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.status(StatusCode::OK)
.body(body)
.unwrap()
}
fn response_404() -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(b"Not Found".to_vec())
.unwrap()
}
fn response_404_msg(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn response_400(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn response_403() -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::FORBIDDEN)
.body(b"Forbidden".to_vec())
.unwrap()
}
fn response_500(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(msg.as_bytes().to_vec())
.unwrap()
}
// ── Pure routing logic (unit-testable) ───────────────────────────────────────
pub mod routing {
#[derive(Debug, PartialEq)]
pub enum Route {
Viewer { ref_id: String },
PdfjsAsset { rel_path: String },
Pdf { ref_id: String },
NotFound,
}
/// Classify a `brittle://app/{path}?{query}` request into a `Route`.
pub fn classify(path: &str, query: Option<&str>) -> Route {
let ref_id = extract_ref_id(query);
if path == "/viewer" {
Route::Viewer { ref_id }
} else if let Some(rel) = path.strip_prefix("/pdfjs/") {
Route::PdfjsAsset {
rel_path: rel.to_owned(),
}
} else if path == "/pdf" {
Route::Pdf { ref_id }
} else {
Route::NotFound
}
}
/// Extract the value of `ref_id=…` from a URL query string.
pub fn extract_ref_id(query: Option<&str>) -> String {
query
.unwrap_or("")
.split('&')
.find_map(|part| part.strip_prefix("ref_id="))
.unwrap_or("")
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewer_route() {
let r = classify("/viewer", Some("ref_id=abc-123"));
assert_eq!(
r,
Route::Viewer {
ref_id: "abc-123".into()
}
);
}
#[test]
fn viewer_route_no_ref_id() {
let r = classify("/viewer", None);
assert_eq!(r, Route::Viewer { ref_id: "".into() });
}
#[test]
fn pdfjs_asset_route_build_file() {
let r = classify("/pdfjs/build/pdf.min.js", None);
assert_eq!(
r,
Route::PdfjsAsset {
rel_path: "build/pdf.min.js".into()
}
);
}
#[test]
fn pdfjs_asset_route_nested() {
let r = classify("/pdfjs/web/pdf_viewer.css", None);
assert_eq!(
r,
Route::PdfjsAsset {
rel_path: "web/pdf_viewer.css".into()
}
);
}
#[test]
fn pdf_route() {
let r = classify("/pdf", Some("ref_id=01234567-89ab-cdef-0123-456789abcdef"));
assert_eq!(
r,
Route::Pdf {
ref_id: "01234567-89ab-cdef-0123-456789abcdef".into()
}
);
}
#[test]
fn unknown_paths_are_not_found() {
assert_eq!(classify("/unknown", None), Route::NotFound);
assert_eq!(classify("/", None), Route::NotFound);
assert_eq!(classify("/favicon.ico", None), Route::NotFound);
}
#[test]
fn extract_ref_id_from_compound_query() {
let id = extract_ref_id(Some("foo=bar&ref_id=my-id&baz=1"));
assert_eq!(id, "my-id");
}
#[test]
fn extract_ref_id_missing_returns_empty() {
assert_eq!(extract_ref_id(None), "");
assert_eq!(extract_ref_id(Some("foo=bar")), "");
}
#[test]
fn path_traversal_rel_path_contains_dotdot() {
// The handler rejects rel_paths containing ".."; verify the routing
// surfaces them so the handler can block them.
if let Route::PdfjsAsset { rel_path } = classify("/pdfjs/../secret", None) {
assert!(rel_path.contains(".."));
} else {
panic!("expected PdfjsAsset route");
}
}
}
}