273 lines
9.0 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|
|
}
|