From 2d83df8b32bff7f0028923bb5b64dc0b55f20d03 Mon Sep 17 00:00:00 2001 From: Jordan Bracco Date: Tue, 20 Dec 2022 00:21:54 +0000 Subject: Nola rename: The Big Move, Refs T77 --- lib/nola_plugins/link_plugin.ex | 271 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 lib/nola_plugins/link_plugin.ex (limited to 'lib/nola_plugins/link_plugin.ex') diff --git a/lib/nola_plugins/link_plugin.ex b/lib/nola_plugins/link_plugin.ex new file mode 100644 index 0000000..dee78e8 --- /dev/null +++ b/lib/nola_plugins/link_plugin.ex @@ -0,0 +1,271 @@ +defmodule Nola.IRC.LinkPlugin do + @moduledoc """ + # Link Previewer + + An extensible link previewer for IRC. + + To extend the supported sites, create a new handler implementing the callbacks. + + See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, + and if the handler returns `:error` or crashes, will fallback to the default preview. + + Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use + the mimetype and size. + + ## Configuration: + + ``` + config :nola, Nola.IRC.LinkPlugin, + handlers: [ + Nola.IRC.LinkPlugin.Youtube: [ + invidious: true + ], + Nola.IRC.LinkPlugin.Twitter: [], + Nola.IRC.LinkPlugin.Imgur: [], + ] + ``` + + """ + + @ircdoc """ + # Link preview + + Previews links (just post a link!). + + Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. + """ + def short_irc_doc, do: false + def irc_doc, do: @ircdoc + require Logger + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false + @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false + @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + + @optional_callbacks [expand: 3, post_expand: 4] + + defstruct [:client] + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) + #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) + Logger.info("Link handler started") + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :text, message = %{text: text}}, state) do + String.split(text) + |> Enum.map(fn(word) -> + if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do + uri = URI.parse(word) + if uri.scheme && uri.host do + spawn(fn() -> + :timer.kill_after(:timer.seconds(30)) + case expand_link([uri]) do + {:ok, uris, text} -> + text = case uris do + [uri] -> text + [luri | _] -> + if luri.host == uri.host && luri.path == luri.path do + text + else + ["-> #{URI.to_string(luri)}", text] + end + end + if is_list(text) do + for line <- text, do: message.replyfun.(line) + else + message.replyfun.(text) + end + _ -> nil + end + end) + end + end + end) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + :ok + end + + # 1. Match the first valid handler + # 2. Try to run the handler + # 3. If :error or crash, default link. + # If :skip, nothing + # 4. ? + + # Over five redirections: cancel. + def expand_link(acc = [_, _, _, _, _ | _]) do + {:ok, acc, "link redirects more than five times"} + end + + def expand_link(acc=[uri | _]) do + Logger.debug("link: expanding: #{inspect uri}") + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> + Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") + module = Module.concat([module]) + case module.match(uri, opts) do + {true, params} -> {:halt, {module, params, opts}} + false -> {:cont, acc} + end + end) + run_expand(acc, handler) + end + + def run_expand(acc, nil) do + expand_default(acc) + end + + def run_expand(acc=[uri|_], {module, params, opts}) do + Logger.debug("link: expanding #{inspect uri} with #{inspect module}") + case module.expand(uri, params, opts) do + {:ok, data} -> {:ok, acc, data} + :error -> expand_default(acc) + :skip -> nil + end + rescue + e -> + Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + expand_default(acc) + catch + e, b -> + Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") + expand_default(acc) + end + + defp get(url, headers \\ [], options \\ []) do + get_req(url, :hackney.get(url, headers, <<>>, options)) + end + + defp get_req(_, {:error, reason}) do + {:error, reason} + end + + defp get_req(url, {:ok, 200, headers, client}) do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + content_type = Map.get(headers, "content-type", "application/octect-stream") + length = Map.get(headers, "content-length", "0") + {length, _} = Integer.parse(length) + + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> + module = Module.concat([module]) + try do + case module.post_match(url, content_type, headers, opts) do + {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} + false -> {:cont, acc} + end + rescue + e -> + Logger.error(inspect(e)) + {:cont, false} + catch + e, b -> + Logger.error(inspect({b})) + {:cont, false} + end + end) + + cond do + handler != false and length <= 30_000_000 -> + case get_body(url, 30_000_000, client, handler, <<>>) do + {:ok, _} = ok -> ok + :error -> + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> + # get_body(url, 30_000_000, client, <<>>) + true -> + :hackney.close(client) + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + end + + defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + location = Map.get(headers, "location") + + :hackney.close(client) + {:redirect, location} + end + + defp get_req(_, {:ok, status, headers, client}) do + :hackney.close(client) + {:error, status, headers} + end + + defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do + case :hackney.stream_body(client) do + {:ok, data} -> + get_body(url, len, client, h, << acc::binary, data::binary >>) + :done -> + body = case mode do + :body -> acc + :file -> + {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") + File.write!(tmpfile, acc) + tmpfile + end + handler.post_expand(url, body, params, opts) + {:error, reason} -> + {:ok, "failed to fetch body: #{inspect reason}"} + end + end + + defp get_body(_, len, client, h, _acc) do + :hackney.close(client) + IO.inspect(h) + {:ok, "Error: file over 30"} + end + + def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do + Logger.debug("link: expanding #{uri} with default") + headers = [{"user-agent", "DmzBot (like TwitterBot)"}] + options = [follow_redirect: false, max_body_length: 30_000_000] + case get(URI.to_string(uri), headers, options) do + {:ok, text} -> + {:ok, acc, text} + {:redirect, link} -> + new_uri = URI.parse(link) + #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} + expand_link([new_uri | acc]) + {:error, status, _headers} -> + text = Plug.Conn.Status.reason_phrase(status) + {:ok, acc, "Error: HTTP #{text} (#{status})"} + {:error, {:tls_alert, {:handshake_failure, err}}} -> + {:ok, acc, "TLS Error: #{to_string(err)}"} + {:error, reason} -> + {:ok, acc, "Error: #{to_string(reason)}"} + end + end + + # Unsupported scheme, came from a redirect. + def expand_default(acc = [uri | _]) do + {:ok, [uri], "-> #{URI.to_string(uri)}"} + end + + + defp human_size(bytes) do + bytes + |> FileSize.new(:b) + |> FileSize.scale() + |> FileSize.format() + end +end -- cgit v1.2.3