diff --git a/runtime/src/file_actions.rs b/runtime/src/file_actions.rs index 8365b5e2..737ecde7 100644 --- a/runtime/src/file_actions.rs +++ b/runtime/src/file_actions.rs @@ -1,11 +1,13 @@ -use log::{error, info}; +use log::{error, info, warn}; use axum::extract::Query; use axum::Json; +use file_format::FileFormat; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; use crate::api_token::APIToken; use crate::app_window::MAIN_WINDOW; +use crate::file_data::is_executable_content; #[cfg(any(windows, target_os = "macos"))] use std::process::Command; @@ -55,6 +57,14 @@ pub struct OpenPathOptions { path: String, } +#[derive(Clone, Deserialize)] +pub struct OpenDocumentOptions { + path: String, + + /// The page to show, counted from one, or `None` when the document has no page to show. + page: Option, +} + #[derive(Serialize)] pub struct DirectorySelectionResponse { user_cancelled: bool, @@ -85,6 +95,20 @@ pub struct OpenPathResponse { issue: String, } +#[derive(Serialize)] +pub struct OpenDocumentResponse { + success: bool, + + /// Whether the document was handed to a program together with the page it should show. + /// + /// False means the document opens on its first page: no page was asked for, the system uses a + /// program we cannot tell a page, or the attempt to start that program failed. None of these + /// is an error — the document opens either way — so the app only notes it in its log. + page_applied: bool, + + issue: String, +} + #[derive(Clone, Deserialize)] pub struct PreviousFile { file_path: String, @@ -386,6 +410,418 @@ async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> { } } +/// Opens a document in the program the system uses for it, on the given page where that is possible. +/// +/// The page is best effort and never decides whether this succeeded: a viewer which cannot be told +/// a page still shows the document, which is what the user asked for by clicking a source. +pub async fn open_document( + _token: APIToken, + payload: Json, +) -> Json { + let requested_path = PathBuf::from(payload.path.trim()); + if let Some(issue) = refuse_document(&requested_path) { + error!(Source = "Tauri"; "Refused to open a document: {issue}"); + return Json(OpenDocumentResponse { + success: false, + page_applied: false, + issue, + }); + } + + // + // A page of zero is how a caller says it has none: a slide and a spreadsheet row are not + // pages, and neither is a passage whose page the index never learned. + // + let page = payload.page.filter(|page| *page > 0); + if let Some(page) = page && try_open_at_page(&requested_path, page).await { + info!("Opened document at page {page}: {requested_path:?}"); + return Json(OpenDocumentResponse { + success: true, + page_applied: true, + issue: String::new(), + }); + } + + match tauri_plugin_opener::open_path(&requested_path, None::<&str>) { + Ok(()) => { + info!("Opened document: {requested_path:?}"); + Json(OpenDocumentResponse { + success: true, + page_applied: false, + issue: String::new(), + }) + }, + + Err(error) => { + let issue = format!("Failed to open the document: {error}"); + error!(Source = "Tauri"; "{issue}"); + Json(OpenDocumentResponse { + success: false, + page_applied: false, + issue, + }) + }, + } +} + +/// Extensions which start something instead of being something. +/// +/// Such a file gives nothing away by its content — a `.desktop` entry and a `.cmd` script are +/// plain text, a `.lnk` is a shortcut — so its name is the only thing left to recognize it by. +const LAUNCHER_EXTENSIONS: [&str; 10] = [ + "desktop", "command", "lnk", "url", "bat", "cmd", "ps1", "vbs", "scpt", "app", +]; + +/// Says why a document must not be opened, or `None` when it may be. +/// +/// The path arrives from a data source: a folder the user pointed us at, or an ERI server which is +/// free to name any file it likes. This endpoint hands a file to whatever the system has registered +/// for it, so the line worth drawing is that a document is opened and a program is never started. +/// It is drawn here because this is the one place every caller passes through. +fn refuse_document(requested_path: &Path) -> Option { + if requested_path.as_os_str().is_empty() { + return Some(String::from("The path is empty.")); + } + + if !requested_path.is_file() { + return Some(format!("The path is not a file: {}", requested_path.to_string_lossy())); + } + + let extension = requested_path.extension() + .map(|extension| extension.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + + if LAUNCHER_EXTENSIONS.contains(&extension.as_str()) { + return Some(format!( + "A file of type '{extension}' starts a program instead of showing a document and is not opened: {}", + requested_path.to_string_lossy(), + )); + } + + match FileFormat::from_file(requested_path) { + Ok(format) if is_executable_content(format) => Some(format!( + "The file is a program, not a document, and is not opened: {}", + requested_path.to_string_lossy(), + )), + + // + // A file whose content we cannot place is not a file we refuse. The format is asked in + // order to catch a program carrying a harmless extension, nothing else; what the system + // makes of anything else is the system's decision, as it is for every other file. + // + Ok(_) => None, + + Err(error) => { + warn!(Source = "Tauri"; "Could not identify the content of '{}': {error}", requested_path.to_string_lossy()); + None + }, + } +} + +/// Tries to show the document on the given page, and says whether it did. +/// +/// On macOS this always answers `false`: `open` drops the fragment of a URL before the program it +/// starts ever sees it, with and without `-a`, so a page cannot be named from the command line. +/// The document opens on its first page there, and the source names the page for the reader. +async fn try_open_at_page(path: &Path, page: u32) -> bool { + #[cfg(any(windows, target_os = "linux"))] + { + let DocumentOpenPlan::WithPage { program, arguments } = resolve_document_open_plan(path, page).await else { + return false; + }; + + return match start_page_aware_viewer(&program, &arguments) { + Ok(()) => true, + + // + // Failing to start the viewer ourselves is not something the user has to hear about: + // the caller opens the document plainly afterwards, only without the page. + // + Err(issue) => { + warn!(Source = "Tauri"; "Could not open '{}' at page {page}, opening it without a page instead: {issue}", path.to_string_lossy()); + false + }, + }; + } + + #[cfg(target_os = "macos")] + { + let _ = (path, page); + false + } +} + +/// How a document viewer wants to be told which page to show. +/// +/// They all mean the same thing and every one of them spells it differently. A viewer which is not +/// covered here shows its first page, which is what the system would have done anyway. +#[cfg(any(windows, target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PageArgument { + /// The page travels in the URL fragment, the way the PDF Open Parameters define it. Browsers + /// read it, and on Windows a browser is what most people open a PDF with. + UrlFragment, + + /// Acrobat and Acrobat Reader take an open action: `/A page=12`. + AcrobatOpenAction, + + /// The GNOME document viewer and its forks count from zero, so page 12 is index 11. + ZeroBasedIndex, + + /// Okular takes `-p 12`. + OkularPage, + + /// Zathura takes `-P 12`. + ZathuraPage, +} + +/// What it takes to show a document on a page. +#[cfg(any(windows, target_os = "linux"))] +#[derive(Debug, PartialEq, Eq)] +enum DocumentOpenPlan { + /// Hand the file to the system and let it decide. The document opens on its first page. + Plain, + + /// Start this program ourselves, because it takes the page as an argument. + WithPage { program: String, arguments: Vec }, +} + +/// Whether this file is a PDF. +/// +/// Only PDFs are sent to a page: the handler is looked up for PDFs, and the arguments below are +/// the ones PDF viewers understand. A Word file has a page too, but the programs which show one +/// cannot be told to go there. +#[cfg(any(windows, target_os = "linux", test))] +fn is_pdf_document(path: &Path) -> bool { + path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("pdf")) +} + +/// Builds the arguments which name the page, in the spelling this viewer expects. +#[cfg(any(windows, target_os = "linux", test))] +fn page_arguments(argument: PageArgument, path: &Path, page: u32) -> Option> { + let path_argument = path.to_string_lossy().to_string(); + Some(match argument { + PageArgument::UrlFragment => vec![document_url_with_page(path, page)?], + PageArgument::AcrobatOpenAction => vec![String::from("/A"), format!("page={page}"), path_argument], + PageArgument::ZeroBasedIndex => vec![format!("--page-index={}", page.saturating_sub(1)), path_argument], + PageArgument::OkularPage => vec![String::from("-p"), page.to_string(), path_argument], + PageArgument::ZathuraPage => vec![String::from("-P"), page.to_string(), path_argument], + }) +} + +/// Builds a `file:` URL which names the page, the way the PDF Open Parameters define it. +/// +/// The URL is built instead of written by hand because a path may hold spaces, umlauts or a hash +/// of its own, and writing one by hand turns those into a different path or into a second fragment. +#[cfg(any(windows, target_os = "linux", test))] +fn document_url_with_page(path: &Path, page: u32) -> Option { + let mut url = tauri::Url::from_file_path(path).ok()?; + url.set_fragment(Some(&format!("page={page}"))); + Some(url.to_string()) +} + +/// Starts the viewer. Success means the program was started, not that it showed the page. +/// +/// Waiting for it to say so is not possible: a viewer runs until the user closes it, so waiting +/// would hold the request open for as long as the document stays on screen. +#[cfg(any(windows, target_os = "linux"))] +fn start_page_aware_viewer(program: &str, arguments: &[String]) -> Result<(), String> { + let mut command = std::process::Command::new(program); + command.args(arguments); + + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + + command.spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to start '{program}': {error}")) +} + +#[cfg(any(windows, target_os = "linux"))] +async fn resolve_document_open_plan(path: &Path, page: u32) -> DocumentOpenPlan { + if !is_pdf_document(path) { + return DocumentOpenPlan::Plain; + } + + #[cfg(windows)] + { + let Some(prog_id) = windows_default_pdf_prog_id() else { + return DocumentOpenPlan::Plain; + }; + + let Some(argument) = windows_page_argument(&prog_id) else { + return DocumentOpenPlan::Plain; + }; + + let Some(program) = windows_handler_executable(&prog_id) else { + return DocumentOpenPlan::Plain; + }; + + let Some(arguments) = page_arguments(argument, path, page) else { + return DocumentOpenPlan::Plain; + }; + + DocumentOpenPlan::WithPage { program, arguments } + } + + #[cfg(target_os = "linux")] + { + let Some(desktop_id) = linux_default_pdf_handler().await else { + return DocumentOpenPlan::Plain; + }; + + let Some((program, argument)) = linux_page_aware_program(&desktop_id) else { + return DocumentOpenPlan::Plain; + }; + + let Some(arguments) = page_arguments(argument, path, page) else { + return DocumentOpenPlan::Plain; + }; + + DocumentOpenPlan::WithPage { program, arguments } + } +} + +/// Reads which program the user opens PDFs with. +/// +/// The user's own choice comes first; the class registration is what is left when they never made +/// one, for instance right after the system was installed. +#[cfg(windows)] +fn windows_default_pdf_prog_id() -> Option { + use windows_registry::*; + + const USER_CHOICE_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.pdf\UserChoice"; + + if let Ok(key) = CURRENT_USER.open(USER_CHOICE_KEY) && let Ok(prog_id) = key.get_string("ProgId") { + return Some(prog_id); + } + + CLASSES_ROOT.open(".pdf").ok() + .and_then(|key| key.get_string("").ok()) + .filter(|prog_id| !prog_id.is_empty()) +} + +/// Reads the program behind a registered file type. +#[cfg(windows)] +fn windows_handler_executable(prog_id: &str) -> Option { + use windows_registry::*; + + let command = CLASSES_ROOT.open(format!(r"{prog_id}\shell\open\command")).ok()? + .get_string("").ok()?; + + executable_from_command(&command) +} + +/// Picks the program out of a registry open command such as +/// `"C:\Program Files\...\msedge.exe" --single-argument %1`. +/// +/// The arguments the command carries are dropped on purpose: they are written for a file name, and +/// what follows is a URL naming a page instead. +#[cfg(any(windows, test))] +fn executable_from_command(command: &str) -> Option { + let command = command.trim(); + let executable = match command.strip_prefix('"') { + Some(quoted) => quoted.split('"').next()?, + None => command.split_whitespace().next()?, + }; + + let executable = executable.trim(); + if executable.is_empty() { + None + } else { + Some(String::from(executable)) + } +} + +/// Maps the registered file type onto the way its program wants to hear about a page. +#[cfg(any(windows, test))] +fn windows_page_argument(prog_id: &str) -> Option { + let prog_id = prog_id.to_ascii_lowercase(); + + // + // Acrobat is asked about first, because its registration says nothing about a browser while + // the browsers below are recognized by their own name in it. + // + if prog_id.contains("acroexch") || prog_id.contains("acrobat") { + return Some(PageArgument::AcrobatOpenAction); + } + + const BROWSERS: [&str; 5] = ["msedge", "chrome", "firefox", "opera", "brave"]; + if BROWSERS.iter().any(|browser| prog_id.contains(browser)) { + return Some(PageArgument::UrlFragment); + } + + None +} + +/// Reads which program the desktop opens PDFs with. +/// +/// Inside a Flatpak there is nothing to read: the sandbox has its own list of registered programs +/// rather than the desktop's, and even the right answer would name a program which is not in the +/// sandbox to be started. The document is handed to the desktop portal instead, which opens it on +/// its first page. +#[cfg(target_os = "linux")] +async fn linux_default_pdf_handler() -> Option { + if crate::environment::is_flatpak() { + return None; + } + + let output = tokio::process::Command::new("xdg-mime") + .args(["query", "default", "application/pdf"]) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + // + // More than one entry can be registered, and the first one is the one the desktop uses. + // + let desktop_id = String::from_utf8_lossy(&output.stdout).lines().next()?.trim().to_string(); + if desktop_id.is_empty() { + None + } else { + Some(desktop_id) + } +} + +/// Maps a desktop entry onto the program behind it and the way that program wants to hear about a page. +/// +/// A desktop id is not the name of a binary — GNOME's viewer answers `org.gnome.Evince.desktop` — +/// so reading the desktop file would be the thorough way to find the program. Recognizing the few +/// viewers which can be sent to a page at all is the short one, and everything else opens the way +/// it always did, through the desktop's own handler. +#[cfg(any(target_os = "linux", test))] +fn linux_page_aware_program(desktop_id: &str) -> Option<(String, PageArgument)> { + const KNOWN_VIEWERS: [(&str, &str, PageArgument); 9] = [ + // + // Atril and Xreader are forks of Evince and count their pages from zero just as it does. + // + ("evince", "evince", PageArgument::ZeroBasedIndex), + ("atril", "atril", PageArgument::ZeroBasedIndex), + ("xreader", "xreader", PageArgument::ZeroBasedIndex), + + ("okular", "okular", PageArgument::OkularPage), + ("zathura", "zathura", PageArgument::ZathuraPage), + + // + // Chrome is asked about before Chromium, so that a desktop entry naming both lands on the + // program the user actually installed. + // + ("google-chrome", "google-chrome", PageArgument::UrlFragment), + ("chromium", "chromium", PageArgument::UrlFragment), + ("microsoft-edge", "microsoft-edge", PageArgument::UrlFragment), + ("firefox", "firefox", PageArgument::UrlFragment), + ]; + + let desktop_id = desktop_id.to_ascii_lowercase(); + KNOWN_VIEWERS.iter() + .find(|(needle, _, _)| desktop_id.contains(needle)) + .map(|(_, program, argument)| (String::from(*program), *argument)) +} + /// Applies an optional file type filter to a FileDialogBuilder. fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option) -> FileDialogBuilder { match filter { @@ -625,4 +1061,153 @@ mod tests { assert!(resolve_file_manager_target(&invalid_path).is_none()); } + + /// The bytes an ELF binary starts with. A file which begins like this is a program, whatever + /// its name promises. + const ELF_HEADER: &[u8] = b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00"; + + #[test] + fn a_document_may_be_opened() { + let temp_dir = tempfile::tempdir().unwrap(); + let document_path = temp_dir.path().join("handbook.pdf"); + fs::write(&document_path, b"%PDF-1.7\n% a handbook\n").unwrap(); + + assert_eq!(refuse_document(&document_path), None); + } + + /// A program which carries a harmless extension is the case this guard exists for: nothing + /// about the name says what it is, so the content has to. + #[test] + fn a_program_named_like_a_document_is_refused() { + let temp_dir = tempfile::tempdir().unwrap(); + let disguised_path = temp_dir.path().join("handbook.pdf"); + fs::write(&disguised_path, ELF_HEADER).unwrap(); + + let refusal = refuse_document(&disguised_path).unwrap(); + + assert!(refusal.contains("is a program"), "The refusal says why: {refusal}"); + } + + /// The other way round: a launcher is plain text and gives nothing away, so it is refused by + /// its name. + #[test] + fn a_launcher_is_refused_although_it_reads_like_text() { + let temp_dir = tempfile::tempdir().unwrap(); + let launcher_path = temp_dir.path().join("handbook.desktop"); + fs::write(&launcher_path, "[Desktop Entry]\nExec=rm -rf ~\n").unwrap(); + + let refusal = refuse_document(&launcher_path).unwrap(); + + assert!(refusal.contains("starts a program"), "The refusal says why: {refusal}"); + } + + #[test] + fn a_launcher_is_refused_whatever_its_extension_is_spelled_like() { + let temp_dir = tempfile::tempdir().unwrap(); + let launcher_path = temp_dir.path().join("handbook.CMD"); + fs::write(&launcher_path, "echo nothing to see here\n").unwrap(); + + assert!(refuse_document(&launcher_path).is_some()); + } + + #[test] + fn a_path_which_is_no_file_is_refused() { + let temp_dir = tempfile::tempdir().unwrap(); + + assert!(refuse_document(&temp_dir.path().join("missing.pdf")).is_some(), "A file which is not there cannot be opened."); + assert!(refuse_document(temp_dir.path()).is_some(), "A folder is not a document."); + assert!(refuse_document(Path::new("")).is_some(), "An empty path names nothing."); + } + + #[test] + fn only_a_pdf_is_sent_to_a_page() { + assert!(is_pdf_document(Path::new("/docs/handbook.pdf"))); + assert!(is_pdf_document(Path::new("/docs/handbook.PDF")), "How the extension is spelled says nothing about the file."); + assert!(!is_pdf_document(Path::new("/docs/handbook.docx")), "A Word file has pages, but no program which shows one can be told to go there."); + assert!(!is_pdf_document(Path::new("/docs/handbook"))); + } + + /// Writing the URL by hand would leave the space in the name as it is, and the browser would + /// look for a file whose name ends before it. + #[test] + fn a_browser_is_told_the_page_in_the_url() { + let temp_dir = tempfile::tempdir().unwrap(); + let document_path = temp_dir.path().join("Größere Übersicht.pdf"); + + let arguments = page_arguments(PageArgument::UrlFragment, &document_path, 12).unwrap(); + + assert_eq!(arguments.len(), 1, "A browser takes the document and the page as one URL."); + + let url = tauri::Url::parse(&arguments[0]).unwrap(); + assert_eq!(url.fragment(), Some("page=12"), "The page travels in the fragment, the way the PDF Open Parameters define it."); + assert_eq!(url.to_file_path().unwrap(), document_path, "A name with spaces and umlauts still names the same file."); + } + + /// Everybody means page twelve, and everybody says it differently. + #[test] + fn every_viewer_spells_the_page_its_own_way() { + let document = Path::new("/docs/handbook.pdf"); + + assert_eq!( + page_arguments(PageArgument::AcrobatOpenAction, document, 12).unwrap(), + vec![String::from("/A"), String::from("page=12"), String::from("/docs/handbook.pdf")], + ); + + assert_eq!( + page_arguments(PageArgument::ZeroBasedIndex, document, 12).unwrap(), + vec![String::from("--page-index=11"), String::from("/docs/handbook.pdf")], + "The GNOME viewer counts from zero, so page twelve is index eleven.", + ); + + assert_eq!( + page_arguments(PageArgument::OkularPage, document, 12).unwrap(), + vec![String::from("-p"), String::from("12"), String::from("/docs/handbook.pdf")], + ); + + assert_eq!( + page_arguments(PageArgument::ZathuraPage, document, 12).unwrap(), + vec![String::from("-P"), String::from("12"), String::from("/docs/handbook.pdf")], + ); + } + + #[test] + fn windows_recognizes_the_programs_it_can_send_to_a_page() { + assert_eq!(windows_page_argument("AcroExch.Document.DC"), Some(PageArgument::AcrobatOpenAction)); + assert_eq!(windows_page_argument("MSEdgePDF"), Some(PageArgument::UrlFragment)); + assert_eq!(windows_page_argument("ChromePDF"), Some(PageArgument::UrlFragment)); + assert_eq!(windows_page_argument("FirefoxPDF"), Some(PageArgument::UrlFragment)); + assert_eq!(windows_page_argument("Applications\\SumatraPDF.exe"), None, "A viewer we know nothing about opens its first page."); + } + + #[test] + fn the_program_is_read_out_of_the_registered_command() { + assert_eq!( + executable_from_command(r#""C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --single-argument %1"#).as_deref(), + Some(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), + "A quoted program keeps the spaces in its path and loses the arguments written for a file name.", + ); + + assert_eq!( + executable_from_command(r#"C:\Windows\System32\viewer.exe "%1""#).as_deref(), + Some(r"C:\Windows\System32\viewer.exe"), + ); + + assert_eq!(executable_from_command(" "), None); + } + + #[test] + fn linux_recognizes_the_programs_it_can_send_to_a_page() { + assert_eq!( + linux_page_aware_program("org.gnome.Evince.desktop"), + Some((String::from("evince"), PageArgument::ZeroBasedIndex)), + "A desktop entry is not the name of a binary, and the binary is what we have to start.", + ); + + assert_eq!(linux_page_aware_program("okularApplication_pdf.desktop"), Some((String::from("okular"), PageArgument::OkularPage))); + assert_eq!(linux_page_aware_program("org.pwmt.zathura.desktop"), Some((String::from("zathura"), PageArgument::ZathuraPage))); + assert_eq!(linux_page_aware_program("firefox.desktop"), Some((String::from("firefox"), PageArgument::UrlFragment))); + assert_eq!(linux_page_aware_program("google-chrome.desktop"), Some((String::from("google-chrome"), PageArgument::UrlFragment))); + assert_eq!(linux_page_aware_program("chromium_chromium.desktop"), Some((String::from("chromium"), PageArgument::UrlFragment))); + assert_eq!(linux_page_aware_program("com.example.SomeViewer.desktop"), None, "A viewer we know nothing about opens its first page."); + } } \ No newline at end of file diff --git a/runtime/src/file_data.rs b/runtime/src/file_data.rs index 0978bc65..c814e9ed 100644 --- a/runtime/src/file_data.rs +++ b/runtime/src/file_data.rs @@ -827,6 +827,16 @@ fn route_from_content(fmt: FileFormat) -> Option { } } +/// Whether the content of a file is a program rather than something to read. +/// +/// The extension is not asked: recognizing a program by its content is the whole point, because a +/// program which carries a harmless extension is exactly the case worth stopping. Answering this +/// here keeps one place in charge of what counts as a program — the reader which refuses to read +/// one, and the endpoint which refuses to hand one to the system. +pub(crate) fn is_executable_content(fmt: FileFormat) -> bool { + matches!(route_from_content(fmt), Some(ExtractionRoute::Executable)) +} + async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) -> Result { if !Path::new(file_path).exists() { error!("File does not exist: '{file_path}'"); diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 5c41b4b3..f369029e 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -55,6 +55,7 @@ pub fn start_runtime_api() { .route("/select/files", post(crate::file_actions::select_files)) .route("/save/file", post(crate::file_actions::save_file)) .route("/open/path", post(crate::file_actions::open_path_in_file_manager)) + .route("/open/document", post(crate::file_actions::open_document)) .route("/secrets/get", post(crate::secret::get_secret)) .route("/secrets/store", post(crate::secret::store_secret)) .route("/secrets/delete", post(crate::secret::delete_secret))