Merge branch 'develop' into audit-mix-audit-report

This commit is contained in:
Phantasm 2026-08-19 22:20:55 +02:00
commit 45459c8398
No known key found for this signature in database
GPG key ID: 2669E588BCC634C8
156 changed files with 8723 additions and 731 deletions

View file

@ -62,7 +62,8 @@ defmodule Mix.Pleroma do
{Majic.Pool,
[name: Pleroma.MajicPool, pool_size: Pleroma.Config.get([:majic_pool, :size], 2)]}
] ++
http_children(adapter)
http_children(adapter) ++
search_children()
cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, []))
@ -141,4 +142,14 @@ defmodule Mix.Pleroma do
end
defp http_children(_), do: []
defp search_children do
case Pleroma.Config.get([Pleroma.Search, :module]) do
Pleroma.Search.ParadeDB ->
[Pleroma.Search.ParadeDB.Repo]
_ ->
[]
end
end
end

View file

@ -335,9 +335,12 @@ defmodule Mix.Tasks.Pleroma.Config do
group
|> Pleroma.Config.Loader.filter_group(settings)
|> Enum.each(fn {key, value} ->
{:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value})
shell_info("Settings for key #{key} migrated.")
if ConfigDB.static?(group, key) do
shell_info("Settings for key #{key} must remain in static configuration; skipping.")
else
{:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value})
shell_info("Settings for key #{key} migrated.")
end
end)
shell_info("Settings for group #{inspect(group)} migrated.")

View file

@ -4,9 +4,6 @@
defmodule Mix.Tasks.Pleroma.Search.Indexer do
import Mix.Pleroma
import Ecto.Query
alias Pleroma.Workers.SearchIndexingWorker
def run(["create_index"]) do
start_pleroma()
@ -35,49 +32,30 @@ defmodule Mix.Tasks.Pleroma.Search.Indexer do
strict: [
chunk: :integer,
limit: :integer,
step: :integer
step: :integer,
before: :string
]
)
start_pleroma()
chunk_size = Keyword.get(options, :chunk, 100)
limit = Keyword.get(options, :limit, 100_000)
per_step = Keyword.get(options, :step, 1000)
result =
Pleroma.Search.Backfill.run(
options ++
[
on_page: fn page ->
IO.puts(
"Queued #{page.count} activities (#{page.enqueued} total), " <>
"checkpoint: #{page.next_cursor}"
)
end
]
)
chunks = max(div(limit, per_step), 1)
IO.puts("Queued #{result.enqueued} activities")
1..chunks
|> Enum.each(fn step ->
q =
from(a in Pleroma.Activity,
limit: ^per_step,
offset: ^per_step * (^step - 1),
select: [:id],
order_by: [desc: :id]
)
{:ok, ids} =
Pleroma.Repo.transaction(fn ->
Pleroma.Repo.stream(q, timeout: :infinity)
|> Enum.map(fn a ->
a.id
end)
end)
IO.puts("Got #{length(ids)} activities, adding to indexer")
ids
|> Enum.chunk_every(chunk_size)
|> Enum.each(fn chunk ->
IO.puts("Adding #{length(chunk)} activities to indexing queue")
chunk
|> Enum.map(fn id ->
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => id})
end)
|> Oban.insert_all()
end)
end)
unless result.exhausted do
IO.puts("Resume with --before #{result.next_cursor}")
end
end
end

View file

@ -81,12 +81,15 @@ defmodule Pleroma.Application do
[
Pleroma.PromEx,
Pleroma.LDAP,
Pleroma.Repo,
Config.TransferTask,
Pleroma.Emoji,
Pleroma.Web.Plugs.RateLimiter.Supervisor,
{Task.Supervisor, name: Pleroma.TaskSupervisor}
Pleroma.Repo
] ++
search_children() ++
[
Config.TransferTask,
Pleroma.Emoji,
Pleroma.Web.Plugs.RateLimiter.Supervisor,
{Task.Supervisor, name: Pleroma.TaskSupervisor}
] ++
cachex_children() ++
http_children(adapter) ++
[
@ -198,6 +201,16 @@ defmodule Pleroma.Application do
]
end
defp search_children do
case Config.get([Pleroma.Search, :module]) do
Pleroma.Search.ParadeDB ->
[Pleroma.Search.ParadeDB.Repo]
_ ->
[]
end
end
defp emoji_packs_expiration,
do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60))
@ -317,7 +330,8 @@ defmodule Pleroma.Application do
config = Config.get(ConcurrentLimiter, [])
[
Pleroma.Search
Pleroma.Search,
Pleroma.Webhook.Notify
]
|> Enum.each(fn module ->
mod_config = Keyword.get(config, module, [])

View file

@ -47,6 +47,7 @@ defmodule Pleroma.Config.TransferTask do
settings =
(Repo.all(ConfigDB) ++ deleted_settings)
|> Enum.reject(&ConfigDB.static?/1)
|> Enum.map(&merge_with_default/1)
started_applications = Application.started_applications()

View file

@ -23,6 +23,12 @@ defmodule Pleroma.ConfigDB do
{:pleroma, :mrf_keyword, :replace}
]
@static_config [
{:pleroma, Pleroma.Search},
{:pleroma, Pleroma.Search.ParadeDB},
{:pleroma, Pleroma.Search.ParadeDB.Repo}
]
schema "config" do
field(:key, Pleroma.EctoType.Config.Atom)
field(:group, Pleroma.EctoType.Config.Atom)
@ -32,16 +38,32 @@ defmodule Pleroma.ConfigDB do
timestamps()
end
@spec get_all_as_keyword() :: keyword()
def get_all_as_keyword do
@spec get_all_as_keyword(keyword()) :: keyword()
def get_all_as_keyword(options \\ []) do
ConfigDB
|> select([c], {c.group, c.key, c.value})
|> Repo.all()
|> maybe_reject_static(options)
|> Enum.reduce([], fn {group, key, value}, acc ->
Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}]))
end)
end
def static?(%{group: group, key: key}), do: static?(group, key)
def static?(_config), do: false
def static?(group, key) do
Enum.any?(@static_config, fn {static_group, static_key} ->
group in [static_group, inspect(static_group)] and key in [static_key, inspect(static_key)]
end)
end
defp maybe_reject_static(configs, exclude_static: true) do
Enum.reject(configs, fn {group, key, _value} -> static?(group, key) end)
end
defp maybe_reject_static(configs, _options), do: configs
@spec get_all_by_group(atom() | String.t()) :: [t()]
def get_all_by_group(group) do
from(c in ConfigDB, where: c.group == ^group) |> Repo.all()

View file

@ -346,6 +346,16 @@ defmodule Pleroma.ModerationLog do
"@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}"
end
def get_log_entry_message(%ModerationLog{
data: %{
"actor" => %{"nickname" => actor_nickname},
"action" => "reject",
"subject" => users
}
}) do
"@#{actor_nickname} rejected users: #{users_to_nicknames_string(users)}"
end
def get_log_entry_message(%ModerationLog{
data: %{
"actor" => %{"nickname" => actor_nickname},

View file

@ -15,12 +15,28 @@ defmodule Pleroma.Object.Containment do
actor
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
def get_actor(%{"actor" => nil}), do: nil
def get_actor(%{"actor" => []}), do: nil
def get_actor(%{"actor" => actor}) when is_list(actor) do
if is_binary(Enum.at(actor, 0)) do
Enum.at(actor, 0)
else
Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
|> Map.get("id")
case actor do
[first | _] when is_binary(first) ->
first
_ ->
Enum.find_value(actor, fn
%{"type" => type, "id" => id}
when type in ["Person", "Service", "Application"] and is_binary(id) ->
id
_ ->
nil
end)
end
end
@ -28,10 +44,6 @@ defmodule Pleroma.Object.Containment do
id
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
def get_object(%{"object" => id}) when is_binary(id) do
id
end
@ -67,9 +79,11 @@ defmodule Pleroma.Object.Containment do
def contain_origin(id, %{"actor" => _actor} = params) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
compare_uris(actor_uri, id_uri)
case get_actor(params) do
actor when is_binary(actor) -> compare_uris(URI.parse(actor), id_uri)
_ -> :error
end
end
def contain_origin(id, %{"attributedTo" => actor} = params),

View file

@ -12,18 +12,27 @@ defmodule Pleroma.ReverseProxy do
@keep_resp_headers @resp_cache_headers ++
~w(content-length content-type content-disposition content-encoding) ++
~w(content-range accept-ranges vary)
@default_cache_control_header "public, max-age=1209600, immutable"
@default_cache_control_header "public, max-age=1209600, immutable, no-transform"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@failed_request_ttl :timer.seconds(60)
@sniff_bytes 8 * 1024
@methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
defmodule StreamError do
@moduledoc false
defexception [:url, :reason]
@impl true
def message(%{url: url, reason: reason}) do
"reverse proxy stream from #{url} failed: #{inspect(reason)}"
end
end
def max_read_duration_default, do: @max_read_duration
def default_cache_control_header, do: @default_cache_control_header
@ -60,12 +69,7 @@ defmodule Pleroma.ReverseProxy do
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request,
* a list of whitelisted content types for which `content-disposition: inline`
is always set (overriding any upstream header) so the media can be embedded
in pages; the filename is derived from the content type
* `sniff_content_type` (default `false`): detects an image MIME type from the first
response chunk when the upstream type is missing or `application/octet-stream`.
* a list of whitelisted content types
* `req_headers`, `resp_headers` additional headers.
@ -102,7 +106,6 @@ defmodule Pleroma.ReverseProxy do
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())}
| {:sniff_content_type, boolean()}
| {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
@ -196,151 +199,93 @@ defmodule Pleroma.ReverseProxy do
end
defp request_with_constraints(method, url, headers, client_opts, opts) do
case request(method, url, headers, client_opts) do
{:ok, _code, headers, client} = response ->
case header_length_constraint(
headers,
Keyword.get(opts, :max_body_length, @max_body_length)
) do
:ok ->
response
method
|> request(url, headers, client_opts)
|> constrain_response(Keyword.get(opts, :max_body_length, @max_body_length))
end
error ->
client().close(client)
error
end
defp constrain_response({:ok, code, headers, client}, limit) do
case header_length_constraint(headers, limit) do
{:ok, headers} ->
{:ok, code, headers, client}
response ->
response
error ->
client().close(client)
error
end
end
defp constrain_response({:ok, code, headers}, limit) do
case header_length_constraint(headers, limit) do
{:ok, headers} -> {:ok, code, headers}
error -> error
end
end
defp constrain_response(response, _limit), do: response
defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
{headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat
|> send_chunked(status)
|> chunk_reply(client, opts, prefetched)
|> chunk_reply(client, opts)
case result do
{:ok, conn} ->
halt(conn)
{:error, :closed, conn} ->
{:error, {:downstream, _error}, conn} ->
client().close(client)
halt(conn)
{:error, error, conn} ->
{:error, {:upstream, error}, conn} ->
Logger.warning(
"#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
)
client().close(client)
halt(conn)
track_failed_url(url, error, opts)
raise_stream_error(conn, url, error)
end
end
defp chunk_reply(conn, client, opts, :none) do
defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0)
end
defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
with :ok <-
body_size_constraint(
byte_size(data),
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- chunk(conn, data) do
chunk_reply(conn, client, opts, byte_size(data), duration)
else
{:error, error} -> {:error, error, conn}
end
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, data, client, duration} <- read_chunk(client, duration, opts),
with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data),
:ok <-
body_size_constraint(
sent_so_far,
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- chunk(conn, data) do
{:ok, conn} <- write_chunk(conn, data) do
chunk_reply(conn, client, opts, sent_so_far, duration)
else
:done -> {:ok, conn}
{:error, error} -> {:error, error, conn}
{:error, {source, error}} -> {:error, {source, error}, conn}
{:error, error} -> {:error, {:upstream, error}, conn}
end
end
defp read_chunk(client, duration, opts) do
with {:ok, timer} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(timer) do
{:ok, data, client, duration}
defp write_chunk(conn, data) do
case chunk(conn, data) do
{:error, error} -> {:error, {:downstream, error}}
result -> result
end
end
defp maybe_prefetch_for_content_type(headers, client, opts) do
if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do
case read_chunk(client, 0, opts) do
{:ok, data, client, duration} ->
{maybe_put_image_content_type(headers, data), client, {:ok, data, duration}}
:done ->
{headers, client, :done}
{:error, error} ->
{headers, client, {:error, error}}
end
else
{headers, client, :none}
end
end
defp generic_content_type?(headers) do
headers
|> get_content_type()
|> String.trim()
|> String.downcase()
|> then(&(&1 in ["", "application/octet-stream"]))
end
defp maybe_put_image_content_type(headers, data) do
with false <- data == "",
{:ok, %{mime_type: "image/" <> _ = content_type}} <-
Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))},
pool: Pleroma.MajicPool
) do
[
{"content-type", content_type}
| Enum.reject(headers, fn {key, _} -> key == "content-type" end)
]
else
_ -> headers
end
rescue
error ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}")
headers
catch
kind, reason ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}")
headers
end
defp head_response(conn, url, code, headers, opts) do
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
@ -388,33 +333,16 @@ defmodule Pleroma.ReverseProxy do
|> build_req_range_or_encoding_header(opts)
|> build_req_user_agent_header(opts)
|> merge_headers(Keyword.get(opts, :req_headers, []))
|> maybe_force_identity_encoding(opts)
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp maybe_force_identity_encoding(headers, opts) do
if Keyword.get(opts, :sniff_content_type, false) do
List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"})
else
headers
end
end
# Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
cond do
range? && List.keymember?(headers, "accept-encoding", 0) ->
List.keydelete(headers, "accept-encoding", 0)
true ->
headers
if range? && List.keymember?(headers, "accept-encoding", 0) do
List.keydelete(headers, "accept-encoding", 0)
else
headers
end
end
@ -433,7 +361,30 @@ defmodule Pleroma.ReverseProxy do
|> build_resp_cache_headers(opts)
|> sanitise_content_type()
|> build_resp_content_disposition_header(opts)
|> Keyword.merge(Keyword.get(opts, :resp_headers, []))
|> merge_headers(Keyword.get(opts, :resp_headers, []))
|> ensure_no_transform()
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp ensure_no_transform(headers) do
{_, cache_control} =
List.keyfind(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
directives =
cache_control
|> String.split(",")
|> Enum.map(&(&1 |> String.trim() |> String.downcase()))
if "no-transform" in directives do
headers
else
replace_header(headers, "cache-control", cache_control <> ", no-transform")
end
end
defp sanitise_content_type(headers) do
@ -509,20 +460,7 @@ defmodule Pleroma.ReverseProxy do
replace_header(headers, "content-disposition", disposition)
else
if opt == true do
headers
else
name = inline_filename(content_type)
disposition =
if name do
"inline; filename=\"#{name}\""
else
"inline"
end
replace_header(headers, "content-disposition", disposition)
end
headers
end
end
@ -530,28 +468,34 @@ defmodule Pleroma.ReverseProxy do
[{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)]
end
defp inline_filename(content_type) do
case MIME.extensions(content_type) do
[ext | _] when ext != "" -> "inline.#{ext}"
_ -> nil
end
end
defp header_length_constraint(headers, limit) do
lengths =
for {"content-length", value} <- headers,
do: parse_content_length(value)
defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
with {_, size} <- List.keyfind(headers, "content-length", 0),
{size, _} <- Integer.parse(size),
true <- size <= limit do
:ok
else
false ->
{:error, :body_too_large}
case Enum.uniq(lengths) do
[] ->
{:ok, headers}
[size] when is_integer(size) ->
case body_size_constraint(size, limit) do
:ok -> {:ok, replace_header(headers, "content-length", to_string(size))}
error -> error
end
_ ->
:ok
{:error, :invalid_content_length}
end
end
defp header_length_constraint(_, _), do: :ok
defp parse_content_length(value) when is_binary(value) do
case Integer.parse(value) do
{size, ""} when size >= 0 -> size
_ -> :invalid
end
end
defp parse_content_length(_value), do: :invalid
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size > limit do
{:error, :body_too_large}
@ -587,6 +531,40 @@ defmodule Pleroma.ReverseProxy do
defp client, do: Pleroma.ReverseProxy.Client.Wrapper
# Neither Plug adapter exposes a public way to abort a committed response.
# Cowboy finalizes chunked HTTP/1 responses when the request process exits,
# so terminate the connection first to leave the response visibly incomplete.
defp raise_stream_error(
%Plug.Conn{adapter: {Plug.Cowboy.Conn, %{pid: connection_pid}}} = conn,
url,
error
) do
if Plug.Conn.get_http_protocol(conn) in [:"HTTP/1.0", :"HTTP/1.1"] do
Process.exit(connection_pid, :kill)
end
raise StreamError, url: url, reason: error
end
# Bandit turns this exception into RST_STREAM; a regular exception sends an
# empty DATA frame with END_STREAM and makes the partial body look complete.
defp raise_stream_error(
%Plug.Conn{
adapter: {Bandit.Adapter, %{transport: %{stream_id: stream_id}}}
},
url,
error
) do
raise Bandit.HTTP2.Errors.StreamError,
message: StreamError.message(%StreamError{url: url, reason: error}),
error_code: Bandit.HTTP2.Errors.internal_error(),
stream_id: stream_id
end
defp raise_stream_error(_conn, url, error) do
raise StreamError, url: url, reason: error
end
defp track_failed_url(url, error, opts) do
ttl =
unless error in [:body_too_large, 400, 204] do
@ -598,19 +576,6 @@ defmodule Pleroma.ReverseProxy do
@cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)
end
# When Cowboy handles a chunked response with a content-length header it streams
# over HTTP 1.1 instead of chunking. Bandit cannot stream over HTTP 1.1 so the header
# must be stripped or it breaks RFC compliance for Transfer Encoding: Chunked. RFC9112§6.2
#
# HTTP2 is always streamed for all adapters.
defp streaming_compat(conn) do
with Phoenix.Endpoint.Cowboy2Adapter <- Pleroma.Web.Endpoint.config(:adapter) do
conn
else
_ -> delete_resp_header(conn, "content-length")
end
end
# Only when Tesla adapter is Hackney or Finch does the URL
# need encoding before Reverse Proxying as both end up
# using the raw Hackney client and cannot leverage our

View file

@ -5,13 +5,12 @@ defmodule Pleroma.Search do
alias Pleroma.Workers.SearchIndexingWorker
@spec add_to_index(Activity.t()) :: {:ok, Oban.Job.t() | :noop} | {:error, Oban.Job.changeset()}
def add_to_index(%Activity{id: activity_id, object: %Object{} = object} = activity) do
with {_, true} <- {:indexable, indexable?(activity)},
{_, "public"} <- {:visibility, Visibility.get_visibility(object)} do
def add_to_index(%Activity{id: activity_id, object: %Object{}} = activity) do
if indexable?(activity) do
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => activity_id})
|> Oban.insert()
else
_ -> {:ok, :noop}
{:ok, :noop}
end
end
@ -41,40 +40,60 @@ defmodule Pleroma.Search do
def object_to_search_data(%Object{} = object) do
data = object.data
content_str =
case data["content"] do
[nil | rest] -> to_string(rest)
str -> str
end
content =
with {:ok, scrubbed} <-
FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing),
trimmed <- String.trim(scrubbed) do
trimmed
end
# Make sure we have a non-empty string
if content != "" do
{:ok, published, _} = DateTime.from_iso8601(data["published"])
data
|> search_texts()
|> Enum.map(&sanitize_search_text/1)
|> Enum.reject(&(&1 in ["", "."]))
|> Enum.join(" ")
with true <- content != "",
published when is_binary(published) <- data["published"],
{:ok, published, _} <- DateTime.from_iso8601(published) do
%{
id: object.id,
content: content,
ap: data["id"],
published: published |> DateTime.to_unix()
}
else
_ -> nil
end
end
defp indexable?(%Activity{
data: %{"type" => "Create"},
object: %Object{
data: %{"content" => content, "published" => published, "type" => "Note"}
}
})
when not is_nil(content) and content not in ["", "."] and not is_nil(published),
do: true
def indexable?(%Activity{
data: %{"type" => "Create"},
object: %Object{data: %{"published" => published, "type" => "Note"}} = object
})
when not is_nil(published) do
Visibility.get_visibility(object) in ["public", "unlisted"] and
not is_nil(object_to_search_data(object))
end
defp indexable?(_), do: false
def indexable?(_), do: false
defp search_texts(data) do
[data["content"], data["summary"] | attachment_names(data["attachment"])]
end
defp attachment_names(attachments) when is_list(attachments) do
Enum.map(attachments, fn
%{"summary" => summary} when is_binary(summary) -> summary
%{"name" => name} -> name
_ -> nil
end)
end
defp attachment_names(_), do: []
defp sanitize_search_text([nil | rest]), do: sanitize_search_text(to_string(rest))
defp sanitize_search_text(text) when is_binary(text) do
case FastSanitize.Sanitizer.scrub(text, Pleroma.HTML.Scrubber.SearchIndexing) do
{:ok, scrubbed} -> String.trim(scrubbed)
_ -> ""
end
end
defp sanitize_search_text(_), do: ""
end

View file

@ -0,0 +1,137 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.Backfill do
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.Workers.SearchIndexingWorker
@default_chunk_size 100
@default_step_size 1_000
@default_limit 100_000
@doc """
Enqueues existing Create activities for search indexing.
The returned `next_cursor` can be passed as `before` to resume a bounded run.
Pass `limit: :infinity` to continue until the oldest activity is reached.
"""
def run(options \\ []) do
options =
Keyword.validate!(options,
chunk: @default_chunk_size,
step: @default_step_size,
limit: @default_limit,
before: nil,
on_page: nil
)
chunk_size = positive_option!(options, :chunk, @default_chunk_size)
step_size = positive_option!(options, :step, @default_step_size)
limit = limit_option!(options)
before = cursor_option!(options[:before])
on_page = callback_option!(options[:on_page])
do_run(before, limit, step_size, chunk_size, on_page, 0)
end
defp do_run(before, remaining, step_size, chunk_size, on_page, enqueued) do
page_size = page_size(remaining, step_size)
ids = fetch_ids(before, page_size)
case ids do
[] ->
result(enqueued, before, true)
ids ->
enqueue(ids, chunk_size)
page_count = length(ids)
next_cursor = List.last(ids)
total = enqueued + page_count
remaining = decrement(remaining, page_count)
on_page.(%{count: page_count, enqueued: total, next_cursor: next_cursor})
cond do
remaining == 0 -> result(total, next_cursor, false)
page_count < page_size -> result(total, next_cursor, true)
true -> do_run(next_cursor, remaining, step_size, chunk_size, on_page, total)
end
end
end
defp fetch_ids(before, limit) do
Activity
|> where([a], fragment("?->>'type' = 'Create'", a.data))
|> maybe_before(before)
|> order_by([a], desc: a.id)
|> limit(^limit)
|> select([a], a.id)
|> Pleroma.Repo.all()
end
defp maybe_before(query, nil), do: query
defp maybe_before(query, before), do: where(query, [a], a.id < ^before)
defp enqueue(ids, chunk_size) do
ids
|> Enum.chunk_every(chunk_size)
|> Enum.each(fn chunk ->
chunk
|> Enum.map(&SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => &1}))
|> Oban.insert_all()
end)
end
defp page_size(:infinity, step_size), do: step_size
defp page_size(remaining, step_size), do: min(remaining, step_size)
defp decrement(:infinity, _count), do: :infinity
defp decrement(remaining, count), do: remaining - count
defp result(enqueued, next_cursor, exhausted) do
%{enqueued: enqueued, next_cursor: next_cursor, exhausted: exhausted}
end
defp positive_option!(options, key, default) do
case Keyword.get(options, key, default) do
value when is_integer(value) and value > 0 -> value
value -> raise ArgumentError, "#{key} must be a positive integer, got: #{inspect(value)}"
end
end
defp limit_option!(options) do
case Keyword.fetch!(options, :limit) do
:infinity -> :infinity
value when is_integer(value) and value > 0 -> value
value -> raise ArgumentError, "limit must be a positive integer, got: #{inspect(value)}"
end
end
defp cursor_option!(nil), do: nil
defp cursor_option!(cursor) do
valid? =
try do
match?({:ok, <<_::binary-size(16)>>}, FlakeId.Ecto.CompatType.dump(cursor))
rescue
_ -> false
end
if valid? do
cursor
else
raise ArgumentError, "before must be a valid activity id, got: #{inspect(cursor)}"
end
end
defp callback_option!(nil), do: fn _ -> :ok end
defp callback_option!(callback) when is_function(callback, 1), do: callback
defp callback_option!(callback) do
raise ArgumentError, "on_page must be a function of arity 1, got: #{inspect(callback)}"
end
end

View file

@ -0,0 +1,287 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB do
@behaviour Pleroma.Search.SearchBackend
require Logger
require Pleroma.Constants
import Ecto.Query
import Pleroma.Search.DatabaseSearch,
only: [
maybe_fetch: 3,
maybe_restrict_author: 2,
maybe_restrict_blocked: 2,
maybe_restrict_local: 2
]
alias Pleroma.Activity
alias Pleroma.Config.Getting, as: Config
alias Pleroma.Search
@max_limit 40
@default_table "pleroma_search_documents"
defp client_impl do
Config.get([Pleroma.Search.ParadeDB, :client_impl]) ||
Pleroma.Search.ParadeDB.Client.Postgres
end
defp table do
Config.get([Pleroma.Search.ParadeDB, :table], @default_table)
end
defp fuzzy_distance do
case Config.get([Pleroma.Search.ParadeDB, :fuzzy_distance], 0) do
value when is_integer(value) and value in 0..2 ->
value
value when is_binary(value) ->
case Integer.parse(value) do
{parsed, ""} when parsed in 0..2 -> parsed
_ -> 0
end
_ ->
0
end
end
@impl true
def create_index do
with {:ok, _} <- client_impl().query("CREATE EXTENSION IF NOT EXISTS pg_search", []),
{:ok, _} <- client_impl().query(create_table_sql(table()), []),
{:ok, _} <- client_impl().query(create_object_id_index_sql(table()), []),
{:ok, _} <- client_impl().query(create_bm25_index_sql(table()), []) do
:ok
else
{:error, error} ->
Logger.error("ParadeDB create_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def drop_index do
case client_impl().query("DROP TABLE IF EXISTS #{table()}", []) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB drop_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def healthcheck_endpoints, do: nil
@impl true
def add_to_index(activity) do
with true <- Search.indexable?(activity),
maybe_search_data when not is_nil(maybe_search_data) <-
Search.object_to_search_data(activity.object) do
dumped_activity_id = dump_activity_id(activity.id)
actor_ap_id = activity.data["actor"]
published_at = DateTime.from_unix!(maybe_search_data.published)
if is_nil(dumped_activity_id) do
Logger.error("ParadeDB add_to_index failed: invalid activity id #{inspect(activity.id)}")
:ok
else
sql =
"""
INSERT INTO #{table()} (id, object_id, object_ap_id, actor_ap_id, content, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
object_id = EXCLUDED.object_id,
object_ap_id = EXCLUDED.object_ap_id,
actor_ap_id = EXCLUDED.actor_ap_id,
content = EXCLUDED.content,
published_at = EXCLUDED.published_at
"""
params = [
dumped_activity_id,
maybe_search_data.id,
maybe_search_data.ap,
actor_ap_id,
maybe_search_data.content,
published_at
]
case client_impl().query(sql, params) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB add_to_index failed: #{inspect(error)}")
{:error, error}
end
end
else
_ -> :ok
end
end
@impl true
def remove_from_index(object) do
sql = "DELETE FROM #{table()} WHERE object_id = $1"
case client_impl().query(sql, [object.id]) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB remove_from_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def search(user, query, options \\ []) do
limit = options |> Keyword.get(:limit, @max_limit) |> min(@max_limit)
offset = Keyword.get(options, :offset, 0)
author = Keyword.get(options, :author)
case search_ids(query, limit, offset, author) do
{:ok, []} ->
maybe_fetch([], user, query)
{:ok, ids} ->
ids
|> fetch_activities(user, query, author)
{:error, error} ->
Logger.error("ParadeDB search failed: #{inspect(error)}")
maybe_fetch([], user, query)
end
end
defp fetch_activities(ids, user, query, author) do
dumped_ids =
ids
|> Enum.map(&dump_activity_id/1)
|> Enum.reject(&is_nil/1)
from(a in Activity, where: a.id in ^dumped_ids)
|> Activity.with_preloaded_object()
|> Activity.restrict_deactivated_users()
|> maybe_restrict_public(user)
|> maybe_restrict_local(user)
|> maybe_restrict_author(author)
|> maybe_restrict_blocked(user)
|> order_by(
[a],
fragment(
"array_position(?, ?)",
^dumped_ids,
a.id
)
)
|> Pleroma.Repo.all()
|> maybe_fetch(user, query)
end
defp maybe_restrict_public(query, %Pleroma.User{}) do
intended_recipients = [
Pleroma.Constants.as_public(),
Pleroma.Web.ActivityPub.Utils.as_local_public()
]
from(a in query,
where: fragment("?->>'type' = 'Create'", a.data),
where: fragment("? && ?", ^intended_recipients, a.recipients)
)
end
defp maybe_restrict_public(query, _user) do
from(a in query,
where: fragment("?->>'type' = 'Create'", a.data),
where: ^Pleroma.Constants.as_public() in a.recipients
)
end
defp search_ids(query, limit, offset, author) do
base_sql =
"SELECT id FROM #{table()} WHERE content &&& #{search_query_param_sql(fuzzy_distance())}"
params = [query]
{base_sql, params} =
case author do
%Pleroma.User{ap_id: ap_id} when is_binary(ap_id) ->
{base_sql <> " AND actor_ap_id = $2", params ++ [ap_id]}
_ ->
{base_sql, params}
end
limit_arg = length(params) + 1
offset_arg = length(params) + 2
sql =
base_sql <>
" ORDER BY published_at DESC, id DESC LIMIT $#{limit_arg} OFFSET $#{offset_arg}"
params = params ++ [limit, offset]
with {:ok, %{rows: rows}} <- client_impl().query(sql, params) do
{:ok, Enum.map(rows, fn [id] -> id end)}
end
end
defp search_query_param_sql(0), do: "$1"
defp search_query_param_sql(distance), do: "($1::text)::pdb.fuzzy(#{distance})"
defp create_table_sql(table) do
"""
CREATE TABLE IF NOT EXISTS #{table} (
id uuid PRIMARY KEY,
object_id bigint NOT NULL,
object_ap_id text NOT NULL,
actor_ap_id text,
content text NOT NULL,
published_at timestamptz
)
"""
end
defp create_object_id_index_sql(table) do
"CREATE INDEX IF NOT EXISTS #{table}_object_id_idx ON #{table} (object_id)"
end
defp create_bm25_index_sql(table) do
"""
CREATE INDEX IF NOT EXISTS #{table}_bm25_idx ON #{table}
USING bm25 (id, content, (actor_ap_id::pdb.literal), published_at)
WITH (key_field = 'id')
"""
end
defp dump_activity_id(<<_::binary-size(16)>> = uuid), do: uuid
defp dump_activity_id(id) when is_binary(id) do
case Ecto.UUID.dump(id) do
{:ok, <<_::binary-size(16)>> = uuid} ->
uuid
:error ->
dump_flake_activity_id(id)
end
end
defp dump_activity_id(_), do: nil
defp dump_flake_activity_id(id) do
case FlakeId.Ecto.CompatType.dump(id) do
{:ok, <<_::binary-size(16)>> = uuid} -> uuid
_ -> nil
end
rescue
_ -> nil
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB.Client do
@moduledoc false
@type sql :: String.t()
@type params :: list()
@type result :: %{optional(:rows) => list(list())}
@callback query(sql(), params()) :: {:ok, result()} | {:error, any()}
end
defmodule Pleroma.Search.ParadeDB.Client.Postgres do
@moduledoc false
@behaviour Pleroma.Search.ParadeDB.Client
@impl true
def query(sql, params \\ []) do
Ecto.Adapters.SQL.query(Pleroma.Search.ParadeDB.Repo, sql, params)
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB.Repo do
use Ecto.Repo,
otp_app: :pleroma,
adapter: Ecto.Adapters.Postgres,
migration_timestamps: [type: :naive_datetime_usec]
@doc """
Loads the ParadeDB database URL from `PARADEDB_DATABASE_URL`.
If the env var is not set, falls back to `config :pleroma, Pleroma.Search.ParadeDB, url: ...`.
"""
def init(_, opts) do
config = Application.get_env(:pleroma, Pleroma.Search.ParadeDB, [])
url = System.get_env("PARADEDB_DATABASE_URL") || config[:url]
url = if is_nil(url), do: nil, else: to_string(url)
{:ok, Keyword.put(opts, :url, url)}
end
end

View file

@ -39,6 +39,7 @@ defmodule Pleroma.User do
alias Pleroma.Web.Endpoint
alias Pleroma.Web.OAuth
alias Pleroma.Web.RelMe
alias Pleroma.Webhook.Notify
alias Pleroma.Workers.BackgroundWorker
alias Pleroma.Workers.DeleteWorker
alias Pleroma.Workers.UserRefreshWorker
@ -326,7 +327,7 @@ defmodule Pleroma.User do
@spec visible_for(User.t(), User.t() | nil) ::
:visible
| :invisible
| :restricted_unauthenticated
| :restrict_unauthenticated
| :deactivated
| :confirmation_pending
def visible_for(user, for_user \\ nil)
@ -987,6 +988,7 @@ defmodule Pleroma.User do
@doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
def register(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Repo.insert(changeset) do
Notify.trigger_webhooks(user, :"account.created")
post_register_action(user)
end
end
@ -1970,6 +1972,12 @@ defmodule Pleroma.User do
def approve(%User{} = user), do: {:ok, user}
def reject(%User{is_approved: false} = user) do
delete(user)
end
def reject(%User{} = _user), do: {:error, "User is approved"}
def confirm(users) when is_list(users) do
Repo.transaction(fn ->
Enum.map(users, fn user ->

View file

@ -63,7 +63,8 @@ defmodule Pleroma.User.Query do
limit: pos_integer(),
actor_types: [String.t()],
birthday_day: pos_integer(),
birthday_month: pos_integer()
birthday_month: pos_integer(),
staff: boolean()
}
| map()
@ -179,6 +180,10 @@ defmodule Pleroma.User.Query do
defp compose_query({:external, _}, query), do: location_query(query, false)
defp compose_query({:active, false}, query) do
where(query, [u], u.is_active == false or u.is_approved == false or u.is_confirmed == false)
end
defp compose_query({:active, _}, query) do
where(query, [u], u.is_active == true)
|> where([u], u.is_approved == true)
@ -202,6 +207,10 @@ defmodule Pleroma.User.Query do
where(query, [u], u.is_confirmed != ^bool)
end
defp compose_query({:need_approval, false}, query) do
where(query, [u], u.is_approved == true)
end
defp compose_query({:need_approval, _}, query) do
where(query, [u], u.is_approved == false)
end
@ -285,6 +294,19 @@ defmodule Pleroma.User.Query do
|> where([u], fragment("date_part('month', ?)", u.birthday) == ^month)
end
defp compose_query({:staff, false}, query) do
where(query, [u], u.is_admin == false and u.is_moderator == false)
end
defp compose_query({:staff, _}, query) do
where(query, [u], u.is_admin == true or u.is_moderator == true)
end
defp compose_query({:domain, domain}, query) do
query
|> where([u], like(u.nickname, ^"%@#{domain}"))
end
defp compose_query(_unsupported_param, query), do: query
defp location_query(query, local) do

View file

@ -30,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
import Ecto.Query
import Pleroma.Web.ActivityPub.Utils
import Pleroma.Web.ActivityPub.Visibility
import Pleroma.Webhook.Notify, only: [trigger_webhooks: 2]
require Logger
require Pleroma.Constants
@ -415,7 +416,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
_ <- trigger_webhooks(activity, :"report.created"),
: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)
@ -710,6 +713,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:user, reading_user)
|> Map.put(:actor_id, user.ap_id)
|> Map.put(:actor_local, user.local)
|> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects))
params =
@ -1016,6 +1020,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_state(query, _), do: query
defp restrict_report_target(query, %{report_target_id: actor_id}) do
from(activity in query,
where: fragment("?->'object'->>0 = ?", activity.data, ^actor_id)
)
end
defp restrict_report_target(query, _), do: query
defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do
from(activity in query,
where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account)
@ -1349,6 +1361,50 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_unauthenticated(query, _), do: query
defp restrict_activity_and_object_origin(query, local?) do
local_object_prefix = Pleroma.Web.Endpoint.url() <> "/"
# Keeping the correlated origin checks inside CASE prevents PostgreSQL from choosing a
# global bitmap-and-sort plan before applying the account statuses limit.
from([activity, object: object] in query,
where:
fragment(
"CASE WHEN ? = ? AND starts_with((?->>'id'), ?) = ? THEN true ELSE false END",
activity.local,
^local?,
object.data,
^local_object_prefix,
^local?
)
)
end
defp maybe_restrict_unauthenticated(
query,
%{restrict_unauthenticated: true, user: nil, actor_local: actor_local}
)
when is_boolean(actor_local) do
local_restricted = Config.restrict_unauthenticated_access?(:activities, :local)
remote_restricted = Config.restrict_unauthenticated_access?(:activities, :remote)
actor_restricted = if actor_local, do: local_restricted, else: remote_restricted
cond do
actor_restricted ->
from(activity in query, where: false)
local_restricted ->
restrict_activity_and_object_origin(query, false)
remote_restricted ->
restrict_activity_and_object_origin(query, true)
true ->
query
end
end
defp maybe_restrict_unauthenticated(query, _), do: query
defp restrict_quote_url(query, %{quote_url: quote_url}) do
from([_activity, object] in query,
where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url)
@ -1506,6 +1562,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts)
|> maybe_order(opts)
|> maybe_restrict_unauthenticated(opts)
|> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
|> restrict_replies(opts)
|> restrict_since(opts)
@ -1514,6 +1571,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> restrict_actor(opts)
|> restrict_type(opts)
|> restrict_state(opts)
|> restrict_report_target(opts)
|> restrict_assigned_account(opts)
|> restrict_favorited_by(opts)
|> restrict_blocked(restrict_blocked_opts)

View file

@ -309,7 +309,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
} = conn,
%{"nickname" => nickname} = params
) do
with {:recipient_exists, %User{} = recipient} <-
with false <- unsupported_http_message_signature?(conn),
{:recipient_exists, %User{} = recipient} <-
{:recipient_exists, User.get_cached_by_nickname(nickname)},
{:sender_exists, {:ok, %User{} = actor}} <-
{:sender_exists, User.get_or_fetch_by_ap_id(params["actor"])},
@ -320,6 +321,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
Federator.incoming_ap_doc(params)
json(conn, "ok")
else
true ->
reject_unsupported_http_message_signature(conn)
{:recipient_exists, _} ->
conn
|> put_status(:not_found)
@ -348,20 +352,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, params) do
Federator.incoming_ap_doc(params)
json(conn, "ok")
if unsupported_http_message_signature?(conn) do
reject_unsupported_http_message_signature(conn)
else
Federator.incoming_ap_doc(params)
json(conn, "ok")
end
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
if unsupported_http_message_signature?(conn) do
reject_unsupported_http_message_signature(conn)
else
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
json(conn, "ok")
json(conn, "ok")
end
end
# POST /relay/inbox -or- POST /internal/fetch/inbox
@ -381,6 +393,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> json("error, missing HTTP Signature")
end
defp reject_unsupported_http_message_signature(conn) do
conn
|> put_status(:unauthorized)
|> json("error, unsupported HTTP Message Signature")
end
defp unsupported_http_message_signature?(conn) do
signature_headers = Plug.Conn.get_req_header(conn, "signature")
multiple_signature_headers?(signature_headers) or
(Plug.Conn.get_req_header(conn, "signature-input") != [] and
not cavage_signature?(signature_headers))
end
defp multiple_signature_headers?([_, _ | _]), do: true
defp multiple_signature_headers?(_), do: false
defp cavage_signature?(signature_headers) do
Enum.any?(signature_headers, &Regex.match?(~r/(^|,)\s*keyId\s*=/, &1))
end
defp post_inbox_relayed_create(conn, params) do
Logger.debug(
"Signature missing or not from author, relayed Create message, fetching object from source"

View file

@ -118,6 +118,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
when objtype in ~w[Question Answer Audio Video Image Event Article Note Page] do
with {:ok, object_data} <-
object
|> CommonFixes.maybe_set_attributed_to_from_activity(create_activity)
|> CommonFixes.maybe_add_language_from_activity(create_activity)
|> cast_and_apply_and_stringify_with_history(),
meta = Keyword.put(meta, :object_data, object_data),
@ -172,6 +173,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
{_, {:ok, object_data, _}} <-
{:object_validation,
object
|> CommonFixes.maybe_set_attributed_to_from_activity(update_activity)
|> CommonFixes.maybe_add_language_from_activity(update_activity)
|> validate(meta)},
meta = Keyword.put(meta, :object_data, object_data),

View file

@ -50,7 +50,32 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
end
defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data
defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
defp fix_url(%{"url" => url} = data) when is_map(url) do
if is_binary(url["href"]) do
Map.put(data, "url", url["href"])
else
Map.delete(data, "url")
end
end
defp fix_url(%{"url" => url} = data) when is_list(url) do
first_element = Enum.at(url, 0)
url_string =
cond do
is_bitstring(first_element) -> first_element
is_map(first_element) -> first_element["href"]
true -> nil
end
if is_binary(url_string) do
Map.put(data, "url", url_string)
else
Map.delete(data, "url")
end
end
defp fix_url(data), do: data
defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
@ -79,10 +104,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
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])
def fix_attachments(data), do: data
def fix_attachments(data), do: Transmogrifier.fix_attachments(data)
defp remote_mention_resolver(
%{"id" => ap_id, "tag" => tags},

View file

@ -41,22 +41,34 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
def fix_object_defaults(data) do
data = Maps.filter_empty_values(data)
in_reply_to_id = in_reply_to_id(data["inReplyTo"])
context =
Utils.maybe_create_context(
data["context"] || data["conversation"] || data["inReplyTo"] || data["id"]
data["context"] || data["conversation"] || in_reply_to_id || data["id"]
)
%User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"])
data = Map.put(data, "context", context)
data
|> Map.put("context", context)
|> cast_and_filter_recipients("to", follower_collection)
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
|> Transmogrifier.fix_implicit_addressing(follower_collection)
with attributed_to when is_binary(attributed_to) <- data["attributedTo"],
%User{follower_address: follower_collection} <- User.get_cached_by_ap_id(attributed_to) do
data
|> cast_and_filter_recipients("to", follower_collection)
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
|> Transmogrifier.fix_implicit_addressing(follower_collection)
else
_ -> data
end
end
defp in_reply_to_id(in_reply_to) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id(%{"id" => in_reply_to}) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id(%{"href" => in_reply_to}) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id([in_reply_to | _]), do: in_reply_to_id(in_reply_to)
defp in_reply_to_id(_), do: nil
def fix_activity_addressing(activity) do
%User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"])
@ -79,6 +91,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
|> Map.put("attributedTo", actor)
end
def maybe_set_attributed_to_from_activity(object, activity) when is_map(object) do
if is_nil(object["attributedTo"]) and is_nil(object["actor"]) do
case Containment.get_actor(activity) do
actor when is_binary(actor) -> Map.put(object, "attributedTo", actor)
_ -> object
end
else
object
end
end
def fix_activity_context(data, %Object{data: %{"context" => object_context}}) do
data
|> Map.put("context", object_context)
@ -96,23 +119,37 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
Map.put(data, "to", to)
end
def fix_quote_url(%{"quoteUrl" => _quote_url} = data), do: data
def fix_quote_url(%{"quoteUrl" => quote_url} = data) do
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> Map.delete(data, "quoteUrl")
end
end
# Fedibird
# https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac
def fix_quote_url(%{"quoteUri" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
# Old Fedibird (bug)
# https://github.com/fedibird/mastodon/issues/9
def fix_quote_url(%{"quoteURL" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
# Misskey fallback
def fix_quote_url(%{"_misskey_quote" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
def fix_quote_url(%{"tag" => [_ | _] = tags} = data) do
@ -128,6 +165,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
def fix_quote_url(data), do: data
defp normalize_object_id(object_id) when is_binary(object_id), do: object_id
defp normalize_object_id(%{"id" => object_id}) when is_binary(object_id), do: object_id
defp normalize_object_id([object_id | _]) when is_binary(object_id), do: object_id
defp normalize_object_id(_), do: nil
# On Mastodon, `"likes"` attribute includes an inlined `Collection` with `totalItems`,
# not a list of users.
# https://github.com/mastodon/mastodon/pull/32007

View file

@ -12,6 +12,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
require Pleroma.Constants
@primary_key false
@tag_types ~w[Mention Hashtag Emoji Link]
embedded_schema do
# Common
field(:type, :string)
@ -49,7 +51,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href])
end
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) do
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) when is_binary(name) do
name = String.downcase(name)
data = Map.put(data, "name", name)
@ -58,13 +60,42 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :name])
end
def changeset(struct, %{"type" => "Emoji"} = data) do
data = Map.put(data, "name", String.trim(data["name"], ":"))
def changeset(struct, %{"type" => "Hashtag"} = data) do
struct
|> cast(data, [:type, :name, :updated, :id])
|> cast_embed(:icon, with: &icon_changeset/2)
|> validate_required([:type, :name, :icon])
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Emoji", "name" => name} = data) when is_binary(name) do
data =
data
|> Map.put("name", String.trim(data["name"], ":"))
|> normalize_emoji_icon()
case data["icon"] do
%{"url" => url} when is_binary(url) ->
if valid_http_url?(url) do
struct
|> cast(data, [:type, :name, :updated, :id])
|> cast_embed(:icon, with: &icon_changeset/2)
|> validate_required([:type, :name, :icon])
else
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
_ ->
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
end
def changeset(struct, %{"type" => "Emoji"} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Link"} = data) do
@ -74,12 +105,80 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href, :mediaType])
end
def changeset(struct, %{"type" => types} = data) when is_list(types) do
changeset(struct, infer_type(data))
end
def changeset(struct, %{"type" => _} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, data) when is_map(data) do
data = normalize(data)
if Map.has_key?(data, "type") do
changeset(struct, data)
else
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
end
def normalize(data) when is_map(data), do: infer_type(data)
defp infer_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @tag_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: Map.delete(data, "type")
end
defp infer_type(%{"type" => _} = data), do: data
defp infer_type(%{"name" => "#" <> _} = data), do: Map.put(data, "type", "Hashtag")
defp infer_type(%{"name" => "@" <> _} = data), do: Map.put(data, "type", "Mention")
defp infer_type(%{"mediaType" => _media_type, "href" => _href} = data),
do: Map.put(data, "type", "Link")
defp infer_type(data), do: data
defp normalize_emoji_icon(%{"icon" => icon} = data) when is_binary(icon) do
Map.put(data, "icon", %{"type" => "Image", "url" => icon})
end
defp normalize_emoji_icon(%{"icon" => icon} = data) when is_map(icon) do
case extract_icon_url(icon) do
url when is_binary(url) -> Map.put(data, "icon", %{"type" => "Image", "url" => url})
_ -> Map.delete(data, "icon")
end
end
defp normalize_emoji_icon(data), do: data
defp extract_icon_url(%{"url" => url}) when is_binary(url), do: url
defp extract_icon_url(%{"url" => %{"href" => href}}) when is_binary(href), do: href
defp extract_icon_url(%{"url" => [first | _]}) do
cond do
is_binary(first) -> first
is_map(first) -> first["href"]
true -> nil
end
end
defp extract_icon_url(%{"href" => href}) when is_binary(href), do: href
defp extract_icon_url(_), do: nil
defp valid_http_url?(url) when is_binary(url) do
match?({:ok, _}, ObjectValidators.Uri.cast(url))
end
defp valid_http_url?(_), do: false
def icon_changeset(struct, data) do
struct
|> cast(data, [:type, :url])

View file

@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
@ -28,6 +29,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
require Pleroma.Constants
@supported_types Pleroma.Constants.activity_types() ++
Pleroma.Constants.object_types() ++ Pleroma.Constants.actor_types()
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
@ -201,9 +205,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
when not is_nil(in_reply_to) do
in_reply_to_id = prepare_in_reply_to(in_reply_to)
object =
cond do
is_list(in_reply_to) ->
if not_empty_string(in_reply_to_id) do
Map.put(object, "inReplyTo", in_reply_to_id)
else
Map.delete(object, "inReplyTo")
end
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_binary(in_reply_to["href"]) ->
Map.put(object, "inReplyTo", in_reply_to["href"])
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_nil(in_reply_to["href"]) ->
Map.delete(object, "inReplyTo")
true ->
object
end
depth = (options[:depth] || 0) + 1
if Federator.allowed_thread_distance?(depth) do
if not_empty_string(in_reply_to_id) and Federator.allowed_thread_distance?(depth) do
with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
%Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
object
@ -249,9 +273,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
in_reply_to["id"]
is_map(in_reply_to) && is_bitstring(in_reply_to["href"]) ->
in_reply_to["href"]
is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
Enum.at(in_reply_to, 0)
is_list(in_reply_to) && is_map(Enum.at(in_reply_to, 0)) ->
in_reply_to
|> Enum.at(0)
|> prepare_in_reply_to()
true ->
""
end
@ -265,59 +297,74 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> Map.drop(["conversation"])
end
defp valid_http_url?(href) when is_binary(href) do
match?({:ok, _}, ObjectValidators.Uri.cast(href))
end
defp valid_http_url?(_), do: false
def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
attachments =
Enum.map(attachment, fn data ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
Enum.map(attachment, fn
data when is_map(data) ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
media_type =
cond do
is_map(url) && url =~ Pleroma.Constants.mime_regex() ->
url["mediaType"]
url_data = if is_map(url), do: url, else: %{}
is_bitstring(data["mediaType"]) && data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
media_type =
cond do
is_bitstring(url_data["mediaType"]) &&
url_data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
url_data["mediaType"]
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
is_bitstring(data["mediaType"]) &&
data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
true ->
nil
end
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
href =
cond do
is_map(url) && is_binary(url["href"]) -> url["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
true ->
nil
end
href =
cond do
is_binary(url) -> url
is_binary(url_data["href"]) -> url_data["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
if href && valid_http_url?(href) do
attachment_url =
%{
"href" => href,
"type" => Map.get(url_data, "type", "Link")
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", url_data["width"] || data["width"])
|> Maps.put_if_present("height", url_data["height"] || data["height"])
if href do
attachment_url =
%{
"href" => href,
"type" => Map.get(url || %{}, "type", "Link")
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", (url || %{})["width"] || data["width"])
|> Maps.put_if_present("height", (url || %{})["height"] || data["height"])
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
nil
end
%{
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
_ ->
nil
end
end)
|> Enum.filter(& &1)
@ -351,24 +398,58 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_url(object), do: object
defp emoji_icon_url(icon) when is_binary(icon), do: icon
defp emoji_icon_url(%{"url" => url}) when is_binary(url), do: url
defp emoji_icon_url(%{"url" => %{"href" => href}}) when is_binary(href), do: href
defp emoji_icon_url(%{"url" => [first | _]}) do
cond do
is_binary(first) -> first
is_map(first) -> first["href"] || first["url"] || first["id"]
true -> nil
end
end
defp emoji_icon_url(%{"href" => href}) when is_binary(href), do: href
defp emoji_icon_url(_), do: nil
defp valid_emoji_icon_url(icon) do
case emoji_icon_url(icon) do
url when is_binary(url) -> if valid_http_url?(url), do: url
_ -> nil
end
end
def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
emoji =
tags
|> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Emoji" and is_binary(data["name"]) and data["icon"]
end)
|> Enum.reduce(%{}, fn data, mapping ->
name = String.trim(data["name"], ":")
Map.put(mapping, name, data["icon"]["url"])
case valid_emoji_icon_url(data["icon"]) do
url when is_binary(url) -> Map.put(mapping, name, url)
_ -> mapping
end
end)
Map.put(object, "emoji", emoji)
end
def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
name = String.trim(tag["name"], ":")
emoji = %{name => tag["icon"]["url"]}
def fix_emoji(%{"tag" => %{"type" => "Emoji", "name" => name} = tag} = object)
when is_binary(name) do
name = String.trim(name, ":")
Map.put(object, "emoji", emoji)
with url when is_binary(url) <- valid_emoji_icon_url(tag["icon"]) do
Map.put(object, "emoji", %{name => url})
else
_ -> object
end
end
def fix_emoji(object), do: object
@ -376,7 +457,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
tags =
tag
|> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Hashtag" and is_binary(data["name"])
end)
|> Enum.map(fn
%{"name" => "#" <> hashtag} -> String.downcase(hashtag)
%{"name" => hashtag} -> String.downcase(hashtag)
@ -439,9 +522,37 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def handle_incoming(data, options \\ []) do
data
|> fix_recursive(&strip_internal_fields/1)
|> fix_recursive(&fix_type_as_string/1)
|> handle_incoming_normalized(options)
end
defp fix_type_as_string(data) do
data
|> normalize_type()
|> normalize_tag_types()
end
defp normalize_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @supported_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: data
end
defp normalize_type(data), do: data
defp normalize_tag_types(%{"tag" => tags} = data) when is_list(tags) do
Map.put(data, "tag", Enum.map(tags, &normalize_tag_type/1))
end
defp normalize_tag_types(%{"tag" => tag} = data) when is_map(tag) do
Map.put(data, "tag", normalize_tag_type(tag))
end
defp normalize_tag_types(data), do: data
defp normalize_tag_type(tag) when is_map(tag), do: TagValidator.normalize(tag)
defp normalize_tag_type(tag), do: tag
# Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
# with nil ID.
defp handle_incoming_normalized(

View file

@ -75,6 +75,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
Pleroma.Docs.JSON.compiled_descriptions()
|> Enum.filter(&allowed_config?(&1, :whitelist))
|> Enum.reject(&allowed_config?(&1, :blacklist))
|> Enum.reject(&ConfigDB.static?/1)
json(conn, translate_descriptions(descriptions))
end
@ -92,7 +93,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
def show(conn, _params) do
with :ok <- configurable_from_database() do
configs = ConfigDB.get_all_as_keyword()
configs = ConfigDB.get_all_as_keyword(exclude_static: true)
merged =
Config.Holder.default_config()
@ -123,6 +124,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
end)
end)
|> List.flatten()
|> Enum.reject(&ConfigDB.static?/1)
render(conn, "index.json", %{
configs: merged,
@ -137,6 +139,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
configs
|> Enum.filter(&allowed_config?(&1, :whitelist))
|> Enum.reject(&allowed_config?(&1, :blacklist))
|> Enum.reject(&(ConfigDB.static?(&1) and not &1[:delete]))
|> Enum.map(fn
%{group: group, key: key, delete: true} = params ->
ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]})

View file

@ -11,6 +11,12 @@ defmodule Pleroma.Web.AdminAPI.FallbackController do
|> json(%{error: dgettext("errors", "Not found")})
end
def call(conn, {:error, :forbidden}) do
conn
|> put_status(:forbidden)
|> json(%{error: dgettext("errors", "Forbidden")})
end
def call(conn, {:error, reason}) do
conn
|> put_status(:bad_request)

View file

@ -8,13 +8,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do
import Pleroma.Web.ControllerHelper,
only: [fetch_integer_param: 3]
alias Ecto.Multi
alias Pleroma.ModerationLog
alias Pleroma.PasswordResetToken
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50
@ -148,9 +152,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do
} = conn,
_
) do
changesets =
conn =
if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do
put_private(conn, :skip_idempotency_cache, true)
else
conn
end
multi =
users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
|> Enum.map(fn %{nickname: nickname, email: email} = attrs ->
{password, passwordless?} =
case Map.fetch(attrs, :password) do
{:ok, password} ->
{password, false}
:error ->
{:crypto.strong_rand_bytes(16) |> Base.encode64(), true}
end
user_data = %{
nickname: nickname,
name: nickname,
@ -160,44 +180,80 @@ defmodule Pleroma.Web.AdminAPI.UserController do
bio: "."
}
User.register_changeset(%User{}, user_data, need_confirmation: false)
{User.register_changeset(%User{}, user_data, confirmed: true), passwordless?}
end)
|> Enum.reduce(Ecto.Multi.new(), fn changeset, multi ->
Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset)
|> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi ->
user_operation = {:user, Ecto.UUID.generate()}
multi = Multi.insert(multi, user_operation, changeset)
if passwordless? do
Multi.run(
multi,
{:password_reset_token, Ecto.UUID.generate()},
fn _repo, changes ->
changes
|> Map.fetch!(user_operation)
|> PasswordResetToken.create_token()
end
)
else
multi
end
end)
case Pleroma.Repo.transaction(changesets) do
{:ok, users_map} ->
users =
users_map
case Pleroma.Repo.transaction(multi) do
{:ok, results} ->
{users, password_reset_tokens} =
results
|> Map.values()
|> Enum.split_with(&match?(%User{}, &1))
users =
users
|> Enum.map(fn user ->
{:ok, user} = User.post_register_action(user)
user
end)
password_reset_links =
Map.new(password_reset_tokens, fn token ->
{token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)}
end)
ModerationLog.insert_log(%{
actor: admin,
subjects: users,
action: "create"
})
render(conn, "created_many.json", users: users)
render(conn, "created_many.json",
users: users,
password_reset_links: password_reset_links
)
{:error, id, changeset, _} ->
{:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} ->
changesets =
Enum.map(changesets.operations, fn
Enum.flat_map(multi.operations, fn
{^id, {:changeset, _current_changeset, _}} ->
changeset
[changeset]
{_, {:changeset, current_changeset, _}} ->
current_changeset
{{:user, _}, {:changeset, current_changeset, _}} ->
[current_changeset]
_ ->
[]
end)
conn
|> put_status(:conflict)
|> render("create_errors.json", changesets: changesets)
{:error, {:password_reset_token, _}, _reason, _} ->
Pleroma.Web.AdminAPI.FallbackController.call(
conn,
{:password_reset_token_error, :creation_failed}
)
end
end

View file

@ -0,0 +1,101 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.WebhookController do
use Pleroma.Web, :controller
alias Pleroma.Repo
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Webhook
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write"]}
when action in [:update, :create, :delete, :enable, :disable, :rotate_secret]
)
plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show])
action_fallback(Pleroma.Web.AdminAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.WebhookOperation
def index(conn, _) do
webhooks =
Webhook
|> Repo.all()
render(conn, "index.json", webhooks: webhooks)
end
def show(conn, %{id: id}) do
with %Webhook{} = webhook <- Webhook.get(id) do
render(conn, "show.json", webhook: webhook)
else
nil -> {:error, :not_found}
end
end
def create(%{body_params: params} = conn, _) do
with {:ok, webhook} <- Webhook.create(webhook_params(params)) do
render(conn, "show.json", webhook: webhook)
end
end
def update(%{body_params: params} = conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.update(webhook, webhook_params(params)) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def delete(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.delete(webhook) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def enable(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.set_enabled(webhook, true) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def disable(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.set_enabled(webhook, false) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def rotate_secret(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.rotate_secret(webhook) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
defp webhook_params(params) do
Map.drop(params, [:internal, "internal"])
end
end

View file

@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
def render("created_many.json", %{users: users}) do
render_many(users, AccountView, "created.json", as: :user)
def render("created_many.json", %{users: users} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
render_many(users, AccountView, "created.json",
as: :user,
password_reset_links: password_reset_links
)
end
def render("created.json", %{user: user}) do
%{
type: "success",
code: 200,
data: %{
def render("created.json", %{user: user} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
data =
%{
nickname: user.nickname,
email: user.email
}
|> maybe_put_password_reset_link(Map.get(password_reset_links, user.id))
%{
type: "success",
code: 200,
data: data
}
end
@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
defp maybe_put_password_reset_link(data, nil), do: data
defp maybe_put_password_reset_link(data, link),
do: Map.put(data, :password_reset_link, link)
def merge_account_views(%User{} = user) do
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
|> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user}))

View file

@ -0,0 +1,34 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.WebhookView do
use Pleroma.Web, :view
alias Pleroma.Web.CommonAPI.Utils
def render("index.json", %{webhooks: webhooks}) do
render_many(webhooks, __MODULE__, "show.json")
end
def render("show.json", %{webhook: webhook}) do
%{
id: webhook.id |> to_string(),
url: webhook.url,
events: webhook.events,
secret: webhook.secret,
enabled: webhook.enabled,
internal: webhook.internal,
created_at: Utils.to_masto_date(webhook.inserted_at),
updated_at: Utils.to_masto_date(webhook.updated_at)
}
end
def render("event.json", %{type: type, object: object}) do
%{
event: type,
created_at: Utils.to_masto_date(NaiveDateTime.utc_now()),
object: object
}
end
end

View file

@ -105,7 +105,15 @@ defmodule Pleroma.Web.ApiSpec do
"Report management",
"Status administration",
"User administration",
"Announcement management"
"Announcement management",
"Webhooks"
]
},
%{
"name" => "Administration (Mastodon API)",
"tags" => [
"User administration (Mastodon API)",
"Report management (Mastodon API)"
]
},
%{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]},

View file

@ -82,8 +82,15 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
properties: %{
nickname: %Schema{type: :string},
email: %Schema{type: :string},
password: %Schema{type: :string}
}
password: %Schema{
type: :string,
description:
"Optional. When omitted, a `password_reset_link` is returned, " <>
"allowing the user to set their own password. When provided, " <>
"it must not be empty."
}
},
required: [:nickname, :email]
}
}
}
@ -102,7 +109,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
type: :object,
properties: %{
email: %Schema{type: :string, format: :email},
nickname: %Schema{type: :string}
nickname: %Schema{type: :string},
password_reset_link: %Schema{type: :string}
}
}
}

View file

@ -0,0 +1,208 @@
# 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.Admin.WebhookOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Webhooks"],
summary: "Retrieve a list of webhooks",
operationId: "AdminAPI.WebhookController.index",
security: [%{"oAuth" => ["admin:read"]}],
responses: %{
200 =>
Operation.response("Array of webhooks", "application/json", %Schema{
type: :array,
items: webhook()
})
}
}
end
def show_operation do
%Operation{
tags: ["Webhooks"],
summary: "Retrieve a webhook",
operationId: "AdminAPI.WebhookController.show",
security: [%{"oAuth" => ["admin:read"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook())
}
}
end
def create_operation do
%Operation{
tags: ["Webhooks"],
summary: "Create a webhook",
operationId: "AdminAPI.WebhookController.create",
security: [%{"oAuth" => ["admin:write"]}],
requestBody:
request_body(
"Parameters",
%Schema{
description: "POST body for creating a webhook",
type: :object,
required: [:url, :events],
properties: %{
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(min_items: 1),
enabled: %Schema{type: :boolean}
},
additionalProperties: false
}
),
responses: %{
200 => Operation.response("Webhook", "application/json", webhook())
}
}
end
def update_operation do
%Operation{
tags: ["Webhooks"],
summary: "Update a webhook",
operationId: "AdminAPI.WebhookController.update",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
requestBody:
request_body(
"Parameters",
%Schema{
description: "POST body for updating a webhook",
type: :object,
properties: %{
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(min_items: 1),
enabled: %Schema{type: :boolean}
},
additionalProperties: false
}
),
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Webhooks"],
summary: "Delete a webhook",
operationId: "AdminAPI.WebhookController.delete",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def enable_operation do
%Operation{
tags: ["Webhooks"],
summary: "Enable a webhook",
operationId: "AdminAPI.WebhookController.enable",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def disable_operation do
%Operation{
tags: ["Webhooks"],
summary: "Disable a webhook",
operationId: "AdminAPI.WebhookController.disable",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def rotate_secret_operation do
%Operation{
tags: ["Webhooks"],
summary: "Rotate webhook signing secret",
operationId: "AdminAPI.WebhookController.rotate_secret",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
defp webhook do
%Schema{
title: "Webhook",
description: "Schema for a webhook",
type: :object,
properties: %{
id: %Schema{type: :string},
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(),
secret: %Schema{type: :string},
enabled: %Schema{type: :boolean},
internal: %Schema{type: :boolean},
created_at: %Schema{type: :string, format: :"date-time"},
updated_at: %Schema{type: :string, format: :"date-time"}
},
example: %{
"id" => "1",
"url" => "https://example.com/webhook",
"events" => ["report.created"],
"secret" => "d3d8cf4bc11fd9c41fd34dcc38d282e451c8bd34",
"enabled" => true,
"internal" => false,
"created_at" => "2022-06-24T16:19:38.523Z",
"updated_at" => "2022-06-24T16:19:38.523Z"
}
}
end
defp event_type(opts \\ []) do
schema = %Schema{
type: :array,
items: %Schema{
title: "Event",
description: "Event type",
type: :string,
enum: ["account.created", "report.created"]
}
}
case Keyword.fetch(opts, :min_items) do
{:ok, min_items} -> %{schema | minItems: min_items}
:error -> schema
end
end
defp id_param do
Operation.parameter(:id, :path, :string, "Webhook ID",
example: "123",
required: true
)
end
end

View file

@ -0,0 +1,348 @@
# 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.MastodonAdmin.AccountOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View accounts by criteria (v1)",
operationId: "MastodonAdmin.AccountController.index",
description: "View accounts matching certain criteria for filtering, up to 40 at a time.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters:
[
Operation.parameter(:local, :query, :boolean, "Filter for local accounts?"),
Operation.parameter(:remote, :query, :boolean, "Filter for remote accounts?"),
Operation.parameter(
:active,
:query,
:boolean,
"Filter for currently active accounts?"
),
Operation.parameter(
:pending,
:query,
:boolean,
"Filter for currently pending accounts?"
),
Operation.parameter(
:disabled,
:query,
:boolean,
"Filter for currently disabled accounts?"
),
Operation.parameter(
:silenced,
:query,
:boolean,
"Filter for currently silenced accounts? (not implemented yet)"
),
Operation.parameter(
:suspended,
:query,
:boolean,
"Filter for currently suspended accounts? (not implemented yet)"
),
Operation.parameter(
:sensitized,
:query,
:boolean,
"Filter for accounts force-marked as sensitive? (not implemented yet)"
),
Operation.parameter(:username, :query, :string, "Search for the given username"),
Operation.parameter(
:display_name,
:query,
:string,
"Search for the given display name"
),
Operation.parameter(
:by_domain,
:query,
:string,
"Filter by the given domain"
),
Operation.parameter(:email, :query, :string, "Lookup a user with this email"),
Operation.parameter(
:ip,
:query,
:string,
"Lookup users with this IP address (not implemented yet)"
),
Operation.parameter(:staff, :query, :boolean, "Filter for staff accounts?")
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Account", "application/json", %Schema{
title: "ArrayOfAccounts",
type: :array,
items: account()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def index2_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View accounts by criteria (v2)",
operationId: "MastodonAdmin.AccountController.index2",
description: "View accounts matching certain criteria for filtering, up to 40 at a time.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters:
[
Operation.parameter(
:origin,
:query,
%Schema{type: :string, enum: ["local", "remote"]},
"Filter for local or remote accounts"
),
Operation.parameter(
:status,
:query,
%Schema{
type: :string,
enum: ["active", "inactive", "pending", "disabled", "silenced", "suspended"]
},
"Filter for active, pending, disabled, silenced or suspended accounts"
),
Operation.parameter(
:permissions,
:query,
:string,
"Filter for accounts with staff permissions (users that can manage reports)."
),
Operation.parameter(
:role_ids,
:query,
%Schema{
oneOf: [
%Schema{type: :array, items: %Schema{type: :string}},
%Schema{type: :string}
]
},
"Filter for users with these roles. (not implemented yet)"
),
Operation.parameter(
:invited_by,
:query,
:string,
"Lookup users invited by the account with this ID. (not implemented yet)"
),
Operation.parameter(:username, :query, :string, "Search for the given username"),
Operation.parameter(
:display_name,
:query,
:string,
"Search for the given display name"
),
Operation.parameter(
:by_domain,
:query,
:string,
"Filter by the given domain"
),
Operation.parameter(:email, :query, :string, "Lookup a user with this email"),
Operation.parameter(
:ip,
:query,
:string,
"Lookup users with this IP address (not implemented yet)"
)
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Account", "application/json", %Schema{
title: "ArrayOfAccounts",
type: :array,
items: account()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View a specific account",
operationId: "MastodonAdmin.AccountController.show",
description: "View admin-level information about the given account.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def account_action_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Perform an action against an account",
operationId: "MastodonAdmin.AccountController.account_action",
description:
"Perform an action against an account and log this action in the moderation history.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
requestBody:
request_body(
"Parameters",
%Schema{
type: :object,
properties: %{
type: %Schema{
type: :string,
enum: ["none", "disable"]
},
report_id: %Schema{
type: :string,
nullable: true,
description: "ID of an associated report that caused this action to be taken"
}
},
required: [:type]
},
required: true
),
responses: %{
204 => no_content_response(),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
403 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Delete a specific account",
operationId: "MastodonAdmin.AccountController.delete",
description: "Delete the given account.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def enable_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Re-enable account",
operationId: "MastodonAdmin.AccountController.enable",
description: "Re-enable a local account whose login is currently disabled.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def approve_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Approve pending account",
operationId: "MastodonAdmin.AccountController.approve",
description: "Approve the given local account if it is currently pending approval.",
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def reject_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Reject pending account",
operationId: "MastodonAdmin.AccountController.reject",
description: "Reject the given local account if it is currently pending approval.",
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def account do
%Schema{
title: "Admin::Account",
description: "Admin-level information about a given account.",
type: :object,
properties: %{
id: FlakeID,
username: %Schema{type: :string},
domain: %Schema{type: :string, nullable: true},
created_at: %Schema{type: :string, format: "date-time"},
email: %Schema{type: :string, format: "email", nullable: true},
ip: %Schema{type: :string, nullable: true},
ips: %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
ip: %Schema{type: :string},
used_at: %Schema{type: :string, format: "date-time"}
}
}
},
locale: %Schema{type: :string, nullable: true},
invite_request: %Schema{type: :string, nullable: true},
role: %Schema{type: :string, nullable: true},
confirmed: %Schema{type: :boolean},
approved: %Schema{type: :boolean},
disabled: %Schema{type: :boolean},
silenced: %Schema{type: :boolean, nullable: true},
suspended: %Schema{type: :boolean, nullable: true},
account: Account,
created_by_application_id: %Schema{type: :string, nullable: true},
invited_by_account_id: %Schema{type: :string, nullable: true}
}
}
end
end

View file

@ -0,0 +1,180 @@
# 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.MastodonAdmin.ReportOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "View all reports",
operationId: "MastodonAdmin.ReportController.index",
description:
"View all reports. Pagination may be done with HTTP Link header in the response.",
security: [%{"oAuth" => ["admin:read:reports"]}],
parameters:
[
Operation.parameter(:resolved, :query, :boolean, "Filter for resolved reports"),
Operation.parameter(:unresolved, :query, :boolean, "Filter for open reports"),
Operation.parameter(:account_id, :query, :string, "Filter by author account id"),
Operation.parameter(
:target_account_id,
:query,
:string,
"Filter by report target account id"
)
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Reports", "application/json", %Schema{
title: "ArrayOfReports",
type: :array,
items: report()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "View a single report",
operationId: "MastodonAdmin.ReportController.show",
description: "View information about the report with the given ID.",
security: [%{"oAuth" => ["admin:read:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def resolve_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Mark as resolved",
operationId: "MastodonAdmin.ReportController.resolve",
description: "Mark a report as resolved with no further action taken.",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def reopen_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Re-open report",
operationId: "MastodonAdmin.ReportController.reopen",
description: "Reopen a currently closed report.",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def assign_to_self_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Assign report to self",
operationId: "MastodonAdmin.ReportController.assign_to_self",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
def unassign_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Unassign report",
operationId: "MastodonAdmin.ReportController.unassign",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
defp report_update_responses do
%{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
end
defp report do
%Schema{
title: "Admin::Report",
type: :object,
properties: %{
id: FlakeID,
action_taken: %Schema{type: :boolean},
action_taken_at: %Schema{type: :string, format: "date-time", nullable: true},
category: %Schema{type: :string, enum: ["spam", "legal", "violation", "other"]},
comment: %Schema{type: :string, nullable: true},
forwarded: %Schema{type: :boolean, nullable: true},
created_at: %Schema{type: :string, format: "date-time"},
updated_at: %Schema{type: :string, format: "date-time"},
account: AccountOperation.account(),
target_account: AccountOperation.account(),
assigned_account: %Schema{
oneOf: [AccountOperation.account()],
nullable: true
},
action_taken_by_account: %Schema{
oneOf: [AccountOperation.account()],
nullable: true
},
statuses: %Schema{
type: :array,
items: Status
},
rules: %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
text: %Schema{type: :string}
}
}
}
}
}
end
end

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.ChatMessage
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@ -304,7 +305,25 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
page_max_id: %Schema{type: :string, nullable: true},
latest_page_notification_at: %Schema{type: :string, format: :"date-time", nullable: true},
sample_account_ids: %Schema{type: :array, items: %Schema{type: :string}},
status_id: %Schema{type: :string, nullable: true}
status_id: %Schema{type: :string, nullable: true},
target_id: %Schema{
type: :string,
description: "ID of the target account for a move notification"
},
emoji: %Schema{
type: :string,
description: "Emoji used for an emoji reaction notification"
},
emoji_url: %Schema{
type: :string,
nullable: true,
description: "URL of the custom emoji used for an emoji reaction notification"
},
chat_message: ChatMessage,
report: %Schema{
type: :object,
description: "Report embedded in a report notification"
}
},
required: [
:group_key,

View file

@ -356,34 +356,6 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
end
def card_operation do
%Operation{
tags: ["Retrieve status information"],
deprecated: true,
summary: "Preview card",
description: "Deprecated in favor of card property inlined on Status entity",
operationId: "StatusController.card",
parameters: [id_param()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 =>
Operation.response("Card", "application/json", %Schema{
type: :object,
nullable: true,
properties: %{
type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]},
provider_name: %Schema{type: :string, nullable: true},
provider_url: %Schema{type: :string, format: :uri},
url: %Schema{type: :string, format: :uri},
image: %Schema{type: :string, nullable: true, format: :uri},
title: %Schema{type: :string},
description: %Schema{type: :string}
}
})
}
}
end
def favourited_by_operation do
%Operation{
tags: ["Retrieve status information"],

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.PreviewCard
require OpenApiSpex
@ -22,36 +23,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do
emojis: %Schema{type: :array, items: Emoji},
attachment: %Schema{type: :object, nullable: true},
card: %Schema{
type: :object,
nullable: true,
description: "Preview card for links included within status content",
required: [:url, :title, :description, :type],
properties: %{
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
provider_name: %Schema{
type: :string,
nullable: true,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
format: :uri,
description: "A link to the provider of the original resource"
},
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"}
}
allOf: [PreviewCard],
nullable: true
},
unread: %Schema{type: :boolean, description: "Whether a message has been marked as read."}
},

View file

@ -0,0 +1,109 @@
# 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.PreviewCard do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
require OpenApiSpex
OpenApiSpex.schema(%{
title: "PreviewCard",
description: "Preview card for a link included within status or chat message content",
type: :object,
required: [
:url,
:title,
:description,
:language,
:type,
:authors,
:author_name,
:author_url,
:provider_name,
:provider_url,
:html,
:width,
:height,
:image,
:image_description,
:embed_url,
:blurhash,
:published_at
],
properties: %{
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"},
language: %Schema{
type: :string,
nullable: true,
description: "Language of the linked resource"
},
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
authors: %Schema{
type: :array,
description: "Fediverse accounts of the authors of the original resource",
items: %Schema{
type: :object,
required: [:name, :url, :account],
properties: %{
name: %Schema{type: :string},
url: %Schema{type: :string},
account: %Schema{allOf: [Account], nullable: true}
}
}
},
author_name: %Schema{type: :string, description: "Author of the original resource"},
author_url: %Schema{
type: :string,
description: "Link to the author of the original resource"
},
provider_name: %Schema{
type: :string,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
description: "A link to the provider of the original resource"
},
html: %Schema{
type: :string,
format: :html,
description: "HTML to be used for generating the preview card"
},
width: %Schema{type: :integer, description: "Width of preview, in pixels"},
height: %Schema{type: :integer, description: "Height of preview, in pixels"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
image_description: %Schema{
type: :string,
description: "Alternate text that describes what is in the thumbnail"
},
embed_url: %Schema{
type: :string,
description: "Used for photo embeds, instead of custom `html`"
},
blurhash: %Schema{
type: :string,
nullable: true,
description:
"A hash computed by the [BlurHash algorithm](https://github.com/woltapp/blurhash), for generating colorful preview thumbnails when media has not been downloaded yet."
},
published_at: %Schema{
type: :string,
nullable: true,
description: "Publication date of the linked resource"
}
}
})
end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll
alias Pleroma.Web.ApiSpec.Schemas.PreviewCard
alias Pleroma.Web.ApiSpec.Schemas.Tag
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@ -31,40 +32,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
},
bookmarked: %Schema{type: :boolean, description: "Have you bookmarked this status?"},
card: %Schema{
type: :object,
nullable: true,
description: "Preview card for links included within status content",
required: [:url, :title, :description, :type],
properties: %{
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
provider_name: %Schema{
type: :string,
nullable: true,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
format: :uri,
description: "A link to the provider of the original resource"
},
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
image_description: %Schema{
type: :string,
description: "Alternate text that describes what is in the thumbnail"
},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"}
}
allOf: [PreviewCard],
nullable: true
},
content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"},
text: %Schema{

View file

@ -700,7 +700,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def update_report_state(activity_id, state) do
with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.update_report_state(activity, state)
else
nil -> {:error, :not_found}
@ -715,7 +715,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def assign_report_to_account(activity_id, user) do
with %Activity{} = activity <- Activity.get_by_id(activity_id) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.assign_report_to_account(activity, user)
else
nil -> {:error, :not_found}

View file

@ -0,0 +1,305 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [
add_link_headers: 2,
assign_account_by_id: 2,
json_response: 3
]
alias Pleroma.Activity
alias Pleroma.ModerationLog
alias Pleroma.Pagination
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
@filter_params ~W(
local external active needing_approval deactivated nickname name domain email staff origin status
)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read:accounts"]}
when action in [:index, :index2, :show]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:accounts"]}
when action in [
:delete,
:enable,
:account_action,
:approve,
:reject
]
)
plug(
:assign_account_by_id
when action in [
:show,
:delete,
:enable,
:account_action,
:approve,
:reject
]
)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation
def index(conn, params) do
users =
params
|> build_criteria()
|> User.Query.build()
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(users)
|> render("index.json", users: users)
end
def index2(conn, params) do
users =
params
|> build_criteria_v2()
|> User.Query.build()
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(users)
|> render("index.json", users: users)
end
def show(%{assigns: %{user: _admin, account: user}} = conn, _params) do
render(conn, "show.json", user: user)
end
def account_action(
%{assigns: %{user: admin, account: user}, body_params: %{type: type} = body_params} =
conn,
_params
) do
with :ok <- validate_account_action(user, type),
{:ok, report} <- associated_report(user, body_params),
:ok <- resolve_report(admin, report),
{:ok, _user} <- handle_account_action(user, admin, type) do
json_response(conn, :no_content, "")
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, delete_data, _} = Builder.delete(admin, user.ap_id)
Pipeline.common_pipeline(delete_data, local: true)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "delete"
})
render(conn, "show.json", user: user)
end
def enable(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, user} = User.set_activation(user, true)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "activate"
})
render(conn, "show.json", user: user)
end
def approve(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, user} = User.approve(user)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "approve"
})
render(conn, "show.json", user: user)
end
def reject(%{assigns: %{user: admin, account: user}} = conn, _params) do
with {:ok, _} <- User.reject(user) do
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "reject"
})
render(conn, "show.json", user: user)
else
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
defp handle_account_action(%User{local: true} = user, admin, "disable") do
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "deactivate"
})
User.set_activation(user, false)
end
defp handle_account_action(%User{}, _admin, "disable") do
{:error, "Only local accounts can be disabled"}
end
defp handle_account_action(user, _admin, "none") do
{:ok, user}
end
defp validate_account_action(%User{local: true}, "disable"), do: :ok
defp validate_account_action(%User{}, "disable"),
do: {:error, "Only local accounts can be disabled"}
defp validate_account_action(%User{}, "none"), do: :ok
defp build_criteria(params) do
%{}
|> maybe_filter_local(params)
|> maybe_filter_external(params)
|> maybe_filter_active(params)
|> maybe_filter_needing_approval(params)
|> maybe_filter_deactivated(params)
|> maybe_filter_nickname(params)
|> maybe_filter_name(params)
|> maybe_filter_domain(params)
|> maybe_filter_email(params)
|> maybe_filter_staff(params)
end
defp build_criteria_v2(params) do
%{}
|> maybe_filter_origin(params)
|> maybe_filter_status(params)
|> maybe_filter_staff(params)
|> maybe_filter_nickname(params)
|> maybe_filter_name(params)
|> maybe_filter_domain(params)
|> maybe_filter_email(params)
end
defp maybe_filter_local(criteria, %{local: true} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_local(criteria, %{local: false} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_external(criteria, %{remote: true} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_external(criteria, %{remote: false} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_origin(criteria, %{origin: "local"} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_origin(criteria, %{origin: "remote"} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_active(criteria, %{active: active} = _params),
do: Map.put(criteria, :active, active)
defp maybe_filter_needing_approval(criteria, %{pending: need_approval} = _params),
do: Map.put(criteria, :need_approval, need_approval)
defp maybe_filter_deactivated(criteria, %{disabled: deactivated} = _params),
do: Map.put(criteria, :deactivated, deactivated)
defp maybe_filter_status(criteria, %{status: "active"} = _params),
do: Map.put(criteria, :active, true)
defp maybe_filter_status(criteria, %{status: "inactive"} = _params),
do: Map.put(criteria, :active, false)
defp maybe_filter_status(criteria, %{status: "pending"} = _params),
do: Map.put(criteria, :need_approval, true)
defp maybe_filter_status(criteria, %{status: "disabled"} = _params),
do: Map.put(criteria, :deactivated, true)
defp maybe_filter_nickname(criteria, %{username: nickname} = _params),
do: Map.put(criteria, :nickname, nickname)
defp maybe_filter_name(criteria, %{display_name: name} = _params),
do: Map.put(criteria, :name, name)
defp maybe_filter_domain(criteria, %{by_domain: domain} = _params),
do: Map.put(criteria, :domain, domain)
defp maybe_filter_email(criteria, %{email: email} = _params),
do: Map.put(criteria, :email, email)
defp maybe_filter_staff(criteria, %{permissions: "staff"} = _params),
do: Map.put(criteria, :staff, true)
defp maybe_filter_staff(criteria, %{staff: staff} = _params),
do: Map.put(criteria, :staff, staff)
for filter_param <- @filter_params do
defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria
end
defp associated_report(user, %{report_id: id}) do
case Activity.get_report(id) do
%Activity{data: %{"object" => [target_ap_id | _]}} = report
when target_ap_id == user.ap_id ->
{:ok, report}
%Activity{} ->
{:error, "Report does not target this account"}
nil ->
{:error, :not_found}
end
end
defp associated_report(_user, _params), do: {:ok, nil}
defp resolve_report(admin, %Activity{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
:ok
end
end
defp resolve_report(_admin, nil), do: :ok
end

View file

@ -0,0 +1,171 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [
add_link_headers: 2,
json_response: 3
]
alias Pleroma.Activity
alias Pleroma.ModerationLog
alias Pleroma.Pagination
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show])
plug(OAuthScopesPlug, %{scopes: ["admin:write:reports"]} when action in [:resolve, :reopen])
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:reports"]} when action in [:assign_to_self, :unassign]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation
def index(conn, params) do
opts =
%{}
|> Map.put(:type, "Flag")
|> Map.put(:skip_preload, true)
|> Map.put(:preload_report_notes, true)
|> Map.put(:total, true)
|> restrict_state(params)
|> restrict_actor(params)
|> restrict_target(params)
reports =
ActivityPub.fetch_activities_query([], opts)
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(reports)
|> render("index.json", reports: reports)
end
def show(conn, %{id: id}) do
with %Activity{} = report <- Activity.get_report(id) do
render(conn, "show.json", Report.extract_report_info(report))
else
_ -> {:error, :not_found}
end
end
def resolve(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def reopen(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "open"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def assign_to_self(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.assign_report_to_account(id, admin.id),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_assigned",
actor: admin,
subject: activity,
subject_actor: report.user_actor,
assigned_account: admin.nickname
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def unassign(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_unassigned",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
defp restrict_state(opts, %{resolved: true, unresolved: true}), do: opts
defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved")
defp restrict_state(opts, _params), do: Map.put(opts, :state, "open")
defp restrict_actor(opts, %{account_id: actor}) do
with %User{ap_id: ap_id} <- User.get_by_id(actor) do
Map.put(opts, :actor_id, ap_id)
else
_ -> Map.put(opts, :actor_id, actor)
end
end
defp restrict_actor(opts, _params), do: opts
defp restrict_target(opts, %{target_account_id: actor}) do
case User.get_by_id(actor) do
%User{ap_id: ap_id} -> Map.put(opts, :report_target_id, ap_id)
_ -> Map.put(opts, :report_target_id, actor)
end
end
defp restrict_target(opts, _params), do: opts
end

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.AccountView do
use Pleroma.Web, :view
alias Pleroma.User
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI
def render("index.json", %{users: users}) do
render_many(users, __MODULE__, "show.json", as: :user)
end
def render("show.json", %{user: user}) do
account =
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
%{
id: user.id,
username: username_from_nickname(user.nickname),
domain: domain_from_nickname(user.nickname),
created_at: Utils.to_masto_date(user.inserted_at),
email: user.email,
ip: nil,
ips: [],
locale: nil,
invite_request: user.registration_reason,
role: role(user),
confirmed: user.is_confirmed,
approved: user.is_approved,
disabled: !user.is_active,
silenced: nil,
suspended: nil,
account: account
}
end
defp username_from_nickname(string) when is_binary(string) do
hd(String.split(string, "@"))
end
defp username_from_nickname(_), do: nil
defp domain_from_nickname(string) when is_binary(string) do
String.split(string, "@")
|> Enum.at(1, nil)
end
defp domain_from_nickname(_), do: nil
defp role(%User{is_admin: true}) do
"admin"
end
defp role(%User{is_moderator: true}) do
"moderator"
end
defp role(_user) do
nil
end
end

View file

@ -0,0 +1,71 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do
use Pleroma.Web, :view
alias Pleroma.HTML
alias Pleroma.Rule
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.Admin.AccountView
alias Pleroma.Web.MastodonAPI.StatusView
def render("index.json", %{reports: reports}) do
reports
|> Enum.map(&Report.extract_report_info/1)
|> Enum.map(&render(__MODULE__, "show.json", &1))
end
def render("show.json", %{
report: report,
user: account,
account: target_account,
statuses: statuses,
assigned_account: assigned_account
}) do
created_at = Utils.to_masto_date(report.data["published"])
updated_at = Utils.to_masto_date(report.updated_at)
action_taken = report.data["state"] != "open"
content =
unless is_nil(report.data["content"]) do
HTML.filter_tags(report.data["content"])
else
nil
end
%{
id: report.id,
action_taken: action_taken,
action_taken_at: if(action_taken, do: updated_at),
category: "other",
comment: content,
forwarded: false,
created_at: created_at,
updated_at: updated_at,
account: AccountView.render("show.json", %{user: account}),
target_account: AccountView.render("show.json", %{user: target_account}),
assigned_account: render_account(assigned_account),
action_taken_by_account: nil,
statuses:
StatusView.render("index.json", %{
activities: statuses,
as: :activity
}),
rules: rules(report.data["rules"])
}
end
defp render_account(nil), do: nil
defp render_account(account), do: AccountView.render("show.json", %{user: account})
defp rules(nil), do: []
defp rules(rule_ids) do
rule_ids
|> Rule.get()
|> Enum.map(&%{id: to_string(&1.id), text: &1.text})
end
end

View file

@ -348,6 +348,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
params
|> Map.delete(:tagged)
|> Map.put(:tag, params[:tagged])
|> Map.put(:restrict_unauthenticated, true)
activities = ActivityPub.fetch_user_activities(user, reading_user, params)

View file

@ -101,13 +101,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
|> Enum.filter(& &1)
|> Enum.uniq_by(& &1[:id])
actors =
accounts =
notification_groups
|> List.flatten()
|> notification_actors()
|> notification_accounts()
%{
accounts: AccountView.render("index.json", %{users: actors, for: reading_user}),
accounts: AccountView.render("index.json", %{users: accounts, for: reading_user}),
statuses: statuses,
notification_groups:
Enum.map(
@ -191,7 +191,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
defp render_group(
[%Notification{} = notification | _] = notifications,
_reading_user,
reading_user,
grouped_types,
notification_group_counts,
notification_group_bounds,
@ -237,13 +237,49 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
response
end
if status_activity do
Map.put(response, :status_id, to_string(status_activity.id))
else
response
response =
if status_activity do
Map.put(response, :status_id, to_string(status_activity.id))
else
response
end
put_group_details(response, notification, reading_user)
end
defp put_group_details(response, %Notification{type: "move", activity: activity}, _reading_user) do
case User.get_cached_by_ap_id(activity.data["target"]) do
%User{id: id} -> Map.put(response, :target_id, to_string(id))
_ -> response
end
end
defp put_group_details(
response,
%Notification{type: "pleroma:emoji_reaction", activity: activity},
_reading_user
) do
put_emoji(response, activity)
end
defp put_group_details(
response,
%Notification{type: "pleroma:chat_mention", activity: activity},
reading_user
) do
put_chat_message(response, activity, reading_user, %{})
end
defp put_group_details(
response,
%Notification{type: "pleroma:report", activity: activity},
_reading_user
) do
put_report(response, activity)
end
defp put_group_details(response, _notification, _reading_user), do: response
defp status_activity_for_group([%Notification{} = notification | _], grouped_types) do
status_activity_for(notification, grouped_types)
end
@ -287,6 +323,19 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
|> Enum.uniq_by(& &1.id)
end
defp notification_accounts(notifications) do
move_targets =
notifications
|> Enum.filter(&(&1.type == "move"))
|> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["target"]))
|> Enum.filter(& &1)
notifications
|> notification_actors()
|> Kernel.++(move_targets)
|> Enum.uniq_by(& &1.id)
end
defp put_emoji(response, activity) do
response
|> Map.put(:emoji, activity.data["content"])

View file

@ -587,14 +587,25 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
video_url = proxied_url(rich_media["video"], page_url_data)
%{
type: "link",
provider_name: page_url_data.host,
provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
type: card_type(rich_media["type"]),
provider_name: rich_media["provider_name"] || page_url_data.host,
provider_url:
rich_media["provider_url"] || page_url_data.scheme <> "://" <> page_url_data.host,
url: page_url,
image: image_url,
image_description: rich_media["image:alt"] || "",
title: rich_media["title"] || "",
description: rich_media["description"] || "",
language: nil,
authors: [],
author_name: rich_media["author_name"] || "",
author_url: rich_media["author_url"] || "",
html: rich_media["html"] || "",
width: card_dimension(rich_media["width"]),
height: card_dimension(rich_media["height"]),
embed_url: rich_media["embed_url"] || "",
blurhash: nil,
published_at: nil,
pleroma: %{
opengraph:
rich_media
@ -620,7 +631,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio"
attachment["type"] == "Image" -> "image"
true -> "unknown"
end
@ -699,6 +709,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
%{content: content, detected_source_language: detected_source_language, provider: provider}
end
defp card_type(type) when type in ["link", "photo", "video", "rich"], do: type
defp card_type(_), do: "link"
defp card_dimension(value) when is_integer(value) and value >= 0, do: value
defp card_dimension(value) when is_binary(value) do
case Integer.parse(value) do
{dimension, ""} when dimension >= 0 -> dimension
_ -> 0
end
end
defp card_dimension(_), do: 0
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
object = Object.normalize(activity, fetch: false)

View file

@ -208,7 +208,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], [])
|> Keyword.put_new(:sniff_content_type, true)
end
defp sandbox(conn, _params) do

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.OAuth.App do
import Ecto.Query
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token
@type t :: %__MODULE__{}
@ -191,15 +192,26 @@ defmodule Pleroma.Web.OAuth.App do
@spec remove_orphans(pos_integer()) :: :ok
def remove_orphans(limit \\ 100) do
fifteen_mins_ago = DateTime.add(DateTime.utc_now(), -900, :second)
fifteen_mins_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -900)
Repo.transaction(fn ->
app_ids =
from(a in __MODULE__,
where: is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago,
limit: ^limit,
lock: "FOR UPDATE SKIP LOCKED",
select: a.id
)
|> Repo.all()
# Bulk deletes bypass the schema's association cleanup.
from(a in Authorization, where: a.app_id in ^app_ids) |> Repo.delete_all()
from(t in Token, where: t.app_id in ^app_ids) |> Repo.delete_all()
from(a in __MODULE__,
where: is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago,
limit: ^limit
where: a.id in ^app_ids and is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago
)
|> Repo.all()
|> Enum.each(&Repo.delete(&1))
|> Repo.delete_all()
end)
:ok

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
import Phoenix.Controller, only: [json: 2]
import Plug.Conn
alias Pleroma.Config
@behaviour Plug
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
def call(conn, _), do: conn
def process_request(conn, key) do
case @cachex.get(:idempotency_cache, key) do
cache_key = {conn.method, conn.request_path, actor_key(conn), key}
case @cachex.get(:idempotency_cache, cache_key) do
{:ok, nil} ->
cache_resposnse(conn, key)
cache_resposnse(conn, key, cache_key)
{:ok, record} ->
send_cached(conn, key, record)
@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
end
end
defp cache_resposnse(conn, key) do
register_before_send(conn, fn conn ->
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
defp cache_resposnse(conn, key, cache_key) do
register_before_send(conn, &maybe_cache_response(&1, key, cache_key))
end
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, key, record)
defp maybe_cache_response(
%{private: %{skip_idempotency_cache: true}} = conn,
_key,
_cache_key
),
do: conn
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end)
defp maybe_cache_response(conn, key, cache_key) do
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, cache_key, record)
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end
defp actor_key(%{assigns: assigns}) do
{principal_key(assigns), token_key(assigns[:token]), staff_privileges()}
end
defp principal_key(%{user: %{id: id} = user}) do
{:user, id, Map.get(user, :is_admin), Map.get(user, :is_moderator)}
end
defp principal_key(%{app: %{id: id}}), do: {:app, id}
defp principal_key(_assigns), do: nil
defp token_key(%{id: id} = token), do: {id, token |> Map.get(:scopes, []) |> Enum.sort()}
defp token_key(_token), do: nil
defp staff_privileges do
{
Config.get([:instance, :admin_privileges], []) |> Enum.sort(),
Config.get([:instance, :moderator_privileges], []) |> Enum.sort()
}
end
defp send_cached(conn, key, record) do

View file

@ -137,6 +137,12 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_account_actions do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
@ -316,6 +322,15 @@ defmodule Pleroma.Web.Router do
post("/rules", RuleController, :create)
patch("/rules/:id", RuleController, :update)
delete("/rules/:id", RuleController, :delete)
get("/webhooks", WebhookController, :index)
get("/webhooks/:id", WebhookController, :show)
post("/webhooks", WebhookController, :create)
patch("/webhooks/:id", WebhookController, :update)
delete("/webhooks/:id", WebhookController, :delete)
post("/webhooks/:id/enable", WebhookController, :enable)
post("/webhooks/:id/disable", WebhookController, :disable)
post("/webhooks/:id/rotate_secret", WebhookController, :rotate_secret)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
@ -424,6 +439,60 @@ defmodule Pleroma.Web.Router do
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_read])
get("/accounts", AccountController, :index)
get("/accounts/:id", AccountController, :show)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through(:require_privileged_role_users_delete)
delete("/accounts/:id", AccountController, :delete)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_activation_state])
post("/accounts/:id/enable", AccountController, :enable)
end
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_account_actions])
post("/accounts/:id/action", AccountController, :account_action)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_invites])
post("/accounts/:id/approve", AccountController, :approve)
post("/accounts/:id/reject", AccountController, :reject)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_reports_manage_reports])
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
post("/reports/:id/resolve", ReportController, :resolve)
post("/reports/:id/reopen", ReportController, :reopen)
post("/reports/:id/assign_to_self", ReportController, :assign_to_self)
post("/reports/:id/unassign", ReportController, :unassign)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v2/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_read])
get("/accounts", AccountController, :index2)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_emoji_manage_emoji)

98
lib/pleroma/webhook.ex Normal file
View file

@ -0,0 +1,98 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Webhook do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Repo
@event_types [:"account.created", :"report.created"]
schema "webhooks" do
field(:url, ObjectValidators.Uri)
field(:events, {:array, Ecto.Enum}, values: @event_types, default: [])
field(:secret, :string, default: "")
field(:enabled, :boolean, default: true)
field(:internal, :boolean, default: false)
timestamps()
end
def get(id), do: Repo.get(__MODULE__, id)
def get_by_type(type) do
__MODULE__
|> where([w], ^type in w.events)
|> where([w], w.enabled == true)
|> Repo.all()
end
def changeset(%__MODULE__{} = webhook, params) do
webhook
|> cast(params, [:url, :events, :enabled, :internal])
|> validate_required([:url, :events])
|> validate_length(:url, max: 255)
|> validate_events_present()
|> unique_constraint(:url)
|> put_secret()
end
def update_changeset(%__MODULE__{} = webhook, params \\ %{}) do
webhook
|> cast(params, [:url, :events, :enabled, :internal])
|> validate_length(:url, max: 255)
|> validate_events_present()
|> unique_constraint(:url)
end
def create(params) do
%__MODULE__{}
|> changeset(params)
|> Repo.insert()
end
def update(%__MODULE__{} = webhook, params) do
webhook
|> update_changeset(params)
|> Repo.update()
end
def delete(webhook), do: webhook |> Repo.delete()
def rotate_secret(%__MODULE__{} = webhook) do
webhook
|> cast(%{}, [])
|> put_secret()
|> Repo.update()
end
def set_enabled(%__MODULE__{} = webhook, enabled) do
webhook
|> cast(%{enabled: enabled}, [:enabled])
|> Repo.update()
end
defp validate_events_present(changeset) do
cond do
Keyword.has_key?(changeset.errors, :events) -> changeset
get_field(changeset, :internal) -> changeset
match?([_ | _], get_field(changeset, :events)) -> changeset
true -> add_error(changeset, :events, "can't be blank")
end
end
defp put_secret(changeset) do
changeset
|> put_change(:secret, generate_secret())
end
defp generate_secret do
Base.encode16(:crypto.strong_rand_bytes(20))
|> String.downcase()
end
end

View file

@ -0,0 +1,94 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Webhook.Notify do
alias Phoenix.View
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Webhook
alias Pleroma.Workers.WebhookWorker
def trigger_webhooks(%Activity{} = activity, :"report.created" = type) do
webhooks = Webhook.get_by_type(type)
webhooks
|> Enum.map(fn webhook ->
WebhookWorker.new(%{
"op" => "notify",
"type" => type,
"webhook_id" => webhook.id,
"activity_id" => activity.id
})
end)
|> Oban.insert_all()
end
def trigger_webhooks(%User{} = user, :"account.created" = type) do
webhooks = Webhook.get_by_type(type)
webhooks
|> Enum.map(fn webhook ->
WebhookWorker.new(%{
"op" => "notify",
"type" => type,
"webhook_id" => webhook.id,
"user_id" => user.id
})
end)
|> Oban.insert_all()
end
def report_created(%Webhook{} = webhook, %Activity{} = report) do
object =
View.render(
Pleroma.Web.MastodonAPI.Admin.ReportView,
"show.json",
Report.extract_report_info(report)
)
deliver(webhook, object, :"report.created")
end
def account_created(%Webhook{} = webhook, %User{} = user) do
object =
View.render(
Pleroma.Web.MastodonAPI.Admin.AccountView,
"show.json",
user: user
)
deliver(webhook, object, :"account.created")
end
defp deliver(%Webhook{url: url, secret: secret}, object, type) do
body =
View.render_to_string(Pleroma.Web.AdminAPI.WebhookView, "event.json",
type: type,
object: object
)
headers = [
{"Content-Type", "application/json"},
{"X-Hub-Signature", "sha256=#{signature(body, secret)}"}
]
ConcurrentLimiter.limit(__MODULE__, fn ->
case Pleroma.HTTP.post(url, body, headers) do
{:ok, %Tesla.Env{status: status}} when status in 200..299 ->
:ok
{:ok, %Tesla.Env{status: status}} ->
{:error, {:http_status, status}}
{:error, _reason} = error ->
error
end
end)
end
defp signature(body, secret) do
:crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower)
end
end

View file

@ -0,0 +1,50 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.WebhookWorker do
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Webhook
alias Pleroma.Webhook.Notify
use Oban.Worker, queue: :background
@impl true
def perform(%Job{
args: %{
"op" => "notify",
"type" => "report.created",
"webhook_id" => webhook_id,
"activity_id" => report_id
}
}) do
with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id),
%Activity{} = report <- Activity.get_by_id(report_id) do
Notify.report_created(webhook, report)
else
%Webhook{enabled: false} -> {:cancel, :disabled}
nil -> {:cancel, :not_found}
end
end
def perform(%Job{
args: %{
"op" => "notify",
"type" => "account.created",
"webhook_id" => webhook_id,
"user_id" => user_id
}
}) do
with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id),
%User{} = user <- User.get_by_id(user_id) do
Notify.account_created(webhook, user)
else
%Webhook{enabled: false} -> {:cancel, :disabled}
nil -> {:cancel, :not_found}
end
end
@impl true
def timeout(_job), do: :timer.seconds(10)
end