fix: fixed bds2: protocol handling
This commit is contained in:
@@ -22,6 +22,11 @@ defmodule BDS.Blogmark do
|
||||
@scheme "bds2"
|
||||
@new_post_action "new-post"
|
||||
|
||||
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||
@max_title_length 200
|
||||
@max_url_length 2048
|
||||
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||
|
||||
@type candidate :: %{required(String.t()) => term()}
|
||||
|
||||
@type receive_result :: %{
|
||||
@@ -34,7 +39,8 @@ defmodule BDS.Blogmark do
|
||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||
|
||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
||||
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||
or action is unsupported.
|
||||
"""
|
||||
@spec parse_deep_link(String.t()) ::
|
||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||
@@ -54,10 +60,27 @@ defmodule BDS.Blogmark do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||
before importing.
|
||||
"""
|
||||
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||
def deep_link_project_id(url) when is_binary(url) do
|
||||
case parse_deep_link(url) do
|
||||
{:ok, %{"project_id" => project_id}} -> project_id
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||
pipeline, and creates a draft post from the resulting candidate.
|
||||
|
||||
When the deep link URL carries a `project_id` query parameter, it takes
|
||||
precedence over the passed `project_id` so the bookmarklet can target a
|
||||
specific project regardless of which project is currently active.
|
||||
|
||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||
budget-enforced transform messages and `errors` records any failed transforms.
|
||||
"""
|
||||
@@ -66,10 +89,12 @@ defmodule BDS.Blogmark do
|
||||
def receive_deep_link(project_id, url, opts \\ [])
|
||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, candidate} <- parse_deep_link(url),
|
||||
target_project <- resolve_target_project(project_id, candidate),
|
||||
candidate <- apply_default_content(candidate),
|
||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||
Transforms.run(project_id, candidate, opts),
|
||||
data <- apply_default_category(project_id, data),
|
||||
{:ok, post} <- create_draft(project_id, data) do
|
||||
Transforms.run(target_project, candidate, opts),
|
||||
data <- apply_default_category(target_project, data),
|
||||
{:ok, post} <- create_draft(target_project, data) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||
end
|
||||
end
|
||||
@@ -77,15 +102,102 @@ defmodule BDS.Blogmark do
|
||||
defp candidate_from_query(query) do
|
||||
params = URI.decode_query(query || "")
|
||||
|
||||
project_id =
|
||||
case Map.get(params, "project_id") do
|
||||
nil -> nil
|
||||
"" -> nil
|
||||
id -> id
|
||||
end
|
||||
|
||||
url = sanitize_http_url(Map.get(params, "url"))
|
||||
|
||||
%{
|
||||
"title" => Map.get(params, "title", "") |> to_string(),
|
||||
"url" => optional(params, "url"),
|
||||
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||
"url" => url,
|
||||
"content" => optional(params, "content"),
|
||||
"tags" => list_param(params, "tags"),
|
||||
"categories" => list_param(params, "categories")
|
||||
"categories" => list_param(params, "categories"),
|
||||
"project_id" => project_id
|
||||
}
|
||||
end
|
||||
|
||||
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||
# control-character stripping is safe here (unlike the multi-line body).
|
||||
defp sanitize_title(raw, fallback_host) do
|
||||
title =
|
||||
raw
|
||||
|> to_string()
|
||||
|> strip_control()
|
||||
|> String.trim()
|
||||
|> String.slice(0, @max_title_length)
|
||||
|
||||
if title == "", do: fallback_host || "", else: title
|
||||
end
|
||||
|
||||
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||
# cap; anything else collapses to nil so it can never reach the post body.
|
||||
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||
trimmed = raw |> strip_control() |> String.trim()
|
||||
|
||||
case URI.parse(trimmed) do
|
||||
%URI{scheme: scheme, host: host} = uri
|
||||
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitize_http_url(_other), do: nil
|
||||
|
||||
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||
defp url_host(_other), do: nil
|
||||
|
||||
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||
|
||||
# If the deep link carries a project_id, route to that project; otherwise
|
||||
# use the currently active project passed by the caller. This lets the
|
||||
# bookmarklet target a specific project even when a different one is open.
|
||||
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||
pid
|
||||
end
|
||||
|
||||
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||
|
||||
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||
# post content with a markdown link to the bookmarked page so transforms run
|
||||
# against the same input. Explicit content always wins.
|
||||
defp apply_default_content(candidate) do
|
||||
with nil <- optional_string(Map.get(candidate, "content")),
|
||||
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||
title = Map.get(candidate, "title", "")
|
||||
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||
else
|
||||
_ -> candidate
|
||||
end
|
||||
end
|
||||
|
||||
defp blogmark_markdown_link(title, url) do
|
||||
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||
end
|
||||
|
||||
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||
# then collapse newlines to spaces.
|
||||
defp escape_markdown_link_text(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("[", "\\[")
|
||||
|> String.replace("]", "\\]")
|
||||
|> String.replace("(", "\\(")
|
||||
|> String.replace(")", "\\)")
|
||||
|> String.replace(~r/\r?\n/, " ")
|
||||
end
|
||||
|
||||
defp optional(params, key) do
|
||||
case Map.get(params, key) do
|
||||
nil -> nil
|
||||
|
||||
Reference in New Issue
Block a user