use std::fs; use std::path::Path; use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use url::Url; use crate::db::DbConnection as Connection; use crate::db::queries::script as script_queries; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, Script, ScriptKind}; use crate::scripting::{self, CoreHost, ExecutionControl, ExecutionKind, HostApi, UnavailableHost}; const MAX_TITLE_LENGTH: usize = 200; const MAX_URL_LENGTH: usize = 2_048; const MAX_TOASTS_TOTAL: usize = 20; const MAX_TOAST_LENGTH: usize = 300; const BLOGMARK_SCHEME: &str = "ruds"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BlogmarkCandidate { pub title: String, pub url: Option, pub content: Option, pub tags: Vec, pub categories: Vec, pub project_id: Option, } #[derive(Debug, Clone)] pub struct BlogmarkImportResult { pub post: Post, pub toasts: Vec, pub transform_errors: Vec, } pub fn parse_deep_link(raw: &str) -> EngineResult { let parsed = Url::parse(raw).map_err(|_| EngineError::Validation("invalid blogmark URL".into()))?; if parsed.scheme() != BLOGMARK_SCHEME { return Err(EngineError::Validation( "unsupported blogmark scheme".into(), )); } if parsed.host_str() != Some("new-post") { return Err(EngineError::Validation( "unsupported blogmark action".into(), )); } let params = parsed .query_pairs() .into_owned() .collect::>(); let url = params.get("url").and_then(|value| sanitize_http_url(value)); let fallback = url .as_ref() .and_then(|value| Url::parse(value).ok()) .and_then(|value| value.host_str().map(str::to_string)); let title = sanitize_title(params.get("title").map(String::as_str), fallback.as_deref()); Ok(BlogmarkCandidate { title, url, content: nonempty(params.get("content")), tags: list_param(params.get("tags")), categories: list_param(params.get("categories")), project_id: nonempty(params.get("project_id")), }) } pub fn bookmarklet(project_id: &str) -> String { let project_id = url::form_urlencoded::byte_serialize(project_id.as_bytes()).collect::(); format!( "javascript:(()=>{{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='ruds://new-post?title='+t+'&url='+u+'&project_id={project_id}';}})();" ) } pub fn receive_deep_link( conn: &Connection, data_dir: &Path, project_id: &str, raw: &str, ) -> EngineResult { let host = CoreHost::from_connection(conn, project_id, data_dir) .map(|host| Arc::new(host) as Arc) .unwrap_or_else(|_| Arc::new(UnavailableHost)); receive_deep_link_with_host( conn, data_dir, project_id, raw, &ExecutionControl::default(), host, ) } pub fn receive_deep_link_with_host( conn: &Connection, data_dir: &Path, project_id: &str, raw: &str, control: &ExecutionControl, host: Arc, ) -> EngineResult { let mut candidate = parse_deep_link(raw)?; if let Some(target) = &candidate.project_id && target != project_id { return Err(EngineError::Validation( "blogmark targets a different project".into(), )); } if candidate.content.is_none() && let Some(url) = &candidate.url { candidate.content = Some(format!( "[{}]({url})", escape_markdown_link_text(&candidate.title) )); } let (mut candidate, toasts, transform_errors) = run_transforms(conn, data_dir, project_id, candidate, control, host)?; if control.is_cancelled() { return Err(EngineError::Validation("script cancelled".into())); } if candidate.categories.is_empty() { let metadata = crate::engine::meta::read_project_json(data_dir)?; if let Some(category) = metadata .blogmark_category .filter(|value| !value.trim().is_empty()) { candidate.categories.push(category); } } let metadata = crate::engine::meta::read_project_json(data_dir)?; let post = crate::engine::post::create_post( conn, data_dir, project_id, &candidate.title, candidate.content.as_deref(), candidate.tags, candidate.categories, metadata.default_author.as_deref(), metadata.main_language.as_deref(), None, )?; Ok(BlogmarkImportResult { post, toasts, transform_errors, }) } fn run_transforms( conn: &Connection, data_dir: &Path, project_id: &str, candidate: BlogmarkCandidate, control: &ExecutionControl, host: Arc, ) -> EngineResult<(BlogmarkCandidate, Vec, Vec)> { let mut transforms = script_queries::list_scripts_by_project(conn, project_id)? .into_iter() .filter(|script| script.kind == ScriptKind::Transform && script.enabled) .collect::>(); transforms.sort_by(|left, right| { left.updated_at .cmp(&right.updated_at) .then_with(|| left.slug.cmp(&right.slug)) .then_with(|| left.id.cmp(&right.id)) }); let mut current = serde_json::to_value(candidate).map_err(|error| EngineError::Parse(error.to_string()))?; let mut toasts = Vec::new(); let mut errors = Vec::new(); for script in transforms { if control.is_cancelled() { return Err(EngineError::Validation("script cancelled".into())); } if script.entrypoint.trim().is_empty() { continue; } let source = resolved_script_content(data_dir, &script)?; let context = json!({ "source": "blogmark", "url": current.get("url").cloned().unwrap_or(Value::Null), }); match scripting::execute_many_with_host( &source, &script.entrypoint, &[current.clone(), context], ExecutionKind::Transform, control, Arc::clone(&host), ) { Ok(execution) => { let (next, returned_toasts) = split_transform_result(execution.value, ¤t); current = next; accept_toasts( &mut toasts, execution.toasts.into_iter().chain(returned_toasts), ); } Err(_error) if control.is_cancelled() => { return Err(EngineError::Validation("script cancelled".into())); } Err(error) => errors.push(format!("{}: {error}", script.slug)), } } let candidate = serde_json::from_value(current).map_err(|error| { EngineError::Validation(format!("transform returned invalid post data: {error}")) })?; Ok((candidate, toasts, errors)) } fn split_transform_result(value: Value, previous: &Value) -> (Value, Vec) { if let Some(object) = value.as_object() { if let Some(data) = object.get("data").filter(|value| value.is_object()) { let toasts = object .get("toasts") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::to_string) .collect(); return (data.clone(), toasts); } return (value, Vec::new()); } (previous.clone(), Vec::new()) } fn accept_toasts(target: &mut Vec, source: impl IntoIterator) { for message in source.into_iter().take(5) { if target.len() >= MAX_TOASTS_TOTAL { break; } target.push(message.chars().take(MAX_TOAST_LENGTH).collect()); } } fn resolved_script_content(data_dir: &Path, script: &Script) -> EngineResult { if let Some(content) = &script.content { return Ok(content.clone()); } let raw = fs::read_to_string(data_dir.join(&script.file_path))?; crate::util::frontmatter::read_script_file(&raw) .map(|(_, body)| body) .map_err(EngineError::Parse) } fn sanitize_title(value: Option<&str>, fallback: Option<&str>) -> String { let cleaned = value .unwrap_or_default() .chars() .filter(|ch| !ch.is_control()) .collect::(); let cleaned = cleaned.trim(); let selected = if cleaned.is_empty() { fallback.unwrap_or_default() } else { cleaned }; selected.chars().take(MAX_TITLE_LENGTH).collect() } fn sanitize_http_url(value: &str) -> Option { let cleaned = value .chars() .filter(|ch| !ch.is_control()) .collect::(); let mut parsed = Url::parse(cleaned.trim()).ok()?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { return None; } parsed.set_fragment(None); parsed.set_username("").ok()?; parsed.set_password(None).ok()?; let normalized = parsed.to_string(); (normalized.chars().count() <= MAX_URL_LENGTH).then_some(normalized) } fn nonempty(value: Option<&String>) -> Option { value .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(str::to_string) } fn list_param(value: Option<&String>) -> Vec { value .map(|value| { value .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string) .collect() }) .unwrap_or_default() } fn escape_markdown_link_text(value: &str) -> String { value .trim() .replace('\\', "\\\\") .replace('[', "\\[") .replace(']', "\\]") .replace('(', "\\(") .replace(')', "\\)") .replace(['\r', '\n'], " ") } #[cfg(test)] mod tests { use super::*; use crate::db::Database; use crate::model::ScriptKind; use tempfile::TempDir; struct PostsHost; impl HostApi for PostsHost { fn call( &self, namespace: &str, method: &str, _arguments: Vec, ) -> Result { match (namespace, method) { ("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])), _ => Err("unsupported test capability".into()), } } } #[test] fn parses_and_hardens_blogmark_links() { let candidate = parse_deep_link( "ruds://new-post?title=%00Hello&url=https%3A%2F%2Fuser%3Apass%40example.com%2Fa%23frag&tags=one%2Ctwo", ) .unwrap(); assert_eq!(candidate.title, "Hello"); assert_eq!(candidate.url.as_deref(), Some("https://example.com/a")); assert_eq!(candidate.tags, vec!["one", "two"]); } #[test] fn rejects_other_schemes_and_actions() { assert!(parse_deep_link("bds://new-post?title=x").is_err()); assert!(parse_deep_link("bds2://new-post?title=x").is_err()); assert!(parse_deep_link("ruds://other?title=x").is_err()); } #[test] fn bookmarklet_targets_only_ruds_and_the_selected_project() { let value = bookmarklet("project & seven"); assert!(value.contains("ruds://new-post?")); assert!(value.contains("project_id=project+%26+seven")); assert!(!value.contains("bds2://")); } #[test] fn invalid_source_url_never_reaches_candidate() { let candidate = parse_deep_link("ruds://new-post?title=x&url=javascript%3Aalert%281%29").unwrap(); assert!(candidate.url.is_none()); } #[test] fn imports_draft_after_ordered_transform_pipeline() { let db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); let directory = TempDir::new().unwrap(); let project = crate::engine::project::create_project( db.conn(), "Blog", Some(directory.path().to_str().unwrap()), ) .unwrap(); crate::engine::script::create_script( db.conn(), &project.id, "Add tag", ScriptKind::Transform, "function main(data, context) data.tags = {context.source}; bds.app.toast('done'); return data end", None, ) .unwrap(); let result = receive_deep_link( db.conn(), directory.path(), &project.id, "ruds://new-post?title=Example&url=https%3A%2F%2Fexample.com", ) .unwrap(); assert_eq!(result.post.tags, vec!["blogmark"]); assert_eq!( result.post.content.as_deref(), Some("[Example](https://example.com/)") ); assert_eq!(result.toasts, vec!["done"]); assert!(result.transform_errors.is_empty()); } #[test] fn transforms_use_the_supplied_project_host() { let db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); let directory = TempDir::new().unwrap(); let project = crate::engine::project::create_project( db.conn(), "Blog", Some(directory.path().to_str().unwrap()), ) .unwrap(); crate::engine::script::create_script( db.conn(), &project.id, "Count posts", ScriptKind::Transform, "function main(data) data.title = tostring(#bds.posts.get_all()) .. ' posts'; return data end", None, ) .unwrap(); let result = receive_deep_link_with_host( db.conn(), directory.path(), &project.id, "ruds://new-post?title=Example", &ExecutionControl::default(), Arc::new(PostsHost), ) .unwrap(); assert_eq!(result.post.title, "2 posts"); } #[test] fn cancellation_stops_transform_import_before_post_creation() { let db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); let directory = TempDir::new().unwrap(); let project = crate::engine::project::create_project( db.conn(), "Blog", Some(directory.path().to_str().unwrap()), ) .unwrap(); crate::engine::script::create_script( db.conn(), &project.id, "Cancelled", ScriptKind::Transform, "function main(data) return data end", None, ) .unwrap(); let control = ExecutionControl::default(); control.cancel(); assert!( receive_deep_link_with_host( db.conn(), directory.path(), &project.id, "ruds://new-post?title=Example", &control, Arc::new(PostsHost), ) .is_err() ); assert_eq!( crate::db::queries::post::count_posts_by_project(db.conn(), &project.id).unwrap(), 0 ); } }