Commit c3ac9424 authored by minibikini's avatar minibikini Committed by kaniini

AutoLinker

parent e9703a53
......@@ -344,6 +344,16 @@ config :pleroma, Pleroma.Jobs,
federator_outgoing: [max_jobs: 50],
mailer: [max_jobs: 10]
config :auto_linker,
opts: [
scheme: true,
extra: true,
class: false,
strip_prefix: false,
new_window: false,
rel: false
]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"
......@@ -107,7 +107,7 @@ config :pleroma, Pleroma.Mailer,
An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed:
```
config :logger,
config :logger,
backends: [{ExSyslogger, :ex_syslogger}]
config :logger, :ex_syslogger,
......@@ -301,3 +301,28 @@ For each pool, the options are:
* `max_connections` - how much connections a pool can hold
* `timeout` - retention duration for connections
## :auto_linker
Configuration for the `auto_linker` library:
* `class: "auto-linker"` - specify the class to be added to the generated link. false to clear
* `rel: "noopener noreferrer"` - override the rel attribute. false to clear
* `new_window: true` - set to false to remove `target='_blank'` attribute
* `scheme: false` - Set to true to link urls with schema `http://google.com`
* `truncate: false` - Set to a number to truncate urls longer then the number. Truncated urls will end in `..`
* `strip_prefix: true` - Strip the scheme prefix
* `extra: false` - link urls with rarely used schemes (magnet, ipfs, irc, etc.)
Example:
```exs
config :auto_linker,
opts: [
scheme: true,
extra: true,
class: false,
strip_prefix: false,
new_window: false,
rel: false
]
```
......@@ -8,33 +8,51 @@ defmodule Pleroma.Formatter do
alias Pleroma.User
alias Pleroma.Web.MediaProxy
@tag_regex ~r/((?<=[^&])|\A)(\#)(\w+)/u
@markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/
@link_regex ~r{((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+}ui
# Modified from https://www.w3.org/TR/html5/forms.html#valid-e-mail-address
@mentions_regex ~r/@[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]*@?[a-zA-Z0-9_-](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*/u
def parse_tags(text, data \\ %{}) do
Regex.scan(@tag_regex, text)
|> Enum.map(fn ["#" <> tag = full_tag | _] -> {full_tag, String.downcase(tag)} end)
|> (fn map ->
if data["sensitive"] in [true, "True", "true", "1"],
do: [{"#nsfw", "nsfw"}] ++ map,
else: map
end).()
@auto_linker_config hashtag: true,
hashtag_handler: &Pleroma.Formatter.hashtag_handler/4,
mention: true,
mention_handler: &Pleroma.Formatter.mention_handler/4
def mention_handler("@" <> nickname, buffer, opts, acc) do
case User.get_cached_by_nickname(nickname) do
%User{id: id} = user ->
ap_id = get_ap_id(user)
nickname_text = get_nickname_text(nickname, opts) |> maybe_escape(opts)
link =
"<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{ap_id}'>@<span>#{
nickname_text
}</span></a></span>"
{link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}}
_ ->
{buffer, acc}
end
end
@doc "Parses mentions text and returns list {nickname, user}."
@spec parse_mentions(binary()) :: list({binary(), User.t()})
def parse_mentions(text) do
Regex.scan(@mentions_regex, text)
|> List.flatten()
|> Enum.uniq()
|> Enum.map(fn nickname ->
with nickname <- String.trim_leading(nickname, "@"),
do: {"@" <> nickname, User.get_cached_by_nickname(nickname)}
end)
|> Enum.filter(fn {_match, user} -> user end)
def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do
tag = String.downcase(tag)
url = "#{Pleroma.Web.base_url()}/tag/#{tag}"
link = "<a class='hashtag' data-tag='#{tag}' href='#{url}' rel='tag'>#{tag_text}</a>"
{link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}}
end
@doc """
Parses a text and replace plain text links with HTML. Returns a tuple with a result text, mentions, and hashtags.
"""
@spec linkify(String.t(), keyword()) ::
{String.t(), [{String.t(), User.t()}], [{String.t(), String.t()}]}
def linkify(text, options \\ []) do
options = options ++ @auto_linker_config
acc = %{mentions: MapSet.new(), tags: MapSet.new()}
{text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options)
{text, MapSet.to_list(mentions), MapSet.to_list(tags)}
end
def emojify(text) do
......@@ -48,9 +66,7 @@ defmodule Pleroma.Formatter do
emoji = HTML.strip_tags(emoji)
file = HTML.strip_tags(file)
String.replace(
text,
":#{emoji}:",
html =
if not strip do
"<img height='32px' width='32px' alt='#{emoji}' title='#{emoji}' src='#{
MediaProxy.url(file)
......@@ -58,8 +74,8 @@ defmodule Pleroma.Formatter do
else
""
end
)
|> HTML.filter_tags()
String.replace(text, ":#{emoji}:", html) |> HTML.filter_tags()
end)
end
......@@ -75,12 +91,6 @@ defmodule Pleroma.Formatter do
def get_emoji(_), do: []
@link_regex ~r/[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+/ui
@uri_schemes Application.get_env(:pleroma, :uri_schemes, [])
@valid_schemes Keyword.get(@uri_schemes, :valid_schemes, [])
# TODO: make it use something other than @link_regex
def html_escape(text, "text/html") do
HTML.filter_tags(text)
end
......@@ -94,112 +104,6 @@ defmodule Pleroma.Formatter do
|> Enum.join("")
end
@doc """
Escapes a special characters in mention names.
"""
@spec mentions_escape(String.t(), list({String.t(), any()})) :: String.t()
def mentions_escape(text, mentions) do
mentions
|> Enum.reduce(text, fn {name, _}, acc ->
escape_name = String.replace(name, @markdown_characters_regex, "\\\\\\1")
String.replace(acc, name, escape_name)
end)
end
@doc "changes scheme:... urls to html links"
def add_links({subs, text}) do
links =
text
|> String.split([" ", "\t", "<br>"])
|> Enum.filter(fn word -> String.starts_with?(word, @valid_schemes) end)
|> Enum.filter(fn word -> Regex.match?(@link_regex, word) end)
|> Enum.map(fn url -> {Ecto.UUID.generate(), url} end)
|> Enum.sort_by(fn {_, url} -> -String.length(url) end)
uuid_text =
links
|> Enum.reduce(text, fn {uuid, url}, acc -> String.replace(acc, url, uuid) end)
subs =
subs ++
Enum.map(links, fn {uuid, url} ->
{uuid, "<a href=\"#{url}\">#{url}</a>"}
end)
{subs, uuid_text}
end
@doc "Adds the links to mentioned users"
def add_user_links({subs, text}, mentions, options \\ []) do
mentions =
mentions
|> Enum.sort_by(fn {name, _} -> -String.length(name) end)
|> Enum.map(fn {name, user} -> {name, user, Ecto.UUID.generate()} end)
uuid_text =
mentions
|> Enum.reduce(text, fn {match, _user, uuid}, text ->
String.replace(text, match, uuid)
end)
subs =
subs ++
Enum.map(mentions, fn {match, %User{id: id, ap_id: ap_id, info: info}, uuid} ->
ap_id =
if is_binary(info.source_data["url"]) do
info.source_data["url"]
else
ap_id
end
nickname =
if options[:format] == :full do
User.full_nickname(match)
else
User.local_nickname(match)
end
{uuid,
"<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{ap_id}'>" <>
"@<span>#{nickname}</span></a></span>"}
end)
{subs, uuid_text}
end
@doc "Adds the hashtag links"
def add_hashtag_links({subs, text}, tags) do
tags =
tags
|> Enum.sort_by(fn {name, _} -> -String.length(name) end)
|> Enum.map(fn {name, short} -> {name, short, Ecto.UUID.generate()} end)
uuid_text =
tags
|> Enum.reduce(text, fn {match, _short, uuid}, text ->
String.replace(text, ~r/((?<=[^&])|(\A))#{match}/, uuid)
end)
subs =
subs ++
Enum.map(tags, fn {tag_text, tag, uuid} ->
url =
"<a class='hashtag' data-tag='#{tag}' href='#{Pleroma.Web.base_url()}/tag/#{tag}' rel='tag'>#{
tag_text
}</a>"
{uuid, url}
end)
{subs, uuid_text}
end
def finalize({subs, text}) do
Enum.reduce(subs, text, fn {uuid, replacement}, result_text ->
String.replace(result_text, uuid, replacement)
end)
end
def truncate(text, max_length \\ 200, omission \\ "...") do
# Remove trailing whitespace
text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}")
......@@ -211,4 +115,16 @@ defmodule Pleroma.Formatter do
String.slice(text, 0, length_with_omission) <> omission
end
end
defp get_ap_id(%User{info: %{source_data: %{"url" => url}}}) when is_binary(url), do: url
defp get_ap_id(%User{ap_id: ap_id}), do: ap_id
defp get_nickname_text(nickname, %{mentions_format: :full}), do: User.full_nickname(nickname)
defp get_nickname_text(nickname, _), do: User.local_nickname(nickname)
defp maybe_escape(str, %{mentions_escape: true}) do
String.replace(str, @markdown_characters_regex, "\\\\\\1")
end
defp maybe_escape(str, _), do: str
end
......@@ -1193,9 +1193,6 @@ defmodule Pleroma.User do
def parse_bio(bio, _user) when bio == "", do: bio
def parse_bio(bio, user) do
mentions = Formatter.parse_mentions(bio)
tags = Formatter.parse_tags(bio)
emoji =
(user.info.source_data["tag"] || [])
|> Enum.filter(fn %{"type" => t} -> t == "Emoji" end)
......@@ -1204,7 +1201,8 @@ defmodule Pleroma.User do
end)
bio
|> CommonUtils.format_input(mentions, tags, "text/plain", user_links: [format: :full])
|> CommonUtils.format_input("text/plain", mentions_format: :full)
|> elem(0)
|> Formatter.emojify(emoji)
end
......
......@@ -82,40 +82,20 @@ defmodule Pleroma.Web.CommonAPI do
def get_visibility(_), do: "public"
defp get_content_type(content_type) do
if Enum.member?(Pleroma.Config.get([:instance, :allowed_post_formats]), content_type) do
content_type
else
"text/plain"
end
end
def post(user, %{"status" => status} = data) do
visibility = get_visibility(data)
limit = Pleroma.Config.get([:instance, :limit])
with status <- String.trim(status),
attachments <- attachments_from_ids(data),
mentions <- Formatter.parse_mentions(status),
inReplyTo <- get_replied_to_activity(data["in_reply_to_status_id"]),
{to, cc} <- to_for_user_and_mentions(user, mentions, inReplyTo, visibility),
tags <- Formatter.parse_tags(status, data),
content_html <-
{content_html, mentions, tags} <-
make_content_html(
status,
mentions,
attachments,
tags,
get_content_type(data["content_type"]),
Enum.member?(
[true, "true"],
Map.get(
data,
"no_attachment_links",
Pleroma.Config.get([:instance, :no_attachment_links], false)
)
)
data
),
{to, cc} <- to_for_user_and_mentions(user, mentions, inReplyTo, visibility),
context <- make_context(inReplyTo),
cw <- data["spoiler_text"],
full_payload <- String.trim(status <> (data["spoiler_text"] || "")),
......@@ -247,7 +227,7 @@ defmodule Pleroma.Web.CommonAPI do
def report(user, data) do
with {:account_id, %{"account_id" => account_id}} <- {:account_id, data},
{:account, %User{} = account} <- {:account, User.get_by_id(account_id)},
{:ok, content_html} <- make_report_content_html(data["comment"]),
{:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]),
{:ok, statuses} <- get_report_statuses(account, data),
{:ok, activity} <-
ActivityPub.flag(%{
......
......@@ -10,7 +10,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web
alias Pleroma.Config
alias Pleroma.Web.Endpoint
alias Pleroma.Web.MediaProxy
alias Pleroma.Web.ActivityPub.Utils
......@@ -100,24 +100,45 @@ defmodule Pleroma.Web.CommonAPI.Utils do
def make_content_html(
status,
mentions,
attachments,
tags,
content_type,
no_attachment_links \\ false
data
) do
no_attachment_links =
data
|> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links]))
|> Kernel.in([true, "true"])
content_type = get_content_type(data["content_type"])
status
|> format_input(mentions, tags, content_type)
|> format_input(content_type)
|> maybe_add_attachments(attachments, no_attachment_links)
|> maybe_add_nsfw_tag(data)
end
defp get_content_type(content_type) do
if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
content_type
else
"text/plain"
end
end
defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
when sensitive in [true, "True", "true", "1"] do
{text, mentions, [{"#nsfw", "nsfw"} | tags]}
end
defp maybe_add_nsfw_tag(data, _), do: data
def make_context(%Activity{data: %{"context" => context}}), do: context
def make_context(_), do: Utils.generate_context_id()
def maybe_add_attachments(text, _attachments, true = _no_links), do: text
def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed
def maybe_add_attachments(text, attachments, _no_links) do
add_attachments(text, attachments)
def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
text = add_attachments(text, attachments)
{text, mentions, tags}
end
def add_attachments(text, attachments) do
......@@ -135,56 +156,39 @@ defmodule Pleroma.Web.CommonAPI.Utils do
Enum.join([text | attachment_text], "<br>")
end
def format_input(text, mentions, tags, format, options \\ [])
def format_input(text, format, options \\ [])
@doc """
Formatting text to plain text.
"""
def format_input(text, mentions, tags, "text/plain", options) do
def format_input(text, "text/plain", options) do
text
|> Formatter.html_escape("text/plain")
|> String.replace(~r/\r?\n/, "<br>")
|> (&{[], &1}).()
|> Formatter.add_links()
|> Formatter.add_user_links(mentions, options[:user_links] || [])
|> Formatter.add_hashtag_links(tags)
|> Formatter.finalize()
|> Formatter.linkify(options)
|> (fn {text, mentions, tags} ->
{String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
end).()
end
@doc """
Formatting text to html.
"""
def format_input(text, mentions, _tags, "text/html", options) do
def format_input(text, "text/html", options) do
text
|> Formatter.html_escape("text/html")
|> (&{[], &1}).()
|> Formatter.add_user_links(mentions, options[:user_links] || [])
|> Formatter.finalize()
|> Formatter.linkify(options)
end
@doc """
Formatting text to markdown.
"""
def format_input(text, mentions, tags, "text/markdown", options) do
def format_input(text, "text/markdown", options) do
options = Keyword.put(options, :mentions_escape, true)
text
|> Formatter.mentions_escape(mentions)
|> Earmark.as_html!()
|> Formatter.html_escape("text/html")
|> (&{[], &1}).()
|> Formatter.add_user_links(mentions, options[:user_links] || [])
|> Formatter.add_hashtag_links(tags)
|> Formatter.finalize()
end
def add_tag_links(text, tags) do
tags =
tags
|> Enum.sort_by(fn {tag, _} -> -String.length(tag) end)
Enum.reduce(tags, text, fn {full, tag}, text ->
url = "<a href='#{Web.base_url()}/tag/#{tag}' rel='tag'>##{tag}</a>"
String.replace(text, full, url)
end)
|> Formatter.linkify(options)
|> (fn {text, mentions, tags} -> {Earmark.as_html!(text), mentions, tags} end).()
end
def make_note_data(
......@@ -323,13 +327,13 @@ defmodule Pleroma.Web.CommonAPI.Utils do
def maybe_extract_mentions(_), do: []
def make_report_content_html(nil), do: {:ok, nil}
def make_report_content_html(nil), do: {:ok, {nil, [], []}}
def make_report_content_html(comment) do
max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
if String.length(comment) <= max_size do
{:ok, format_input(comment, [], [], "text/plain")}
{:ok, format_input(comment, "text/plain")}
else
{:error, "Comment must be up to #{max_size} characters"}
end
......
......@@ -90,7 +90,10 @@ defmodule Pleroma.Mixfile do
{:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test},
{:floki, "~> 0.20.0"},
{:ex_syslogger, github: "slashmili/ex_syslogger", tag: "1.4.0"},
{:timex, "~> 3.5"}
{:timex, "~> 3.5"},
{:auto_linker,
git: "https://git.pleroma.social/pleroma/auto_linker.git",
ref: "94193ca5f97c1f9fdf3d1469653e2d46fac34bcd"}
]
end
......
%{
"auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "94193ca5f97c1f9fdf3d1469653e2d46fac34bcd", [ref: "94193ca5f97c1f9fdf3d1469653e2d46fac34bcd"]},
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
......
This diff is collapsed.
......@@ -57,19 +57,19 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
assert expected == Utils.emoji_from_profile(user)
end
describe "format_input/4" do
describe "format_input/3" do
test "works for bare text/plain" do
text = "hello world!"
expected = "hello world!"
output = Utils.format_input(text, [], [], "text/plain")
{output, [], []} = Utils.format_input(text, "text/plain")
assert output == expected
text = "hello world!\n\nsecond paragraph!"
expected = "hello world!<br><br>second paragraph!"
output = Utils.format_input(text, [], [], "text/plain")
{output, [], []} = Utils.format_input(text, "text/plain")
assert output == expected
end
......@@ -78,14 +78,14 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
text = "<p>hello world!</p>"
expected = "<p>hello world!</p>"
output = Utils.format_input(text, [], [], "text/html")
{output, [], []} = Utils.format_input(text, "text/html")
assert output == expected
text = "<p>hello world!</p>\n\n<p>second paragraph</p>"
expected = "<p>hello world!</p>\n\n<p>second paragraph</p>"
output = Utils.format_input(text, [], [], "text/html")
{output, [], []} = Utils.format_input(text, "text/html")
assert output == expected
end
......@@ -94,14 +94,32 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do
text = "**hello world**"
expected = "<p><strong>hello world</strong></p>\n"
output = Utils.format_input(text, [], [], "text/markdown")
{output, [], []} = Utils.format_input(text, "text/markdown")
assert output == expected
text = "**hello world**\n\n*another paragraph*"
expected = "<p><strong>hello world</strong></p>\n<p><em>another paragraph</em></p>\n"
output = Utils.format_input(text, [], [], "text/markdown")
{output, [], []} = Utils.format_input(text, "text/markdown")
assert output == expected
end
test "works for text/markdown with mentions" do
{:ok, user} =
UserBuilder.insert(%{nickname: "user__test", ap_id: "http://foo.com/user__test"})
text = "**hello world**\n\n*another @user__test and @user__test google.com paragraph*"
expected =
"<p><strong>hello world</strong></p>\n<p><em>another <span class='h-card'><a data-user='#{
user.id
}' class='u-url mention' href='http://foo.com/user__test'>@<span>user__test</span></a></span> and <span class='h-card'><a data-user='#{
user.id
}' class='u-url mention' href='http://foo.com/user__test'>@<span>user__test</span></a></span> <a href=\"http://google.com\">google.com</a> paragraph</em></p>\n"
{output, _, _} = Utils.format_input(text, "text/markdown")
assert output == expected
end
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment