logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma
commit: 6383fa3a5d1a269315001b445dafc73a059dd8cb
parent: b9cd5117a1388a7ba4ce40eebe96464576da0514
Author: kaniini <nenolod@gmail.com>
Date:   Fri, 25 Jan 2019 15:16:12 +0000

Merge branch 'oembed_provider' into 'develop'

Opengraph/TwitterCard::summary for statuses and user profiles

See merge request pleroma/pleroma!533

Diffstat:

Mconfig/config.exs2++
Mdocs/config.md6++++++
Mlib/pleroma/formatter.ex30++++++++++++++++++++++++++----
Mlib/pleroma/user.ex26++++++++++++++++++++++++++
Alib/pleroma/web/metadata.ex40++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/metadata/opengraph.ex154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/pleroma/web/metadata/provider.ex7+++++++
Alib/pleroma/web/metadata/twitter_card.ex46++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/ostatus/ostatus_controller.ex28++++++++++++++++++++++------
Mlib/pleroma/web/router.ex40++++++++++++++++++++++++++++++++++++++--
Mmix.exs1+
Atest/web/metadata/opengraph_test.exs94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/web/ostatus/ostatus_controller_test.exs24++++++++++++++----------
13 files changed, 476 insertions(+), 22 deletions(-)

diff --git a/config/config.exs b/config/config.exs @@ -208,6 +208,8 @@ config :pleroma, :gopher, ip: {0, 0, 0, 0}, port: 9999 +config :pleroma, Pleroma.Web.Metadata, providers: [], unfurl_nsfw: false + config :pleroma, :suggestions, enabled: false, third_party_engine: diff --git a/docs/config.md b/docs/config.md @@ -211,3 +211,9 @@ curl "http://localhost:4000/api/pleroma/admin/invite_token?admin_token=somerando * `max_jobs`: The maximum amount of parallel federation jobs running at the same time. * `initial_timeout`: The initial timeout in seconds * `max_retries`: The maximum number of times a federation job is retried + +## Pleroma.Web.Metadata +* `providers`: a list of metadata providers to enable. Providers availible: + * Pleroma.Web.Metadata.Providers.OpenGraph + * Pleroma.Web.Metadata.Providers.TwitterCard +* `unfurl_nsfw`: If set to `true` nsfw attachments will be shown in previews diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex @@ -43,7 +43,7 @@ defmodule Pleroma.Formatter do def emojify(text, nil), do: text - def emojify(text, emoji) do + def emojify(text, emoji, strip \\ false) do Enum.reduce(emoji, text, fn {emoji, file}, text -> emoji = HTML.strip_tags(emoji) file = HTML.strip_tags(file) @@ -51,14 +51,24 @@ defmodule Pleroma.Formatter do String.replace( text, ":#{emoji}:", - "<img height='32px' width='32px' alt='#{emoji}' title='#{emoji}' src='#{ - MediaProxy.url(file) - }' />" + if not strip do + "<img height='32px' width='32px' alt='#{emoji}' title='#{emoji}' src='#{ + MediaProxy.url(file) + }' />" + else + "" + end ) |> HTML.filter_tags() end) end + def demojify(text) do + emojify(text, Emoji.get_all(), true) + end + + def demojify(text, nil), do: text + def get_emoji(text) when is_binary(text) do Enum.filter(Emoji.get_all(), fn {emoji, _} -> String.contains?(text, ":#{emoji}:") end) end @@ -189,4 +199,16 @@ defmodule Pleroma.Formatter do 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}") + + if String.length(text) < max_length do + text + else + length_with_omission = max_length - String.length(omission) + String.slice(text, 0, length_with_omission) <> omission + end + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex @@ -406,6 +406,10 @@ defmodule Pleroma.User do user.info.locked || false end + def get_by_id(id) do + Repo.get_by(User, id: id) + end + def get_by_ap_id(ap_id) do Repo.get_by(User, ap_id: ap_id) end @@ -441,11 +445,33 @@ defmodule Pleroma.User do Cachex.fetch!(:user_cache, key, fn _ -> get_by_ap_id(ap_id) end) end + def get_cached_by_id(id) do + key = "id:#{id}" + + ap_id = + Cachex.fetch!(:user_cache, key, fn _ -> + user = get_by_id(id) + + if user do + Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + {:commit, user.ap_id} + else + {:ignore, ""} + end + end) + + get_cached_by_ap_id(ap_id) + end + def get_cached_by_nickname(nickname) do key = "nickname:#{nickname}" Cachex.fetch!(:user_cache, key, fn _ -> get_or_fetch_by_nickname(nickname) end) end + def get_cached_by_nickname_or_id(nickname_or_id) do + get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) + end + def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata do + alias Phoenix.HTML + + def build_tags(params) do + Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), "", fn parser, acc -> + rendered_html = + params + |> parser.build_tags() + |> Enum.map(&to_tag/1) + |> Enum.map(&HTML.safe_to_string/1) + |> Enum.join() + + acc <> rendered_html + end) + end + + def to_tag(data) do + with {name, attrs, _content = []} <- data do + HTML.Tag.tag(name, attrs) + else + {name, attrs, content} -> + HTML.Tag.content_tag(name, content, attrs) + + _ -> + raise ArgumentError, message: "make_tag invalid args" + end + end + + def activity_nsfw?(%{data: %{"sensitive" => sensitive}}) do + Pleroma.Config.get([__MODULE__, :unfurl_nsfw], false) == false and sensitive + end + + def activity_nsfw?(_) do + false + end +end diff --git a/lib/pleroma/web/metadata/opengraph.ex b/lib/pleroma/web/metadata/opengraph.ex @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.OpenGraph do + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Metadata + alias Pleroma.{HTML, Formatter, User} + alias Pleroma.Web.MediaProxy + + @behaviour Provider + + @impl Provider + def build_tags(%{ + object: object, + url: url, + user: user + }) do + attachments = build_attachments(object) + scrubbed_content = scrub_html_and_truncate(object) + # Zero width space + content = + if scrubbed_content != "" and scrubbed_content != "\u200B" do + ": “" <> scrubbed_content <> "”" + else + "" + end + + # Most previews only show og:title which is inconvenient. Instagram + # hacks this by putting the description in the title and making the + # description longer prefixed by how many likes and shares the post + # has. Here we use the descriptive nickname in the title, and expand + # the full account & nickname in the description. We also use the cute^Wevil + # smart quotes around the status text like Instagram, too. + [ + {:meta, + [ + property: "og:title", + content: "#{user.name}" <> content + ], []}, + {:meta, [property: "og:url", content: url], []}, + {:meta, + [ + property: "og:description", + content: "#{user_name_string(user)}" <> content + ], []}, + {:meta, [property: "og:type", content: "website"], []} + ] ++ + if attachments == [] or Metadata.activity_nsfw?(object) do + [ + {:meta, [property: "og:image", content: attachment_url(User.avatar_url(user))], []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + ] + else + attachments + end + end + + @impl Provider + def build_tags(%{user: user}) do + with truncated_bio = scrub_html_and_truncate(user.bio || "") do + [ + {:meta, + [ + property: "og:title", + content: user_name_string(user) + ], []}, + {:meta, [property: "og:url", content: User.profile_url(user)], []}, + {:meta, [property: "og:description", content: truncated_bio], []}, + {:meta, [property: "og:type", content: "website"], []}, + {:meta, [property: "og:image", content: attachment_url(User.avatar_url(user))], []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + ] + end + end + + defp build_attachments(%{data: %{"attachment" => attachments}}) do + Enum.reduce(attachments, [], fn attachment, acc -> + rendered_tags = + Enum.reduce(attachment["url"], [], fn url, acc -> + media_type = + Enum.find(["image", "audio", "video"], fn media_type -> + String.starts_with?(url["mediaType"], media_type) + end) + + # TODO: Add additional properties to objects when we have the data available. + # Also, Whatsapp only wants JPEG or PNGs. It seems that if we add a second og:image + # object when a Video or GIF is attached it will display that in the Whatsapp Rich Preview. + case media_type do + "audio" -> + [ + {:meta, [property: "og:" <> media_type, content: attachment_url(url["href"])], []} + | acc + ] + + "image" -> + [ + {:meta, [property: "og:" <> media_type, content: attachment_url(url["href"])], + []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + | acc + ] + + "video" -> + [ + {:meta, [property: "og:" <> media_type, content: attachment_url(url["href"])], []} + | acc + ] + + _ -> + acc + end + end) + + acc ++ rendered_tags + end) + end + + defp scrub_html_and_truncate(%{data: %{"content" => content}} = object) do + content + # html content comes from DB already encoded, decode first and scrub after + |> HtmlEntities.decode() + |> String.replace(~r/<br\s?\/?>/, " ") + |> HTML.get_cached_stripped_html_for_object(object, __MODULE__) + |> Formatter.demojify() + |> Formatter.truncate() + end + + defp scrub_html_and_truncate(content) when is_binary(content) do + content + # html content comes from DB already encoded, decode first and scrub after + |> HtmlEntities.decode() + |> String.replace(~r/<br\s?\/?>/, " ") + |> HTML.strip_tags() + |> Formatter.demojify() + |> Formatter.truncate() + end + + defp attachment_url(url) do + MediaProxy.url(url) + end + + defp user_name_string(user) do + "#{user.name} " <> + if user.local do + "(@#{user.nickname}@#{Pleroma.Web.Endpoint.host()})" + else + "(@#{user.nickname})" + end + end +end diff --git a/lib/pleroma/web/metadata/provider.ex b/lib/pleroma/web/metadata/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.Provider do + @callback build_tags(map()) :: list() +end diff --git a/lib/pleroma/web/metadata/twitter_card.ex b/lib/pleroma/web/metadata/twitter_card.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.TwitterCard do + alias Pleroma.Web.Metadata.Providers.Provider + alias Pleroma.Web.Metadata + + @behaviour Provider + + @impl Provider + def build_tags(%{object: object}) do + if Metadata.activity_nsfw?(object) or object.data["attachment"] == [] do + build_tags(nil) + else + case find_first_acceptable_media_type(object) do + "image" -> + [{:meta, [property: "twitter:card", content: "summary_large_image"], []}] + + "audio" -> + [{:meta, [property: "twitter:card", content: "player"], []}] + + "video" -> + [{:meta, [property: "twitter:card", content: "player"], []}] + + _ -> + build_tags(nil) + end + end + end + + @impl Provider + def build_tags(_) do + [{:meta, [property: "twitter:card", content: "summary"], []}] + end + + def find_first_acceptable_media_type(%{data: %{"attachment" => attachment}}) do + Enum.find_value(attachment, fn attachment -> + Enum.find_value(attachment["url"], fn url -> + Enum.find(["image", "audio", "video"], fn media_type -> + String.starts_with?(url["mediaType"], media_type) + end) + end) + end) + end +end diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.{User, Activity, Object} alias Pleroma.Web.OStatus.{FeedRepresenter, ActivityRepresenter} - alias Pleroma.Repo alias Pleroma.Web.{OStatus, Federator} alias Pleroma.Web.XML alias Pleroma.Web.ActivityPub.ObjectView @@ -20,7 +19,11 @@ defmodule Pleroma.Web.OStatus.OStatusController do def feed_redirect(conn, %{"nickname" => nickname}) do case get_format(conn) do "html" -> - Fallback.RedirectController.redirector(conn, nil) + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + Fallback.RedirectController.redirector_with_meta(conn, %{user: user}) + else + nil -> {:error, :not_found} + end "activity+json" -> ActivityPubController.call(conn, :user) @@ -136,14 +139,27 @@ defmodule Pleroma.Web.OStatus.OStatusController do end def notice(conn, %{"id" => id}) do - with {_, %Activity{} = activity} <- {:activity, Repo.get(Activity, id)}, + with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id(id)}, {_, true} <- {:public?, ActivityPub.is_public?(activity)}, %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do case format = get_format(conn) do "html" -> - conn - |> put_resp_content_type("text/html") - |> send_file(200, Pleroma.Plugs.InstanceStatic.file_path("index.html")) + if activity.data["type"] == "Create" do + %Object{} = object = Object.normalize(activity.data["object"]) + + Fallback.RedirectController.redirector_with_meta(conn, %{ + object: object, + url: + Pleroma.Web.Router.Helpers.o_status_url( + Pleroma.Web.Endpoint, + :notice, + activity.id + ), + user: user + }) + else + Fallback.RedirectController.redirector(conn, nil) + end _ -> represent_activity(conn, format, activity, user) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -396,7 +396,11 @@ defmodule Pleroma.Web.Router do end pipeline :ostatus do - plug(:accepts, ["xml", "atom", "html", "activity+json"]) + plug(:accepts, ["html", "xml", "atom", "activity+json"]) + end + + pipeline :oembed do + plug(:accepts, ["json", "xml"]) end scope "/", Pleroma.Web do @@ -414,6 +418,12 @@ defmodule Pleroma.Web.Router do post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming) end + scope "/", Pleroma.Web do + pipe_through(:oembed) + + get("/oembed", OEmbed.OEmbedController, :url) + end + pipeline :activitypub do plug(:accepts, ["activity+json"]) plug(Pleroma.Web.Plugs.HTTPSignaturePlug) @@ -501,6 +511,7 @@ defmodule Pleroma.Web.Router do scope "/", Fallback do get("/registration/:token", RedirectController, :registration_page) + get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) get("/*path", RedirectController, :redirector) options("/*path", RedirectController, :empty) @@ -509,11 +520,36 @@ end defmodule Fallback.RedirectController do use Pleroma.Web, :controller + alias Pleroma.Web.Metadata + alias Pleroma.User def redirector(conn, _params) do conn |> put_resp_content_type("text/html") - |> send_file(200, Pleroma.Plugs.InstanceStatic.file_path("index.html")) + |> send_file(200, index_file_path()) + end + + def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} = params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(maybe_nickname_or_id) do + redirector_with_meta(conn, %{user: user}) + else + nil -> + redirector(conn, params) + end + end + + def redirector_with_meta(conn, params) do + {:ok, index_content} = File.read(index_file_path()) + tags = Metadata.build_tags(params) + response = String.replace(index_content, "<!--server-generated-meta-->", tags) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, response) + end + + def index_file_path do + Pleroma.Plugs.InstanceStatic.file_path("index.html") end def registration_page(conn, params) do diff --git a/mix.exs b/mix.exs @@ -59,6 +59,7 @@ defmodule Pleroma.Mixfile do {:pbkdf2_elixir, "~> 0.12.3"}, {:trailing_format_plug, "~> 0.0.7"}, {:html_sanitize_ex, "~> 1.3.0"}, + {:html_entities, "~> 0.4"}, {:phoenix_html, "~> 2.10"}, {:calendar, "~> 0.17.4"}, {:cachex, "~> 3.0.2"}, diff --git a/test/web/metadata/opengraph_test.exs b/test/web/metadata/opengraph_test.exs @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.OpenGraphTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.Metadata.Providers.OpenGraph + + test "it renders all supported types of attachments and skips unknown types" do + user = insert(:user) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "tag" => [], + "id" => "https://pleroma.gov/objects/whatever", + "content" => "pleroma in a nutshell", + "attachment" => [ + %{ + "url" => [ + %{"mediaType" => "image/png", "href" => "https://pleroma.gov/tenshi.png"} + ] + }, + %{ + "url" => [ + %{ + "mediaType" => "application/octet-stream", + "href" => "https://pleroma.gov/fqa/badapple.sfc" + } + ] + }, + %{ + "url" => [ + %{"mediaType" => "video/webm", "href" => "https://pleroma.gov/about/juche.webm"} + ] + }, + %{ + "url" => [ + %{ + "mediaType" => "audio/basic", + "href" => "http://www.gnu.org/music/free-software-song.au" + } + ] + } + ] + } + }) + + result = OpenGraph.build_tags(%{object: note, url: note.data["id"], user: user}) + + assert Enum.all?( + [ + {:meta, [property: "og:image", content: "https://pleroma.gov/tenshi.png"], []}, + {:meta, + [property: "og:audio", content: "http://www.gnu.org/music/free-software-song.au"], + []}, + {:meta, [property: "og:video", content: "https://pleroma.gov/about/juche.webm"], + []} + ], + fn element -> element in result end + ) + end + + test "it does not render attachments if post is nsfw" do + Pleroma.Config.put([Pleroma.Web.Metadata, :unfurl_nsfw], false) + user = insert(:user, avatar: %{"url" => [%{"href" => "https://pleroma.gov/tenshi.png"}]}) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "id" => "https://pleroma.gov/objects/whatever", + "content" => "#cuteposting #nsfw #hambaga", + "tag" => ["cuteposting", "nsfw", "hambaga"], + "sensitive" => true, + "attachment" => [ + %{ + "url" => [ + %{"mediaType" => "image/png", "href" => "https://misskey.microsoft/corndog.png"} + ] + } + ] + } + }) + + result = OpenGraph.build_tags(%{object: note, url: note.data["id"], user: user}) + + assert {:meta, [property: "og:image", content: "https://pleroma.gov/tenshi.png"], []} in result + + refute {:meta, [property: "og:image", content: "https://misskey.microsoft/corndog.png"], []} in result + end +end diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs @@ -88,6 +88,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do conn = conn + |> put_req_header("accept", "application/xml") |> get(url) expected = @@ -114,31 +115,34 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do |> response(404) end + test "gets an activity in xml format", %{conn: conn} do + note_activity = insert(:note_activity) + [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) + + conn + |> put_req_header("accept", "application/xml") + |> get("/activities/#{uuid}") + |> response(200) + end + test "404s on deleted objects", %{conn: conn} do note_activity = insert(:note_activity) [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["object"]["id"])) object = Object.get_by_ap_id(note_activity.data["object"]["id"]) conn + |> put_req_header("accept", "application/xml") |> get("/objects/#{uuid}") |> response(200) Object.delete(object) conn + |> put_req_header("accept", "application/xml") |> get("/objects/#{uuid}") |> response(404) end - test "gets an activity", %{conn: conn} do - note_activity = insert(:note_activity) - [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) - - conn - |> get("/activities/#{uuid}") - |> response(200) - end - test "404s on private activities", %{conn: conn} do note_activity = insert(:direct_note_activity) [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) @@ -154,7 +158,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do |> response(404) end - test "gets a notice", %{conn: conn} do + test "gets a notice in xml format", %{conn: conn} do note_activity = insert(:note_activity) conn