Merge develop

This commit is contained in:
Rin Toshaka 2018-12-06 18:11:22 +01:00
commit 88f92693f2
40 changed files with 918 additions and 140 deletions

View File

@ -39,6 +39,7 @@ Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`.
* `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesnt makes sense to use in production * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesnt makes sense to use in production
* `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See ``:mrf_simple`` section) * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See ``:mrf_simple`` section)
* `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See ``:mrf_rejectnonpublic`` section) * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See ``:mrf_rejectnonpublic`` section)
* `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:.
* `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network.
* `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send.
* `managed_config`: Whenether the config for pleroma-fe is configured in this config or in ``static/config.json`` * `managed_config`: Whenether the config for pleroma-fe is configured in this config or in ``static/config.json``

View File

@ -27,6 +27,12 @@
config :pleroma, :ostatus, Pleroma.Web.OStatusMock config :pleroma, :ostatus, Pleroma.Web.OStatusMock
config :tesla, adapter: Tesla.Mock config :tesla, adapter: Tesla.Mock
config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com",
public_key:
"BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4",
private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA"
try do try do
import_config "test.secret.exs" import_config "test.secret.exs"
rescue rescue

View File

@ -29,6 +29,12 @@ config :pleroma, Pleroma.Repo,
hostname: "<%= dbhost %>", hostname: "<%= dbhost %>",
pool_size: 10 pool_size: 10
# Configure web push notifications
config :web_push_encryption, :vapid_details,
subject: "mailto:<%= email %>",
public_key: "<%= web_push_public_key %>",
private_key: "<%= web_push_private_key %>"
# Enable Strict-Transport-Security once SSL is working: # Enable Strict-Transport-Security once SSL is working:
# config :pleroma, :http_security, # config :pleroma, :http_security,
# sts: true # sts: true

View File

@ -66,7 +66,8 @@ def start(_type, _args) do
), ),
worker(Pleroma.Web.Federator.RetryQueue, []), worker(Pleroma.Web.Federator.RetryQueue, []),
worker(Pleroma.Web.Federator, []), worker(Pleroma.Web.Federator, []),
worker(Pleroma.Stats, []) worker(Pleroma.Stats, []),
worker(Pleroma.Web.Push, [])
] ++ ] ++
streamer_child() ++ streamer_child() ++
chat_child() ++ chat_child() ++

View File

@ -114,7 +114,7 @@ def add_user_links({subs, text}, mentions) do
subs = subs =
subs ++ subs ++
Enum.map(mentions, fn {match, %User{ap_id: ap_id, info: info}, uuid} -> Enum.map(mentions, fn {match, %User{id: id, ap_id: ap_id, info: info}, uuid} ->
ap_id = ap_id =
if is_binary(info.source_data["url"]) do if is_binary(info.source_data["url"]) do
info.source_data["url"] info.source_data["url"]
@ -125,7 +125,7 @@ def add_user_links({subs, text}, mentions) do
short_match = String.split(match, "@") |> tl() |> hd() short_match = String.split(match, "@") |> tl() |> hd()
{uuid, {uuid,
"<span><a class='mention' href='#{ap_id}'>@<span>#{short_match}</span></a></span>"} "<span><a data-user='#{id}' class='mention' href='#{ap_id}'>@<span>#{short_match}</span></a></span>"}
end) end)
{subs, uuid_text} {subs, uuid_text}
@ -147,7 +147,11 @@ def add_hashtag_links({subs, text}, tags) do
subs = subs =
subs ++ subs ++
Enum.map(tags, fn {tag_text, tag, uuid} -> Enum.map(tags, fn {tag_text, tag, uuid} ->
url = "<a href='#{Pleroma.Web.base_url()}/tag/#{tag}' rel='tag'>#{tag_text}</a>" url =
"<a data-tag='#{tag}' href='#{Pleroma.Web.base_url()}/tag/#{tag}' rel='tag'>#{
tag_text
}</a>"
{uuid, url} {uuid, url}
end) end)

View File

@ -45,7 +45,7 @@ defmodule Pleroma.HTML.Scrubber.TwitterText do
Meta.strip_comments() Meta.strip_comments()
# links # links
Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes)
Meta.allow_tag_with_these_attributes("a", ["name", "title"]) Meta.allow_tag_with_these_attributes("a", ["name", "title"])
# paragraphs and linebreaks # paragraphs and linebreaks
@ -86,7 +86,7 @@ defmodule Pleroma.HTML.Scrubber.Default do
Meta.remove_cdata_sections_before_scrub() Meta.remove_cdata_sections_before_scrub()
Meta.strip_comments() Meta.strip_comments()
Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], @valid_schemes)
Meta.allow_tag_with_these_attributes("a", ["name", "title"]) Meta.allow_tag_with_these_attributes("a", ["name", "title"])
Meta.allow_tag_with_these_attributes("abbr", ["title"]) Meta.allow_tag_with_these_attributes("abbr", ["title"])

View File

@ -3,7 +3,12 @@ defmodule Pleroma.HTTP.Connection do
Connection for http-requests. Connection for http-requests.
""" """
@hackney_options [pool: :default] @hackney_options [
pool: :default,
timeout: 10000,
recv_timeout: 20000,
follow_redirect: true
]
@adapter Application.get_env(:tesla, :adapter) @adapter Application.get_env(:tesla, :adapter)
@doc """ @doc """

View File

@ -110,6 +110,7 @@ def create_notification(%Activity{} = activity, %User{} = user) do
notification = %Notification{user_id: user.id, activity: activity} notification = %Notification{user_id: user.id, activity: activity}
{:ok, notification} = Repo.insert(notification) {:ok, notification} = Repo.insert(notification)
Pleroma.Web.Streamer.stream("user", notification) Pleroma.Web.Streamer.stream("user", notification)
Pleroma.Web.Push.send(notification)
notification notification
end end
end end

View File

@ -1,6 +1,6 @@
defmodule Pleroma.Object do defmodule Pleroma.Object do
use Ecto.Schema use Ecto.Schema
alias Pleroma.{Repo, Object, Activity} alias Pleroma.{Repo, Object, User, Activity}
import Ecto.{Query, Changeset} import Ecto.{Query, Changeset}
schema "objects" do schema "objects" do
@ -31,6 +31,13 @@ def normalize(obj) when is_map(obj), do: Object.get_by_ap_id(obj["id"])
def normalize(ap_id) when is_binary(ap_id), do: Object.get_by_ap_id(ap_id) def normalize(ap_id) when is_binary(ap_id), do: Object.get_by_ap_id(ap_id)
def normalize(_), do: nil def normalize(_), do: nil
# Owned objects can only be mutated by their owner
def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}),
do: actor == ap_id
# Legacy objects can be mutated by anybody
def authorize_mutation(%Object{}, %User{}), do: true
if Mix.env() == :test do if Mix.env() == :test do
def get_cached_by_ap_id(ap_id) do def get_cached_by_ap_id(ap_id) do
get_by_ap_id(ap_id) get_by_ap_id(ap_id)

View File

@ -1,30 +1,70 @@
defmodule Pleroma.Plugs.OAuthPlug do defmodule Pleroma.Plugs.OAuthPlug do
import Plug.Conn import Plug.Conn
alias Pleroma.User import Ecto.Query
alias Pleroma.Repo
alias Pleroma.Web.OAuth.Token
def init(options) do alias Pleroma.{
options User,
end Repo,
Web.OAuth.Token
}
@realm_reg Regex.compile!("Bearer\:?\s+(.*)$", "i")
def init(options), do: options
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(conn, _) do def call(conn, _) do
token = with {:ok, token} <- fetch_token(conn),
case get_req_header(conn, "authorization") do {:ok, user} <- fetch_user(token) do
["Bearer " <> header] -> header
_ -> get_session(conn, :oauth_token)
end
with token when not is_nil(token) <- token,
%Token{user_id: user_id} <- Repo.get_by(Token, token: token),
%User{} = user <- Repo.get(User, user_id),
false <- !!user.info.deactivated do
conn conn
|> assign(:token, token)
|> assign(:user, user) |> assign(:user, user)
else else
_ -> conn _ -> conn
end end
end end
# Gets user by token
#
@spec fetch_user(String.t()) :: {:ok, User.t()} | nil
defp fetch_user(token) do
query = from(q in Token, where: q.token == ^token, preload: [:user])
with %Token{user: %{info: %{deactivated: false} = _} = user} <- Repo.one(query) do
{:ok, user}
end
end
# Gets token from session by :oauth_token key
#
@spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token_from_session(conn) do
case get_session(conn, :oauth_token) do
nil -> :no_token_found
token -> {:ok, token}
end
end
# Gets token from headers
#
@spec fetch_token(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token(%Plug.Conn{} = conn) do
headers = get_req_header(conn, "authorization")
with :no_token_found <- fetch_token(headers),
do: fetch_token_from_session(conn)
end
@spec fetch_token(Keyword.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token([]), do: :no_token_found
defp fetch_token([token | tail]) do
trimmed_token = String.trim(token)
case Regex.run(@realm_reg, trimmed_token) do
[_, match] -> {:ok, String.trim(match)}
_ -> fetch_token(tail)
end
end
end end

View File

@ -56,7 +56,7 @@ defmodule Pleroma.ReverseProxy do
@hackney Application.get_env(:pleroma, :hackney, :hackney) @hackney Application.get_env(:pleroma, :hackney, :hackney)
@httpoison Application.get_env(:pleroma, :httpoison, HTTPoison) @httpoison Application.get_env(:pleroma, :httpoison, HTTPoison)
@default_hackney_options [{:follow_redirect, true}] @default_hackney_options []
@inline_content_types [ @inline_content_types [
"image/gif", "image/gif",

View File

@ -62,10 +62,6 @@ def follow_changeset(struct, params \\ %{}) do
|> validate_required([:following]) |> validate_required([:following])
end end
def info_changeset(struct, params \\ %{}) do
raise "NOT VALID ANYMORE"
end
def user_info(%User{} = user) do def user_info(%User{} = user) do
oneself = if user.local, do: 1, else: 0 oneself = if user.local, do: 1, else: 0

View File

@ -24,6 +24,7 @@ defmodule Pleroma.User.Info do
field(:topic, :string, default: nil) field(:topic, :string, default: nil)
field(:hub, :string, default: nil) field(:hub, :string, default: nil)
field(:salmon, :string, default: nil) field(:salmon, :string, default: nil)
field(:hide_network, :boolean, default: false)
# Found in the wild # Found in the wild
# ap_id -> Where is this used? # ap_id -> Where is this used?
@ -135,6 +136,7 @@ def profile_update(info, params) do
:no_rich_text, :no_rich_text,
:default_scope, :default_scope,
:banner, :banner,
:hide_network,
:background :background
]) ])
end end
@ -147,6 +149,11 @@ def mastodon_profile_update(info, params) do
]) ])
end end
def mastodon_settings_update(info, params) do
info
|> cast(params, [:settings])
end
def set_source_data(info, source_data) do def set_source_data(info, source_data) do
params = %{source_data: source_data} params = %{source_data: source_data}

View File

@ -574,7 +574,14 @@ def fetch_activities_bounded(recipients_to, recipients_cc, opts \\ %{}) do
def upload(file, opts \\ []) do def upload(file, opts \\ []) do
with {:ok, data} <- Upload.store(file, opts) do with {:ok, data} <- Upload.store(file, opts) do
Repo.insert(%Object{data: data}) obj_data =
if opts[:actor] do
Map.put(data, "actor", opts[:actor])
else
data
end
Repo.insert(%Object{data: obj_data})
end end
end end
@ -765,10 +772,7 @@ def fetch_and_contain_remote_object_from_id(id) do
{:ok, %{body: body, status: code}} when code in 200..299 <- {:ok, %{body: body, status: code}} when code in 200..299 <-
@httpoison.get( @httpoison.get(
id, id,
[Accept: "application/activity+json"], [{:Accept, "application/activity+json"}]
follow_redirect: true,
timeout: 10000,
recv_timeout: 20000
), ),
{:ok, data} <- Jason.decode(body), {:ok, data} <- Jason.decode(body),
:ok <- Transmogrifier.contain_origin_from_id(id, data) do :ok <- Transmogrifier.contain_origin_from_id(id, data) do

View File

@ -0,0 +1,40 @@
defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
alias Pleroma.Object
@behaviour Pleroma.Web.ActivityPub.MRF
@reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
def filter_by_summary(
%{"summary" => parent_summary} = parent,
%{"summary" => child_summary} = child
)
when not is_nil(child_summary) and byte_size(child_summary) > 0 and
not is_nil(parent_summary) and byte_size(parent_summary) > 0 do
if (child_summary == parent_summary and not Regex.match?(@reply_prefix, child_summary)) or
(Regex.match?(@reply_prefix, parent_summary) &&
Regex.replace(@reply_prefix, parent_summary, "") == child_summary) do
Map.put(child, "summary", "re: " <> child_summary)
else
child
end
end
def filter_by_summary(parent, child), do: child
def filter(%{"type" => activity_type} = object) when activity_type == "Create" do
child = object["object"]
in_reply_to = Object.normalize(child["inReplyTo"])
child =
if(in_reply_to,
do: filter_by_summary(in_reply_to.data, child),
else: child
)
object = Map.put(object, "object", child)
{:ok, object}
end
def filter(object), do: {:ok, object}
end

View File

@ -82,7 +82,7 @@ def render("following.json", %{user: user, page: page}) do
query = from(user in query, select: [:ap_id]) query = from(user in query, select: [:ap_id])
following = Repo.all(query) following = Repo.all(query)
collection(following, "#{user.ap_id}/following", page) collection(following, "#{user.ap_id}/following", page, !user.info.hide_network)
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -95,7 +95,7 @@ def render("following.json", %{user: user}) do
"id" => "#{user.ap_id}/following", "id" => "#{user.ap_id}/following",
"type" => "OrderedCollection", "type" => "OrderedCollection",
"totalItems" => length(following), "totalItems" => length(following),
"first" => collection(following, "#{user.ap_id}/following", 1) "first" => collection(following, "#{user.ap_id}/following", 1, !user.info.hide_network)
} }
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -105,7 +105,7 @@ def render("followers.json", %{user: user, page: page}) do
query = from(user in query, select: [:ap_id]) query = from(user in query, select: [:ap_id])
followers = Repo.all(query) followers = Repo.all(query)
collection(followers, "#{user.ap_id}/followers", page) collection(followers, "#{user.ap_id}/followers", page, !user.info.hide_network)
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -118,7 +118,7 @@ def render("followers.json", %{user: user}) do
"id" => "#{user.ap_id}/followers", "id" => "#{user.ap_id}/followers",
"type" => "OrderedCollection", "type" => "OrderedCollection",
"totalItems" => length(followers), "totalItems" => length(followers),
"first" => collection(followers, "#{user.ap_id}/followers", 1) "first" => collection(followers, "#{user.ap_id}/followers", 1, !user.info.hide_network)
} }
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -172,7 +172,7 @@ def render("outbox.json", %{user: user, max_id: max_qid}) do
end end
end end
def collection(collection, iri, page, total \\ nil) do def collection(collection, iri, page, show_items \\ true, total \\ nil) do
offset = (page - 1) * 10 offset = (page - 1) * 10
items = Enum.slice(collection, offset, 10) items = Enum.slice(collection, offset, 10)
items = Enum.map(items, fn user -> user.ap_id end) items = Enum.map(items, fn user -> user.ap_id end)
@ -183,7 +183,7 @@ def collection(collection, iri, page, total \\ nil) do
"type" => "OrderedCollectionPage", "type" => "OrderedCollectionPage",
"partOf" => iri, "partOf" => iri,
"totalItems" => total, "totalItems" => total,
"orderedItems" => items "orderedItems" => if(show_items, do: items, else: [])
} }
if offset < total do if offset < total do

View File

@ -2,13 +2,22 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.{Repo, Object, Activity, User, Notification, Stats} alias Pleroma.{Repo, Object, Activity, User, Notification, Stats}
alias Pleroma.Web alias Pleroma.Web
alias Pleroma.Web.MastodonAPI.{StatusView, AccountView, MastodonView, ListView, FilterView}
alias Pleroma.Web.MastodonAPI.{
StatusView,
AccountView,
MastodonView,
ListView,
FilterView,
PushSubscriptionView
}
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth.{Authorization, Token, App} alias Pleroma.Web.OAuth.{Authorization, Token, App}
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
alias Comeonin.Pbkdf2
import Ecto.Query import Ecto.Query
require Logger require Logger
@ -433,33 +442,31 @@ def relationships(%{assigns: %{user: user}} = conn, _) do
|> json([]) |> json([])
end end
def update_media(%{assigns: %{user: _}} = conn, data) do def update_media(%{assigns: %{user: user}} = conn, data) do
with %Object{} = object <- Repo.get(Object, data["id"]), with %Object{} = object <- Repo.get(Object, data["id"]),
true <- Object.authorize_mutation(object, user),
true <- is_binary(data["description"]), true <- is_binary(data["description"]),
description <- data["description"] do description <- data["description"] do
new_data = %{object.data | "name" => description} new_data = %{object.data | "name" => description}
change = Object.change(object, %{data: new_data}) {:ok, _} =
{:ok, _} = Repo.update(change) object
|> Object.change(%{data: new_data})
|> Repo.update()
data = attachment_data = Map.put(new_data, "id", object.id)
new_data render(conn, StatusView, "attachment.json", %{attachment: attachment_data})
|> Map.put("id", object.id)
render(conn, StatusView, "attachment.json", %{attachment: data})
end end
end end
def upload(%{assigns: %{user: _}} = conn, %{"file" => file} = data) do def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
with {:ok, object} <- ActivityPub.upload(file, description: Map.get(data, "description")) do with {:ok, object} <-
change = Object.change(object, %{data: object.data}) ActivityPub.upload(file,
{:ok, object} = Repo.update(change) actor: User.ap_id(user),
description: Map.get(data, "description")
objdata = ) do
object.data attachment_data = Map.put(object.data, "id", object.id)
|> Map.put("id", object.id) render(conn, StatusView, "attachment.json", %{attachment: attachment_data})
render(conn, StatusView, "attachment.json", %{attachment: objdata})
end end
end end
@ -502,17 +509,30 @@ def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do
|> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity}) |> render(StatusView, "index.json", %{activities: activities, for: user, as: :activity})
end end
# TODO: Pagination def followers(%{assigns: %{user: for_user}} = conn, %{"id" => id}) do
def followers(conn, %{"id" => id}) do
with %User{} = user <- Repo.get(User, id), with %User{} = user <- Repo.get(User, id),
{:ok, followers} <- User.get_followers(user) do {:ok, followers} <- User.get_followers(user) do
followers =
cond do
for_user && user.id == for_user.id -> followers
user.info.hide_network -> []
true -> followers
end
render(conn, AccountView, "accounts.json", %{users: followers, as: :user}) render(conn, AccountView, "accounts.json", %{users: followers, as: :user})
end end
end end
def following(conn, %{"id" => id}) do def following(%{assigns: %{user: for_user}} = conn, %{"id" => id}) do
with %User{} = user <- Repo.get(User, id), with %User{} = user <- Repo.get(User, id),
{:ok, followers} <- User.get_friends(user) do {:ok, followers} <- User.get_friends(user) do
followers =
cond do
for_user && user.id == for_user.id -> followers
user.info.hide_network -> []
true -> followers
end
render(conn, AccountView, "accounts.json", %{users: followers, as: :user}) render(conn, AccountView, "accounts.json", %{users: followers, as: :user})
end end
end end
@ -959,9 +979,11 @@ def index(%{assigns: %{user: user}} = conn, _params) do
end end
def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do
with new_info <- Map.put(user.info, "settings", settings), info_cng = User.Info.mastodon_settings_update(user.info, settings)
change <- User.info_changeset(user, %{info: new_info}),
{:ok, _user} <- User.update_and_set_cache(change) do with changeset <- User.update_changeset(user),
changeset <- Ecto.Changeset.put_embed(changeset, :info, info_cng),
{:ok, user} <- User.update_and_set_cache(changeset) do
conn conn
|> json(%{}) |> json(%{})
else else
@ -1149,6 +1171,33 @@ def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do
json(conn, %{}) json(conn, %{})
end end
def create_push_subscription(%{assigns: %{user: user, token: token}} = conn, params) do
Pleroma.Web.Push.Subscription.delete_if_exists(user, token)
{:ok, subscription} = Pleroma.Web.Push.Subscription.create(user, token, params)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
json(conn, view)
end
def get_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do
subscription = Pleroma.Web.Push.Subscription.get(user, token)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
json(conn, view)
end
def update_push_subscription(
%{assigns: %{user: user, token: token}} = conn,
params
) do
{:ok, subscription} = Pleroma.Web.Push.Subscription.update(user, token, params)
view = PushSubscriptionView.render("push_subscription.json", subscription: subscription)
json(conn, view)
end
def delete_push_subscription(%{assigns: %{user: user, token: token}} = conn, _params) do
{:ok, _response} = Pleroma.Web.Push.Subscription.delete(user, token)
json(conn, %{})
end
def errors(conn, _) do def errors(conn, _) do
conn conn
|> put_status(500) |> put_status(500)
@ -1169,7 +1218,14 @@ def suggestions(%{assigns: %{user: user}} = conn, _) do
url = String.replace(api, "{{host}}", host) |> String.replace("{{user}}", user) url = String.replace(api, "{{host}}", host) |> String.replace("{{user}}", user)
with {:ok, %{status: 200, body: body}} <- with {:ok, %{status: 200, body: body}} <-
@httpoison.get(url, [], timeout: timeout, recv_timeout: timeout), @httpoison.get(
url,
[],
adapter: [
timeout: timeout,
recv_timeout: timeout
]
),
{:ok, data} <- Jason.decode(body) do {:ok, data} <- Jason.decode(body) do
data2 = data2 =
Enum.slice(data, 0, limit) Enum.slice(data, 0, limit)

View File

@ -0,0 +1,12 @@
defmodule Pleroma.Web.MastodonAPI.PushSubscriptionView do
use Pleroma.Web, :view
alias Pleroma.Web.MastodonAPI.PushSubscriptionView
def render("push_subscription.json", %{subscription: subscription}) do
%{
id: to_string(subscription.id),
endpoint: subscription.endpoint,
alerts: Map.get(subscription.data, "alerts")
}
end
end

View File

@ -349,12 +349,7 @@ def fetch_activity_from_atom_url(url) do
{:ok, %{body: body, status: code}} when code in 200..299 <- {:ok, %{body: body, status: code}} when code in 200..299 <-
@httpoison.get( @httpoison.get(
url, url,
[Accept: "application/atom+xml"], [{:Accept, "application/atom+xml"}]
follow_redirect: true,
adapter: [
timeout: 10000,
recv_timeout: 20000
]
) do ) do
Logger.debug("Got document from #{url}, handling...") Logger.debug("Got document from #{url}, handling...")
handle_incoming(body) handle_incoming(body)
@ -369,8 +364,7 @@ def fetch_activity_from_html_url(url) do
Logger.debug("Trying to fetch #{url}") Logger.debug("Trying to fetch #{url}")
with true <- String.starts_with?(url, "http"), with true <- String.starts_with?(url, "http"),
{:ok, %{body: body}} <- {:ok, %{body: body}} <- @httpoison.get(url, []),
@httpoison.get(url, [], follow_redirect: true, timeout: 10000, recv_timeout: 20000),
{:ok, atom_url} <- get_atom_url(body) do {:ok, atom_url} <- get_atom_url(body) do
fetch_activity_from_atom_url(atom_url) fetch_activity_from_atom_url(atom_url)
else else

View File

@ -0,0 +1,116 @@
defmodule Pleroma.Web.Push do
use GenServer
alias Pleroma.{Repo, User}
alias Pleroma.Web.Push.Subscription
require Logger
import Ecto.Query
@types ["Create", "Follow", "Announce", "Like"]
@gcm_api_key nil
def start_link() do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
case Application.get_env(:web_push_encryption, :vapid_details) do
nil ->
Logger.warn(
"VAPID key pair is not found. Please, add VAPID configuration to config. Run `mix web_push.gen.keypair` mix task to create a key pair"
)
:ignore
_ ->
{:ok, %{}}
end
end
def send(notification) do
if Application.get_env(:web_push_encryption, :vapid_details) do
GenServer.cast(Pleroma.Web.Push, {:send, notification})
end
end
def handle_cast(
{:send, %{activity: %{data: %{"type" => type}}, user_id: user_id} = notification},
state
)
when type in @types do
actor = User.get_cached_by_ap_id(notification.activity.data["actor"])
body = notification |> format(actor) |> Jason.encode!()
Subscription
|> where(user_id: ^user_id)
|> Repo.all()
|> Enum.each(fn record ->
subscription = %{
keys: %{
p256dh: record.key_p256dh,
auth: record.key_auth
},
endpoint: record.endpoint
}
case WebPushEncryption.send_web_push(body, subscription, @gcm_api_key) do
{:ok, %{status_code: code}} when 400 <= code and code < 500 ->
Logger.debug("Removing subscription record")
Repo.delete!(record)
:ok
{:ok, %{status_code: code}} when 200 <= code and code < 300 ->
:ok
{:ok, %{status_code: code}} ->
Logger.error("Web Push Nonification failed with code: #{code}")
:error
_ ->
Logger.error("Web Push Nonification failed with unknown error")
:error
end
end)
{:noreply, state}
end
def handle_cast({:send, _}, state) do
Logger.warn("Unknown notification type")
{:noreply, state}
end
def format(%{activity: %{data: %{"type" => "Create"}}}, actor) do
%{
title: "New Mention",
body: "@#{actor.nickname} has mentiond you",
icon: User.avatar_url(actor)
}
end
def format(%{activity: %{data: %{"type" => "Follow"}}}, actor) do
%{
title: "New Follower",
body: "@#{actor.nickname} has followed you",
icon: User.avatar_url(actor)
}
end
def format(%{activity: %{data: %{"type" => "Announce"}}}, actor) do
%{
title: "New Announce",
body: "@#{actor.nickname} has announced your post",
icon: User.avatar_url(actor)
}
end
def format(%{activity: %{data: %{"type" => "Like"}}}, actor) do
%{
title: "New Like",
body: "@#{actor.nickname} has liked your post",
icon: User.avatar_url(actor)
}
end
end

View File

@ -0,0 +1,66 @@
defmodule Pleroma.Web.Push.Subscription do
use Ecto.Schema
import Ecto.Changeset
alias Pleroma.{Repo, User}
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Push.Subscription
schema "push_subscriptions" do
belongs_to(:user, User)
belongs_to(:token, Token)
field(:endpoint, :string)
field(:key_p256dh, :string)
field(:key_auth, :string)
field(:data, :map, default: %{})
timestamps()
end
@supported_alert_types ~w[follow favourite mention reblog]
defp alerts(%{"data" => %{"alerts" => alerts}}) do
alerts = Map.take(alerts, @supported_alert_types)
%{"alerts" => alerts}
end
def create(
%User{} = user,
%Token{} = token,
%{
"subscription" => %{
"endpoint" => endpoint,
"keys" => %{"auth" => key_auth, "p256dh" => key_p256dh}
}
} = params
) do
Repo.insert(%Subscription{
user_id: user.id,
token_id: token.id,
endpoint: endpoint,
key_auth: key_auth,
key_p256dh: key_p256dh,
data: alerts(params)
})
end
def get(%User{id: user_id}, %Token{id: token_id}) do
Repo.get_by(Subscription, user_id: user_id, token_id: token_id)
end
def update(user, token, params) do
get(user, token)
|> change(data: alerts(params))
|> Repo.update()
end
def delete(user, token) do
Repo.delete(get(user, token))
end
def delete_if_exists(user, token) do
case get(user, token) do
nil -> {:ok, nil}
sub -> Repo.delete(sub)
end
end
end

View File

@ -198,6 +198,11 @@ defmodule Pleroma.Web.Router do
put("/filters/:id", MastodonAPIController, :update_filter) put("/filters/:id", MastodonAPIController, :update_filter)
delete("/filters/:id", MastodonAPIController, :delete_filter) delete("/filters/:id", MastodonAPIController, :delete_filter)
post("/push/subscription", MastodonAPIController, :create_push_subscription)
get("/push/subscription", MastodonAPIController, :get_push_subscription)
put("/push/subscription", MastodonAPIController, :update_push_subscription)
delete("/push/subscription", MastodonAPIController, :delete_push_subscription)
get("/suggestions", MastodonAPIController, :suggestions) get("/suggestions", MastodonAPIController, :suggestions)
get("/endorsements", MastodonAPIController, :empty_array) get("/endorsements", MastodonAPIController, :empty_array)
@ -324,6 +329,7 @@ defmodule Pleroma.Web.Router do
post("/statusnet/media/upload", TwitterAPI.Controller, :upload) post("/statusnet/media/upload", TwitterAPI.Controller, :upload)
post("/media/upload", TwitterAPI.Controller, :upload_json) post("/media/upload", TwitterAPI.Controller, :upload_json)
post("/media/metadata/create", TwitterAPI.Controller, :update_media)
post("/favorites/create/:id", TwitterAPI.Controller, :favorite) post("/favorites/create/:id", TwitterAPI.Controller, :favorite)
post("/favorites/create", TwitterAPI.Controller, :favorite) post("/favorites/create", TwitterAPI.Controller, :favorite)

View File

@ -162,12 +162,7 @@ defp send_to_user(%{info: %{salmon: salmon}}, feed, poster) do
poster.( poster.(
salmon, salmon,
feed, feed,
[{"Content-Type", "application/magic-envelope+xml"}], [{"Content-Type", "application/magic-envelope+xml"}]
adapter: [
timeout: 10000,
recv_timeout: 20000,
pool: :default
]
) do ) do
Logger.debug(fn -> "Pushed to #{salmon}, code #{code}" end) Logger.debug(fn -> "Pushed to #{salmon}, code #{code}" end)
else else

View File

@ -157,13 +157,17 @@ def config(conn, _params) do
|> send_resp(200, response) |> send_resp(200, response)
_ -> _ ->
vapid_public_key =
Keyword.get(Application.get_env(:web_push_encryption, :vapid_details), :public_key)
data = %{ data = %{
name: Keyword.get(instance, :name), name: Keyword.get(instance, :name),
description: Keyword.get(instance, :description), description: Keyword.get(instance, :description),
server: Web.base_url(), server: Web.base_url(),
textlimit: to_string(Keyword.get(instance, :limit)), textlimit: to_string(Keyword.get(instance, :limit)),
closed: if(Keyword.get(instance, :registrations_open), do: "0", else: "1"), closed: if(Keyword.get(instance, :registrations_open), do: "0", else: "1"),
private: if(Keyword.get(instance, :public, true), do: "0", else: "1") private: if(Keyword.get(instance, :public, true), do: "0", else: "1"),
vapidPublicKey: vapid_public_key
} }
pleroma_fe = %{ pleroma_fe = %{

View File

@ -93,8 +93,8 @@ def unfav(%User{} = user, ap_id_or_id) do
end end
end end
def upload(%Plug.Upload{} = file, format \\ "xml") do def upload(%Plug.Upload{} = file, %User{} = user, format \\ "xml") do
{:ok, object} = ActivityPub.upload(file) {:ok, object} = ActivityPub.upload(file, actor: User.ap_id(user))
url = List.first(object.data["url"]) url = List.first(object.data["url"])
href = url["href"] href = url["href"]

View File

@ -4,7 +4,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
alias Pleroma.Web.TwitterAPI.{TwitterAPI, UserView, ActivityView, NotificationView} alias Pleroma.Web.TwitterAPI.{TwitterAPI, UserView, ActivityView, NotificationView}
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
alias Pleroma.{Repo, Activity, User, Notification} alias Pleroma.{Repo, Activity, Object, User, Notification}
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Ecto.Changeset alias Ecto.Changeset
@ -226,16 +226,51 @@ def fetch_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do
end end
end end
def upload(conn, %{"media" => media}) do @doc """
response = TwitterAPI.upload(media) Updates metadata of uploaded media object.
Derived from [Twitter API endpoint](https://developer.twitter.com/en/docs/media/upload-media/api-reference/post-media-metadata-create).
"""
def update_media(%{assigns: %{user: user}} = conn, %{"media_id" => id} = data) do
object = Repo.get(Object, id)
description = get_in(data, ["alt_text", "text"]) || data["name"] || data["description"]
{conn, status, response_body} =
cond do
!object ->
{halt(conn), :not_found, ""}
!Object.authorize_mutation(object, user) ->
{halt(conn), :forbidden, "You can only update your own uploads."}
!is_binary(description) ->
{conn, :not_modified, ""}
true ->
new_data = Map.put(object.data, "name", description)
{:ok, _} =
object
|> Object.change(%{data: new_data})
|> Repo.update()
{conn, :no_content, ""}
end
conn
|> put_status(status)
|> json(response_body)
end
def upload(%{assigns: %{user: user}} = conn, %{"media" => media}) do
response = TwitterAPI.upload(media, user)
conn conn
|> put_resp_content_type("application/atom+xml") |> put_resp_content_type("application/atom+xml")
|> send_resp(200, response) |> send_resp(200, response)
end end
def upload_json(conn, %{"media" => media}) do def upload_json(%{assigns: %{user: user}} = conn, %{"media" => media}) do
response = TwitterAPI.upload(media, "json") response = TwitterAPI.upload(media, user, "json")
conn conn
|> json_reply(200, response) |> json_reply(200, response)
@ -340,18 +375,32 @@ def external_profile(%{assigns: %{user: current_user}} = conn, %{"profileurl" =>
end end
end end
def followers(conn, params) do def followers(%{assigns: %{user: for_user}} = conn, params) do
with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params), with {:ok, user} <- TwitterAPI.get_user(for_user, params),
{:ok, followers} <- User.get_followers(user) do {:ok, followers} <- User.get_followers(user) do
followers =
cond do
for_user && user.id == for_user.id -> followers
user.info.hide_network -> []
true -> followers
end
render(conn, UserView, "index.json", %{users: followers, for: conn.assigns[:user]}) render(conn, UserView, "index.json", %{users: followers, for: conn.assigns[:user]})
else else
_e -> bad_request_reply(conn, "Can't get followers") _e -> bad_request_reply(conn, "Can't get followers")
end end
end end
def friends(conn, params) do def friends(%{assigns: %{user: for_user}} = conn, params) do
with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params), with {:ok, user} <- TwitterAPI.get_user(conn.assigns[:user], params),
{:ok, friends} <- User.get_friends(user) do {:ok, friends} <- User.get_friends(user) do
friends =
cond do
for_user && user.id == for_user.id -> friends
user.info.hide_network -> []
true -> friends
end
render(conn, UserView, "index.json", %{users: friends, for: conn.assigns[:user]}) render(conn, UserView, "index.json", %{users: friends, for: conn.assigns[:user]})
else else
_e -> bad_request_reply(conn, "Can't get friends") _e -> bad_request_reply(conn, "Can't get friends")
@ -429,7 +478,7 @@ def raw_empty_array(conn, _params) do
defp build_info_cng(user, params) do defp build_info_cng(user, params) do
info_params = info_params =
["no_rich_text", "locked"] ["no_rich_text", "locked", "hide_network"]
|> Enum.reduce(%{}, fn key, res -> |> Enum.reduce(%{}, fn key, res ->
if value = params[key] do if value = params[key] do
Map.put(res, key, value == "true") Map.put(res, key, value == "true")

View File

@ -221,7 +221,7 @@ def get_template_from_xml(body) do
def find_lrdd_template(domain) do def find_lrdd_template(domain) do
with {:ok, %{status: status, body: body}} when status in 200..299 <- with {:ok, %{status: status, body: body}} when status in 200..299 <-
@httpoison.get("http://#{domain}/.well-known/host-meta", [], follow_redirect: true) do @httpoison.get("http://#{domain}/.well-known/host-meta", []) do
get_template_from_xml(body) get_template_from_xml(body)
else else
_ -> _ ->

View File

@ -264,11 +264,6 @@ def publish_one(%{xml: xml, topic: topic, callback: callback, secret: secret}) d
[ [
{"Content-Type", "application/atom+xml"}, {"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"} {"X-Hub-Signature", "sha1=#{signature}"}
],
adapter: [
timeout: 10000,
recv_timeout: 20000,
pool: :default
] ]
) do ) do
Logger.info(fn -> "Pushed to #{callback}, code #{code}" end) Logger.info(fn -> "Pushed to #{callback}, code #{code}" end)

View File

@ -68,7 +68,8 @@ defp deps do
{:crypt, {:crypt,
git: "https://github.com/msantos/crypt", ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"}, git: "https://github.com/msantos/crypt", ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"},
{:cors_plug, "~> 1.5"}, {:cors_plug, "~> 1.5"},
{:ex_doc, "> 0.18.3 and < 0.20.0", only: :dev, runtime: false} {:ex_doc, "> 0.18.3 and < 0.20.0", only: :dev, runtime: false},
{:web_push_encryption, "~> 0.2.1"}
] ]
end end

View File

@ -1,4 +1,5 @@
%{ %{
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, "cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
"calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
@ -25,6 +26,7 @@
"httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
"makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"meck": {:hex, :meck, "0.8.9", "64c5c0bd8bcca3a180b44196265c8ed7594e16bcc845d0698ec6b4e577f48188", [:rebar3], [], "hexpm"}, "meck": {:hex, :meck, "0.8.9", "64c5c0bd8bcca3a180b44196265c8ed7594e16bcc845d0698ec6b4e577f48188", [:rebar3], [], "hexpm"},
@ -52,4 +54,5 @@
"tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
"unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"}, "unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"},
"web_push_encryption": {:hex, :web_push_encryption, "0.2.1", "d42cecf73420d9dc0053ba3299cc8c8d6ff2be2487d67ca2a57265868e4d9a98", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
} }

View File

@ -0,0 +1,18 @@
defmodule Pleroma.Repo.Migrations.CreatePushSubscriptions do
use Ecto.Migration
def change do
create table("push_subscriptions") do
add :user_id, references("users", on_delete: :delete_all)
add :token_id, references("oauth_tokens", on_delete: :delete_all)
add :endpoint, :string
add :key_p256dh, :string
add :key_auth, :string
add :data, :map
timestamps()
end
create index("push_subscriptions", [:user_id, :token_id], unique: true)
end
end

View File

@ -15,7 +15,7 @@ test "turns hashtags into links" do
text = "I love #cofe and #2hu" text = "I love #cofe and #2hu"
expected_text = expected_text =
"I love <a href='http://localhost:4001/tag/cofe' rel='tag'>#cofe</a> and <a href='http://localhost:4001/tag/2hu' rel='tag'>#2hu</a>" "I love <a data-tag='cofe' href='http://localhost:4001/tag/cofe' rel='tag'>#cofe</a> and <a data-tag='2hu' href='http://localhost:4001/tag/2hu' rel='tag'>#2hu</a>"
tags = Formatter.parse_tags(text) tags = Formatter.parse_tags(text)
@ -128,11 +128,11 @@ test "gives a replacement for user links" do
Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end) Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end)
expected_text = expected_text =
"<span><a class='mention' href='#{gsimg.ap_id}'>@<span>gsimg</span></a></span> According to <span><a class='mention' href='#{ "<span><a data-user='#{gsimg.id}' class='mention' href='#{gsimg.ap_id}'>@<span>gsimg</span></a></span> According to <span><a data-user='#{
"https://archeme/@archaeme" archaeme.id
}'>@<span>archaeme</span></a></span>, that is @daggsy. Also hello <span><a class='mention' href='#{ }' class='mention' href='#{"https://archeme/@archaeme"}'>@<span>archaeme</span></a></span>, that is @daggsy. Also hello <span><a data-user='#{
archaeme_remote.ap_id archaeme_remote.id
}'>@<span>archaeme</span></a></span>" }' class='mention' href='#{archaeme_remote.ap_id}'>@<span>archaeme</span></a></span>"
assert expected_text == Formatter.finalize({subs, text}) assert expected_text == Formatter.finalize({subs, text})
end end
@ -150,7 +150,7 @@ test "gives a replacement for user links when the user is using Osada" do
Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end) Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end)
expected_text = expected_text =
"<span><a class='mention' href='#{mike.ap_id}'>@<span>mike</span></a></span> test" "<span><a data-user='#{mike.id}' class='mention' href='#{mike.ap_id}'>@<span>mike</span></a></span> test"
assert expected_text == Formatter.finalize({subs, text}) assert expected_text == Formatter.finalize({subs, text})
end end
@ -166,7 +166,9 @@ test "gives a replacement for single-character local nicknames" do
assert length(subs) == 1 assert length(subs) == 1
Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end) Enum.each(subs, fn {uuid, _} -> assert String.contains?(text, uuid) end)
expected_text = "<span><a class='mention' href='#{o.ap_id}'>@<span>o</span></a></span> hi" expected_text =
"<span><a data-user='#{o.id}' class='mention' href='#{o.ap_id}'>@<span>o</span></a></span> hi"
assert expected_text == Formatter.finalize({subs, text}) assert expected_text == Formatter.finalize({subs, text})
end end

View File

@ -0,0 +1,56 @@
defmodule Pleroma.Plugs.OAuthPlugTest do
use Pleroma.Web.ConnCase, async: true
alias Pleroma.Plugs.OAuthPlug
import Pleroma.Factory
@session_opts [
store: :cookie,
key: "_test",
signing_salt: "cooldude"
]
setup %{conn: conn} do
user = insert(:user)
{:ok, %{token: token}} = Pleroma.Web.OAuth.Token.create_token(insert(:oauth_app), user)
%{user: user, token: token, conn: conn}
end
test "with valid token(uppercase), it assigns the user", %{conn: conn} = opts do
conn =
conn
|> put_req_header("authorization", "BEARER #{opts[:token]}")
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
test "with valid token(downcase), it assigns the user", %{conn: conn} = opts do
conn =
conn
|> put_req_header("authorization", "bearer #{opts[:token]}")
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
test "with invalid token, it not assigns the user", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "bearer TTTTT")
|> OAuthPlug.call(%{})
refute conn.assigns[:user]
end
test "when token is missed but token in session, it assigns the user", %{conn: conn} = opts do
conn =
conn
|> Plug.Session.call(Plug.Session.init(@session_opts))
|> fetch_session()
|> put_session(:oauth_token, opts[:token])
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
end

View File

@ -36,6 +36,23 @@ defmodule Pleroma.DataCase do
:ok :ok
end end
def ensure_local_uploader(_context) do
uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
filters = Pleroma.Config.get([Pleroma.Upload, :filters])
unless uploader == Pleroma.Uploaders.Local || filters != [] do
Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
Pleroma.Config.put([Pleroma.Upload, :filters], [])
on_exit(fn ->
Pleroma.Config.put([Pleroma.Upload, :uploader], uploader)
Pleroma.Config.put([Pleroma.Upload, :filters], filters)
end)
end
:ok
end
@doc """ @doc """
A helper that transform changeset errors to a map of messages. A helper that transform changeset errors to a map of messages.

View File

@ -3,22 +3,7 @@ defmodule Pleroma.UploadTest do
use Pleroma.DataCase use Pleroma.DataCase
describe "Storing a file with the Local uploader" do describe "Storing a file with the Local uploader" do
setup do setup [:ensure_local_uploader]
uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
filters = Pleroma.Config.get([Pleroma.Upload, :filters])
unless uploader == Pleroma.Uploaders.Local || filters != [] do
Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local)
Pleroma.Config.put([Pleroma.Upload, :filters], [])
on_exit(fn ->
Pleroma.Config.put([Pleroma.Upload, :uploader], uploader)
Pleroma.Config.put([Pleroma.Upload, :filters], filters)
end)
end
:ok
end
test "returns a media url" do test "returns a media url" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")

View File

@ -150,6 +150,20 @@ test "it returns the followers in a collection", %{conn: conn} do
assert result["first"]["orderedItems"] == [user.ap_id] assert result["first"]["orderedItems"] == [user.ap_id]
end end
test "it returns returns empty if the user has 'hide_network' set", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, %{info: %{hide_network: true}})
User.follow(user, user_two)
result =
conn
|> get("/users/#{user_two.nickname}/followers")
|> json_response(200)
assert result["first"]["orderedItems"] == []
assert result["totalItems"] == 1
end
test "it works for more than 10 users", %{conn: conn} do test "it works for more than 10 users", %{conn: conn} do
user = insert(:user) user = insert(:user)
@ -191,6 +205,20 @@ test "it returns the following in a collection", %{conn: conn} do
assert result["first"]["orderedItems"] == [user_two.ap_id] assert result["first"]["orderedItems"] == [user_two.ap_id]
end end
test "it returns returns empty if the user has 'hide_network' set", %{conn: conn} do
user = insert(:user, %{info: %{hide_network: true}})
user_two = insert(:user)
User.follow(user, user_two)
result =
conn
|> get("/users/#{user.nickname}/following")
|> json_response(200)
assert result["first"]["orderedItems"] == []
assert result["totalItems"] == 1
end
test "it works for more than 10 users", %{conn: conn} do test "it works for more than 10 users", %{conn: conn} do
user = insert(:user) user = insert(:user)

View File

@ -2,7 +2,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
use Pleroma.Web.ConnCase use Pleroma.Web.ConnCase
alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.TwitterAPI.TwitterAPI
alias Pleroma.{Repo, User, Activity, Notification} alias Pleroma.{Repo, User, Object, Activity, Notification}
alias Pleroma.Web.{OStatus, CommonAPI} alias Pleroma.Web.{OStatus, CommonAPI}
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -590,7 +590,7 @@ test "list of notifications", %{conn: conn} do
|> get("/api/v1/notifications") |> get("/api/v1/notifications")
expected_response = expected_response =
"hi <span><a href=\"#{user.ap_id}\">@<span>#{user.nickname}</span></a></span>" "hi <span><a data-user=\"#{user.id}\" href=\"#{user.ap_id}\">@<span>#{user.nickname}</span></a></span>"
assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200)
assert response == expected_response assert response == expected_response
@ -611,7 +611,7 @@ test "getting a single notification", %{conn: conn} do
|> get("/api/v1/notifications/#{notification.id}") |> get("/api/v1/notifications/#{notification.id}")
expected_response = expected_response =
"hi <span><a href=\"#{user.ap_id}\">@<span>#{user.nickname}</span></a></span>" "hi <span><a data-user=\"#{user.id}\" href=\"#{user.ap_id}\">@<span>#{user.nickname}</span></a></span>"
assert %{"status" => %{"content" => response}} = json_response(conn, 200) assert %{"status" => %{"content" => response}} = json_response(conn, 200)
assert response == expected_response assert response == expected_response
@ -810,7 +810,7 @@ test "gets an users media", %{conn: conn} do
} }
media = media =
TwitterAPI.upload(file, "json") TwitterAPI.upload(file, user, "json")
|> Poison.decode!() |> Poison.decode!()
{:ok, image_post} = {:ok, image_post} =
@ -965,6 +965,10 @@ test "media upload", %{conn: conn} do
assert media["type"] == "image" assert media["type"] == "image"
assert media["description"] == desc assert media["description"] == desc
assert media["id"]
object = Repo.get(Object, media["id"])
assert object.data["actor"] == User.ap_id(user)
end end
test "hashtag timeline", %{conn: conn} do test "hashtag timeline", %{conn: conn} do
@ -1008,6 +1012,31 @@ test "getting followers", %{conn: conn} do
assert id == to_string(user.id) assert id == to_string(user.id)
end end
test "getting followers, hide_network", %{conn: conn} do
user = insert(:user)
other_user = insert(:user, %{info: %{hide_network: true}})
{:ok, user} = User.follow(user, other_user)
conn =
conn
|> get("/api/v1/accounts/#{other_user.id}/followers")
assert [] == json_response(conn, 200)
end
test "getting followers, hide_network, same user requesting", %{conn: conn} do
user = insert(:user)
other_user = insert(:user, %{info: %{hide_network: true}})
{:ok, user} = User.follow(user, other_user)
conn =
conn
|> assign(:user, other_user)
|> get("/api/v1/accounts/#{other_user.id}/followers")
refute [] == json_response(conn, 200)
end
test "getting following", %{conn: conn} do test "getting following", %{conn: conn} do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
@ -1021,6 +1050,31 @@ test "getting following", %{conn: conn} do
assert id == to_string(other_user.id) assert id == to_string(other_user.id)
end end
test "getting following, hide_network", %{conn: conn} do
user = insert(:user, %{info: %{hide_network: true}})
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
conn =
conn
|> get("/api/v1/accounts/#{user.id}/following")
assert [] == json_response(conn, 200)
end
test "getting following, hide_network, same user requesting", %{conn: conn} do
user = insert(:user, %{info: %{hide_network: true}})
other_user = insert(:user)
{:ok, user} = User.follow(user, other_user)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/accounts/#{user.id}/following")
refute [] == json_response(conn, 200)
end
test "following / unfollowing a user", %{conn: conn} do test "following / unfollowing a user", %{conn: conn} do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)
@ -1271,9 +1325,9 @@ test "updates the user's bio", %{conn: conn} do
assert user = json_response(conn, 200) assert user = json_response(conn, 200)
assert user["note"] == assert user["note"] ==
"I drink <a href=\"http://localhost:4001/tag/cofe\">#cofe</a> with <span><a href=\"#{ "I drink <a data-tag=\"cofe\" href=\"http://localhost:4001/tag/cofe\">#cofe</a> with <span><a data-user=\"#{
user2.ap_id user2.id
}\">@<span>#{user2.nickname}</span></a></span>" }\" href=\"#{user2.ap_id}\">@<span>#{user2.nickname}</span></a></span>"
end end
test "updates the user's locking status", %{conn: conn} do test "updates the user's locking status", %{conn: conn} do

View File

@ -861,6 +861,67 @@ test "it returns a user's followers", %{conn: conn} do
result = json_response(conn, 200) result = json_response(conn, 200)
assert Enum.sort(expected) == Enum.sort(result) assert Enum.sort(expected) == Enum.sort(result)
end end
test "it returns a given user's followers with user_id", %{conn: conn} do
user = insert(:user)
follower_one = insert(:user)
follower_two = insert(:user)
not_follower = insert(:user)
{:ok, follower_one} = User.follow(follower_one, user)
{:ok, follower_two} = User.follow(follower_two, user)
conn =
conn
|> assign(:user, not_follower)
|> get("/api/statuses/followers", %{"user_id" => user.id})
assert MapSet.equal?(
MapSet.new(json_response(conn, 200)),
MapSet.new(
UserView.render("index.json", %{
users: [follower_one, follower_two],
for: not_follower
})
)
)
end
test "it returns empty for a hidden network", %{conn: conn} do
user = insert(:user, %{info: %{hide_network: true}})
follower_one = insert(:user)
follower_two = insert(:user)
not_follower = insert(:user)
{:ok, follower_one} = User.follow(follower_one, user)
{:ok, follower_two} = User.follow(follower_two, user)
conn =
conn
|> assign(:user, not_follower)
|> get("/api/statuses/followers", %{"user_id" => user.id})
assert [] == json_response(conn, 200)
end
test "it returns the followers for a hidden network if requested by the user themselves", %{
conn: conn
} do
user = insert(:user, %{info: %{hide_network: true}})
follower_one = insert(:user)
follower_two = insert(:user)
not_follower = insert(:user)
{:ok, follower_one} = User.follow(follower_one, user)
{:ok, follower_two} = User.follow(follower_two, user)
conn =
conn
|> assign(:user, user)
|> get("/api/statuses/followers", %{"user_id" => user.id})
refute [] == json_response(conn, 200)
end
end end
describe "GET /api/statuses/friends" do describe "GET /api/statuses/friends" do
@ -905,6 +966,42 @@ test "it returns a given user's friends with user_id", %{conn: conn} do
) )
end end
test "it returns empty for a hidden network", %{conn: conn} do
user = insert(:user, %{info: %{hide_network: true}})
followed_one = insert(:user)
followed_two = insert(:user)
not_followed = insert(:user)
{:ok, user} = User.follow(user, followed_one)
{:ok, user} = User.follow(user, followed_two)
conn =
conn
|> assign(:user, not_followed)
|> get("/api/statuses/friends", %{"user_id" => user.id})
assert [] == json_response(conn, 200)
end
test "it returns friends for a hidden network if the user themselves request it", %{
conn: conn
} do
user = insert(:user, %{info: %{hide_network: true}})
followed_one = insert(:user)
followed_two = insert(:user)
not_followed = insert(:user)
{:ok, user} = User.follow(user, followed_one)
{:ok, user} = User.follow(user, followed_two)
conn =
conn
|> assign(:user, user)
|> get("/api/statuses/friends", %{"user_id" => user.id})
refute [] == json_response(conn, 200)
end
test "it returns a given user's friends with screen_name", %{conn: conn} do test "it returns a given user's friends with screen_name", %{conn: conn} do
user = insert(:user) user = insert(:user)
followed_one = insert(:user) followed_one = insert(:user)
@ -969,11 +1066,37 @@ test "it updates a user's profile", %{conn: conn} do
assert user.name == "new name" assert user.name == "new name"
assert user.bio == assert user.bio ==
"hi <span><a class='mention' href='#{user2.ap_id}'>@<span>#{user2.nickname}</span></a></span>" "hi <span><a data-user='#{user2.id}' class='mention' href='#{user2.ap_id}'>@<span>#{
user2.nickname
}</span></a></span>"
assert json_response(conn, 200) == UserView.render("user.json", %{user: user, for: user}) assert json_response(conn, 200) == UserView.render("user.json", %{user: user, for: user})
end end
test "it sets and un-sets hide_network", %{conn: conn} do
user = insert(:user)
conn
|> assign(:user, user)
|> post("/api/account/update_profile.json", %{
"hide_network" => "true"
})
user = Repo.get!(User, user.id)
assert user.info.hide_network == true
conn =
conn
|> assign(:user, user)
|> post("/api/account/update_profile.json", %{
"hide_network" => "false"
})
user = Repo.get!(User, user.id)
assert user.info.hide_network == false
assert json_response(conn, 200) == UserView.render("user.json", %{user: user, for: user})
end
test "it locks an account", %{conn: conn} do test "it locks an account", %{conn: conn} do
user = insert(:user) user = insert(:user)
@ -1253,4 +1376,82 @@ test "it returns users, ordered by similarity", %{conn: conn} do
assert [user.id, user_two.id, user_three.id] == Enum.map(resp, fn %{"id" => id} -> id end) assert [user.id, user_two.id, user_three.id] == Enum.map(resp, fn %{"id" => id} -> id end)
end end
end end
describe "POST /api/media/upload" do
setup context do
Pleroma.DataCase.ensure_local_uploader(context)
end
test "it performs the upload and sets `data[actor]` with AP id of uploader user", %{
conn: conn
} do
user = insert(:user)
upload_filename = "test/fixtures/image_tmp.jpg"
File.cp!("test/fixtures/image.jpg", upload_filename)
file = %Plug.Upload{
content_type: "image/jpg",
path: Path.absname(upload_filename),
filename: "image.jpg"
}
response =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/octet-stream")
|> post("/api/media/upload", %{
"media" => file
})
|> json_response(:ok)
assert response["media_id"]
object = Repo.get(Object, response["media_id"])
assert object
assert object.data["actor"] == User.ap_id(user)
end
end
describe "POST /api/media/metadata/create" do
setup do
object = insert(:note)
user = User.get_by_ap_id(object.data["actor"])
%{object: object, user: user}
end
test "it returns :forbidden status on attempt to modify someone else's upload", %{
conn: conn,
object: object
} do
initial_description = object.data["name"]
another_user = insert(:user)
conn
|> assign(:user, another_user)
|> post("/api/media/metadata/create", %{"media_id" => object.id})
|> json_response(:forbidden)
object = Repo.get(Object, object.id)
assert object.data["name"] == initial_description
end
test "it updates `data[name]` of referenced Object with provided value", %{
conn: conn,
object: object,
user: user
} do
description = "Informative description of the image. Initial value: #{object.data["name"]}}"
conn
|> assign(:user, user)
|> post("/api/media/metadata/create", %{
"media_id" => object.id,
"alt_text" => %{"text" => description}
})
|> json_response(:no_content)
object = Repo.get(Object, object.id)
assert object.data["name"] == description
end
end
end end

View File

@ -10,7 +10,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
test "create a status" do test "create a status" do
user = insert(:user) user = insert(:user)
_mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"}) mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"})
object_data = %{ object_data = %{
"type" => "Image", "type" => "Image",
@ -35,7 +35,7 @@ test "create a status" do
{:ok, activity = %Activity{}} = TwitterAPI.create_status(user, input) {:ok, activity = %Activity{}} = TwitterAPI.create_status(user, input)
expected_text = expected_text =
"Hello again, <span><a class='mention' href='shp'>@<span>shp</span></a></span>.&lt;script&gt;&lt;/script&gt;<br>This is on another :moominmamma: line. <a href='http://localhost:4001/tag/2hu' rel='tag'>#2hu</a> <a href='http://localhost:4001/tag/epic' rel='tag'>#epic</a> <a href='http://localhost:4001/tag/phantasmagoric' rel='tag'>#phantasmagoric</a><br><a href=\"http://example.org/image.jpg\" class='attachment'>image.jpg</a>" "Hello again, <span><a data-user='#{mentioned_user.id}' class='mention' href='shp'>@<span>shp</span></a></span>.&lt;script&gt;&lt;/script&gt;<br>This is on another :moominmamma: line. <a data-tag='2hu' href='http://localhost:4001/tag/2hu' rel='tag'>#2hu</a> <a data-tag='epic' href='http://localhost:4001/tag/epic' rel='tag'>#epic</a> <a data-tag='phantasmagoric' href='http://localhost:4001/tag/phantasmagoric' rel='tag'>#phantasmagoric</a><br><a href=\"http://example.org/image.jpg\" class='attachment'>image.jpg</a>"
assert get_in(activity.data, ["object", "content"]) == expected_text assert get_in(activity.data, ["object", "content"]) == expected_text
assert get_in(activity.data, ["object", "type"]) == "Note" assert get_in(activity.data, ["object", "type"]) == "Note"
@ -182,13 +182,15 @@ test "Unblock another user using screen_name" do
end end
test "upload a file" do test "upload a file" do
user = insert(:user)
file = %Plug.Upload{ file = %Plug.Upload{
content_type: "image/jpg", content_type: "image/jpg",
path: Path.absname("test/fixtures/image.jpg"), path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg" filename: "an_image.jpg"
} }
response = TwitterAPI.upload(file) response = TwitterAPI.upload(file, user)
assert is_binary(response) assert is_binary(response)
end end
@ -281,7 +283,7 @@ test "it registers a new user and parses mentions in the bio" do
{:ok, user2} = TwitterAPI.register_user(data2) {:ok, user2} = TwitterAPI.register_user(data2)
expected_text = expected_text =
"<span><a class='mention' href='#{user1.ap_id}'>@<span>john</span></a></span> test" "<span><a data-user='#{user1.id}' class='mention' href='#{user1.ap_id}'>@<span>john</span></a></span> test"
assert user2.bio == expected_text assert user2.bio == expected_text
end end

View File

@ -47,7 +47,7 @@ test "a create activity with a note" do
"repeated" => false, "repeated" => false,
"statusnet_conversation_id" => convo_id, "statusnet_conversation_id" => convo_id,
"statusnet_html" => "statusnet_html" =>
"Hey <span><a href=\"#{other_user.ap_id}\">@<span>shp</span></a></span>!", "Hey <span><a data-user=\"#{other_user.id}\" href=\"#{other_user.ap_id}\">@<span>shp</span></a></span>!",
"tags" => [], "tags" => [],
"text" => "Hey @shp!", "text" => "Hey @shp!",
"uri" => activity.data["object"]["id"], "uri" => activity.data["object"]["id"],