X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fpleroma%2Fformatter.ex;h=e2a658cb3c8ee7a76a47473929746f2ed51a2169;hb=dd4d10b275e76afc029aea642ae3d69b07e33d81;hp=d5565a2cae9e82c3a20c5e7996c7b0a242ff3d4f;hpb=4f03bb22998b9713d84dc8e81a3d63bb60c5b648;p=akkoma diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index d5565a2ca..e2a658cb3 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -1,198 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Formatter do - alias Pleroma.User - alias Pleroma.Web.MediaProxy alias Pleroma.HTML + alias Pleroma.User - @tag_regex ~r/\#\w+/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).() + @safe_mention_regex ~r/^(\s*(?(@.+?\s+){1,})+)(?.*)/s + @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui + @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ + + @auto_linker_config hashtag: true, + hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Pleroma.Formatter.mention_handler/4, + scheme: true + + def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do + case User.get_cached_by_nickname(nickname) do + %User{} -> + # escape markdown characters with `\\` + # (we don't want something like @user__name to be parsed by markdown) + String.replace(mention, @markdown_characters_regex, "\\\\\\1") + + _ -> + buffer + end end - def parse_mentions(text) do - # Modified from https://www.w3.org/TR/html5/forms.html#valid-e-mail-address - 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 - - Regex.scan(regex, text) - |> List.flatten() - |> Enum.uniq() - |> Enum.map(fn "@" <> match = full_match -> - {full_match, User.get_cached_by_nickname(match)} - end) - |> Enum.filter(fn {_match, user} -> user end) - end + 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) - @finmoji [ - "a_trusted_friend", - "alandislands", - "association", - "auroraborealis", - "baby_in_a_box", - "bear", - "black_gold", - "christmasparty", - "crosscountryskiing", - "cupofcoffee", - "education", - "fashionista_finns", - "finnishlove", - "flag", - "forest", - "four_seasons_of_bbq", - "girlpower", - "handshake", - "happiness", - "headbanger", - "icebreaker", - "iceman", - "joulutorttu", - "kaamos", - "kalsarikannit_f", - "kalsarikannit_m", - "karjalanpiirakka", - "kicksled", - "kokko", - "lavatanssit", - "losthopes_f", - "losthopes_m", - "mattinykanen", - "meanwhileinfinland", - "moominmamma", - "nordicfamily", - "out_of_office", - "peacemaker", - "perkele", - "pesapallo", - "polarbear", - "pusa_hispida_saimensis", - "reindeer", - "sami", - "sauna_f", - "sauna_m", - "sauna_whisk", - "sisu", - "stuck", - "suomimainittu", - "superfood", - "swan", - "the_cap", - "the_conductor", - "the_king", - "the_voice", - "theoriginalsanta", - "tomoffinland", - "torillatavataan", - "unbreakable", - "waiting", - "white_nights", - "woollysocks" - ] + link = + ~s(@#{ + nickname_text + }) - @finmoji_with_filenames Enum.map(@finmoji, fn finmoji -> - {finmoji, "/finmoji/128px/#{finmoji}-128.png"} - end) + {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} - @emoji_from_file (with {:ok, default} <- File.read("config/emoji.txt") do - custom = - with {:ok, custom} <- File.read("config/custom_emoji.txt") do - custom - else - _e -> "" - end + _ -> + {buffer, acc} + end + end - (default <> "\n" <> custom) - |> String.trim() - |> String.split(~r/\n+/) - |> Enum.map(fn line -> - [name, file] = String.split(line, ~r/,\s*/) - {name, file} - end) - else - _ -> [] - end) + def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do + tag = String.downcase(tag) + url = "#{Pleroma.Web.base_url()}/tag/#{tag}" + link = ~s(#{tag_text}) - @emoji_from_globs ( - static_path = Path.join(:code.priv_dir(:pleroma), "static") + {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} + end - globs = - Application.get_env(:pleroma, :emoji, []) - |> Keyword.get(:shortcode_globs, []) + @doc """ + Parses a text and replace plain text links with HTML. Returns a tuple with a result text, mentions, and hashtags. - paths = - Enum.map(globs, fn glob -> - Path.join(static_path, glob) - |> Path.wildcard() - end) - |> Enum.concat() + If the 'safe_mention' option is given, only consecutive mentions at the start the post are actually mentioned. + """ + @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 - Enum.map(paths, fn path -> - shortcode = Path.basename(path, Path.extname(path)) - external_path = Path.join("/", Path.relative_to(path, static_path)) - {shortcode, external_path} - end) - ) + if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do + %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) + acc = %{mentions: MapSet.new(), tags: MapSet.new()} - @emoji @finmoji_with_filenames ++ @emoji_from_globs ++ @emoji_from_file + {text_mentions, %{mentions: mentions}} = AutoLinker.link_map(mentions, acc, options) + {text_rest, %{tags: tags}} = AutoLinker.link_map(rest, acc, options) - def emojify(text, emoji \\ @emoji) - def emojify(text, nil), do: text + {text_mentions <> text_rest, MapSet.to_list(mentions), MapSet.to_list(tags)} + else + acc = %{mentions: MapSet.new(), tags: MapSet.new()} + {text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options) - def emojify(text, emoji) do - Enum.reduce(emoji, text, fn {emoji, file}, text -> - emoji = HTML.strip_tags(emoji) - file = HTML.strip_tags(file) + {text, MapSet.to_list(mentions), MapSet.to_list(tags)} + end + end - String.replace( - text, - ":#{emoji}:", - "#{emoji}" + @doc """ + Escapes a special characters in mention names. + """ + def mentions_escape(text, options \\ []) do + options = + Keyword.merge(options, + mention: true, + url: false, + mention_handler: &Pleroma.Formatter.escape_mention_handler/4 ) - |> HTML.filter_tags() - end) - end - def get_emoji(text) when is_binary(text) do - Enum.filter(@emoji, fn {emoji, _} -> String.contains?(text, ":#{emoji}:") end) + if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do + %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) + AutoLinker.link(mentions, options) <> AutoLinker.link(rest, options) + else + AutoLinker.link(text, options) + end end - def get_emoji(_), do: [] - - def get_custom_emoji() do - @emoji + def html_escape({text, mentions, hashtags}, type) do + {html_escape(text, type), mentions, hashtags} end - @link_regex ~r/[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+/ui - - # IANA got a list https://www.iana.org/assignments/uri-schemes/ but - # Stuff like ipfs isn’t in it - # There is very niche stuff - @uri_schemes [ - "https://", - "http://", - "dat://", - "dweb://", - "gopher://", - "ipfs://", - "ipns://", - "irc:", - "ircs:", - "magnet:", - "mailto:", - "mumble:", - "ssb://", - "xmpp:" - ] + def html_escape(text, "text/html") do + HTML.filter_tags(text) + end - # TODO: make it use something other than @link_regex - def html_escape(text) do + def html_escape(text, "text/plain") do Regex.split(@link_regex, text, include_captures: true) |> Enum.map_every(2, fn chunk -> {:safe, part} = Phoenix.HTML.html_escape(chunk) @@ -201,86 +116,21 @@ defmodule Pleroma.Formatter do |> Enum.join("") end - @doc "changes scheme:... urls to html links" - def add_links({subs, text}) do - additionnal_schemes = - Application.get_env(:pleroma, :uri_schemes, []) - |> Keyword.get(:additionnal_schemes, []) + def truncate(text, max_length \\ 200, omission \\ "...") do + # Remove trailing whitespace + text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}") - links = + if String.length(text) < max_length do text - |> String.split([" ", "\t", "
"]) - |> Enum.filter(fn word -> String.starts_with?(word, @uri_schemes ++ additionnal_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, "#{url}"} - end) - - {subs, uuid_text} + else + length_with_omission = max_length - String.length(omission) + String.slice(text, 0, length_with_omission) <> omission + end end - @doc "Adds the links to mentioned users" - def add_user_links({subs, text}, mentions) 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{ap_id: ap_id, info: info}, uuid} -> - ap_id = info["source_data"]["url"] || ap_id - - short_match = String.split(match, "@") |> tl() |> hd() - - {uuid, - "@#{short_match}"} - end) - - {subs, uuid_text} - end + defp get_ap_id(%User{source_data: %{"url" => url}}) when is_binary(url), do: url + defp get_ap_id(%User{ap_id: ap_id}), do: ap_id - @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, match, uuid) - end) - - subs = - subs ++ - Enum.map(tags, fn {tag_text, tag, uuid} -> - url = "" - {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 + defp get_nickname_text(nickname, %{mentions_format: :full}), do: User.full_nickname(nickname) + defp get_nickname_text(nickname, _), do: User.local_nickname(nickname) end