diff --git a/README.md b/README.md index 234a4b6c4..d9896f7ba 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Client applications that are known to work well: * Twidere * Tusky +* Mastalab * Pawoo (Android + iOS) * Subway Tooter * Amaroq (iOS) diff --git a/config/config.exs b/config/config.exs index c0a936b17..8131d9b18 100644 --- a/config/config.exs +++ b/config/config.exs @@ -209,6 +209,8 @@ 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/Pleroma-API.md b/docs/Pleroma-API.md index da58babf9..0c4586dd3 100644 --- a/docs/Pleroma-API.md +++ b/docs/Pleroma-API.md @@ -15,6 +15,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Params: none * Response: JSON * Example response: `{"kalsarikannit_f":"/finmoji/128px/kalsarikannit_f-128.png","perkele":"/finmoji/128px/perkele-128.png","blobdab":"/emoji/blobdab.png","happiness":"/finmoji/128px/happiness-128.png"}` +* Note: Same data as Mastodon API’s `/api/v1/custom_emojis` but in a different format ## `/api/pleroma/follow_import` ### Imports your follows, for example from a Mastodon CSV file. diff --git a/docs/config.md b/docs/config.md index 3f4588299..26ce842d4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -212,3 +212,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/installation/init.d/pleroma b/installation/init.d/pleroma index 9582d65d4..2b211df65 100755 --- a/installation/init.d/pleroma +++ b/installation/init.d/pleroma @@ -12,7 +12,7 @@ export PORT=4000 export MIX_ENV=prod # Ask process to terminate within 30 seconds, otherwise kill it -retry="SIGTERM/30 SIGKILL/5" +retry="SIGTERM/30/SIGKILL/5" pidfile="/var/run/pleroma.pid" diff --git a/lib/pleroma/PasswordResetToken.ex b/lib/pleroma/PasswordResetToken.ex index 1dccdadae..c3c0384d2 100644 --- a/lib/pleroma/PasswordResetToken.ex +++ b/lib/pleroma/PasswordResetToken.ex @@ -10,7 +10,7 @@ defmodule Pleroma.PasswordResetToken do alias Pleroma.{User, PasswordResetToken, Repo} schema "password_reset_tokens" do - belongs_to(:user, User) + belongs_to(:user, User, type: Pleroma.FlakeId) field(:token, :string) field(:used, :boolean, default: false) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index cd61f6ac8..f0aa3ce97 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Activity do import Ecto.Query @type t :: %__MODULE__{} + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @mastodon_notification_types %{ diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index ad2797209..47c0e5b68 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -99,6 +99,7 @@ def start(_type, _args) do ], id: :cachex_idem ), + worker(Pleroma.FlakeId, []), worker(Pleroma.Web.Federator.RetryQueue, []), worker(Pleroma.Web.Federator, []), worker(Pleroma.Stats, []), diff --git a/lib/pleroma/clippy.ex b/lib/pleroma/clippy.ex new file mode 100644 index 000000000..4e9bdbe19 --- /dev/null +++ b/lib/pleroma/clippy.ex @@ -0,0 +1,155 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Clippy do + @moduledoc false + # No software is complete until they have a Clippy implementation. + # A ballmer peak _may_ be required to change this module. + + def tip() do + tips() + |> Enum.random() + |> puts() + end + + def tips() do + host = Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) + + [ + "“πλήρωμα” is “pleroma” in greek", + "For an extended Pleroma Clippy Experience, use the “Redmond” themes in Pleroma FE settings", + "Staff accounts and MRF policies of Pleroma instances are disclosed on the NodeInfo endpoints for easy transparency!\n +- https://catgirl.science/misc/nodeinfo.lua?#{host} +- https://fediverse.network/#{host}/federation", + "Pleroma can federate to the Dark Web!\n +- Tor: https://git.pleroma.social/pleroma/pleroma/wikis/Easy%20Onion%20Federation%20(Tor) +- i2p: https://git.pleroma.social/pleroma/pleroma/wikis/I2p%20federation", + "Lists of Pleroma instances:\n\n- http://distsn.org/pleroma-instances.html\n- https://fediverse.network/pleroma\n- https://the-federation.info/pleroma", + "Pleroma uses the LitePub protocol - https://litepub.social", + "To receive more federated posts, subscribe to relays!\n +- How-to: https://git.pleroma.social/pleroma/pleroma/wikis/Admin%20tasks#relay-managment +- Relays: https://fediverse.network/activityrelay" + ] + end + + @spec puts(String.t() | [[IO.ANSI.ansicode() | String.t(), ...], ...]) :: nil + def puts(text_or_lines) do + import IO.ANSI + + lines = + if is_binary(text_or_lines) do + String.split(text_or_lines, ~r/\n/) + else + text_or_lines + end + + longest_line_size = + lines + |> Enum.map(&charlist_count_text/1) + |> Enum.sort(&>=/2) + |> List.first() + + pad_text = longest_line_size + + pad = + for(_ <- 1..pad_text, do: "_") + |> Enum.join("") + + pad_spaces = + for(_ <- 1..pad_text, do: " ") + |> Enum.join("") + + spaces = " " + + pre_lines = [ + " / \\#{spaces} _#{pad}___", + " | |#{spaces} / #{pad_spaces} \\" + ] + + for l <- pre_lines do + IO.puts(l) + end + + clippy_lines = [ + " #{bright()}@ @#{reset()}#{spaces} ", + " || ||#{spaces}", + " || || <--", + " |\\_/| ", + " \\___/ " + ] + + noclippy_line = " " + + env = %{ + max_size: pad_text, + pad: pad, + pad_spaces: pad_spaces, + spaces: spaces, + pre_lines: pre_lines, + noclippy_line: noclippy_line + } + + # surrond one/five line clippy with blank lines around to not fuck up the layout + # + # yes this fix sucks but it's good enough, have you ever seen a release of windows wihtout some butched + # features anyway? + lines = + if length(lines) == 1 or length(lines) == 5 do + [""] ++ lines ++ [""] + else + lines + end + + clippy_line(lines, clippy_lines, env) + rescue + e -> + IO.puts("(Clippy crashed, sorry: #{inspect(e)})") + IO.puts(text_or_lines) + end + + defp clippy_line([line | lines], [prefix | clippy_lines], env) do + IO.puts([prefix <> "| ", rpad_line(line, env.max_size)]) + clippy_line(lines, clippy_lines, env) + end + + # more text lines but clippy's complete + defp clippy_line([line | lines], [], env) do + IO.puts([env.noclippy_line, "| ", rpad_line(line, env.max_size)]) + + if lines == [] do + IO.puts(env.noclippy_line <> "\\_#{env.pad}___/") + end + + clippy_line(lines, [], env) + end + + # no more text lines but clippy's not complete + defp clippy_line([], [clippy | clippy_lines], env) do + if env.pad do + IO.puts(clippy <> "\\_#{env.pad}___/") + clippy_line([], clippy_lines, %{env | pad: nil}) + else + IO.puts(clippy) + clippy_line([], clippy_lines, env) + end + end + + defp clippy_line(_, _, _) do + end + + defp rpad_line(line, max) do + pad = max - (charlist_count_text(line) - 2) + pads = Enum.join(for(_ <- 1..pad, do: " ")) + [IO.ANSI.format(line), pads <> " |"] + end + + defp charlist_count_text(line) do + if is_list(line) do + text = Enum.join(Enum.filter(line, &is_binary/1)) + String.length(text) + else + String.length(line) + end + end +end diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index df5374a5c..308bd70e1 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Filter do alias Pleroma.{User, Repo} schema "filters" do - belongs_to(:user, User) + belongs_to(:user, User, type: Pleroma.FlakeId) field(:filter_id, :integer) field(:hide, :boolean, default: false) field(:whole_word, :boolean, default: true) diff --git a/lib/pleroma/flake_id.ex b/lib/pleroma/flake_id.ex new file mode 100644 index 000000000..69ab8ccf9 --- /dev/null +++ b/lib/pleroma/flake_id.ex @@ -0,0 +1,172 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FlakeId do + @moduledoc """ + Flake is a decentralized, k-ordered id generation service. + + Adapted from: + + * [flaky](https://github.com/nirvana/flaky), released under the terms of the Truly Free License, + * [Flake](https://github.com/boundary/flake), Copyright 2012, Boundary, Apache License, Version 2.0 + """ + + @type t :: binary + + @behaviour Ecto.Type + use GenServer + require Logger + alias __MODULE__ + import Kernel, except: [to_string: 1] + + defstruct node: nil, time: 0, sq: 0 + + @doc "Converts a binary Flake to a String" + def to_string(<<0::integer-size(64), id::integer-size(64)>>) do + Kernel.to_string(id) + end + + def to_string(flake = <<_::integer-size(64), _::integer-size(48), _::integer-size(16)>>) do + encode_base62(flake) + end + + def to_string(s), do: s + + def from_string(int) when is_integer(int) do + from_string(Kernel.to_string(int)) + end + + for i <- [-1, 0] do + def from_string(unquote(i)), do: <<0::integer-size(128)>> + def from_string(unquote(Kernel.to_string(i))), do: <<0::integer-size(128)>> + end + + def from_string(flake = <<_::integer-size(128)>>), do: flake + + def from_string(string) when is_binary(string) and byte_size(string) < 18 do + case Integer.parse(string) do + {id, _} -> <<0::integer-size(64), id::integer-size(64)>> + _ -> nil + end + end + + def from_string(string) do + string |> decode_base62 |> from_integer + end + + def to_integer(<>), do: integer + + def from_integer(integer) do + <<_time::integer-size(64), _node::integer-size(48), _seq::integer-size(16)>> = + <> + end + + @doc "Generates a Flake" + @spec get :: binary + def get, do: to_string(:gen_server.call(:flake, :get)) + + # -- Ecto.Type API + @impl Ecto.Type + def type, do: :uuid + + @impl Ecto.Type + def cast(value) do + {:ok, FlakeId.to_string(value)} + end + + @impl Ecto.Type + def load(value) do + {:ok, FlakeId.to_string(value)} + end + + @impl Ecto.Type + def dump(value) do + {:ok, FlakeId.from_string(value)} + end + + def autogenerate(), do: get() + + # -- GenServer API + def start_link do + :gen_server.start_link({:local, :flake}, __MODULE__, [], []) + end + + @impl GenServer + def init([]) do + {:ok, %FlakeId{node: worker_id(), time: time()}} + end + + @impl GenServer + def handle_call(:get, _from, state) do + {flake, new_state} = get(time(), state) + {:reply, flake, new_state} + end + + # Matches when the calling time is the same as the state time. Incr. sq + defp get(time, %FlakeId{time: time, node: node, sq: seq}) do + new_state = %FlakeId{time: time, node: node, sq: seq + 1} + {gen_flake(new_state), new_state} + end + + # Matches when the times are different, reset sq + defp get(newtime, %FlakeId{time: time, node: node}) when newtime > time do + new_state = %FlakeId{time: newtime, node: node, sq: 0} + {gen_flake(new_state), new_state} + end + + # Error when clock is running backwards + defp get(newtime, %FlakeId{time: time}) when newtime < time do + {:error, :clock_running_backwards} + end + + defp gen_flake(%FlakeId{time: time, node: node, sq: seq}) do + <> + end + + defp nthchar_base62(n) when n <= 9, do: ?0 + n + defp nthchar_base62(n) when n <= 35, do: ?A + n - 10 + defp nthchar_base62(n), do: ?a + n - 36 + + defp encode_base62(<>) do + integer + |> encode_base62([]) + |> List.to_string() + end + + defp encode_base62(int, acc) when int < 0, do: encode_base62(-int, acc) + defp encode_base62(int, []) when int == 0, do: '0' + defp encode_base62(int, acc) when int == 0, do: acc + + defp encode_base62(int, acc) do + r = rem(int, 62) + id = div(int, 62) + acc = [nthchar_base62(r) | acc] + encode_base62(id, acc) + end + + defp decode_base62(s) do + decode_base62(String.to_charlist(s), 0) + end + + defp decode_base62([c | cs], acc) when c >= ?0 and c <= ?9, + do: decode_base62(cs, 62 * acc + (c - ?0)) + + defp decode_base62([c | cs], acc) when c >= ?A and c <= ?Z, + do: decode_base62(cs, 62 * acc + (c - ?A + 10)) + + defp decode_base62([c | cs], acc) when c >= ?a and c <= ?z, + do: decode_base62(cs, 62 * acc + (c - ?a + 36)) + + defp decode_base62([], acc), do: acc + + defp time do + {mega_seconds, seconds, micro_seconds} = :erlang.timestamp() + 1_000_000_000 * mega_seconds + seconds * 1000 + :erlang.trunc(micro_seconds / 1000) + end + + defp worker_id() do + <> = :crypto.strong_rand_bytes(6) + worker + end +end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 37737853a..386096a52 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -43,7 +43,7 @@ def emojify(text) 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 @@ def emojify(text, emoji) do String.replace( text, ":#{emoji}:", - "#{emoji}" + if not strip do + "#{emoji}" + 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 @@ def finalize({subs, text}) 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/html.ex b/lib/pleroma/html.ex index f5c6e5033..fb602d6b6 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -58,6 +58,20 @@ defp generate_scrubber_signature(scrubbers) do "#{signature}#{to_string(scrubber)}" end) end + + def extract_first_external_url(object, content) do + key = "URL|#{object.id}" + + Cachex.fetch!(:scrubber_cache, key, fn _key -> + result = + content + |> Floki.filter_out("a.mention") + |> Floki.attribute("a", "href") + |> Enum.at(0) + + {:commit, result} + end) + end end defmodule Pleroma.HTML.Scrubber.TwitterText do diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index a75dc006e..ca66c6916 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -8,7 +8,7 @@ defmodule Pleroma.List do alias Pleroma.{User, Repo, Activity} schema "lists" do - belongs_to(:user, Pleroma.User) + belongs_to(:user, User, type: Pleroma.FlakeId) field(:title, :string) field(:following, {:array, :string}, default: []) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index c7d01f63b..2364d36da 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -4,13 +4,14 @@ defmodule Pleroma.Notification do use Ecto.Schema - alias Pleroma.{User, Activity, Notification, Repo, Object} + alias Pleroma.{User, Activity, Notification, Repo} + alias Pleroma.Web.CommonAPI.Utils import Ecto.Query schema "notifications" do field(:seen, :boolean, default: false) - belongs_to(:user, Pleroma.User) - belongs_to(:activity, Pleroma.Activity) + belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:activity, Activity, type: Pleroma.FlakeId) timestamps() end @@ -34,7 +35,8 @@ def for_user(user, opts \\ %{}) do n in Notification, where: n.user_id == ^user.id, order_by: [desc: n.id], - preload: [:activity], + join: activity in assoc(n, :activity), + preload: [activity: activity], limit: 20 ) @@ -65,7 +67,8 @@ def get(%{id: user_id} = _user, id) do from( n in Notification, where: n.id == ^id, - preload: [:activity] + join: activity in assoc(n, :activity), + preload: [activity: activity] ) notification = Repo.one(query) @@ -96,7 +99,7 @@ def dismiss(%{id: user_id} = _user, id) do end end - def create_notifications(%Activity{id: _, data: %{"to" => _, "type" => type}} = activity) + def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) when type in ["Create", "Like", "Announce", "Follow"] do users = get_notified_from_activity(activity) @@ -132,54 +135,12 @@ def get_notified_from_activity( when type in ["Create", "Like", "Announce", "Follow"] do recipients = [] - |> maybe_notify_to_recipients(activity) - |> maybe_notify_mentioned_recipients(activity) + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) |> Enum.uniq() User.get_users_from_set(recipients, local_only) end def get_notified_from_activity(_, _local_only), do: [] - - defp maybe_notify_to_recipients( - recipients, - %Activity{data: %{"to" => to, "type" => _type}} = _activity - ) do - recipients ++ to - end - - defp maybe_notify_mentioned_recipients( - recipients, - %Activity{data: %{"to" => _to, "type" => type} = data} = _activity - ) - when type == "Create" do - object = Object.normalize(data["object"]) - - object_data = - cond do - !is_nil(object) -> - object.data - - is_map(data["object"]) -> - data["object"] - - true -> - %{} - end - - tagged_mentions = maybe_extract_mentions(object_data) - - recipients ++ tagged_mentions - end - - defp maybe_notify_mentioned_recipients(recipients, _), do: recipients - - defp maybe_extract_mentions(%{"tag" => tag}) do - tag - |> Enum.filter(fn x -> is_map(x) end) - |> Enum.filter(fn x -> x["type"] == "Mention" end) - |> Enum.map(fn x -> x["href"] end) - end - - defp maybe_extract_mentions(_), do: [] end diff --git a/lib/pleroma/plugs/oauth_plug.ex b/lib/pleroma/plugs/oauth_plug.ex index 437aa95b3..945a1d49f 100644 --- a/lib/pleroma/plugs/oauth_plug.ex +++ b/lib/pleroma/plugs/oauth_plug.ex @@ -33,7 +33,12 @@ def call(conn, _) do # @spec fetch_user_and_token(String.t()) :: {:ok, User.t(), Token.t()} | nil defp fetch_user_and_token(token) do - query = from(q in Token, where: q.token == ^token, preload: [:user]) + query = + from(t in Token, + where: t.token == ^token, + join: user in assoc(t, :user), + preload: [user: user] + ) with %Token{user: %{info: %{deactivated: false} = _} = user} = token_record <- Repo.one(query) do {:ok, user, token_record} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 18137106e..1468cc133 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -17,6 +17,8 @@ defmodule Pleroma.User do @type t :: %__MODULE__{} + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @email_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])?)*$/ @strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/ @@ -404,6 +406,10 @@ def locked?(%User{} = 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 @@ -439,11 +445,33 @@ def get_cached_by_ap_id(ap_id) 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/user/info.ex b/lib/pleroma/user/info.ex index fb1791c20..c6c923aac 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -31,7 +31,7 @@ defmodule Pleroma.User.Info do field(:hub, :string, default: nil) field(:salmon, :string, default: nil) field(:hide_network, :boolean, default: false) - field(:pinned_activities, {:array, :integer}, default: []) + field(:pinned_activities, {:array, :string}, default: []) # Found in the wild # ap_id -> Where is this used? diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 6cad02da6..22c7824fa 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -36,6 +36,14 @@ defp get_recipients(%{"type" => "Announce"} = data) do {recipients, to, cc} end + defp get_recipients(%{"type" => "Create"} = data) do + to = data["to"] || [] + cc = data["cc"] || [] + actor = data["actor"] || [] + recipients = (to ++ cc ++ [actor]) |> Enum.uniq() + {recipients, to, cc} + end + defp get_recipients(data) do to = data["to"] || [] cc = data["cc"] || [] @@ -56,7 +64,7 @@ defp check_actor_is_active(actor) do end end - defp check_remote_limit(%{"object" => %{"content" => content}}) do + defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil(content) do limit = Pleroma.Config.get([:instance, :remote_limit]) String.length(content) <= limit end @@ -410,13 +418,42 @@ def fetch_user_activities(user, reading_user, params \\ %{}) do |> Enum.reverse() end + defp restrict_since(query, %{"since_id" => ""}), do: query + defp restrict_since(query, %{"since_id" => since_id}) do from(activity in query, where: activity.id > ^since_id) end defp restrict_since(query, _), do: query - defp restrict_tag(query, %{"tag" => tag}) do + defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) + when is_list(tag_reject) and tag_reject != [] do + from( + activity in query, + where: fragment("(not (? #> '{\"object\",\"tag\"}') \\?| ?)", activity.data, ^tag_reject) + ) + end + + defp restrict_tag_reject(query, _), do: query + + defp restrict_tag_all(query, %{"tag_all" => tag_all}) + when is_list(tag_all) and tag_all != [] do + from( + activity in query, + where: fragment("(? #> '{\"object\",\"tag\"}') \\?& ?", activity.data, ^tag_all) + ) + end + + defp restrict_tag_all(query, _), do: query + + defp restrict_tag(query, %{"tag" => tag}) when is_list(tag) do + from( + activity in query, + where: fragment("(? #> '{\"object\",\"tag\"}') \\?| ?", activity.data, ^tag) + ) + end + + defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do from( activity in query, where: fragment("? <@ (? #> '{\"object\",\"tag\"}')", ^tag, activity.data) @@ -465,6 +502,8 @@ defp restrict_local(query, %{"local_only" => true}) do defp restrict_local(query, _), do: query + defp restrict_max(query, %{"max_id" => ""}), do: query + defp restrict_max(query, %{"max_id" => max_id}) do from(activity in query, where: activity.id < ^max_id) end @@ -563,6 +602,8 @@ def fetch_activities_query(recipients, opts \\ %{}) do base_query |> restrict_recipients(recipients, opts["user"]) |> restrict_tag(opts) + |> restrict_tag_reject(opts) + |> restrict_tag_all(opts) |> restrict_since(opts) |> restrict_local(opts) |> restrict_limit(opts) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex new file mode 100644 index 000000000..7c6ad582a --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF + + # XXX: this should become User.normalize_by_ap_id() or similar, really. + defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id) + defp normalize_by_ap_id(uri) when is_binary(uri), do: User.get_cached_by_ap_id(uri) + defp normalize_by_ap_id(_), do: nil + + defp score_nickname("followbot@" <> _), do: 1.0 + defp score_nickname("federationbot@" <> _), do: 1.0 + defp score_nickname("federation_bot@" <> _), do: 1.0 + defp score_nickname(_), do: 0.0 + + defp score_displayname("federation bot"), do: 1.0 + defp score_displayname("federationbot"), do: 1.0 + defp score_displayname("fedibot"), do: 1.0 + defp score_displayname(_), do: 0.0 + + defp determine_if_followbot(%User{nickname: nickname, name: displayname}) do + nick_score = + nickname + |> String.downcase() + |> score_nickname() + + name_score = + displayname + |> String.downcase() + |> score_displayname() + + nick_score + name_score + end + + defp determine_if_followbot(_), do: 0.0 + + @impl true + def filter(%{"type" => "Follow", "actor" => actor_id} = message) do + %User{} = actor = normalize_by_ap_id(actor_id) + + score = determine_if_followbot(actor) + + # TODO: scan biography data for keywords and score it somehow. + if score < 0.8 do + {:ok, message} + else + {:reject, nil} + end + end + + @impl true + def filter(message), do: {:ok, message} +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 46b1646f7..c2ced51d8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -141,11 +141,11 @@ def fix_actor(%{"attributedTo" => actor} = object) do |> Map.put("actor", get_actor(%{"actor" => actor})) end - def fix_likes(%{"likes" => likes} = object) - when is_bitstring(likes) do - # Check for standardisation - # This is what Peertube does - # curl -H 'Accept: application/activity+json' $likes | jq .totalItems + # Check for standardisation + # This is what Peertube does + # curl -H 'Accept: application/activity+json' $likes | jq .totalItems + # Prismo returns only an integer (count) as "likes" + def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do object |> Map.put("likes", []) |> Map.put("like_count", 0) @@ -900,15 +900,10 @@ defp user_upgrade_task(user) do maybe_retire_websub(user.ap_id) - # Only do this for recent activties, don't go through the whole db. - # Only look at the last 1000 activities. - since = (Repo.aggregate(Activity, :max, :id) || 0) - 1_000 - q = from( a in Activity, where: ^old_follower_address in a.recipients, - where: a.id > ^since, update: [ set: [ recipients: diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index fe8248107..dcf681b6d 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -160,7 +160,7 @@ def render("outbox.json", %{user: user, max_id: max_qid}) do "partOf" => iri, "totalItems" => info.note_count, "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id - 1}" + "next" => "#{iri}?max_id=#{min_id}" } if max_qid == nil do @@ -207,7 +207,7 @@ def render("inbox.json", %{user: user, max_id: max_qid}) do "partOf" => iri, "totalItems" => -1, "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id - 1}" + "next" => "#{iri}?max_id=#{min_id}" } if max_qid == nil do diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index a0f59d900..208677bd7 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -261,4 +261,46 @@ def emoji_from_profile(%{info: _info} = user) do } end) end + + def maybe_notify_to_recipients( + recipients, + %Activity{data: %{"to" => to, "type" => _type}} = _activity + ) do + recipients ++ to + end + + def maybe_notify_mentioned_recipients( + recipients, + %Activity{data: %{"to" => _to, "type" => type} = data} = _activity + ) + when type == "Create" do + object = Object.normalize(data["object"]) + + object_data = + cond do + !is_nil(object) -> + object.data + + is_map(data["object"]) -> + data["object"] + + true -> + %{} + end + + tagged_mentions = maybe_extract_mentions(object_data) + + recipients ++ tagged_mentions + end + + def maybe_notify_mentioned_recipients(recipients, _), do: recipients + + def maybe_extract_mentions(%{"tag" => tag}) do + tag + |> Enum.filter(fn x -> is_map(x) end) + |> Enum.filter(fn x -> x["type"] == "Mention" end) + |> Enum.map(fn x -> x["href"] end) + end + + def maybe_extract_mentions(_), do: [] end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index f4736fcb5..a366a149f 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller alias Pleroma.{Repo, Object, Activity, User, Notification, Stats} alias Pleroma.Web + alias Pleroma.HTML alias Pleroma.Web.MastodonAPI.{ StatusView, @@ -540,15 +541,34 @@ def reblogged_by(conn, %{"id" => id}) do def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do local_only = params["local"] in [true, "True", "true", "1"] - params = + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params["all"] || + [] + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params["none"] || + [] + |> Enum.map(&String.downcase(&1)) + + query_params = params |> Map.put("type", "Create") |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) - |> Map.put("tag", String.downcase(params["tag"])) + |> Map.put("tag", tags) + |> Map.put("tag_all", tag_all) + |> Map.put("tag_reject", tag_reject) activities = - ActivityPub.fetch_public_activities(params) + ActivityPub.fetch_public_activities(query_params) |> Enum.reverse() conn @@ -1322,6 +1342,29 @@ def suggestions(%{assigns: %{user: user}} = conn, _) do end end + def get_status_card(status_id) do + with %Activity{} = activity <- Repo.get(Activity, status_id), + true <- ActivityPub.is_public?(activity), + %Object{} = object <- Object.normalize(activity.data["object"]), + page_url <- HTML.extract_first_external_url(object, object.data["content"]), + {:ok, rich_media} <- Pleroma.Web.RichMedia.Parser.parse(page_url) do + page_url = rich_media[:url] || page_url + site_name = rich_media[:site_name] || URI.parse(page_url).host + + rich_media + |> Map.take([:image, :title, :description]) + |> Map.put(:type, "link") + |> Map.put(:provider_name, site_name) + |> Map.put(:url, page_url) + else + _ -> %{} + end + end + + def status_card(conn, %{"id" => status_id}) do + json(conn, get_status_card(status_id)) + end + def try_render(conn, target, params) when is_binary(target) do res = render(conn, target, params) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index bfd6b8b22..0ba4289da 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -112,7 +112,9 @@ defp do_render("account.json", %{user: user} = opts) do # Pleroma extension pleroma: %{ confirmation_pending: user_info.confirmation_pending, - tags: user.tags + tags: user.tags, + is_moderator: user.info.is_moderator, + is_admin: user.info.is_admin } } end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 7a384e941..ddfe6788c 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -49,12 +49,11 @@ def render("index.json", opts) do replied_to_activities = get_replied_to_activities(opts.activities) opts.activities - |> render_many( + |> safe_render_many( StatusView, "status.json", Map.put(opts, :replied_to_activities, replied_to_activities) ) - |> Enum.filter(fn x -> not is_nil(x) end) end def render( diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex new file mode 100644 index 000000000..8761260f2 --- /dev/null +++ b/lib/pleroma/web/metadata.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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 new file mode 100644 index 000000000..30333785e --- /dev/null +++ b/lib/pleroma/web/metadata/opengraph.ex @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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//, " ") + |> 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//, " ") + |> 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 new file mode 100644 index 000000000..197fb2a77 --- /dev/null +++ b/lib/pleroma/web/metadata/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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 new file mode 100644 index 000000000..32b979357 --- /dev/null +++ b/lib/pleroma/web/metadata/twitter_card.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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/oauth/authorization.ex b/lib/pleroma/web/oauth/authorization.ex index cc4b74bc5..f8c65602d 100644 --- a/lib/pleroma/web/oauth/authorization.ex +++ b/lib/pleroma/web/oauth/authorization.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.OAuth.Authorization do field(:token, :string) field(:valid_until, :naive_datetime) field(:used, :boolean, default: false) - belongs_to(:user, Pleroma.User) + belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/oauth/fallback_controller.ex b/lib/pleroma/web/oauth/fallback_controller.ex index 1eeda3d24..f0fe3b578 100644 --- a/lib/pleroma/web/oauth/fallback_controller.ex +++ b/lib/pleroma/web/oauth/fallback_controller.ex @@ -9,7 +9,8 @@ defmodule Pleroma.Web.OAuth.FallbackController do # No user/password def call(conn, _) do conn + |> put_status(:unauthorized) |> put_flash(:error, "Invalid Username/Password") - |> OAuthController.authorize(conn.params) + |> OAuthController.authorize(conn.params["authorization"]) end end diff --git a/lib/pleroma/web/oauth/token.ex b/lib/pleroma/web/oauth/token.ex index f0ebc63f6..4e01b123b 100644 --- a/lib/pleroma/web/oauth/token.ex +++ b/lib/pleroma/web/oauth/token.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.OAuth.Token do field(:token, :string) field(:refresh_token, :string) field(:valid_until, :naive_datetime) - belongs_to(:user, Pleroma.User) + belongs_to(:user, Pleroma.User, type: Pleroma.FlakeId) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index e483447ed..9392a97f0 100644 --- 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 @@ -22,7 +21,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) @@ -138,24 +141,40 @@ def activity(conn, %{"uuid" => uuid}) 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) end else {:public?, false} -> - {:error, :not_found} + conn + |> put_status(404) + |> Fallback.RedirectController.redirector(nil, 404) {:activity, nil} -> - {:error, :not_found} + conn + |> Fallback.RedirectController.redirector(nil, 404) e -> e diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 82b30950c..bd9d9f3a7 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Push.Subscription do alias Pleroma.Web.Push.Subscription schema "push_subscriptions" do - belongs_to(:user, User) + belongs_to(:user, User, type: Pleroma.FlakeId) belongs_to(:token, Token) field(:endpoint, :string) field(:key_p256dh, :string) diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 6da83c6e4..947dc0c3c 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -5,11 +5,19 @@ defmodule Pleroma.Web.RichMedia.Parser do Pleroma.Web.RichMedia.Parsers.OEmbed ] + def parse(nil), do: {:error, "No URL provided"} + if Mix.env() == :test do def parse(url), do: parse_url(url) else - def parse(url), - do: Cachex.fetch!(:rich_media_cache, url, fn _ -> parse_url(url) end) + def parse(url) do + with {:ok, data} <- Cachex.fetch(:rich_media_cache, url, fn _ -> parse_url(url) end) do + data + else + _e -> + {:error, "Parsing error"} + end + end end defp parse_url(url) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 69ab58c6a..31f739738 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -258,7 +258,7 @@ defmodule Pleroma.Web.Router do get("/statuses/:id", MastodonAPIController, :get_status) get("/statuses/:id/context", MastodonAPIController, :get_context) - get("/statuses/:id/card", MastodonAPIController, :empty_object) + get("/statuses/:id/card", MastodonAPIController, :status_card) get("/statuses/:id/favourited_by", MastodonAPIController, :favourited_by) get("/statuses/:id/reblogged_by", MastodonAPIController, :reblogged_by) @@ -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 @@ defmodule Pleroma.Web.Router do defmodule Fallback.RedirectController do use Pleroma.Web, :controller + alias Pleroma.Web.Metadata + alias Pleroma.User - def redirector(conn, _params) do + def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") - |> send_file(200, Pleroma.Plugs.InstanceStatic.file_path("index.html")) + |> send_file(code, 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, "", 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/lib/pleroma/web/twitter_api/representers/activity_representer.ex b/lib/pleroma/web/twitter_api/representers/activity_representer.ex index 4f8f228ab..19b723586 100644 --- a/lib/pleroma/web/twitter_api/representers/activity_representer.ex +++ b/lib/pleroma/web/twitter_api/representers/activity_representer.ex @@ -158,7 +158,9 @@ def to_map( mentions = opts[:mentioned] || [] attentions = - activity.recipients + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) |> Enum.map(fn ap_id -> Enum.find(mentions, fn user -> ap_id == user.ap_id end) end) |> Enum.filter(& &1) |> Enum.map(fn user -> UserView.render("show.json", %{user: user, for: opts[:for]}) end) diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 8c9060cf2..3064d61ea 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -265,8 +265,6 @@ def fetch_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do end def fetch_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do - id = String.to_integer(id) - with context when is_binary(context) <- TwitterAPI.conversation_id_to_context(id), activities <- ActivityPub.fetch_activities_for_context(context, %{ @@ -340,44 +338,47 @@ def get_by_id_or_ap_id(id) do end def favorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.fav(user, id) do + with {:ok, activity} <- TwitterAPI.fav(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) + else + _ -> json_reply(conn, 400, Jason.encode!(%{})) end end def unfavorite(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.unfav(user, id) do + with {:ok, activity} <- TwitterAPI.unfav(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) + else + _ -> json_reply(conn, 400, Jason.encode!(%{})) end end def retweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.repeat(user, id) do + with {:ok, activity} <- TwitterAPI.repeat(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) + else + _ -> json_reply(conn, 400, Jason.encode!(%{})) end end def unretweet(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.unrepeat(user, id) do + with {:ok, activity} <- TwitterAPI.unrepeat(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) + else + _ -> json_reply(conn, 400, Jason.encode!(%{})) end end def pin(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.pin(user, id) do + with {:ok, activity} <- TwitterAPI.pin(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) @@ -388,8 +389,7 @@ def pin(%{assigns: %{user: user}} = conn, %{"id" => id}) do end def unpin(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {_, {:ok, id}} <- {:param_cast, Ecto.Type.cast(:integer, id)}, - {:ok, activity} <- TwitterAPI.unpin(user, id) do + with {:ok, activity} <- TwitterAPI.unpin(user, id) do conn |> put_view(ActivityView) |> render("activity.json", %{activity: activity, for: user}) @@ -556,7 +556,6 @@ def friend_requests(conn, params) do def approve_friend_request(conn, %{"user_id" => uid} = _params) do with followed <- conn.assigns[:user], - uid when is_number(uid) <- String.to_integer(uid), %User{} = follower <- Repo.get(User, uid), {:ok, follower} <- User.maybe_follow(follower, followed), %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), @@ -578,7 +577,6 @@ def approve_friend_request(conn, %{"user_id" => uid} = _params) do def deny_friend_request(conn, %{"user_id" => uid} = _params) do with followed <- conn.assigns[:user], - uid when is_number(uid) <- String.to_integer(uid), %User{} = follower <- Repo.get(User, uid), %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"), diff --git a/lib/pleroma/web/twitter_api/views/activity_view.ex b/lib/pleroma/web/twitter_api/views/activity_view.ex index 5eb06a26e..a01ee0010 100644 --- a/lib/pleroma/web/twitter_api/views/activity_view.ex +++ b/lib/pleroma/web/twitter_api/views/activity_view.ex @@ -114,7 +114,7 @@ def render("index.json", opts) do |> Map.put(:context_ids, context_ids) |> Map.put(:users, users) - render_many( + safe_render_many( opts.activities, ActivityView, "activity.json", @@ -236,7 +236,9 @@ def render( pinned = activity.id in user.info.pinned_activities attentions = - activity.recipients + [] + |> Utils.maybe_notify_to_recipients(activity) + |> Utils.maybe_notify_mentioned_recipients(activity) |> Enum.map(fn ap_id -> get_user(ap_id, opts) end) |> Enum.filter(& &1) |> Enum.map(fn user -> UserView.render("show.json", %{user: user, for: opts[:for]}) end) diff --git a/lib/pleroma/web/twitter_api/views/user_view.ex b/lib/pleroma/web/twitter_api/views/user_view.ex index a8cf83613..15682db8f 100644 --- a/lib/pleroma/web/twitter_api/views/user_view.ex +++ b/lib/pleroma/web/twitter_api/views/user_view.ex @@ -108,6 +108,7 @@ defp do_render("user.json", %{user: user = %User{}} = assigns) do "locked" => user.info.locked, "default_scope" => user.info.default_scope, "no_rich_text" => user.info.no_rich_text, + "hide_network" => user.info.hide_network, "fields" => fields, # Pleroma extension diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index 74b13f929..30558e692 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -38,6 +38,33 @@ def view do import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] import Pleroma.Web.{ErrorHelpers, Gettext, Router.Helpers} + + require Logger + + @doc "Same as `render/3` but wrapped in a rescue block" + def safe_render(view, template, assigns \\ %{}) do + Phoenix.View.render(view, template, assigns) + rescue + error -> + Logger.error( + "#{__MODULE__} failed to render #{inspect({view, template})}: #{inspect(error)}" + ) + + Logger.error(inspect(__STACKTRACE__)) + nil + end + + @doc """ + Same as `render_many/4` but wrapped in rescue block. + """ + def safe_render_many(collection, view, template, assigns \\ %{}) do + Enum.map(collection, fn resource -> + as = Map.get(assigns, :as) || view.__resource__ + assigns = Map.put(assigns, as, resource) + safe_render(view, template, assigns) + end) + |> Enum.filter(& &1) + end end end diff --git a/lib/pleroma/web/websub/websub_client_subscription.ex b/lib/pleroma/web/websub/websub_client_subscription.ex index 105b0069f..969ee0684 100644 --- a/lib/pleroma/web/websub/websub_client_subscription.ex +++ b/lib/pleroma/web/websub/websub_client_subscription.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.Websub.WebsubClientSubscription do field(:state, :string) field(:subscribers, {:array, :string}, default: []) field(:hub, :string) - belongs_to(:user, User) + belongs_to(:user, User, type: Pleroma.FlakeId) timestamps() end diff --git a/mix.exs b/mix.exs index ccf7790b0..d46998891 100644 --- a/mix.exs +++ b/mix.exs @@ -59,6 +59,7 @@ defp deps 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/priv/repo/migrations/20181218172826_users_and_activities_flake_id.exs b/priv/repo/migrations/20181218172826_users_and_activities_flake_id.exs new file mode 100644 index 000000000..47d2d02da --- /dev/null +++ b/priv/repo/migrations/20181218172826_users_and_activities_flake_id.exs @@ -0,0 +1,125 @@ +defmodule Pleroma.Repo.Migrations.UsersAndActivitiesFlakeId do + use Ecto.Migration + alias Pleroma.Clippy + require Integer + import Ecto.Query + alias Pleroma.Repo + + # This migrates from int serial IDs to custom Flake: + # 1- create a temporary uuid column + # 2- fill this column with compatibility ids (see below) + # 3- remove pkeys constraints + # 4- update relation pkeys with the new ids + # 5- rename the temporary column to id + # 6- re-create the constraints + def change do + # Old serial int ids are transformed to 128bits with extra padding. + # The application (in `Pleroma.FlakeId`) handles theses IDs properly as integers; to keep compatibility + # with previously issued ids. + #execute "update activities set external_id = CAST( LPAD( TO_HEX(id), 32, '0' ) AS uuid);" + #execute "update users set external_id = CAST( LPAD( TO_HEX(id), 32, '0' ) AS uuid);" + + clippy = start_clippy_heartbeats() + + # Lock both tables to avoid a running server to meddling with our transaction + execute "LOCK TABLE activities;" + execute "LOCK TABLE users;" + + execute """ + ALTER TABLE activities + DROP CONSTRAINT activities_pkey CASCADE, + ALTER COLUMN id DROP default, + ALTER COLUMN id SET DATA TYPE uuid USING CAST( LPAD( TO_HEX(id), 32, '0' ) AS uuid), + ADD PRIMARY KEY (id); + """ + + execute """ + ALTER TABLE users + DROP CONSTRAINT users_pkey CASCADE, + ALTER COLUMN id DROP default, + ALTER COLUMN id SET DATA TYPE uuid USING CAST( LPAD( TO_HEX(id), 32, '0' ) AS uuid), + ADD PRIMARY KEY (id); + """ + + execute "UPDATE users SET info = jsonb_set(info, '{pinned_activities}', array_to_json(ARRAY(select jsonb_array_elements_text(info->'pinned_activities')))::jsonb);" + + # Fkeys: + # Activities - Referenced by: + # TABLE "notifications" CONSTRAINT "notifications_activity_id_fkey" FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE + # Users - Referenced by: + # TABLE "filters" CONSTRAINT "filters_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + # TABLE "lists" CONSTRAINT "lists_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + # TABLE "notifications" CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + # TABLE "oauth_authorizations" CONSTRAINT "oauth_authorizations_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) + # TABLE "oauth_tokens" CONSTRAINT "oauth_tokens_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) + # TABLE "password_reset_tokens" CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) + # TABLE "push_subscriptions" CONSTRAINT "push_subscriptions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + # TABLE "websub_client_subscriptions" CONSTRAINT "websub_client_subscriptions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) + + execute """ + ALTER TABLE notifications + ALTER COLUMN activity_id SET DATA TYPE uuid USING CAST( LPAD( TO_HEX(activity_id), 32, '0' ) AS uuid), + ADD CONSTRAINT notifications_activity_id_fkey FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE; + """ + + for table <- ~w(notifications filters lists oauth_authorizations oauth_tokens password_reset_tokens push_subscriptions websub_client_subscriptions) do + execute """ + ALTER TABLE #{table} + ALTER COLUMN user_id SET DATA TYPE uuid USING CAST( LPAD( TO_HEX(user_id), 32, '0' ) AS uuid), + ADD CONSTRAINT #{table}_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + """ + end + + flush() + + stop_clippy_heartbeats(clippy) + end + + defp start_clippy_heartbeats() do + count = from(a in "activities", select: count(a.id)) |> Repo.one! + + if count > 5000 do + heartbeat_interval = :timer.minutes(2) + :timer.seconds(30) + all_tips = Clippy.tips() ++ [ + "The migration is still running, maybe it's time for another “tea”?", + "Happy rabbits practice a cute behavior known as a\n“binky:” they jump up in the air\nand twist\nand spin around!", + "Nothing and everything.\n\nI still work.", + "Pleroma runs on a Raspberry Pi!\n\n … but this migration will take forever if you\nactually run on a raspberry pi", + "Status? Stati? Post? Note? Toot?\nRepeat? Reboost? Boost? Retweet? Retoot??\n\nI-I'm confused.", + ] + + heartbeat = fn(heartbeat, runs, all_tips, tips) -> + tips = if Integer.is_even(runs) do + tips = if tips == [], do: all_tips, else: tips + [tip | tips] = Enum.shuffle(tips) + Clippy.puts(tip) + tips + else + IO.puts "\n -- #{DateTime.to_string(DateTime.utc_now())} Migration still running, please wait…\n" + tips + end + :timer.sleep(heartbeat_interval) + heartbeat.(heartbeat, runs + 1, all_tips, tips) + end + + Clippy.puts [ + [:red, :bright, "It looks like you are running an older instance!"], + [""], + [:bright, "This migration may take a long time", :reset, " -- so you probably should"], + ["go drink a cofe, or a tea, or a beer, a whiskey, a vodka,"], + ["while it runs to deal with your temporary fediverse pause!"] + ] + :timer.sleep(heartbeat_interval) + spawn_link(fn() -> heartbeat.(heartbeat, 1, all_tips, []) end) + end + end + + defp stop_clippy_heartbeats(pid) do + if pid do + Process.unlink(pid) + Process.exit(pid, :kill) + Clippy.puts [[:green, :bright, "Hurray!!", "", "", "Migration completed!"]] + end + end + +end diff --git a/priv/repo/migrations/20190124131141_update_activity_visibility_again.exs b/priv/repo/migrations/20190124131141_update_activity_visibility_again.exs new file mode 100644 index 000000000..0519a5143 --- /dev/null +++ b/priv/repo/migrations/20190124131141_update_activity_visibility_again.exs @@ -0,0 +1,37 @@ +defmodule Pleroma.Repo.Migrations.UpdateActivityVisibilityAgain do + use Ecto.Migration + @disable_ddl_transaction true + + def up do + definition = """ + create or replace function activity_visibility(actor varchar, recipients varchar[], data jsonb) returns varchar as $$ + DECLARE + fa varchar; + public varchar := 'https://www.w3.org/ns/activitystreams#Public'; + BEGIN + SELECT COALESCE(users.follower_address, '') into fa from public.users where users.ap_id = actor; + + IF data->'to' ? public THEN + RETURN 'public'; + ELSIF data->'cc' ? public THEN + RETURN 'unlisted'; + ELSIF ARRAY[fa] && recipients THEN + RETURN 'private'; + ELSIF not(ARRAY[fa, public] && recipients) THEN + RETURN 'direct'; + ELSE + RETURN 'unknown'; + END IF; + END; + $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE SECURITY DEFINER; + """ + + execute(definition) + + end + + def down do + + end + +end diff --git a/priv/repo/migrations/20190126160540_change_push_subscriptions_varchar.exs b/priv/repo/migrations/20190126160540_change_push_subscriptions_varchar.exs new file mode 100644 index 000000000..337fed156 --- /dev/null +++ b/priv/repo/migrations/20190126160540_change_push_subscriptions_varchar.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.ChangePushSubscriptionsVarchar do + use Ecto.Migration + + def change do + alter table(:push_subscriptions) do + modify(:endpoint, :varchar) + end + end +end diff --git a/priv/repo/migrations/20190127151220_add_activities_likes_index.exs b/priv/repo/migrations/20190127151220_add_activities_likes_index.exs new file mode 100644 index 000000000..b1822d265 --- /dev/null +++ b/priv/repo/migrations/20190127151220_add_activities_likes_index.exs @@ -0,0 +1,8 @@ +defmodule Pleroma.Repo.Migrations.AddActivitiesLikesIndex do + use Ecto.Migration + @disable_ddl_transaction true + + def change do + create index(:activities, ["((data #> '{\"object\",\"likes\"}'))"], concurrently: true, name: :activities_likes, using: :gin) + end +end diff --git a/priv/static/index.html b/priv/static/index.html index 74792bf82..4a1a35282 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
\ No newline at end of file +Pleroma
\ No newline at end of file diff --git a/priv/static/static/config.json b/priv/static/static/config.json index cc72900a8..24e26696f 100644 --- a/priv/static/static/config.json +++ b/priv/static/static/config.json @@ -18,5 +18,6 @@ "hideUserStats": false, "loginMethod": "password", "webPushNotifications": false, - "noAttachmentLinks": false + "noAttachmentLinks": false, + "nsfwCensorImage": "" } diff --git a/priv/static/static/js/app.76e23c93f1de5902c4d7.js b/priv/static/static/js/app.76e23c93f1de5902c4d7.js deleted file mode 100644 index 4413aa4dc..000000000 Binary files a/priv/static/static/js/app.76e23c93f1de5902c4d7.js and /dev/null differ diff --git a/priv/static/static/js/app.76e23c93f1de5902c4d7.js.map b/priv/static/static/js/app.76e23c93f1de5902c4d7.js.map deleted file mode 100644 index 01b977adb..000000000 Binary files a/priv/static/static/js/app.76e23c93f1de5902c4d7.js.map and /dev/null differ diff --git a/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js b/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js new file mode 100644 index 000000000..95af860ad Binary files /dev/null and b/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js differ diff --git a/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js.map b/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js.map new file mode 100644 index 000000000..0fc435ffb Binary files /dev/null and b/priv/static/static/js/app.ddbd2a89e264d04e0d6d.js.map differ diff --git a/priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js b/priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js new file mode 100644 index 000000000..712cb9ae8 Binary files /dev/null and b/priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js differ diff --git a/priv/static/static/js/manifest.e58590e04ca06ebbea1e.js.map b/priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js.map similarity index 92% rename from priv/static/static/js/manifest.e58590e04ca06ebbea1e.js.map rename to priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js.map index dde57f245..e76faf6e8 100644 Binary files a/priv/static/static/js/manifest.e58590e04ca06ebbea1e.js.map and b/priv/static/static/js/manifest.8dc8d7a1dc85bfdf2b14.js.map differ diff --git a/priv/static/static/js/manifest.e58590e04ca06ebbea1e.js b/priv/static/static/js/manifest.e58590e04ca06ebbea1e.js deleted file mode 100644 index 6c72d1f6f..000000000 Binary files a/priv/static/static/js/manifest.e58590e04ca06ebbea1e.js and /dev/null differ diff --git a/priv/static/static/js/vendor.61fac267296f19262d14.js.map b/priv/static/static/js/vendor.61fac267296f19262d14.js.map deleted file mode 100644 index 8fa2f5cb5..000000000 Binary files a/priv/static/static/js/vendor.61fac267296f19262d14.js.map and /dev/null differ diff --git a/priv/static/static/js/vendor.61fac267296f19262d14.js b/priv/static/static/js/vendor.61fd03d8471aaadcf63c.js similarity index 51% rename from priv/static/static/js/vendor.61fac267296f19262d14.js rename to priv/static/static/js/vendor.61fd03d8471aaadcf63c.js index 6ca06dfab..c0bd08259 100644 Binary files a/priv/static/static/js/vendor.61fac267296f19262d14.js and b/priv/static/static/js/vendor.61fd03d8471aaadcf63c.js differ diff --git a/priv/static/static/js/vendor.61fd03d8471aaadcf63c.js.map b/priv/static/static/js/vendor.61fd03d8471aaadcf63c.js.map new file mode 100644 index 000000000..b827959a7 Binary files /dev/null and b/priv/static/static/js/vendor.61fd03d8471aaadcf63c.js.map differ diff --git a/priv/static/sw.js b/priv/static/sw.js index 73e42aa29..e3c265153 100644 Binary files a/priv/static/sw.js and b/priv/static/sw.js differ diff --git a/priv/static/sw.js.map b/priv/static/sw.js.map index dae63a1fd..9ac7414bf 100644 Binary files a/priv/static/sw.js.map and b/priv/static/sw.js.map differ diff --git a/test/flake_id_test.exs b/test/flake_id_test.exs new file mode 100644 index 000000000..ca2338041 --- /dev/null +++ b/test/flake_id_test.exs @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FlakeIdTest do + use Pleroma.DataCase + import Kernel, except: [to_string: 1] + import Pleroma.FlakeId + + describe "fake flakes (compatibility with older serial integers)" do + test "from_string/1" do + fake_flake = <<0::integer-size(64), 42::integer-size(64)>> + assert from_string("42") == fake_flake + assert from_string(42) == fake_flake + end + + test "zero or -1 is a null flake" do + fake_flake = <<0::integer-size(128)>> + assert from_string("0") == fake_flake + assert from_string("-1") == fake_flake + end + + test "to_string/1" do + fake_flake = <<0::integer-size(64), 42::integer-size(64)>> + assert to_string(fake_flake) == "42" + end + end + + test "ecto type behaviour" do + flake = <<0, 0, 1, 104, 80, 229, 2, 235, 140, 22, 69, 201, 53, 210, 0, 0>> + flake_s = "9eoozpwTul5mjSEDRI" + + assert cast(flake) == {:ok, flake_s} + assert cast(flake_s) == {:ok, flake_s} + + assert load(flake) == {:ok, flake_s} + assert load(flake_s) == {:ok, flake_s} + + assert dump(flake_s) == {:ok, flake} + assert dump(flake) == {:ok, flake} + end +end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 3d6efd52c..bcdf2e006 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -653,6 +653,14 @@ def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/acti {:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)} end + def get("http://example.com/ogp", _, _, _) do + {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/rich_media/ogp.html")}} + end + + def get("http://example.com/empty", _, _, _) do + {:ok, %Tesla.Env{status: 200, body: "hello"}} + end + def get("http://404.site" <> _, _, _, _) do {:ok, %Tesla.Env{ diff --git a/test/user_test.exs b/test/user_test.exs index 092cfc5dc..a0657c7b6 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -672,12 +672,13 @@ test "get recipients from activity" do "status" => "hey @#{addressed.nickname} @#{addressed_remote.nickname}" }) - assert [addressed] == User.get_recipients_from_activity(activity) + assert Enum.map([actor, addressed], & &1.ap_id) -- + Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == [] {:ok, user} = User.follow(user, actor) {:ok, _user_two} = User.follow(user_two, actor) recipients = User.get_recipients_from_activity(activity) - assert length(recipients) == 2 + assert length(recipients) == 3 assert user in recipients assert addressed in recipients end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index d517c7aa7..75b0918a6 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -65,6 +65,34 @@ test "it returns a user" do assert user.info.ap_enabled assert user.follower_address == "http://mastodon.example.org/users/admin/followers" end + + test "it fetches the appropriate tag-restricted posts" do + user = insert(:user) + + {:ok, status_one} = CommonAPI.post(user, %{"status" => ". #test"}) + {:ok, status_two} = CommonAPI.post(user, %{"status" => ". #essais"}) + {:ok, status_three} = CommonAPI.post(user, %{"status" => ". #test #reject"}) + + fetch_one = ActivityPub.fetch_activities([], %{"tag" => "test"}) + fetch_two = ActivityPub.fetch_activities([], %{"tag" => ["test", "essais"]}) + + fetch_three = + ActivityPub.fetch_activities([], %{ + "tag" => ["test", "essais"], + "tag_reject" => ["reject"] + }) + + fetch_four = + ActivityPub.fetch_activities([], %{ + "tag" => ["test"], + "tag_all" => ["test", "reject"] + }) + + assert fetch_one == [status_one, status_three] + assert fetch_two == [status_one, status_two, status_three] + assert fetch_three == [status_one, status_two] + assert fetch_four == [status_three] + end end describe "insertion" do @@ -86,6 +114,17 @@ test "drops activities beyond a certain limit" do assert {:error, {:remote_limit_error, _}} = ActivityPub.insert(data) end + test "doesn't drop activities with content being null" do + data = %{ + "ok" => true, + "object" => %{ + "content" => nil + } + } + + assert {:ok, _} = ActivityPub.insert(data) + end + test "returns the activity if one with the same id is already in" do activity = insert(:note_activity) {:ok, new_activity} = ActivityPub.insert(activity.data) @@ -161,7 +200,7 @@ test "removes doubled 'to' recipients" do assert activity.data["to"] == ["user1", "user2"] assert activity.actor == user.ap_id - assert activity.recipients == ["user1", "user2"] + assert activity.recipients == ["user1", "user2", user.ap_id] end end diff --git a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs new file mode 100644 index 000000000..2ea4f9d3f --- /dev/null +++ b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicyTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy + + describe "blocking based on attributes" do + test "matches followbots by nickname" do + actor = insert(:user, %{nickname: "followbot@example.com"}) + target = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Follow", + "actor" => actor.ap_id, + "object" => target.ap_id, + "id" => "https://example.com/activities/1234" + } + + {:reject, nil} = AntiFollowbotPolicy.filter(message) + end + + test "matches followbots by display name" do + actor = insert(:user, %{name: "Federation Bot"}) + target = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Follow", + "actor" => actor.ap_id, + "object" => target.ap_id, + "id" => "https://example.com/activities/1234" + } + + {:reject, nil} = AntiFollowbotPolicy.filter(message) + end + end + + test "it allows non-followbots" do + actor = insert(:user) + target = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Follow", + "actor" => actor.ap_id, + "object" => target.ap_id, + "id" => "https://example.com/activities/1234" + } + + {:ok, _} = AntiFollowbotPolicy.filter(message) + end +end diff --git a/test/web/mastodon_api/account_view_test.exs b/test/web/mastodon_api/account_view_test.exs index d53e11963..f8cd68173 100644 --- a/test/web/mastodon_api/account_view_test.exs +++ b/test/web/mastodon_api/account_view_test.exs @@ -61,7 +61,9 @@ test "Represent a user account" do }, pleroma: %{ confirmation_pending: false, - tags: [] + tags: [], + is_admin: false, + is_moderator: false } } @@ -102,7 +104,9 @@ test "Represent a Service(bot) account" do }, pleroma: %{ confirmation_pending: false, - tags: [] + tags: [], + is_admin: false, + is_moderator: false } } diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 8443dc856..b8f901e6c 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -148,7 +148,7 @@ test "posting a direct status", %{conn: conn} do assert %{"id" => id, "visibility" => "direct"} = json_response(conn, 200) assert activity = Repo.get(Activity, id) - assert activity.recipients == [user2.ap_id] + assert activity.recipients == [user2.ap_id, user1.ap_id] assert activity.data["to"] == [user2.ap_id] assert activity.data["cc"] == [] end @@ -182,6 +182,16 @@ test "direct timeline", %{conn: conn} do assert %{"visibility" => "direct"} = status assert status["url"] != direct.data["id"] + # User should be able to see his own direct message + res_conn = + build_conn() + |> assign(:user, user_one) + |> get("api/v1/timelines/direct") + + [status] = json_response(res_conn, 200) + + assert %{"visibility" => "direct"} = status + # Both should be visible here res_conn = conn @@ -1034,6 +1044,34 @@ test "hashtag timeline", %{conn: conn} do end) end + test "multi-hashtag timeline", %{conn: conn} do + user = insert(:user) + + {:ok, activity_test} = CommonAPI.post(user, %{"status" => "#test"}) + {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"}) + {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"}) + + any_test = + conn + |> get("/api/v1/timelines/tag/test", %{"any" => ["test1"]}) + + [status_none, status_test1, status_test] = json_response(any_test, 200) + + assert to_string(activity_test.id) == status_test["id"] + assert to_string(activity_test1.id) == status_test1["id"] + assert to_string(activity_none.id) == status_none["id"] + + restricted_test = + conn + |> get("/api/v1/timelines/tag/test", %{"all" => ["test1"], "none" => ["none"]}) + + assert [status_test1] == json_response(restricted_test, 200) + + all_test = conn |> get("/api/v1/timelines/tag/test", %{"all" => ["none"]}) + + assert [status_none] == json_response(all_test, 200) + end + test "getting followers", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -1613,5 +1651,22 @@ test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do |> post("/api/v1/statuses/#{activity_two.id}/pin") |> json_response(400) end + + test "Status rich-media Card", %{conn: conn, user: user} do + {:ok, activity} = CommonAPI.post(user, %{"status" => "http://example.com/ogp"}) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/card") + |> json_response(200) + + assert response == %{ + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "provider_name" => "www.imdb.com", + "title" => "The Rock", + "type" => "link", + "url" => "http://www.imdb.com/title/tt0117500/" + } + end end end diff --git a/test/web/mastodon_api/status_view_test.exs b/test/web/mastodon_api/status_view_test.exs index e33479368..ebf6273e8 100644 --- a/test/web/mastodon_api/status_view_test.exs +++ b/test/web/mastodon_api/status_view_test.exs @@ -149,7 +149,10 @@ test "contains mentions" do status = StatusView.render("status.json", %{activity: activity}) - assert status.mentions == [AccountView.render("mention.json", %{user: user})] + actor = Repo.get_by(User, ap_id: activity.actor) + + assert status.mentions == + Enum.map([user, actor], fn u -> AccountView.render("mention.json", %{user: u}) end) end test "attachments" do diff --git a/test/web/metadata/opengraph_test.exs b/test/web/metadata/opengraph_test.exs new file mode 100644 index 000000000..4283f72cd --- /dev/null +++ b/test/web/metadata/opengraph_test.exs @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# 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/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index ccd552258..e0d3cb55f 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -34,6 +34,31 @@ test "redirects with oauth authorization" do assert Repo.get_by(Authorization, token: code) end + test "correctly handles wrong credentials", %{conn: conn} do + user = insert(:user) + app = insert(:oauth_app) + + result = + conn + |> post("/oauth/authorize", %{ + "authorization" => %{ + "name" => user.nickname, + "password" => "wrong", + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "state" => "statepassed" + } + }) + |> html_response(:unauthorized) + + # Keep the details + assert result =~ app.client_id + assert result =~ app.redirect_uris + + # Error message + assert result =~ "Invalid" + end + test "issues a token for an all-body request" do user = insert(:user) app = insert(:oauth_app) diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index ad9bc418a..cba12b3f7 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -108,6 +108,7 @@ test "gets an object", %{conn: conn} do conn = conn + |> put_req_header("accept", "application/xml") |> get(url) expected = @@ -134,31 +135,34 @@ test "404s on nonexisting objects", %{conn: conn} 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"])) @@ -174,7 +178,7 @@ test "404s on nonexistent activities", %{conn: conn} 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 diff --git a/test/web/rich_media/controllers/rich_media_controller_test.exs b/test/web/rich_media/controllers/rich_media_controller_test.exs index 37c82631f..fef126513 100644 --- a/test/web/rich_media/controllers/rich_media_controller_test.exs +++ b/test/web/rich_media/controllers/rich_media_controller_test.exs @@ -1,19 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.RichMedia.RichMediaControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + import Tesla.Mock setup do - Tesla.Mock.mock(fn - %{ - method: :get, - url: "http://example.com/ogp" - } -> - %Tesla.Env{status: 200, body: File.read!("test/fixtures/rich_media/ogp.html")} - - %{method: :get, url: "http://example.com/empty"} -> - %Tesla.Env{status: 200, body: "hello"} - end) - + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok end diff --git a/test/web/twitter_api/twitter_api_controller_test.exs b/test/web/twitter_api/twitter_api_controller_test.exs index f22cdd870..863abd10f 100644 --- a/test/web/twitter_api/twitter_api_controller_test.exs +++ b/test/web/twitter_api/twitter_api_controller_test.exs @@ -797,7 +797,7 @@ test "with credentials, invalid activity", %{conn: conn, user: current_user} do |> with_credentials(current_user.nickname, "test") |> post("/api/favorites/create/1.json") - assert json_response(conn, 500) + assert json_response(conn, 400) end end @@ -1621,7 +1621,7 @@ test "it approves a friend request" do conn = build_conn() |> assign(:user, user) - |> post("/api/pleroma/friendships/approve", %{"user_id" => to_string(other_user.id)}) + |> post("/api/pleroma/friendships/approve", %{"user_id" => other_user.id}) assert relationship = json_response(conn, 200) assert other_user.id == relationship["id"] @@ -1644,7 +1644,7 @@ test "it denies a friend request" do conn = build_conn() |> assign(:user, user) - |> post("/api/pleroma/friendships/deny", %{"user_id" => to_string(other_user.id)}) + |> post("/api/pleroma/friendships/deny", %{"user_id" => other_user.id}) assert relationship = json_response(conn, 200) assert other_user.id == relationship["id"] diff --git a/test/web/twitter_api/views/user_view_test.exs b/test/web/twitter_api/views/user_view_test.exs index 5f7481eb6..daf18c1c5 100644 --- a/test/web/twitter_api/views/user_view_test.exs +++ b/test/web/twitter_api/views/user_view_test.exs @@ -100,6 +100,7 @@ test "A user" do "locked" => false, "default_scope" => "public", "no_rich_text" => false, + "hide_network" => false, "fields" => [], "pleroma" => %{ "confirmation_pending" => false, @@ -146,6 +147,7 @@ test "A user for a given other follower", %{user: user} do "locked" => false, "default_scope" => "public", "no_rich_text" => false, + "hide_network" => false, "fields" => [], "pleroma" => %{ "confirmation_pending" => false, @@ -193,6 +195,7 @@ test "A user that follows you", %{user: user} do "locked" => false, "default_scope" => "public", "no_rich_text" => false, + "hide_network" => false, "fields" => [], "pleroma" => %{ "confirmation_pending" => false, @@ -254,6 +257,7 @@ test "A blocked user for the blocker" do "locked" => false, "default_scope" => "public", "no_rich_text" => false, + "hide_network" => false, "fields" => [], "pleroma" => %{ "confirmation_pending" => false,