Merge remote-tracking branch 'origin/develop' into shigusegubu

This commit is contained in:
Henry Jameson 2026-01-03 02:01:33 +02:00
commit 8d82808d7a
632 changed files with 7519 additions and 1314 deletions

View file

@ -7,7 +7,7 @@ defmodule Pleroma.Activity.Queries do
Contains queries for Activity.
"""
import Ecto.Query, only: [from: 2, where: 3]
import Ecto.Query, only: [from: 2]
@type query :: Ecto.Queryable.t() | Pleroma.Activity.t()
@ -70,22 +70,6 @@ defmodule Pleroma.Activity.Queries do
)
end
@spec by_object_in_reply_to_id(query, String.t(), keyword()) :: query
def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do
query =
if opts[:skip_preloading] do
Activity.with_joined_object(query)
else
Activity.with_preloaded_object(query)
end
where(
query,
[activity, object: o],
fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id))
)
end
@spec by_type(query, String.t()) :: query
def by_type(query \\ Activity, activity_type) do
from(

View file

@ -25,6 +25,8 @@ defmodule Pleroma.Chat do
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
field(:recipient, :string)
field(:pinned, :boolean)
timestamps()
end
@ -94,4 +96,16 @@ defmodule Pleroma.Chat do
order_by: [desc: c.updated_at]
)
end
def pin(%__MODULE__{} = chat) do
chat
|> cast(%{pinned: true}, [:pinned])
|> Repo.update()
end
def unpin(%__MODULE__{} = chat) do
chat
|> cast(%{pinned: false}, [:pinned])
|> Repo.update()
end
end

View file

@ -21,7 +21,8 @@ defmodule Pleroma.Constants do
"pleroma_internal",
"generator",
"rules",
"language"
"language",
"voters"
]
)

View file

@ -157,6 +157,16 @@ defmodule Pleroma.FollowingRelationship do
|> Repo.all()
end
def get_outgoing_follow_requests(%User{id: id}) do
__MODULE__
|> join(:inner, [r], f in assoc(r, :following))
|> where([r], r.state == ^:follow_pending)
|> where([r], r.follower_id == ^id)
|> where([r, f], f.is_active == true)
|> select([r, f], f)
|> Repo.all()
end
def following?(%User{id: follower_id}, %User{id: followed_id}) do
__MODULE__
|> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept)

View file

@ -131,31 +131,4 @@ defmodule Pleroma.HTTP do
defp default_middleware,
do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl]
def encode_url(url) when is_binary(url) do
URI.parse(url)
|> then(fn parsed ->
path = encode_path(parsed.path)
query = encode_query(parsed.query)
%{parsed | path: path, query: query}
end)
|> URI.to_string()
end
defp encode_path(nil), do: nil
defp encode_path(path) when is_binary(path) do
path
|> URI.decode()
|> URI.encode()
end
defp encode_query(nil), do: nil
defp encode_query(query) when is_binary(query) do
query
|> URI.decode_query()
|> URI.encode_query()
end
end

View file

@ -16,7 +16,12 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do
config_opts = Pleroma.Config.get([:http, :adapter], [])
url_encoding =
Keyword.new()
|> Keyword.put(:path_encode_fun, fn path -> path end)
@defaults
|> Keyword.merge(url_encoding)
|> Keyword.merge(config_opts)
|> Keyword.merge(connection_opts)
|> add_scheme_opts(uri)

View file

@ -0,0 +1,109 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Language.Translation.Mozhi do
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
alias Pleroma.Language.Translation.Provider
use Provider
@behaviour Provider
@name "Mozhi"
@impl Provider
def configured?, do: not_empty_string(base_url()) and not_empty_string(engine())
@impl Provider
def translate(content, source_language, target_language) do
endpoint =
base_url()
|> URI.merge("/api/translate")
|> URI.to_string()
case Pleroma.HTTP.get(
endpoint <>
"?" <>
URI.encode_query(%{
engine: engine(),
text: content,
from: source_language,
to: target_language
}),
[{"Accept", "application/json"}]
) do
{:ok, %{status: 200} = res} ->
%{
"translated-text" => content,
"source_language" => source_language
} = Jason.decode!(res.body)
{:ok,
%{
content: content,
detected_source_language: source_language,
provider: @name
}}
_ ->
{:error, :internal_server_error}
end
end
@impl Provider
def supported_languages(type) when type in [:source, :target] do
path =
case type do
:source -> "/api/source_languages"
:target -> "/api/target_languages"
end
endpoint =
base_url()
|> URI.merge(path)
|> URI.to_string()
case Pleroma.HTTP.get(
endpoint <>
"?" <>
URI.encode_query(%{
engine: engine()
}),
[{"Accept", "application/json"}]
) do
{:ok, %{status: 200} = res} ->
languages =
Jason.decode!(res.body)
|> Enum.map(fn %{"Id" => language} -> language end)
{:ok, languages}
_ ->
{:error, :internal_server_error}
end
end
@impl Provider
def languages_matrix do
with {:ok, source_languages} <- supported_languages(:source),
{:ok, target_languages} <- supported_languages(:target) do
{:ok,
Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
else
{:error, error} -> {:error, error}
end
end
@impl Provider
def name, do: @name
defp base_url do
Pleroma.Config.get([__MODULE__, :base_url])
end
defp engine do
Pleroma.Config.get([__MODULE__, :engine])
end
end

View file

@ -0,0 +1,129 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Language.Translation.TranslateLocally do
alias Pleroma.Language.Translation.Provider
use Provider
@behaviour Provider
@name "translateLocally"
@impl Provider
def missing_dependencies do
if Pleroma.Utils.command_available?("translateLocally") do
[]
else
["translateLocally"]
end
end
@impl Provider
def configured?, do: is_map(models())
@impl Provider
def translate(content, source_language, target_language) do
model =
models()
|> Map.get(source_language, %{})
|> Map.get(target_language)
models =
if model do
[model]
else
[
models()
|> Map.get(source_language, %{})
|> Map.get(intermediary_language()),
models()
|> Map.get(intermediary_language(), %{})
|> Map.get(target_language)
]
end
translated_content =
Enum.reduce(models, content, fn model, content ->
text_path = Path.join(System.tmp_dir!(), "translateLocally-#{Ecto.UUID.generate()}")
File.write(text_path, content)
translated_content =
case System.cmd("translateLocally", ["-m", model, "-i", text_path, "--html"]) do
{content, _} -> content
_ -> nil
end
File.rm(text_path)
translated_content
end)
{:ok,
%{
content: translated_content,
detected_source_language: source_language,
provider: @name
}}
end
@impl Provider
def supported_languages(:source) do
languages =
languages_matrix()
|> elem(1)
|> Map.keys()
{:ok, languages}
end
@impl Provider
def supported_languages(:target) do
languages =
languages_matrix()
|> elem(1)
|> Map.values()
|> List.flatten()
|> Enum.uniq()
{:ok, languages}
end
@impl Provider
def languages_matrix do
languages =
models()
|> Map.to_list()
|> Enum.map(fn {key, value} -> {key, Map.keys(value)} end)
|> Enum.into(%{})
matrix =
if intermediary_language() do
languages
|> Map.to_list()
|> Enum.map(fn {key, value} ->
with_intermediary =
(((value ++ languages[intermediary_language()])
|> Enum.uniq()) --
[key])
|> Enum.sort()
{key, with_intermediary}
end)
|> Enum.into(%{})
else
languages
end
{:ok, matrix}
end
@impl Provider
def name, do: @name
defp models, do: Pleroma.Config.get([__MODULE__, :models])
defp intermediary_language, do: Pleroma.Config.get([__MODULE__, :intermediary_language])
end

View file

@ -575,6 +575,12 @@ defmodule Pleroma.ModerationLog do
"@#{actor_nickname} requested account backup for @#{user_nickname}"
end
def get_log_entry_message(%ModerationLog{data: data}) do
actor_name = get_in(data, ["actor", "nickname"]) || "unknown"
action = data["action"] || "unknown"
"@#{actor_name} performed action #{action}"
end
defp nicknames_to_string(nicknames) do
nicknames
|> Enum.map(&"@#{&1}")

View file

@ -74,6 +74,7 @@ defmodule Pleroma.Notification do
reblog
poll
status
update
}
def changeset(%Notification{} = notification, attrs) do
@ -281,10 +282,15 @@ defmodule Pleroma.Notification do
select: n.id
)
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
{:ok, %{marker: marker}} =
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
Streamer.stream(["user", "user:notification"], marker)
{:ok, %{marker: marker}}
end
@spec read_one(User.t(), String.t()) ::
@ -525,9 +531,7 @@ defmodule Pleroma.Notification do
%Activity{data: %{"type" => "Create"}} = activity,
local_only
) do
notification_enabled_ap_ids =
[]
|> Utils.maybe_notify_subscribers(activity)
notification_enabled_ap_ids = Utils.get_notified_subscribers(activity)
potential_receivers =
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)

View file

@ -126,7 +126,7 @@ defmodule Pleroma.Object do
Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}")
end
def normalize(_, options \\ [fetch: false, id_only: false])
def normalize(_, options \\ [fetch: false])
# If we pass an Activity to Object.normalize(), we can try to use the preloaded object.
# Use this whenever possible, especially when walking graphs in an O(N) loop!
@ -155,9 +155,6 @@ defmodule Pleroma.Object do
def normalize(ap_id, options) when is_binary(ap_id) do
cond do
Keyword.get(options, :id_only) ->
ap_id
Keyword.get(options, :fetch) ->
case Fetcher.fetch_object_from_id(ap_id, options) do
{:ok, object} -> object
@ -401,28 +398,6 @@ defmodule Pleroma.Object do
String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
end
def replies(object, opts \\ []) do
object = Object.normalize(object, fetch: false)
query =
Object
|> where(
[o],
fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
)
|> order_by([o], asc: o.id)
if opts[:self_only] do
actor = object.data["actor"]
where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
else
query
end
end
def self_replies(object, opts \\ []),
do: replies(object, Keyword.put(opts, :self_only, true))
def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
def tags(_), do: []

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Object.Updater do
require Pleroma.Constants
alias Pleroma.Maps
alias Pleroma.Object
alias Pleroma.Repo
@ -115,6 +116,7 @@ defmodule Pleroma.Object.Updater do
# Choices are the same, but counts are different
to_be_updated
|> Map.put(key, updated_object[key])
|> Maps.put_if_present("votersCount", updated_object["votersCount"])
else
# Choices (or vote type) have changed, do not allow this
_ -> to_be_updated

View file

@ -95,13 +95,30 @@ defmodule Pleroma.Pagination do
offset: :integer,
limit: :integer,
skip_extra_order: :boolean,
skip_order: :boolean
skip_order: :boolean,
order_asc: :boolean
}
changeset = cast({%{}, param_types}, params, Map.keys(param_types))
changeset.changes
end
defp order_statement(query, table_binding, :asc) do
order_by(
query,
[{u, table_position(query, table_binding)}],
fragment("? asc nulls last", u.id)
)
end
defp order_statement(query, table_binding, :desc) do
order_by(
query,
[{u, table_position(query, table_binding)}],
fragment("? desc nulls last", u.id)
)
end
defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do
where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id)
end
@ -119,19 +136,16 @@ defmodule Pleroma.Pagination do
defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query
defp restrict(query, :order, %{min_id: _}, table_binding) do
order_by(
query,
[{u, table_position(query, table_binding)}],
fragment("? asc nulls last", u.id)
)
order_statement(query, table_binding, :asc)
end
defp restrict(query, :order, _options, table_binding) do
order_by(
query,
[{u, table_position(query, table_binding)}],
fragment("? desc nulls last", u.id)
)
defp restrict(query, :order, %{max_id: _}, table_binding) do
order_statement(query, table_binding, :desc)
end
defp restrict(query, :order, options, table_binding) do
dir = if options[:order_asc], do: :asc, else: :desc
order_statement(query, table_binding, dir)
end
defp restrict(query, :offset, %{offset: offset}, _table_binding) do
@ -151,11 +165,9 @@ defmodule Pleroma.Pagination do
defp restrict(query, _, _, _), do: query
defp enforce_order(result, %{min_id: _}) do
result
|> Enum.reverse()
end
defp enforce_order(result, %{min_id: _, order_asc: true}), do: result
defp enforce_order(result, %{min_id: _}), do: Enum.reverse(result)
defp enforce_order(result, %{max_id: _, order_asc: true}), do: Enum.reverse(result)
defp enforce_order(result, _), do: result
defp table_position(%Ecto.Query{} = query, binding_name) do

View file

@ -3,6 +3,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxy do
alias Pleroma.Utils.URIEncoding
@range_headers ~w(range if-range)
@keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++
~w(if-unmodified-since if-none-match) ++ @range_headers
@ -155,11 +157,12 @@ defmodule Pleroma.ReverseProxy do
end
defp request(method, url, headers, opts) do
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
method = method |> String.downcase() |> String.to_existing_atom()
url = maybe_encode_url(url)
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
case client().request(method, url, headers, "", opts) do
{:ok, code, headers, client} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers), client}
@ -459,9 +462,9 @@ defmodule Pleroma.ReverseProxy do
# Also do it for test environment
defp maybe_encode_url(url) do
case Application.get_env(:tesla, :adapter) do
Tesla.Adapter.Hackney -> Pleroma.HTTP.encode_url(url)
{Tesla.Adapter.Finch, _} -> Pleroma.HTTP.encode_url(url)
Tesla.Mock -> Pleroma.HTTP.encode_url(url)
Tesla.Adapter.Hackney -> URIEncoding.encode_url(url)
{Tesla.Adapter.Finch, _} -> URIEncoding.encode_url(url)
Tesla.Mock -> URIEncoding.encode_url(url)
_ -> url
end
end

View file

@ -7,6 +7,11 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do
@impl true
def request(method, url, headers, body, opts \\ []) do
opts =
Keyword.put_new(opts, :path_encode_fun, fn path ->
path
end)
:hackney.request(method, url, headers, body, opts)
end

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Signature do
@behaviour Pleroma.Signature.API
@behaviour HTTPSignatures.Adapter
alias Pleroma.EctoType.ActivityPub.ObjectValidators
@ -53,7 +54,7 @@ defmodule Pleroma.Signature do
def fetch_public_key(conn) do
with {:ok, actor_id} <- get_actor_id(conn),
{:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do
{:ok, public_key} <- User.get_or_fetch_public_key_for_ap_id(actor_id) do
{:ok, public_key}
else
e ->

View file

@ -0,0 +1,14 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Signature.API do
@moduledoc """
Behaviour for signing requests and producing HTTP Date headers.
This is used to allow tests to replace the signing implementation with Mox.
"""
@callback sign(user :: Pleroma.User.t(), headers :: map()) :: String.t()
@callback signed_date() :: String.t()
end

View file

@ -17,7 +17,7 @@ defmodule Pleroma.Tesla.Middleware.EncodeUrl do
@impl Tesla.Middleware
def call(%Tesla.Env{url: url} = env, next, _) do
url = Pleroma.HTTP.encode_url(url)
url = Pleroma.Utils.URIEncoding.encode_url(url)
env = %{env | url: url}

View file

@ -35,6 +35,7 @@ defmodule Pleroma.Upload do
"""
alias Ecto.UUID
alias Pleroma.Maps
alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.ActivityPub.Utils
require Logger
@ -230,11 +231,18 @@ defmodule Pleroma.Upload do
tmp_path
end
# Encoding the whole path here is fine since the path is in a
# UUID/<file name> form.
# The file at this point isn't %-encoded, so the path shouldn't
# be decoded first like Pleroma.Utils.URIEncoding.encode_url/1 does.
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
encode_opts = [bypass_decode: true, bypass_parse: true]
path =
URI.encode(path, &char_unescaped?/1) <>
URIEncoding.encode_url(path, encode_opts) <>
if Pleroma.Config.get([__MODULE__, :link_name], false) do
"?name=#{URI.encode(name, &char_unescaped?/1)}"
enum = %{name: name}
"?#{URI.encode_query(enum)}"
else
""
end

View file

@ -233,8 +233,8 @@ defmodule Pleroma.User do
for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <-
@user_relationships_config do
# `def blocked_users_relation/2`, `def muted_users_relation/2`,
# `def reblog_muted_users_relation/2`, `def notification_muted_users/2`,
# `def subscriber_users/2`, `def endorsed_users_relation/2`
# `def reblog_muted_users_relation/2`, `def notification_muted_users_relation/2`,
# `def subscriber_users_relation/2`, `def endorsed_users_relation/2`
def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do
target_users_query = assoc(user, unquote(outgoing_relation_target))
@ -288,6 +288,7 @@ defmodule Pleroma.User do
defdelegate following?(follower, followed), to: FollowingRelationship
defdelegate following_ap_ids(user), to: FollowingRelationship
defdelegate get_follow_requests(user), to: FollowingRelationship
defdelegate get_outgoing_follow_requests(user), to: FollowingRelationship
defdelegate search(query, opts \\ []), to: User.Search
@doc """
@ -801,13 +802,6 @@ defmodule Pleroma.User do
when is_nil(password) do
params = Map.put_new(params, :accepts_chat_messages, true)
params =
if Map.has_key?(params, :email) do
Map.put_new(params, :email, params[:email])
else
params
end
struct
|> cast(params, [
:name,
@ -1364,7 +1358,7 @@ defmodule Pleroma.User do
@spec get_by_nickname(String.t()) :: User.t() | nil
def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname) ||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()}$)i, nickname) do
Repo.get_by(User, nickname: local_nickname(nickname))
end
end
@ -2314,6 +2308,15 @@ defmodule Pleroma.User do
def public_key(_), do: {:error, "key not found"}
def get_or_fetch_public_key_for_ap_id(ap_id) do
with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id),
{:ok, public_key} <- public_key(user) do
{:ok, public_key}
else
_ -> :error
end
end
def get_public_key_for_ap_id(ap_id) do
with %User{} = user <- get_cached_by_ap_id(ap_id),
{:ok, public_key} <- public_key(user) do

View file

@ -16,6 +16,7 @@ defmodule Pleroma.User.Search do
following = Keyword.get(opts, :following, false)
result_limit = Keyword.get(opts, :limit, @limit)
offset = Keyword.get(opts, :offset, 0)
capabilities = Keyword.get(opts, :capabilities, [])
for_user = Keyword.get(opts, :for_user)
@ -32,7 +33,7 @@ defmodule Pleroma.User.Search do
results =
query_string
|> search_query(for_user, following, top_user_ids)
|> search_query(for_user, following, top_user_ids, capabilities)
|> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset)
results
@ -80,7 +81,7 @@ defmodule Pleroma.User.Search do
end
end
defp search_query(query_string, for_user, following, top_user_ids) do
defp search_query(query_string, for_user, following, top_user_ids, capabilities) do
for_user
|> base_query(following)
|> filter_blocked_user(for_user)
@ -94,6 +95,7 @@ defmodule Pleroma.User.Search do
|> subquery()
|> order_by(desc: :search_rank)
|> maybe_restrict_local(for_user)
|> maybe_restrict_accepting_chat_messages(capabilities)
|> filter_deactivated_users()
end
@ -214,6 +216,14 @@ defmodule Pleroma.User.Search do
end
end
defp maybe_restrict_accepting_chat_messages(query, capabilities) do
if "accepts_chat_messages" in capabilities do
from(q in query, where: q.accepts_chat_messages == true)
else
query
end
end
defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated)
defp restrict_local(q), do: where(q, [u], u.local == true)

View file

@ -0,0 +1,142 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2025 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Utils.URIEncoding do
@moduledoc """
Utility functions for dealing with URI encoding of paths and queries
with support for query-encoding quirks.
"""
require Pleroma.Constants
# We don't always want to decode the path first, like is the case in
# Pleroma.Upload.url_from_spec/3.
@doc """
Wraps URI encoding/decoding functions from Elixir's standard library to fix usually unintended side-effects.
Supports two URL processing options in the optional 2nd argument with the default being `false`:
* `bypass_parse` - Bypasses `URI.parse` stage, useful when it's not desirable to parse to URL first
before encoding it. Supports only encoding as the Path segment of a URI.
* `bypass_decode` - Bypasses `URI.decode` stage for the Path segment of a URI. Used when a URL
has to be double %-encoded for internal reasons.
Options must be specified as a Keyword with tuples with booleans, otherwise
`{:error, :invalid_opts}` is returned. Example:
`encode_url(url, [bypass_parse: true, bypass_decode: true])`
"""
@spec encode_url(String.t(), Keyword.t()) :: String.t() | {:error, :invalid_opts}
def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do
bypass_parse = Keyword.get(opts, :bypass_parse, false)
bypass_decode = Keyword.get(opts, :bypass_decode, false)
with true <- is_boolean(bypass_parse),
true <- is_boolean(bypass_decode) do
cond do
bypass_parse ->
encode_path(url, bypass_decode)
true ->
URI.parse(url)
|> then(fn parsed ->
path = encode_path(parsed.path, bypass_decode)
query = encode_query(parsed.query, parsed.host)
%{parsed | path: path, query: query}
end)
|> URI.to_string()
end
else
_ -> {:error, :invalid_opts}
end
end
defp encode_path(nil, _bypass_decode), do: nil
# URI.encode/2 deliberately does not encode all chars that are forbidden
# in the path component of a URI. It only encodes chars that are forbidden
# in the whole URI. A predicate in the 2nd argument is used to fix that here.
# URI.encode/2 uses the predicate function to determine whether each byte
# (in an integer representation) should be encoded or not.
defp encode_path(path, bypass_decode) when is_binary(path) do
path =
cond do
bypass_decode ->
path
true ->
URI.decode(path)
end
path
|> URI.encode(fn byte ->
URI.char_unreserved?(byte) ||
Enum.any?(
Pleroma.Constants.uri_path_allowed_reserved_chars(),
fn char ->
char == byte
end
)
end)
end
# Order of kv pairs in query is not preserved when using URI.decode_query.
# URI.query_decoder/2 returns a stream which so far appears to not change order.
# Immediately switch to a list to prevent breakage for sites that expect
# the order of query keys to be always the same.
defp encode_query(query, host) when is_binary(query) do
query
|> URI.query_decoder()
|> Enum.to_list()
|> do_encode_query(host)
end
defp encode_query(nil, _), do: nil
# Always uses www_form encoding.
# Taken from Elixir's URI module.
defp do_encode_query(enumerable, host) do
Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1, host))
end
# https://git.pleroma.social/pleroma/pleroma/-/issues/1055
defp maybe_apply_query_quirk({key, value}, "i.guim.co.uk" = _host) do
case key do
"precrop" ->
query_encode_kv_pair({key, value}, ~c":,")
key ->
query_encode_kv_pair({key, value})
end
end
defp maybe_apply_query_quirk({key, value}, _), do: query_encode_kv_pair({key, value})
# Taken from Elixir's URI module and modified to support quirks.
defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do
cond do
length(rules) > 0 ->
# URI.encode_query/2 does not appear to follow spec and encodes all parts
# of our URI path Constant. This appears to work outside of edge-cases
# like The Guardian Rich Media Cards, keeping behavior same as with
# URI.encode_query/2 unless otherwise specified via rules.
(URI.encode_www_form(Kernel.to_string(key)) <>
"=" <>
URI.encode(value, fn byte ->
URI.char_unreserved?(byte) ||
Enum.any?(
rules,
fn char ->
char == byte
end
)
end))
|> String.replace("%20", "+")
true ->
URI.encode_www_form(Kernel.to_string(key)) <>
"=" <> URI.encode_www_form(Kernel.to_string(value))
end
end
end

View file

@ -414,10 +414,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local),
{:ok, stripped_activity} <- strip_report_status_data(activity),
_ <- notify_and_stream(activity),
:ok <-
maybe_federate(stripped_activity) do
:ok <- maybe_federate(activity) do
User.all_users_with_privilege(:reports_manage_reports)
|> Enum.filter(fn user -> user.ap_id != actor end)
|> Enum.filter(fn user -> not is_nil(user.email) end)
@ -501,6 +499,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Repo.all()
end
def fetch_objects_for_replies_collection(parent_ap_id, opts \\ %{}) do
opts =
opts
|> Map.put(:order_asc, true)
|> Map.put(:id_type, :integer)
from(o in Object,
where:
fragment("?->>'inReplyTo' = ?", o.data, ^parent_ap_id) and
fragment(
"(?->'to' \\? ?::text OR ?->'cc' \\? ?::text)",
o.data,
^Pleroma.Constants.as_public(),
o.data,
^Pleroma.Constants.as_public()
) and
fragment("?->>'type' <> 'Answer'", o.data),
select: %{id: o.id, ap_id: fragment("?->>'id'", o.data)}
)
|> Pagination.fetch_paginated(opts, :keyset)
end
@spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) ::
Ecto.UUID.t() | nil
def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do
@ -1065,6 +1085,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data))
end
defp restrict_reblogs(query, %{only_reblogs: true}) do
from(activity in query, where: fragment("?->>'type' = 'Announce'", activity.data))
end
defp restrict_reblogs(query, _), do: query
defp restrict_muted(query, %{with_muted: true}), do: query
@ -1567,7 +1591,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp get_actor_url(_url), do: nil
defp normalize_image(%{"url" => url} = data) do
defp normalize_image(%{"url" => url} = data) when is_binary(url) do
%{
"type" => "Image",
"url" => [%{"href" => url}]
@ -1575,6 +1599,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> maybe_put_description(data)
end
defp normalize_image(%{"url" => urls}) when is_list(urls) do
url = urls |> List.first()
%{"url" => url}
|> normalize_image()
end
defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image()
defp normalize_image(_), do: nil

View file

@ -31,6 +31,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
@federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers]
@object_replies_known_param_keys ["page", "min_id", "max_id", "since_id", "limit"]
plug(FederatingPlug when action in @federating_only_actions)
plug(
@ -95,6 +97,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
end
def object_replies(%{assigns: assigns, query_params: params} = conn, _all_params) do
object_ap_id = conn.path_info |> Enum.reverse() |> tl() |> Enum.reverse()
object_ap_id = Endpoint.url() <> "/" <> Enum.join(object_ap_id, "/")
# Most other API params are converted to atoms by OpenAPISpex 3.x
# and therefore helper functions assume atoms. For consistency,
# also convert our params to atoms here.
params =
params
|> Map.take(@object_replies_known_param_keys)
|> Enum.into(%{}, fn {k, v} -> {String.to_existing_atom(k), v} end)
|> Map.put(:object_ap_id, object_ap_id)
|> Map.put(:order_asc, true)
|> Map.put(:conn, conn)
with %Object{} = object <- Object.get_cached_by_ap_id(object_ap_id),
user <- Map.get(assigns, :user, nil),
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
conn
|> maybe_skip_cache(user)
|> set_cache_ttl_for(object)
|> put_resp_content_type("application/activity+json")
|> put_view(ObjectView)
|> render("object_replies.json", render_params: params)
else
{:visible?, false} -> {:error, :not_found}
nil -> {:error, :not_found}
end
end
def track_object_fetch(conn, nil), do: conn
def track_object_fetch(conn, object_id) do
@ -257,8 +289,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
pagination: ControllerHelper.get_pagination_fields(conn, activities),
iri: "#{user.ap_id}/outbox"
pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
end
@ -404,8 +435,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> put_view(UserView)
|> render("activity_collection_page.json", %{
activities: activities,
pagination: ControllerHelper.get_pagination_fields(conn, activities),
iri: "#{user.ap_id}/inbox"
pagination: ControllerHelper.get_pagination_fields(conn, activities)
})
end
@ -482,6 +512,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
{:ok, activity}
end
# We currently lack a Flag ObjectValidator since both CommonAPI and Transmogrifier
# both send it straight to ActivityPub.flag and C2S currently has to go through
# the normal pipeline which requires an ObjectValidator.
# TODO: Add a Flag Activity ObjectValidator
defp check_allowed_action(_, %{"type" => "Flag"}) do
{:error, "Flag activities aren't currently supported in C2S"}
end
# It would respond with 201 and silently fail with:
# Could not decode featured collection at fetch #{user.ap_id} \
# {:error, "Trying to fetch local resource"}
defp check_allowed_action(%{ap_id: ap_id}, %{"type" => "Update", "object" => %{"id" => ap_id}}),
do: {:error, "Updating profile is not currently supported in C2S"}
defp check_allowed_action(_, activity), do: {:ok, activity}
defp validate_visibility(%User{} = user, %{"type" => type, "object" => object} = activity) do
with {_, %Object{} = normalized_object} <-
{:normalize, Object.normalize(object, fetch: false)},
{_, true} <- {:visibility, Visibility.visible_for_user?(normalized_object, user)} do
{:ok, activity}
else
{:normalize, _} ->
if type in ["Create", "Listen"] do
# Creating new object via C2S; user is local and authenticated
# via the :authenticate Plug pipeline.
{:ok, activity}
else
{:error, "No such object found"}
end
{:visibility, _} ->
{:forbidden, "You can't interact with this object"}
end
end
def update_outbox(
%{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn,
%{"nickname" => nickname} = params
@ -493,6 +559,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> Map.put("actor", actor)
with {:ok, params} <- fix_user_message(user, params),
{:ok, params} <- check_allowed_action(user, params),
{:ok, params} <- validate_visibility(user, params),
{:ok, activity, _} <- Pipeline.common_pipeline(params, local: true),
%Activity{data: activity_data} <- Activity.normalize(activity) do
conn

View file

@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do
content =~ quote_url -> true
# Does the content already have a .quote-inline span?
content =~ "<span class=\"quote-inline\">" -> true
# Does the content already have a .quote-inline p? (Mastodon)
content =~ "<p class=\"quote-inline\">" -> true
# No inline quote found
true -> false
end

View file

@ -56,20 +56,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
defp fix_tag(data), do: Map.drop(data, ["tag"])
# legacy internal *oma format
defp fix_replies(%{"replies" => replies} = data) when is_list(replies), do: data
defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data)
when is_list(replies),
do: Map.put(data, "replies", replies)
defp fix_replies(%{"replies" => %{"first" => %{"orderedItems" => replies}}} = data)
when is_list(replies),
do: Map.put(data, "replies", replies)
defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
do: Map.put(data, "replies", replies)
# TODO: Pleroma does not have any support for Collections at the moment.
# If the `replies` field is not something the ObjectID validator can handle,
# the activity/object would be rejected, which is bad behavior.
defp fix_replies(%{"replies" => replies} = data) when not is_list(replies),
do: Map.drop(data, ["replies"])
defp fix_replies(%{"replies" => %{"orderedItems" => replies}} = data) when is_list(replies),
do: Map.put(data, "replies", replies)
defp fix_replies(data), do: data
defp fix_replies(data), do: Map.delete(data, "replies")
def fix_attachments(%{"attachment" => attachment} = data) when is_map(attachment),
do: Map.put(data, "attachment", [attachment])

View file

@ -20,7 +20,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
require Pleroma.Constants
def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do
{:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback)
# Fix as:Public/Public before ObjectID casting drops it, but keep `field_fallback`
# semantics (only used when the field is missing).
recipients =
%{field => message[field] || field_fallback}
|> Transmogrifier.fix_addressing_list(field)
|> Transmogrifier.fix_addressing_public(field)
|> Map.fetch!(field)
{:ok, data} = ObjectValidators.Recipients.cast(recipients)
data =
Enum.reject(data, fn x ->

View file

@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Publisher.Prepared
alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Workers.PublisherWorker
require Pleroma.Constants
@ -26,6 +25,18 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
ActivityPub outgoing federation module.
"""
@signature_impl Application.compile_env(
:pleroma,
[__MODULE__, :signature_impl],
Pleroma.Signature
)
@transmogrifier_impl Application.compile_env(
:pleroma,
[__MODULE__, :transmogrifier_impl],
Pleroma.Web.ActivityPub.Transmogrifier
)
@doc """
Enqueue publishing a single activity.
"""
@ -68,7 +79,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
Determine if an activity can be represented by running it through Transmogrifier.
"""
def representable?(%Activity{} = activity) do
with {:ok, _data} <- Transmogrifier.prepare_outgoing(activity.data) do
with {:ok, _data} <- @transmogrifier_impl.prepare_outgoing(activity.data) do
true
else
_e ->
@ -91,7 +102,17 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
Logger.debug("Federating #{ap_id} to #{inbox}")
uri = %{path: path} = URI.parse(inbox)
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
{:ok, data} = @transmogrifier_impl.prepare_outgoing(activity.data)
{actor, data} =
with {_, false} <- {:actor_changed?, data["actor"] != activity.data["actor"]} do
{actor, data}
else
{:actor_changed?, true} ->
# If prepare_outgoing changes the actor, re-get it from the db
new_actor = User.get_cached_by_ap_id(data["actor"])
{new_actor, data}
end
param_cc = Map.get(params, :cc, [])
@ -115,10 +136,10 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64())
date = Pleroma.Signature.signed_date()
date = @signature_impl.signed_date()
signature =
Pleroma.Signature.sign(actor, %{
@signature_impl.sign(actor, %{
"(request-target)": "post #{path}",
host: signature_host(uri),
"content-length": byte_size(json),
@ -310,17 +331,21 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
Repo.checkout(fn ->
Enum.each([priority_inboxes, other_inboxes], fn inboxes ->
Enum.each(inboxes, fn inbox ->
%User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end)
{%User{ap_id: ap_id}, priority} =
get_user_with_priority(inbox, priority_recipients, recipients)
# Get all the recipients on the same host and add them to cc. Otherwise, a remote
# instance would only accept a first message for the first recipient and ignore the rest.
cc = get_cc_ap_ids(ap_id, recipients)
__MODULE__.enqueue_one(%{
inbox: inbox,
cc: cc,
activity_id: activity.id
})
__MODULE__.enqueue_one(
%{
inbox: inbox,
cc: cc,
activity_id: activity.id
},
priority: priority
)
end)
end)
end)
@ -382,4 +407,15 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
end
def gather_nodeinfo_protocol_names, do: ["activitypub"]
defp get_user_with_priority(inbox, priority_recipients, recipients) do
[{priority_recipients, 0}, {recipients, 1}]
|> Enum.find_value(fn {recipients, priority} ->
with %User{} = user <- Enum.find(recipients, fn actor -> actor.inbox == inbox end) do
{user, priority}
else
_ -> nil
end
end)
end
end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
@moduledoc """
A module to handle coding from internal to wire ActivityPub and back.
"""
@behaviour Pleroma.Web.ActivityPub.Transmogrifier.API
alias Pleroma.Activity
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Maps
@ -22,7 +23,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Federator
import Ecto.Query
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
require Pleroma.Constants
@ -103,6 +103,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end
end
@doc """
Bovine compatibility
https://codeberg.org/bovine/bovine/issues/53
"""
def fix_addressing_public(map, field) do
addrs = Map.get(map, field, []) |> List.wrap()
Map.put(
map,
field,
Enum.map(addrs, fn
"Public" -> Pleroma.Constants.as_public()
"as:Public" -> Pleroma.Constants.as_public()
x -> x
end)
)
end
# if directMessage flag is set to true, leave the addressing alone
def fix_explicit_addressing(%{"directMessage" => true} = object, _follower_collection),
do: object
@ -160,6 +178,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> fix_addressing_list("cc")
|> fix_addressing_list("bto")
|> fix_addressing_list("bcc")
|> fix_addressing_public("to")
|> fix_addressing_public("cc")
|> fix_addressing_public("bto")
|> fix_addressing_public("bcc")
|> fix_explicit_addressing(follower_collection)
|> fix_implicit_addressing(follower_collection)
end
@ -739,48 +761,26 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def set_quote_url(obj), do: obj
@doc """
Serialized Mastodon-compatible `replies` collection containing _self-replies_.
Based on Mastodon's ActivityPub::NoteSerializer#replies.
Inline first page of the `replies` collection,
containing any replies in chronological order.
"""
def set_replies(obj_data) do
replies_uris =
with limit when limit > 0 <-
Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
%Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
object
|> Object.self_replies()
|> select([o], fragment("?->>'id'", o.data))
|> limit(^limit)
|> Repo.all()
else
_ -> []
end
set_replies(obj_data, replies_uris)
def set_replies(%{"type" => type} = obj_data)
when type in Pleroma.Constants.status_object_types() do
with obj_ap_id when is_binary(obj_ap_id) <- obj_data["id"],
limit when limit > 0 <-
Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
collection <-
Pleroma.Web.ActivityPub.ObjectView.render("object_replies.json", %{
render_params: %{object_ap_id: obj_data["id"], limit: limit, skip_ap_ctx: true}
}) do
Map.put(obj_data, "replies", collection)
else
0 -> Map.put(obj_data, "replies", obj_data["id"] <> "/replies")
_ -> obj_data
end
end
defp set_replies(obj, []) do
obj
end
defp set_replies(obj, replies_uris) do
replies_collection = %{
"type" => "Collection",
"items" => replies_uris
}
Map.merge(obj, %{"replies" => replies_collection})
end
def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
items
end
def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
items
end
def replies(_), do: []
def set_replies(obj_data), do: obj_data
# Prepares the object of an outgoing create activity.
def prepare_object(object) do
@ -850,6 +850,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
{:ok, data}
end
def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
when objtype in Pleroma.Constants.actor_types() do
object =
object
|> maybe_fix_user_object()
|> strip_internal_fields()
data =
data
|> Map.put("object", object)
|> strip_internal_fields()
|> Map.merge(Utils.make_json_ld_header(object))
|> Map.delete("bcc")
{:ok, data}
end
def prepare_outgoing(%{"type" => "Update", "object" => %{}} = data) do
raise "Requested to serve an Update for non-updateable object type: #{inspect(data)}"
end
def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
object =
object_id
@ -909,6 +930,14 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end
end
def prepare_outgoing(%{"type" => "Flag"} = data) do
with {:ok, stripped_activity} <- Utils.strip_report_status_data(data),
stripped_activity <- Utils.maybe_anonymize_reporter(stripped_activity),
stripped_activity <- Map.merge(stripped_activity, Utils.make_json_ld_header()) do
{:ok, stripped_activity}
end
end
def prepare_outgoing(%{"type" => _type} = data) do
data =
data

View file

@ -0,0 +1,11 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier.API do
@moduledoc """
Behaviour for the subset of Transmogrifier used by Publisher.
"""
@callback prepare_outgoing(map()) :: {:ok, map()} | {:error, term()}
end

View file

@ -82,7 +82,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do
def unaddressed_message?(params),
do:
[params["to"], params["cc"], params["bto"], params["bcc"]]
|> Enum.all?(&is_nil(&1))
|> Enum.all?(fn
nil -> true
[] -> true
_ -> false
end)
@spec recipient_in_message(User.t(), User.t(), map()) :: boolean()
def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params),
@ -859,8 +863,14 @@ defmodule Pleroma.Web.ActivityPub.Utils do
def update_report_state(_, _), do: {:error, "Unsupported state"}
def strip_report_status_data(activity) do
[actor | reported_activities] = activity.data["object"]
def strip_report_status_data(%Activity{} = activity) do
with {:ok, new_data} <- strip_report_status_data(activity.data) do
{:ok, %{activity | data: new_data}}
end
end
def strip_report_status_data(data) do
[actor | reported_activities] = data["object"]
stripped_activities =
Enum.reduce(reported_activities, [], fn act, acc ->
@ -870,9 +880,36 @@ defmodule Pleroma.Web.ActivityPub.Utils do
end
end)
new_data = put_in(activity.data, ["object"], [actor | stripped_activities])
new_data = put_in(data, ["object"], [actor | stripped_activities])
{:ok, %{activity | data: new_data}}
{:ok, new_data}
end
def get_anonymized_reporter do
with true <- Pleroma.Config.get([:activitypub, :anonymize_reporter]),
nickname when is_binary(nickname) <-
Pleroma.Config.get([:activitypub, :anonymize_reporter_local_nickname]),
%User{ap_id: ap_id, local: true} <- User.get_cached_by_nickname(nickname) do
ap_id
else
_ -> nil
end
end
def maybe_anonymize_reporter(%Activity{data: data} = activity) do
new_data = maybe_anonymize_reporter(data)
%Activity{activity | actor: new_data["actor"], data: new_data}
end
def maybe_anonymize_reporter(activity) do
ap_id = get_anonymized_reporter()
if is_binary(ap_id) do
activity
|> Map.put("actor", ap_id)
else
activity
end
end
def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do

View file

@ -0,0 +1,59 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# Copyright © 2025 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.CollectionViewHelper do
alias Pleroma.Web.ActivityPub.Utils
def collection_page_offset(collection, iri, page, show_items \\ true, total \\ nil) do
offset = (page - 1) * 10
items = Enum.slice(collection, offset, 10)
items = Enum.map(items, fn user -> user.ap_id end)
total = total || length(collection)
map = %{
"id" => "#{iri}?page=#{page}",
"type" => "OrderedCollectionPage",
"partOf" => iri,
"totalItems" => total,
"orderedItems" => if(show_items, do: items, else: [])
}
if offset + 10 < total do
Map.put(map, "next", "#{iri}?page=#{page + 1}")
else
map
end
end
defp maybe_omit_next(pagination, _items, nil), do: pagination
defp maybe_omit_next(pagination, items, limit) when is_binary(limit) do
case Integer.parse(limit) do
{limit, ""} -> maybe_omit_next(pagination, items, limit)
_ -> maybe_omit_next(pagination, items, nil)
end
end
defp maybe_omit_next(pagination, items, limit) when is_number(limit) do
if Enum.count(items) < limit, do: Map.delete(pagination, "next"), else: pagination
end
def collection_page_keyset(
display_items,
pagination,
limit \\ nil,
skip_ap_context \\ false
) do
%{
"type" => "OrderedCollectionPage",
"orderedItems" => display_items
}
|> Map.merge(pagination)
|> maybe_omit_next(display_items, limit)
|> then(fn m ->
if skip_ap_context, do: m, else: Map.merge(m, Utils.make_json_ld_header())
end)
end
end

View file

@ -6,7 +6,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
use Pleroma.Web, :view
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.CollectionViewHelper
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ControllerHelper
def render("object.json", %{object: %Object{} = object}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(object.data)
@ -15,26 +18,94 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
Map.merge(base, additional)
end
def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity})
when activity_type in ["Create", "Listen"] do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data)
object = Object.normalize(activity, fetch: false)
additional =
Transmogrifier.prepare_object(activity.data)
|> Map.put("object", Transmogrifier.prepare_object(object.data))
Map.merge(base, additional)
def render("object.json", %{object: %Activity{} = activity}) do
{:ok, ap_data} = Transmogrifier.prepare_outgoing(activity.data)
ap_data
end
def render("object.json", %{object: %Activity{} = activity}) do
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data)
object_id = Object.normalize(activity, id_only: true)
def render("object_replies.json", %{
conn: conn,
render_params: %{object_ap_id: object_ap_id, page: "true"} = params
}) do
params = Map.put_new(params, :limit, 40)
additional =
Transmogrifier.prepare_object(activity.data)
|> Map.put("object", object_id)
items = ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
display_items = map_reply_collection_items(items)
Map.merge(base, additional)
pagination = ControllerHelper.get_pagination_fields(conn, items, %{}, :asc)
CollectionViewHelper.collection_page_keyset(display_items, pagination, params[:limit])
end
def render(
"object_replies.json",
%{
render_params: %{object_ap_id: object_ap_id} = params
} = opts
) do
params =
params
|> Map.drop([:max_id, :min_id, :since_id, :object_ap_id])
|> Map.put_new(:limit, 40)
|> Map.put(:total, true)
%{total: total, items: items} =
ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
display_items = map_reply_collection_items(items)
first_pagination = reply_collection_first_pagination(items, opts)
col_ap =
%{
"id" => object_ap_id <> "/replies",
"type" => "OrderedCollection",
"totalItems" => total
}
col_ap =
if total > 0 do
first_page =
CollectionViewHelper.collection_page_keyset(
display_items,
first_pagination,
params[:limit],
true
)
Map.put(col_ap, "first", first_page)
else
col_ap
end
if params[:skip_ap_ctx] do
col_ap
else
Map.merge(col_ap, Pleroma.Web.ActivityPub.Utils.make_json_ld_header())
end
end
defp map_reply_collection_items(items), do: Enum.map(items, fn %{ap_id: ap_id} -> ap_id end)
defp reply_collection_first_pagination(items, %{conn: %Plug.Conn{} = conn}) do
pagination = ControllerHelper.get_pagination_fields(conn, items, %{"page" => true}, :asc)
Map.put(pagination, "id", Phoenix.Controller.current_url(conn, %{"page" => true}))
end
defp reply_collection_first_pagination(items, %{render_params: %{object_ap_id: object_ap_id}}) do
%{
"id" => object_ap_id <> "/replies?page=true",
"partOf" => object_ap_id <> "/replies"
}
|> then(fn m ->
case items do
[] ->
m
i ->
next_id = object_ap_id <> "/replies?page=true&min_id=#{List.last(i)[:id]}"
Map.put(m, "next", next_id)
end
end)
end
end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.CollectionViewHelper
alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
@ -164,7 +165,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do
0
end
collection(following, "#{user.ap_id}/following", page, showing_items, total)
CollectionViewHelper.collection_page_offset(
following,
"#{user.ap_id}/following",
page,
showing_items,
total
)
|> Map.merge(Utils.make_json_ld_header())
end
@ -189,7 +196,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do
"totalItems" => total,
"first" =>
if showing_items do
collection(following, "#{user.ap_id}/following", 1, !user.hide_follows)
CollectionViewHelper.collection_page_offset(
following,
"#{user.ap_id}/following",
1,
!user.hide_follows
)
else
"#{user.ap_id}/following?page=1"
end
@ -212,7 +224,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do
0
end
collection(followers, "#{user.ap_id}/followers", page, showing_items, total)
CollectionViewHelper.collection_page_offset(
followers,
"#{user.ap_id}/followers",
page,
showing_items,
total
)
|> Map.merge(Utils.make_json_ld_header())
end
@ -236,7 +254,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do
"type" => "OrderedCollection",
"first" =>
if showing_items do
collection(followers, "#{user.ap_id}/followers", 1, showing_items, total)
CollectionViewHelper.collection_page_offset(
followers,
"#{user.ap_id}/followers",
1,
showing_items
)
else
"#{user.ap_id}/followers?page=1"
end
@ -256,7 +279,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do
def render("activity_collection_page.json", %{
activities: activities,
iri: iri,
pagination: pagination
}) do
collection =
@ -265,13 +287,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do
data
end)
%{
"type" => "OrderedCollectionPage",
"partOf" => iri,
"orderedItems" => collection
}
|> Map.merge(Utils.make_json_ld_header())
|> Map.merge(pagination)
CollectionViewHelper.collection_page_keyset(collection, pagination)
end
def render("featured.json", %{
@ -299,27 +315,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do
Map.put(map, "totalItems", total)
end
def collection(collection, iri, page, show_items \\ true, total \\ nil) do
offset = (page - 1) * 10
items = Enum.slice(collection, offset, 10)
items = Enum.map(items, fn user -> user.ap_id end)
total = total || length(collection)
map = %{
"id" => "#{iri}?page=#{page}",
"type" => "OrderedCollectionPage",
"partOf" => iri,
"totalItems" => total,
"orderedItems" => if(show_items, do: items, else: [])
}
if offset < total do
Map.put(map, "next", "#{iri}?page=#{page + 1}")
else
map
end
end
defp maybe_make_image(func, description, key, user) do
if image = func.(user, no_default: true) do
%{

View file

@ -73,6 +73,22 @@ defmodule Pleroma.Web.ActivityPub.Visibility do
|> Pleroma.List.member?(user)
end
def visible_for_user?(%Activity{object: %Object{} = object} = activity, nil) do
activity_visibility? = restrict_unauthenticated_access?(activity)
activity_public? = public?(activity) and not local_public?(activity)
object_visibility? = restrict_unauthenticated_access?(object)
object_public? = public?(object) and not local_public?(object)
# Activity could be local, but object might not (Announce/Like)
cond do
activity_visibility? or object_visibility? ->
false
true ->
activity_public? and object_public?
end
end
def visible_for_user?(%{__struct__: module} = message, nil)
when module in [Activity, Object] do
if restrict_unauthenticated_access?(message),

View file

@ -240,6 +240,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
render_error(conn, :not_found, "No such permission_group")
end
def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do
render_error(conn, :forbidden, "You can't revoke your own admin status.")
end
def right_delete(
%{assigns: %{user: admin}} = conn,
%{
@ -265,10 +269,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
json(conn, fields)
end
def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do
render_error(conn, :forbidden, "You can't revoke your own admin status.")
end
@doc "Get a password reset token (base64 string) for given nickname"
def get_password_reset(conn, %{"nickname" => nickname}) do
(%User{local: true} = user) = User.get_cached_by_nickname(nickname)

View file

@ -151,7 +151,8 @@ defmodule Pleroma.Web.ApiSpec do
"Suggestions",
"Announcements",
"Remote interaction",
"Others"
"Others",
"Preferred frontends"
]
}
]

View file

@ -143,6 +143,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
"Include statuses from muted accounts."
),
Operation.parameter(:exclude_reblogs, :query, BooleanLike.schema(), "Exclude reblogs"),
Operation.parameter(
:only_reblogs,
:query,
BooleanLike.schema(),
"Include only reblogs"
),
Operation.parameter(:exclude_replies, :query, BooleanLike.schema(), "Exclude replies"),
Operation.parameter(
:exclude_visibilities,
@ -392,6 +398,28 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
}
end
def endorsements_operation do
%Operation{
tags: ["Retrieve account information"],
summary: "Endorsements",
description: "Returns endorsed accounts",
operationId: "AccountController.endorsements",
parameters: [
with_relationships_param(),
%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}
],
responses: %{
200 =>
Operation.response(
"Array of Accounts",
"application/json",
array_of_accounts()
),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def remove_from_followers_operation do
%Operation{
tags: ["Account actions"],
@ -455,7 +483,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
security: [%{"oAuth" => ["follow", "read:mutes"]}],
parameters: [with_relationships_param() | pagination_params()],
responses: %{
200 => Operation.response("Accounts", "application/json", array_of_accounts())
200 => Operation.response("Accounts", "application/json", array_of_muted_accounts())
}
}
end
@ -469,7 +497,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
security: [%{"oAuth" => ["read:blocks"]}],
parameters: [with_relationships_param() | pagination_params()],
responses: %{
200 => Operation.response("Accounts", "application/json", array_of_accounts())
200 => Operation.response("Accounts", "application/json", array_of_blocked_accounts())
}
}
end
@ -489,16 +517,17 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
],
responses: %{
200 => Operation.response("Account", "application/json", Account),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def endorsements_operation do
def own_endorsements_operation do
%Operation{
tags: ["Retrieve account information"],
summary: "Endorsements",
operationId: "AccountController.endorsements",
operationId: "AccountController.own_endorsements",
description: "Returns endorsed accounts",
security: [%{"oAuth" => ["read:accounts"]}],
responses: %{
@ -868,6 +897,54 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
}
end
def array_of_muted_accounts do
%Schema{
title: "ArrayOfMutedAccounts",
type: :array,
items: %Schema{
title: "MutedAccount",
description: "Response schema for a muted account",
allOf: [
Account,
%Schema{
type: :object,
properties: %{
mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true}
}
}
]
},
example: [
Account.schema().example
|> Map.put("mute_expires_at", "2025-11-29T16:23:13Z")
]
}
end
def array_of_blocked_accounts do
%Schema{
title: "ArrayOfBlockedAccounts",
type: :array,
items: %Schema{
title: "BlockedAccount",
description: "Response schema for a blocked account",
allOf: [
Account,
%Schema{
type: :object,
properties: %{
block_expires_at: %Schema{type: :string, format: "date-time", nullable: true}
}
}
]
},
example: [
Account.schema().example
|> Map.put("block_expires_at", "2025-11-29T16:23:13Z")
]
}
end
defp array_of_relationships do
%Schema{
title: "ArrayOfRelationships",

View file

@ -142,7 +142,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do
:query,
BooleanLike.schema(),
"Include chats from muted users"
)
),
Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats")
],
responses: %{
200 => Operation.response("The chats of the user", "application/json", chats_response())
@ -166,7 +167,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do
:query,
BooleanLike.schema(),
"Include chats from muted users"
)
),
Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats")
| pagination_params()
],
responses: %{
@ -257,6 +259,44 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do
}
end
def pin_operation do
%Operation{
tags: ["Chats"],
summary: "Pin a chat",
operationId: "ChatController.pin",
parameters: [
Operation.parameter(:id, :path, :string, "The id of the chat", required: true)
],
responses: %{
200 => Operation.response("The existing chat", "application/json", Chat)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def unpin_operation do
%Operation{
tags: ["Chats"],
summary: "Unpin a chat",
operationId: "ChatController.unpin",
parameters: [
Operation.parameter(:id, :path, :string, "The id of the chat", required: true)
],
responses: %{
200 => Operation.response("The existing chat", "application/json", Chat)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def chats_response do
%Schema{
title: "ChatsResponse",

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
@ -35,7 +36,8 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do
security: [%{"oAuth" => ["read:statuses"]}],
operationId: "EmojiReactionController.index",
responses: %{
200 => array_of_reactions_response()
200 => array_of_reactions_response(),
404 => Operation.response("Access denied", "application/json", ApiNotFoundError)
}
}
end
@ -54,7 +56,8 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do
operationId: "EmojiReactionController.create",
responses: %{
200 => Operation.response("Status", "application/json", Status),
400 => Operation.response("Bad Request", "application/json", ApiError)
400 => Operation.response("Bad Request", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiNotFoundError)
}
}
end

View file

@ -64,25 +64,6 @@ defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do
}
end
def endorsements_operation do
%Operation{
tags: ["Retrieve account information"],
summary: "Endorsements",
description: "Returns endorsed accounts",
operationId: "PleromaAPI.AccountController.endorsements",
parameters: [with_relationships_param(), id_param()],
responses: %{
200 =>
Operation.response(
"Array of Accounts",
"application/json",
AccountOperation.array_of_accounts()
),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def subscribe_operation do
%Operation{
deprecated: true,

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def outgoing_operation do
%Operation{
tags: ["Follow requests"],
summary: "Retrieve outgoing follow requests",
security: [%{"oAuth" => ["read:follows", "follow"]}],
operationId: "PleromaFollowRequestController.outgoing",
responses: %{
200 =>
Operation.response("Array of Account", "application/json", %Schema{
type: :array,
items: Account,
example: [Account.schema().example]
})
}
}
end
end

View file

@ -0,0 +1,65 @@
defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
import Pleroma.Web.ApiSpec.Helpers
@spec open_api_operation(atom) :: Operation.t()
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def available_frontends_operation do
%Operation{
tags: ["Preferred frontends"],
summary: "Frontend settings profiles",
description: "List frontend setting profiles",
operationId: "PleromaAPI.FrontendSettingsController.available_frontends",
responses: %{
200 =>
Operation.response("Frontends", "application/json", %Schema{
type: :array,
items: %Schema{
type: :string
}
})
}
}
end
def update_preferred_frontend_operation do
%Operation{
tags: ["Preferred frontends"],
summary: "Update preferred frontend setting",
description: "Store preferred frontend in cookies",
operationId: "PleromaAPI.FrontendSettingsController.update_preferred_frontend",
requestBody:
request_body(
"Frontend",
%Schema{
type: :object,
required: [:frontend_name],
properties: %{
frontend_name: %Schema{
type: :string,
description: "Frontend name"
}
}
},
required: true
),
responses: %{
200 =>
Operation.response("Preferred frontend", "application/json", %Schema{
type: :object,
properties: %{
frontend_name: %Schema{
type: :string,
description: "Frontend name"
}
}
})
}
}
end
end

View file

@ -20,7 +20,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do
%Operation{
tags: ["Scrobbles"],
summary: "Creates a new Listen activity for an account",
security: [%{"oAuth" => ["write"]}],
security: [%{"oAuth" => ["write:scrobbles"]}],
operationId: "PleromaAPI.ScrobbleController.create",
deprecated: true,
requestBody: request_body("Parameters", create_request(), required: true),
@ -39,7 +39,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do
parameters: [
%Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params()
],
security: [%{"oAuth" => ["read"]}],
security: [%{"oAuth" => ["read:scrobbles"]}],
responses: %{
200 =>
Operation.response("Array of Scrobble", "application/json", %Schema{

View file

@ -19,7 +19,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaStatusOperation do
%Operation{
tags: ["Retrieve status information"],
summary: "Quoted by",
description: "View quotes for a given status",
deprecated: true,
description: "View quotes for a given status. Use /api/v1/statuses/:id/quotes instead.",
operationId: "PleromaAPI.StatusController.quotes",
parameters: [id_param() | pagination_params()],
security: [%{"oAuth" => ["read:statuses"]}],

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Helpers
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
def open_api_operation(action) do
@ -24,7 +25,8 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do
requestBody: Helpers.request_body("Parameters", create_request(), required: true),
responses: %{
200 => Operation.response("Report", "application/json", create_response()),
400 => Operation.response("Report", "application/json", ApiError)
400 => Operation.response("Report", "application/json", ApiError),
404 => Operation.response("Report", "application/json", ApiNotFoundError)
}
}
end

View file

@ -46,6 +46,12 @@ defmodule Pleroma.Web.ApiSpec.SearchOperation do
:query,
%Schema{allOf: [BooleanLike], default: false},
"Only include accounts that the user is following"
),
Operation.parameter(
:capabilities,
:query,
%Schema{type: :array, items: %Schema{type: :string, enum: ["accepts_chat_messages"]}},
"Only include accounts with given capabilities"
)
],
responses: %{

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
alias Pleroma.Web.ApiSpec.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError
alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.Emoji
@ -177,6 +178,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
parameters: [id_param()],
responses: %{
200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
@ -242,14 +244,19 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
"error" => "You have already pinned the maximum number of statuses"
}
}),
404 =>
Operation.response("Not found", "application/json", %Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "Record not found"
404 => Operation.response("Not found", "application/json", ApiNotFoundError),
422 =>
Operation.response(
"Unprocessable Entity",
"application/json",
%Schema{
allOf: [ApiError],
title: "Unprocessable Entity",
example: %{
"error" => "Someone else's status cannot be unpinned"
}
}
})
)
}
}
end
@ -275,7 +282,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
}),
responses: %{
200 => status_response()
200 => status_response(),
404 => Operation.response("Not found", "application/json", ApiNotFoundError)
}
}
end
@ -289,7 +297,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
operationId: "StatusController.unbookmark",
parameters: [id_param()],
responses: %{
200 => status_response()
200 => status_response(),
404 => Operation.response("Not found", "application/json", ApiNotFoundError)
}
}
end
@ -324,7 +333,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
],
responses: %{
200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError)
400 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Not found", "application/json", ApiNotFoundError)
}
}
end
@ -340,7 +350,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
parameters: [id_param()],
responses: %{
200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError)
400 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiNotFoundError)
}
}
end
@ -549,6 +560,27 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
end
def quotes_operation do
%Operation{
tags: ["Retrieve status information"],
summary: "Quoted by",
description: "View quotes for a given status",
operationId: "StatusController.quotes",
parameters: [id_param() | pagination_params()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 =>
Operation.response(
"Array of Status",
"application/json",
array_of_statuses()
),
403 => Operation.response("Forbidden", "application/json", ApiError),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def array_of_statuses do
%Schema{type: :array, items: Status, example: [Status.schema().example]}
end
@ -599,6 +631,20 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
nullable: true,
description: "ISO 639 language code for this status."
},
visibility: %Schema{
nullable: true,
anyOf: [
VisibilityScope,
%Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"}
],
description:
"Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`"
},
quoted_status_id: %Schema{
nullable: true,
allOf: [FlakeID],
description: "ID of the status being quoted, if any"
},
# Pleroma-specific properties:
preview: %Schema{
allOf: [BooleanLike],
@ -619,15 +665,6 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
description:
"A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
},
visibility: %Schema{
nullable: true,
anyOf: [
VisibilityScope,
%Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"}
],
description:
"Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`"
},
expires_in: %Schema{
nullable: true,
type: :integer,
@ -643,7 +680,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
quote_id: %Schema{
nullable: true,
allOf: [FlakeID],
description: "ID of the status being quoted, if any"
description: "Deprecated in favor of `quoted_status_id`",
deprecated: true
}
},
example: %{

View file

@ -33,8 +33,6 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do
header: %Schema{type: :string, format: :uri},
id: FlakeID,
locked: %Schema{type: :boolean},
mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true},
block_expires_at: %Schema{type: :string, format: "date-time", nullable: true},
note: %Schema{type: :string, format: :html},
statuses_count: %Schema{type: :integer},
url: %Schema{type: :string, format: :uri},

View file

@ -0,0 +1,19 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError do
alias OpenApiSpex.Schema
require OpenApiSpex
OpenApiSpex.schema(%{
title: "Not Found",
description: "Response schema for 404 API errors",
type: :object,
properties: %{error: %Schema{type: :string}},
example: %{
"error" => "Record not found"
}
})
end

View file

@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do
account: %Schema{type: :object},
unread: %Schema{type: :integer},
last_message: ChatMessage,
updated_at: %Schema{type: :string, format: :"date-time"}
updated_at: %Schema{type: :string, format: :"date-time"},
pinned: %Schema{type: :boolean}
},
example: %{
"account" => %{
@ -69,7 +70,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do
"id" => "1",
"unread" => 2,
"last_message" => ChatMessage.schema().example,
"updated_at" => "2020-04-21T15:06:45.000Z"
"updated_at" => "2020-04-21T15:06:45.000Z",
"pinned" => false
}
})
end

View file

@ -219,7 +219,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
},
quotes_count: %Schema{
type: :integer,
description: "How many statuses quoted this status"
deprecated: true,
description:
"How many statuses quoted this status. Deprecated, use `quotes_count` from parent object instead."
},
local: %Schema{
type: :boolean,
@ -259,6 +261,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
}
},
poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"},
quotes_count: %Schema{
type: :integer,
description: "How many statuses quoted this status."
},
reblog: %Schema{
allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}],
nullable: true,
@ -385,6 +391,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
"quotes_count" => 0
},
"poll" => nil,
"quotes_count" => 0,
"reblog" => nil,
"reblogged" => false,
"reblogs_count" => 0,

View file

@ -269,6 +269,7 @@ defmodule Pleroma.Web.CommonAPI do
defp favorite_helper(user, id) do
with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)},
{_, true} <- {:visibility_error, activity_visible_to_actor(object, user)},
{_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)},
{_, {:ok, %Activity{} = activity, _meta}} <-
{:common_pipeline,
@ -278,6 +279,9 @@ defmodule Pleroma.Web.CommonAPI do
{:find_object, _} ->
{:error, :not_found}
{:visibility_error, _} ->
{:error, :not_found}
{:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e ->
if {:object, {"already liked by this actor", []}} in changeset.errors do
{:ok, :already_liked}
@ -296,6 +300,7 @@ defmodule Pleroma.Web.CommonAPI do
with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
{:find_activity, Activity.get_by_id(id)},
%Object{} = note <- Object.normalize(activity, fetch: false),
{_, true} <- {:visibility_error, activity_visible_to_actor(note, user)},
%Activity{} = like <- Utils.get_existing_like(user.ap_id, note),
{_, {:ok, _}} <- {:cancel_jobs, maybe_cancel_jobs(like)},
{:ok, undo, _} <- Builder.undo(user, like),
@ -303,6 +308,7 @@ defmodule Pleroma.Web.CommonAPI do
{:ok, activity}
else
{:find_activity, _} -> {:error, :not_found}
{:visibility_error, _} -> {:error, :not_found}
_ -> {:error, dgettext("errors", "Could not unfavorite")}
end
end
@ -311,11 +317,15 @@ defmodule Pleroma.Web.CommonAPI do
{:ok, Activity.t()} | {:error, String.t()}
def react_with_emoji(id, user, emoji) do
with %Activity{} = activity <- Activity.get_by_id(id),
{_, true} <- {:visibility_error, activity_visible_to_actor(activity, user)},
object <- Object.normalize(activity, fetch: false),
{:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji),
{:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do
{:ok, activity}
else
{:visibility_error, _} ->
{:error, :not_found}
_ ->
{:error, dgettext("errors", "Could not add reaction emoji")}
end
@ -506,6 +516,7 @@ defmodule Pleroma.Web.CommonAPI do
@spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors()
def pin(id, %User{} = user) do
with %Activity{} = activity <- create_activity_by_id(id),
true <- activity_visible_to_actor(activity, user),
true <- activity_belongs_to_actor(activity, user.ap_id),
true <- object_type_is_allowed_for_pin(activity.object),
true <- activity_is_public(activity),
@ -531,6 +542,14 @@ defmodule Pleroma.Web.CommonAPI do
defp activity_belongs_to_actor(%{actor: actor}, actor), do: true
defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error}
defp activity_visible_to_actor(activity, %User{} = user) do
if Visibility.visible_for_user?(activity, user) do
true
else
{:error, :visibility_error}
end
end
defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do
with false <- type in ["Note", "Article", "Question"] do
{:error, :not_allowed}
@ -539,13 +558,18 @@ defmodule Pleroma.Web.CommonAPI do
defp activity_is_public(activity) do
with false <- Visibility.public?(activity) do
{:error, :visibility_error}
{:error, :non_public_error}
end
end
@spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors()
def unpin(id, user) do
# Order of visibility/belonging matters for MastoAPI responses.
# post not visible -> 404
# post visible, not owned -> 422
with %Activity{} = activity <- create_activity_by_id(id),
true <- activity_visible_to_actor(activity, user),
true <- activity_belongs_to_actor(activity, user.ap_id),
{:ok, unpin_data, _} <- Builder.unpin(user, activity.object),
{:ok, _unpin, _} <-
Pipeline.common_pipeline(unpin_data,
@ -562,7 +586,8 @@ defmodule Pleroma.Web.CommonAPI do
def add_mute(activity, user, params \\ %{}) do
expires_in = Map.get(params, :expires_in, 0)
with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]),
with true <- activity_visible_to_actor(activity, user),
{:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]),
_ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do
if expires_in > 0 do
Pleroma.Workers.MuteExpireWorker.new(
@ -574,14 +599,21 @@ defmodule Pleroma.Web.CommonAPI do
{:ok, activity}
else
{:error, :visibility_error} -> {:error, :visibility_error}
{:error, _} -> {:error, dgettext("errors", "conversation is already muted")}
end
end
@spec remove_mute(Activity.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()}
def remove_mute(%Activity{} = activity, %User{} = user) do
ThreadMute.remove_mute(user.id, activity.data["context"])
{:ok, activity}
case activity_visible_to_actor(activity, user) do
true ->
ThreadMute.remove_mute(user.id, activity.data["context"])
{:ok, activity}
error ->
error
end
end
@spec remove_mute(String.t(), String.t()) :: {:ok, Activity.t()} | {:error, any()}
@ -612,6 +644,7 @@ defmodule Pleroma.Web.CommonAPI do
with {:ok, account} <- get_reported_account(data.account_id),
{:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]),
{:ok, statuses} <- get_report_statuses(account, data),
true <- check_statuses_visibility(user, statuses),
rules <- get_report_rules(Map.get(data, :rule_ids, nil)) do
ActivityPub.flag(%{
context: Utils.generate_context_id(),
@ -622,9 +655,27 @@ defmodule Pleroma.Web.CommonAPI do
forward: Map.get(data, :forward, false),
rules: rules
})
else
false ->
{:error, :visibility_error}
error ->
error
end
end
defp check_statuses_visibility(user, statuses) when is_list(statuses) do
visibility = for status <- statuses, do: Visibility.visible_for_user?(status, user)
case Enum.all?(visibility) do
true -> true
_ -> false
end
end
# There are no statuses associated with the report, pass!
defp check_statuses_visibility(_, status) when status == nil, do: true
defp get_reported_account(account_id) do
case User.get_cached_by_id(account_id) do
%User{} = account -> {:ok, account}

View file

@ -136,22 +136,34 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft
defp in_reply_to(%{params: %{in_reply_to_status_id: :deleted}} = draft) do
add_error(draft, dgettext("errors", "Cannot reply to a deleted status"))
end
defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do
# If a post was deleted all its activities (except the newly added Delete) are purged too,
# thus lookup by Create db ID will yield nil just as if it never existed in the first place.
#
# We allow replying to Announce here, due to a Pleroma-FE quirk where if presented with
# an Announce id it will render it as if it was just the normal referenced post, but
# use the Announce id for replies in the in_reply_to_id key of a POST request to
# /api/v1/statuses, or as an :id in /api/v1/statuses/:id/*.
# TODO: Fix this quirk in FE and remove here and other affected places
with %Activity{} = activity <- Activity.get_by_id(id),
true <- Visibility.visible_for_user?(activity, draft.user),
{_, type} when type in ["Create", "Announce"] <- {:type, activity.data["type"]} do
%__MODULE__{draft | in_reply_to: activity}
else
nil ->
add_error(draft, dgettext("errors", "Cannot reply to a deleted status"))
defp in_reply_to(%{params: %{in_reply_to_status_id: id} = params} = draft) when is_binary(id) do
activity = Activity.get_by_id(id)
false ->
add_error(draft, dgettext("errors", "Record not found"))
params =
if is_nil(activity) do
# Deleted activities are returned as nil
Map.put(params, :in_reply_to_status_id, :deleted)
else
Map.put(params, :in_reply_to_status_id, activity)
end
in_reply_to(%{draft | params: params})
{:type, type} ->
add_error(
draft,
dgettext("errors", "Can only reply to posts, not %{type} activities",
type: inspect(type)
)
)
end
end
defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do
@ -160,7 +172,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
defp in_reply_to(draft), do: draft
defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do
defp quote_post(%{params: %{quoted_status_id: id}} = draft) when not_empty_string(id) do
case Activity.get_by_id_with_object(id) do
%Activity{} = activity ->
%__MODULE__{draft | quote_post: activity}
@ -170,6 +182,10 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
end
end
defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do
quote_post(%{draft | params: Map.put(draft.params, :quoted_status_id, id)})
end
defp quote_post(draft), do: draft
defp in_reply_to_conversation(draft) do

View file

@ -402,28 +402,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do
def maybe_notify_mentioned_recipients(recipients, _), do: recipients
def maybe_notify_subscribers(
recipients,
%Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
) do
# Do not notify subscribers if author is making a reply
with %Object{data: object} <- Object.normalize(activity, fetch: false),
nil <- object["inReplyTo"],
%User{} = user <- User.get_cached_by_ap_id(actor) do
subscriber_ids =
user
|> User.subscriber_users()
|> Enum.filter(&Visibility.visible_for_user?(activity, &1))
|> Enum.map(& &1.ap_id)
recipients ++ subscriber_ids
else
_e -> recipients
end
end
def maybe_notify_subscribers(recipients, _), do: recipients
def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
user
@ -437,6 +415,27 @@ defmodule Pleroma.Web.CommonAPI.Utils do
def maybe_notify_followers(recipients, _), do: recipients
def get_notified_subscribers(
%Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
) do
# Do not notify subscribers if author is making a reply
with %Object{data: object} <- Object.normalize(activity, fetch: false),
nil <- object["inReplyTo"],
%User{} = user <- User.get_cached_by_ap_id(actor) do
subscriber_ids =
user
|> User.subscriber_users()
|> Enum.filter(&Visibility.visible_for_user?(activity, &1))
|> Enum.map(& &1.ap_id)
subscriber_ids
else
_e -> []
end
end
def get_notified_subscribers(_), do: []
def maybe_extract_mentions(%{"tag" => tag}) do
tag
|> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)

View file

@ -55,7 +55,7 @@ defmodule Pleroma.Web.ControllerHelper do
# TODO: Only fetch the params from open_api_spex when everything is converted
@id_keys Pagination.page_keys() -- ["limit", "order"]
defp build_pagination_fields(conn, min_id, max_id, extra_params) do
defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do
params =
if Map.has_key?(conn.private, :open_api_spex) do
get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)])
@ -66,27 +66,50 @@ defmodule Pleroma.Web.ControllerHelper do
|> Map.merge(extra_params)
|> Map.drop(@id_keys)
{{next_id, nid}, {prev_id, pid}} =
if order == :desc,
do: {{:max_id, max_id}, {:min_id, min_id}},
else: {{:min_id, min_id}, {:max_id, max_id}}
id = Phoenix.Controller.current_url(conn)
base_id = %{URI.parse(id) | query: nil} |> URI.to_string()
%{
"next" => current_url(conn, Map.put(params, :max_id, max_id)),
"prev" => current_url(conn, Map.put(params, :min_id, min_id)),
"id" => current_url(conn)
"next" => current_url(conn, Map.put(params, next_id, nid)),
"prev" => current_url(conn, Map.put(params, prev_id, pid)),
"id" => id,
"partOf" => base_id
}
end
def get_pagination_fields(conn, entries, extra_params \\ %{}) do
defp get_first_last_pagination_id(entries) do
case List.last(entries) do
%{pagination_id: max_id} when not is_nil(max_id) ->
%{pagination_id: min_id} = List.first(entries)
%{pagination_id: last_id} when not is_nil(last_id) ->
%{pagination_id: first_id} = List.first(entries)
{first_id, last_id}
build_pagination_fields(conn, min_id, max_id, extra_params)
%{id: max_id} ->
%{id: min_id} = List.first(entries)
build_pagination_fields(conn, min_id, max_id, extra_params)
%{id: last_id} ->
%{id: first_id} = List.first(entries)
{first_id, last_id}
_ ->
%{}
nil
end
end
def get_pagination_fields(conn, entries, extra_params \\ %{}, order \\ :desc)
def get_pagination_fields(conn, entries, extra_params, :desc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{min_id, max_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :desc)
end
end
def get_pagination_fields(conn, entries, extra_params, :asc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{max_id, min_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :asc)
end
end

View file

@ -30,7 +30,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector(conn, _params, code \\ 200) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
response =
index_content
@ -51,7 +51,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector_with_meta(conn, params) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
tags = build_tags(conn, params)
preloads = preload_data(conn, params)
@ -69,7 +69,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do
end
def redirector_with_preload(conn, params) do
{:ok, index_content} = File.read(index_file_path())
{:ok, index_content} = File.read(index_file_path(conn))
preloads = preload_data(conn, params)
response =
@ -91,8 +91,10 @@ defmodule Pleroma.Web.Fallback.RedirectController do
|> text("")
end
defp index_file_path do
Pleroma.Web.Plugs.InstanceStatic.file_path("index.html")
defp index_file_path(conn) do
frontend_type = Pleroma.Web.Plugs.FrontendStatic.preferred_or_fallback(conn, :primary)
Pleroma.Web.Plugs.InstanceStatic.file_path("index.html", frontend_type)
end
defp build_tags(conn, params) do

View file

@ -28,9 +28,12 @@ defmodule Pleroma.Web.Feed.UserController do
ActivityPubController.call(conn, :user)
end
def feed_redirect(conn, %{"nickname" => nickname}) do
def feed_redirect(%{assigns: assigns} = conn, %{"nickname" => nickname}) do
format = Map.get(assigns, :format, "atom")
format = if format in ["atom", "rss"], do: format, else: "atom"
with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do
redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom")
redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.#{format}")
end
end

View file

@ -0,0 +1,20 @@
defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherController do
use Pleroma.Web, :controller
alias Pleroma.Config
@doc "GET /frontend_switcher"
def switch(conn, _params) do
pickable = Config.get([:frontends, :pickable], [])
conn
|> put_view(Pleroma.Web.FrontendSwitcher.FrontendSwitcherView)
|> render("switch.html", choices: pickable)
end
@doc "POST /frontend_switcher"
def do_switch(conn, params) do
conn
|> put_resp_cookie("preferred_frontend", params["frontend"])
|> html(~s(<meta http-equiv="refresh" content="0; url=/">))
end
end

View file

@ -0,0 +1,5 @@
defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherView do
use Pleroma.Web, :view
import Phoenix.HTML.Form
end

View file

@ -31,14 +31,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(:skip_auth when action in [:create, :lookup])
plug(:skip_auth when action in [:create])
plug(:skip_public_check when action in [:show, :statuses])
plug(
OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
when action in [:show, :followers, :following]
when action in [:show, :followers, :following, :lookup, :endorsements]
)
plug(
@ -50,7 +50,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
plug(
OAuthScopesPlug,
%{scopes: ["read:accounts"]}
when action in [:verify_credentials, :endorsements]
when action in [:verify_credentials, :endorsements, :own_endorsements]
)
plug(
@ -89,7 +89,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
@relationship_actions [:follow, :unfollow, :remove_from_followers]
@needs_account ~W(
followers following lists follow unfollow mute unmute block unblock
note endorse unendorse remove_from_followers
note endorse unendorse endorsements remove_from_followers
)a
plug(
@ -555,6 +555,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
end
end
@doc "GET /api/v1/accounts/:id/endorsements"
def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do
users =
user
|> User.endorsed_users_relation(_restrict_deactivated = true)
|> Pleroma.Repo.all()
conn
|> render("index.json",
for: for_user,
users: users,
as: :user,
embed_relationships: embed_relationships?(params)
)
end
@doc "POST /api/v1/accounts/:id/remove_from_followers"
def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
{:error, "Can not unfollow yourself"}
@ -619,8 +635,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
end
@doc "GET /api/v1/accounts/lookup"
def lookup(%{private: %{open_api_spex: %{params: %{acct: nickname}}}} = conn, _params) do
with %User{} = user <- User.get_by_nickname(nickname) do
def lookup(
%{assigns: %{user: for_user}, private: %{open_api_spex: %{params: %{acct: nickname}}}} =
conn,
_params
) do
with %User{} = user <- User.get_by_nickname(nickname),
:visible <- User.visible_for(user, for_user) do
render(conn, "show.json",
user: user,
skip_visibility_check: true
@ -631,7 +652,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
end
@doc "GET /api/v1/endorsements"
def endorsements(%{assigns: %{user: user}} = conn, params) do
def own_endorsements(%{assigns: %{user: user}} = conn, params) do
users =
user
|> User.endorsed_users_relation(_restrict_deactivated = true)

View file

@ -16,6 +16,12 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do
def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do
render(conn, "show.json", activity: activity)
else
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
end

View file

@ -91,6 +91,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
limit: min(params[:limit], @search_limit),
offset: params[:offset],
type: params[:type],
capabilities: params[:capabilities],
author: get_author(params),
embed_relationships: ControllerHelper.embed_relationships?(params),
for_user: user

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
only: [try_render: 3, add_link_headers: 2]
require Ecto.Query
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.Bookmark
@ -41,7 +42,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
:show,
:context,
:show_history,
:show_source
:show_source,
:quotes
]
)
@ -317,6 +319,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
@doc "DELETE /api/v1/statuses/:id"
def delete(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
# CommonAPI already checks whether user is allowed to delete
{:ok, %Activity{}} <- CommonAPI.delete(id, user) do
try_render(conn, "show.json",
activity: activity,
@ -338,6 +341,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
_
) do
with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params),
# CommonAPI already checks whether user is allowed to reblog
%Activity{} = announce <- Activity.normalize(announce.data) do
try_render(conn, "show.json", %{activity: announce, for: user, as: :activity})
end
@ -362,6 +366,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
_
) do
with {:ok, _fav} <- CommonAPI.favorite(activity_id, user),
# CommonAPI already checks whether user is allowed to reblog
%Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end
@ -388,6 +393,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
# Order matters, if status is not owned by user and is not visible to user
# return 404 just like other endpoints
{:error, :pinned_statuses_limit_reached} ->
{:error, "You have already pinned the maximum number of statuses"}
@ -395,6 +402,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
{:error, :unprocessable_entity, "Someone else's status cannot be pinned"}
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
{:error, :non_public_error} ->
{:error, :unprocessable_entity, "Non-public status cannot be pinned"}
error ->
@ -408,8 +418,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
conn,
_
) do
# CommonAPI already checks whether user can unpin
with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
# Order matters, if status is not owned by user and is not visible to user
# return 404 just like other endpoints
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
{:error, :ownership_error} ->
{:error, :unprocessable_entity, "Someone else's status cannot be unpinned"}
error ->
error
end
end
@ -432,6 +454,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
),
{:ok, _bookmark} <- Bookmark.create(user.id, activity.id, folder_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
false ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@ -445,6 +473,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
true <- Visibility.visible_for_user?(activity, user),
{:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
false ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@ -457,8 +491,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
_
) do
with %Activity{} = activity <- Activity.get_by_id(id),
# CommonAPI already checks whether user is allowed to mute
{:ok, activity} <- CommonAPI.add_mute(activity, user, params) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@ -471,8 +512,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
_
) do
with %Activity{} = activity <- Activity.get_by_id(id),
# CommonAPI already checks whether user is allowed to unmute
{:ok, activity} <- CommonAPI.remove_mute(activity, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@ -488,6 +536,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
users =
User
|> Ecto.Query.where([u], u.ap_id in ^likes)
|> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^likes, u.ap_id))
|> Repo.all()
|> Enum.filter(&(not User.blocks?(user, &1)))
@ -523,6 +572,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
users =
User
|> Ecto.Query.where([u], u.ap_id in ^announces)
|> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^announces, u.ap_id))
|> Repo.all()
|> Enum.filter(&(not User.blocks?(user, &1)))
@ -629,6 +679,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
)
end
@doc "GET /api/v1/statuses/:id/quotes"
def quotes(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} =
conn,
_
) do
with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
params =
params
|> Map.put(:type, "Create")
|> Map.put(:blocking_user, user)
|> Map.put(:quote_url, object.data["id"])
recipients =
if user do
[Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)]
else
[Pleroma.Constants.as_public()]
end
activities =
recipients
|> ActivityPub.fetch_activities(params)
|> Enum.reverse()
conn
|> add_link_headers(activities)
|> render("index.json",
activities: activities,
for: user,
as: :activity
)
else
nil -> {:error, :not_found}
false -> {:error, :not_found}
end
end
defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do
if user.disclose_client do
%{client_name: client_name, website: website} = Repo.preload(token, :app).app

View file

@ -146,6 +146,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
"pleroma_emoji_reactions",
"pleroma_custom_emoji_reactions",
"pleroma_chat_messages",
"pleroma:pin_chats",
if Config.get([:instance, :show_reactions]) do
"exposable_reactions"
end,
@ -257,10 +258,34 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
vapid: %{
public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
},
translation: %{enabled: Pleroma.Language.Translation.configured?()}
translation: %{enabled: Pleroma.Language.Translation.configured?()},
timelines_access: %{
live_feeds: timelines_access(),
hashtag_feeds: timelines_access(),
# not implemented in Pleroma
trending_link_feeds: %{
local: "disabled",
remote: "disabled"
}
}
})
end
defp timelines_access do
%{
local: timeline_access(:local),
remote: timeline_access(:federated)
}
end
defp timeline_access(kind) do
if Config.restrict_unauthenticated_access?(:timelines, kind) do
"authenticated"
else
"public"
end
end
defp pleroma_configuration(instance) do
base_urls = %{}

View file

@ -106,27 +106,15 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
}
case notification.type do
"mention" ->
type when type in ["mention", "status", "poll"] ->
put_status(response, activity, reading_user, status_render_opts)
"status" ->
put_status(response, activity, reading_user, status_render_opts)
"favourite" ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
"reblog" ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
"update" ->
type when type in ["favourite", "reblog", "update"] ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
"move" ->
put_target(response, activity, reading_user, %{})
"poll" ->
put_status(response, activity, reading_user, status_render_opts)
"pleroma:emoji_reaction" ->
response
|> put_status(parent_activity_fn.(), reading_user, status_render_opts)

View file

@ -240,7 +240,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
object = Object.normalize(activity, fetch: false)
user = CommonAPI.get_user(activity.data["actor"])
user = CommonAPI.get_user(object.data["actor"])
user_follower_address = user.follower_address
like_count = object.data["like_count"] || 0
@ -447,6 +447,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
application: build_application(object.data["generator"]),
language: get_language(object),
emojis: build_emojis(object.data["emoji"]),
quotes_count: object.data["quotesCount"] || 0,
pleroma: %{
local: activity.local,
conversation_id: get_context_id(activity),
@ -602,7 +603,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
def render("attachment.json", %{attachment: attachment}) do
[attachment_url | _] = attachment["url"]
media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
href = attachment_url["href"] |> MediaProxy.url()
href_remote = attachment_url["href"]
href = href_remote |> MediaProxy.url()
href_preview = attachment_url["href"] |> MediaProxy.preview_url()
meta = render("attachment_meta.json", %{attachment: attachment})
@ -641,7 +643,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
%{
id: attachment_id,
url: href,
remote_url: href,
remote_url: href_remote,
preview_url: href_preview,
text_url: href,
type: type,

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.MediaProxy do
alias Pleroma.Config
alias Pleroma.Helpers.UriHelper
alias Pleroma.Upload
alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.Endpoint
alias Pleroma.Web.MediaProxy.Invalidation
@ -99,13 +100,21 @@ defmodule Pleroma.Web.MediaProxy do
{base64, sig64}
end
# The URL coming into MediaProxy from the outside might have wrong %-encoding
# (like older Pleroma versions).
# This would cause an inconsistency with the encoded URL here and the requested
# URL fixed with Pleroma.Tesla.Middleware.EncodeUrl.
# End result is a failing HEAD request in
# Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2
def encode_url(url) do
url = URIEncoding.encode_url(url)
{base64, sig64} = base64_sig64(url)
build_url(sig64, base64, filename(url))
end
def encode_preview_url(url, preview_params \\ []) do
url = URIEncoding.encode_url(url)
{base64, sig64} = base64_sig64(url)
build_preview_url(sig64, base64, filename(url), preview_params)

View file

@ -36,7 +36,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do
conn
|> put_resp_header(
"content-type",
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8"
"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/#{version}#\"; charset=utf-8"
)
|> json(node_info)
end

View file

@ -9,7 +9,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
only: [
json_response: 3,
add_link_headers: 2,
embed_relationships?: 1,
assign_account_by_id: 2
]
@ -45,12 +44,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
%{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites
)
plug(
OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
when action == :endorsements
)
plug(
OAuthScopesPlug,
%{scopes: ["read:accounts"]} when action == :birthdays
@ -60,7 +53,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
plug(
:assign_account_by_id
when action in [:favourites, :endorsements, :subscribe, :unsubscribe]
when action in [:favourites, :subscribe, :unsubscribe]
)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation
@ -109,22 +102,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
)
end
@doc "GET /api/v1/pleroma/accounts/:id/endorsements"
def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do
users =
user
|> User.endorsed_users_relation(_restrict_deactivated = true)
|> Pleroma.Repo.all()
conn
|> render("index.json",
for: for_user,
users: users,
as: :user,
embed_relationships: embed_relationships?(params)
)
end
@doc "POST /api/v1/pleroma/accounts/:id/subscribe"
def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do
with {:ok, _subscription} <- User.subscribe(user, subscription_target) do

View file

@ -29,7 +29,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do
:create,
:mark_as_read,
:mark_message_as_read,
:delete_message
:delete_message,
:pin,
:unpin
]
)
@ -199,8 +201,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do
user_id
|> Chat.for_user_query()
|> where([c], c.recipient not in ^exclude_users)
|> restrict_pinned(params)
end
defp restrict_pinned(query, %{pinned: pinned}) when is_boolean(pinned) do
query
|> where([c], c.pinned == ^pinned)
end
defp restrict_pinned(query, _), do: query
def create(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with %User{ap_id: recipient} <- User.get_cached_by_id(id),
{:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do
@ -214,6 +224,20 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do
end
end
def pin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{:ok, chat} <- Chat.pin(chat) do
render(conn, "show.json", chat: chat)
end
end
def unpin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{:ok, chat} <- Chat.unpin(chat) do
render(conn, "show.json", chat: chat)
end
end
defp idempotency_key(conn) do
case get_req_header(conn, "idempotency-key") do
[key] -> key

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.Plugs.OAuthScopesPlug
@ -28,6 +29,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do
with true <- Pleroma.Config.get([:instance, :show_reactions]),
%Activity{} = activity <- Activity.get_by_id_with_object(activity_id),
{_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
%Object{} = object <- Object.normalize(activity, fetch: false),
reactions <- Object.get_emoji_reactions(object) do
reactions =
@ -37,6 +39,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
render(conn, "index.json", emoji_reactions: reactions, user: user)
else
{:visible, _} -> {:error, :not_found}
_e -> json(conn, [])
end
end
@ -76,6 +79,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
|> Pleroma.Emoji.fully_qualify_emoji()
|> Pleroma.Emoji.maybe_quote()
# CommonAPI checks if allowed to react
with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do
activity = Activity.get_by_id(activity_id)
@ -91,6 +95,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
|> Pleroma.Emoji.fully_qualify_emoji()
|> Pleroma.Emoji.maybe_quote()
# CommonAPI checks only author can revoke reactions
with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do
activity = Activity.get_by_id(activity_id)

View file

@ -0,0 +1,27 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.FollowRequestController do
use Pleroma.Web, :controller
alias Pleroma.User
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]})
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFollowRequestOperation
@doc "GET /api/v1/pleroma/outgoing_follow_requests"
def outgoing(%{assigns: %{user: follower}} = conn, _params) do
follow_requests = User.get_outgoing_follow_requests(follower)
conn
|> put_view(Pleroma.Web.MastodonAPI.FollowRequestView)
|> render("index.json", for: follower, users: follow_requests, as: :user)
end
end

View file

@ -0,0 +1,37 @@
defmodule Pleroma.Web.PleromaAPI.FrontendSettingsController do
use Pleroma.Web, :controller
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(
OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: []}
when action in [
:available_frontends,
:update_preferred_frontend
]
)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
@doc "GET /api/v1/pleroma/preferred_frontend/available"
def available_frontends(conn, _params) do
available = Pleroma.Config.get([:frontends, :pickable])
conn
|> json(available)
end
@doc "PUT /api/v1/pleroma/preferred_frontend"
def update_preferred_frontend(
%{body_params: %{frontend_name: preferred_frontend}} = conn,
_params
) do
conn
|> put_resp_cookie("preferred_frontend", preferred_frontend)
|> json(%{frontend_name: preferred_frontend})
end
end

View file

@ -16,10 +16,10 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do
plug(
OAuthScopesPlug,
%{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index
%{scopes: ["read:scrobbles"], fallback: :proceed_unauthenticated} when action == :index
)
plug(OAuthScopesPlug, %{scopes: ["write"]} when action == :create)
plug(OAuthScopesPlug, %{scopes: ["write:scrobbles"]} when action == :create)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaScrobbleOperation

View file

@ -5,16 +5,9 @@
defmodule Pleroma.Web.PleromaAPI.StatusController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
require Ecto.Query
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(Pleroma.Web.ApiSpec.CastAndValidate)
@ -29,38 +22,9 @@ defmodule Pleroma.Web.PleromaAPI.StatusController do
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaStatusOperation
@doc "GET /api/v1/pleroma/statuses/:id/quotes"
def quotes(%{assigns: %{user: user}} = conn, %{id: id} = params) do
with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
params =
params
|> Map.put(:type, "Create")
|> Map.put(:blocking_user, user)
|> Map.put(:quote_url, object.data["id"])
recipients =
if user do
[Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)]
else
[Pleroma.Constants.as_public()]
end
activities =
recipients
|> ActivityPub.fetch_activities(params)
|> Enum.reverse()
conn
|> add_link_headers(activities)
|> put_view(StatusView)
|> render("index.json",
activities: activities,
for: user,
as: :activity
)
else
nil -> {:error, :not_found}
false -> {:error, :not_found}
end
def quotes(conn, _params) do
conn
|> put_view(Pleroma.Web.MastodonAPI.StatusView)
|> Pleroma.Web.MastodonAPI.StatusController.call(:quotes)
end
end

View file

@ -24,7 +24,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do
last_message:
last_message &&
MessageReferenceView.render("show.json", chat_message_reference: last_message),
updated_at: Utils.to_masto_date(chat.updated_at)
updated_at: Utils.to_masto_date(chat.updated_at),
pinned: chat.pinned
}
end

View file

@ -5,17 +5,23 @@
defmodule Pleroma.Web.Plugs.FrontendStatic do
require Pleroma.Constants
@frontend_cookie_name "preferred_frontend"
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends.
"""
@behaviour Plug
def file_path(path, frontend_type \\ :primary) do
if configuration = Pleroma.Config.get([:frontends, frontend_type]) do
instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static")
defp instance_static_path do
Pleroma.Config.get([:instance, :static_dir], "instance/static")
end
def file_path(path, frontend_type \\ :primary)
def file_path(path, frontend_type) when is_atom(frontend_type) do
if configuration = Pleroma.Config.get([:frontends, frontend_type]) do
Path.join([
instance_static_path,
instance_static_path(),
"frontends",
configuration["name"],
configuration["ref"],
@ -26,6 +32,15 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
end
end
def file_path(path, frontend_type) when is_binary(frontend_type) do
Path.join([
instance_static_path(),
"frontends",
frontend_type,
path
])
end
def init(opts) do
opts
|> Keyword.put(:from, "__unconfigured_frontend_static_plug")
@ -36,7 +51,8 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
def call(conn, opts) do
with false <- api_route?(conn.path_info),
false <- invalid_path?(conn.path_info),
frontend_type <- Map.get(opts, :frontend_type, :primary),
fallback_frontend_type <- Map.get(opts, :frontend_type, :primary),
frontend_type <- preferred_or_fallback(conn, fallback_frontend_type),
path when not is_nil(path) <- file_path("", frontend_type) do
call_static(conn, opts, path)
else
@ -45,6 +61,31 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do
end
end
def preferred_frontend(conn) do
%{req_cookies: cookies} =
conn
|> Plug.Conn.fetch_cookies()
Map.get(cookies, @frontend_cookie_name)
end
# Only override primary frontend
def preferred_or_fallback(conn, :primary) do
case preferred_frontend(conn) do
nil ->
:primary
frontend ->
if Enum.member?(Pleroma.Config.get([:frontends, :pickable], []), frontend) do
frontend
else
:primary
end
end
end
def preferred_or_fallback(_conn, fallback), do: fallback
defp invalid_path?(list) do
invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"]))
end

View file

@ -13,11 +13,11 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do
"""
@behaviour Plug
def file_path(path) do
def file_path(path, frontend_type \\ :primary) do
instance_path =
Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path)
frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, :primary)
frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type)
(File.exists?(instance_path) && instance_path) ||
(frontend_path && File.exists?(frontend_path) && frontend_path) ||

View file

@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do
end
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
@supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a
@supported_alert_types ~w[follow favourite mention status reblog poll pleroma:chat_mention pleroma:emoji_reaction]a
defp alerts(%{data: %{alerts: alerts}}) do
alerts = Map.take(alerts, @supported_alert_types)

View file

@ -126,6 +126,11 @@ defmodule Pleroma.Web.RichMedia.Helpers do
end
defp req_headers do
[{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}]
user_agent = Config.get([:rich_media, :user_agent], :default)
case user_agent do
:default -> [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}]
custom -> [{"user-agent", custom}]
end
end
end

View file

@ -561,6 +561,18 @@ defmodule Pleroma.Web.Router do
get("/apps", AppController, :index)
get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index)
get("/statuses/:id/reactions", EmojiReactionController, :index)
get(
"/preferred_frontend/available",
FrontendSettingsController,
:available_frontends
)
put(
"/preferred_frontend",
FrontendSettingsController,
:update_preferred_frontend
)
end
scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do
@ -581,6 +593,8 @@ defmodule Pleroma.Web.Router do
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
post("/chats/:id/read", ChatController, :mark_as_read)
post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read)
post("/chats/:id/pin", ChatController, :pin)
post("/chats/:id/unpin", ChatController, :unpin)
get("/conversations/:id/statuses", ConversationController, :statuses)
get("/conversations/:id", ConversationController, :show)
@ -603,12 +617,13 @@ defmodule Pleroma.Web.Router do
post("/bookmark_folders", BookmarkFolderController, :create)
patch("/bookmark_folders/:id", BookmarkFolderController, :update)
delete("/bookmark_folders/:id", BookmarkFolderController, :delete)
get("/outgoing_follow_requests", FollowRequestController, :outgoing)
end
scope [] do
pipe_through(:api)
get("/accounts/:id/favourites", AccountController, :favourites)
get("/accounts/:id/endorsements", AccountController, :endorsements)
get("/statuses/:id/quotes", StatusController, :quotes)
end
@ -637,6 +652,11 @@ defmodule Pleroma.Web.Router do
get("/accounts/:id/scrobbles", ScrobbleController, :index)
end
scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/:id/endorsements", AccountController, :endorsements)
end
scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
@ -653,7 +673,7 @@ defmodule Pleroma.Web.Router do
get("/accounts/relationships", AccountController, :relationships)
get("/accounts/familiar_followers", AccountController, :familiar_followers)
get("/accounts/:id/lists", AccountController, :lists)
get("/endorsements", AccountController, :endorsements)
get("/endorsements", AccountController, :own_endorsements)
get("/blocks", AccountController, :blocks)
get("/mutes", AccountController, :mutes)
@ -667,6 +687,8 @@ defmodule Pleroma.Web.Router do
post("/accounts/:id/note", AccountController, :note)
post("/accounts/:id/pin", AccountController, :endorse)
post("/accounts/:id/unpin", AccountController, :unendorse)
post("/accounts/:id/endorse", AccountController, :endorse)
post("/accounts/:id/unendorse", AccountController, :unendorse)
post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers)
get("/conversations", ConversationController, :index)
@ -742,6 +764,7 @@ defmodule Pleroma.Web.Router do
post("/statuses/:id/mute", StatusController, :mute_conversation)
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
post("/statuses/:id/translate", StatusController, :translate)
get("/statuses/:id/quotes", StatusController, :quotes)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
@ -782,6 +805,7 @@ defmodule Pleroma.Web.Router do
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id/endorsements", AccountController, :endorsements)
get("/accounts/:id", AccountController, :show)
post("/accounts", AccountController, :create)
@ -894,7 +918,11 @@ defmodule Pleroma.Web.Router do
scope "/", Pleroma.Web do
pipe_through(:browser)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch)
post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch)
end
pipeline :ap_service_actor do
@ -944,6 +972,7 @@ defmodule Pleroma.Web.Router do
get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
get("/objects/:uuid/replies", ActivityPubController, :object_replies)
end
scope "/", Pleroma.Web.ActivityPub do

View file

@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do
alias Pleroma.Chat.MessageReference
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
@ -321,6 +322,16 @@ defmodule Pleroma.Web.Streamer do
end)
end
defp do_stream(topic, %Marker{} = marker) do
Registry.dispatch(@registry, "#{topic}:#{marker.user_id}", fn list ->
Enum.each(list, fn {pid, _auth} ->
text = StreamerView.render("marker.json", marker)
send(pid, {:text, text})
end)
end)
end
defp do_stream(topic, item) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")

View file

@ -0,0 +1,7 @@
<h2>Switch frontend</h2>
<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %>
<%= select(f, :frontend, @choices) %>
<%= submit do: "submit" %>
<% end %>

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.StreamerView do
alias Pleroma.Activity
alias Pleroma.Conversation.Participation
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.User
alias Pleroma.Web.MastodonAPI.NotificationView
@ -164,6 +165,19 @@ defmodule Pleroma.Web.StreamerView do
|> Jason.encode!()
end
def render("marker.json", %Marker{} = marker) do
%{
event: "marker",
payload:
Pleroma.Web.MastodonAPI.MarkerView.render(
"markers.json",
markers: [marker]
)
|> Jason.encode!()
}
|> Jason.encode!()
end
def render("pleroma_respond.json", %{type: type, result: result} = params) do
%{
event: "pleroma:respond",

View file

@ -195,7 +195,9 @@ defmodule Pleroma.Web.WebFinger do
defp get_address_from_domain(_, _), do: {:error, :webfinger_no_domain}
@spec finger(String.t()) :: {:ok, map()} | {:error, any()}
def finger(account) do
def finger(account), do: do_finger(account, true)
defp do_finger(account, follow_redirects) do
account = String.trim_leading(account, "@")
domain =
@ -229,8 +231,15 @@ defmodule Pleroma.Web.WebFinger do
{:error, {:content_type, nil}}
end
|> case do
{:ok, data} -> validate_webfinger(address, data)
error -> error
{:ok, data} ->
if follow_redirects do
validate_webfinger(address, data)
else
{:ok, data}
end
error ->
error
end
else
error ->
@ -241,10 +250,8 @@ defmodule Pleroma.Web.WebFinger do
defp validate_webfinger(request_url, %{"subject" => "acct:" <> acct = subject} = data) do
with [_name, acct_host] <- String.split(acct, "@"),
{_, url} <- {:address, get_address_from_domain(acct_host, subject)},
%URI{host: request_host} <- URI.parse(request_url),
%URI{host: acct_host} <- URI.parse(url),
{_, true} <- {:hosts_match, acct_host == request_host} do
{_, resolved_url} <- {:address, get_address_from_domain(acct_host, subject)},
{_, true} <- {:url_match, resolved_webfinger_matches?(request_url, resolved_url, data)} do
{:ok, data}
else
_ -> {:error, {:webfinger_invalid, request_url, data}}
@ -252,4 +259,29 @@ defmodule Pleroma.Web.WebFinger do
end
defp validate_webfinger(url, data), do: {:error, {:webfinger_invalid, url, data}}
defp resolved_webfinger_matches?(request_url, resolved_url, _data)
when request_url == resolved_url do
true
end
defp resolved_webfinger_matches?(
_request_url,
_resolved_url,
%{"subject" => "acct:" <> acct} = data
) do
with {:ok, %{"subject" => "acct:" <> new_acct} = new_data} <- do_finger(acct, false),
true <- acct == new_acct,
true <- webfinger_data_matches?(data, new_data) do
true
else
_ -> false
end
end
defp webfinger_data_matches?(%{"ap_id" => ap_id}, %{"ap_id" => ap_id}) when ap_id != "" do
true
end
defp webfinger_data_matches?(_data, _new_data), do: false
end