488 lines
15 KiB
Rust
488 lines
15 KiB
Rust
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<String>,
|
|
pub content: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub categories: Vec<String>,
|
|
pub project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BlogmarkImportResult {
|
|
pub post: Post,
|
|
pub toasts: Vec<String>,
|
|
pub transform_errors: Vec<String>,
|
|
}
|
|
|
|
pub fn parse_deep_link(raw: &str) -> EngineResult<BlogmarkCandidate> {
|
|
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::<std::collections::HashMap<_, _>>();
|
|
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::<String>();
|
|
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<BlogmarkImportResult> {
|
|
let host = CoreHost::from_connection(conn, project_id, data_dir)
|
|
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
|
|
.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<dyn HostApi>,
|
|
) -> EngineResult<BlogmarkImportResult> {
|
|
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<dyn HostApi>,
|
|
) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> {
|
|
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
|
|
.into_iter()
|
|
.filter(|script| script.kind == ScriptKind::Transform && script.enabled)
|
|
.collect::<Vec<_>>();
|
|
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<String>) {
|
|
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<String>, source: impl IntoIterator<Item = String>) {
|
|
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<String> {
|
|
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::<String>();
|
|
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<String> {
|
|
let cleaned = value
|
|
.chars()
|
|
.filter(|ch| !ch.is_control())
|
|
.collect::<String>();
|
|
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<String> {
|
|
value
|
|
.map(|value| value.trim())
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn list_param(value: Option<&String>) -> Vec<String> {
|
|
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<Value>,
|
|
) -> Result<Value, String> {
|
|
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
|
|
);
|
|
}
|
|
}
|