Initial commit
This commit is contained in:
320
src-tauri/src/pdf_protocol.rs
Normal file
320
src-tauri/src/pdf_protocol.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! 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");
|
||||
|
||||
// ── 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(&pdfjs_root(app), &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>> {
|
||||
// Substitute the ref_id into the HTML template so the viewer knows which PDF to load.
|
||||
let html = String::from_utf8_lossy(VIEWER_HTML)
|
||||
.replace("ref_id=\"\"", &format!("ref_id=\"{}\"", ref_id));
|
||||
response_ok(html.into_bytes(), "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
fn serve_pdfjs_file(pdfjs_root: &std::path::Path, rel_path: &str) -> Response<Vec<u8>> {
|
||||
let full_path = pdfjs_root.join(rel_path);
|
||||
match std::fs::read(&full_path) {
|
||||
Ok(bytes) => {
|
||||
let mime = routing::mime_for_path(&full_path);
|
||||
let mut resp = response_ok(bytes, mime);
|
||||
resp.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600".parse().unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
Err(_) => response_404(),
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
fn pdfjs_root<R: Runtime>(app: &AppHandle<R>) -> PathBuf {
|
||||
if cfg!(debug_assertions) {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("pdfjs")
|
||||
.join("node_modules")
|
||||
.join("pdfjs-dist")
|
||||
} else {
|
||||
app.path()
|
||||
.resource_dir()
|
||||
.unwrap_or_default()
|
||||
.join("pdfjs-dist")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
use std::path::Path;
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
/// Return the appropriate MIME type for a file path based on its extension.
|
||||
pub fn mime_for_path(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("js") => "application/javascript; charset=utf-8",
|
||||
Some("mjs") => "application/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("woff2") => "font/woff2",
|
||||
Some("woff") => "font/woff",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("map") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[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 mime_for_js_files() {
|
||||
assert!(mime_for_path(Path::new("pdf.min.js")).contains("javascript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_css_files() {
|
||||
assert!(mime_for_path(Path::new("viewer.css")).contains("css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_unknown_extension() {
|
||||
assert_eq!(
|
||||
mime_for_path(Path::new("data.bin")),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user