Merge remote-tracking branch 'origin/develop' into gitlab-mr-iid-4161
This commit is contained in:
commit
7cc9ba6f06
1296 changed files with 27633 additions and 7387 deletions
|
|
@ -26,7 +26,11 @@ defmodule Mix.Pleroma do
|
|||
Application.put_env(:phoenix, :serve_endpoints, false, persistent: true)
|
||||
|
||||
unless System.get_env("DEBUG") do
|
||||
Logger.remove_backend(:console)
|
||||
try do
|
||||
Logger.remove_backend(:console)
|
||||
catch
|
||||
:exit, _ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
adapter = Application.get_env(:tesla, :adapter)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,90 @@ defmodule Mix.Tasks.Pleroma.Config do
|
|||
end
|
||||
end
|
||||
|
||||
# Removes any policies that are not a real module
|
||||
# as they will prevent the server from starting
|
||||
def run(["fix_mrf_policies"]) do
|
||||
check_configdb(fn ->
|
||||
start_pleroma()
|
||||
|
||||
group = :pleroma
|
||||
key = :mrf
|
||||
|
||||
%{value: value} =
|
||||
group
|
||||
|> ConfigDB.get_by_group_and_key(key)
|
||||
|
||||
policies =
|
||||
Keyword.get(value, :policies, [])
|
||||
|> Enum.filter(&is_atom(&1))
|
||||
|> Enum.filter(fn mrf ->
|
||||
case Code.ensure_compiled(mrf) do
|
||||
{:module, _} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end)
|
||||
|
||||
value = Keyword.put(value, :policies, policies)
|
||||
|
||||
ConfigDB.update_or_create(%{group: group, key: key, value: value})
|
||||
end)
|
||||
end
|
||||
|
||||
# Removes non-whitelisted configuration sections
|
||||
def run(["filter_whitelisted" | rest]) do
|
||||
{options, [], []} =
|
||||
OptionParser.parse(
|
||||
rest,
|
||||
strict: [force: :boolean],
|
||||
aliases: [f: :force]
|
||||
)
|
||||
|
||||
force = Keyword.get(options, :force, false)
|
||||
|
||||
start_pleroma()
|
||||
|
||||
whitelisted_configs = Pleroma.Config.get(:database_config_whitelist)
|
||||
|
||||
if whitelisted_configs in [nil, false] do
|
||||
shell_error("No unwanted settings in ConfigDB. No changes made.")
|
||||
else
|
||||
whitelisted_groups =
|
||||
whitelisted_configs
|
||||
|> Enum.filter(fn
|
||||
{_group} -> true
|
||||
_ -> false
|
||||
end)
|
||||
|> Enum.map(fn {group} -> group end)
|
||||
|
||||
whitelisted_keys =
|
||||
whitelisted_configs
|
||||
|> Enum.filter(fn
|
||||
{_group, _key} -> true
|
||||
_ -> false
|
||||
end)
|
||||
|
||||
filtered =
|
||||
from(c in ConfigDB)
|
||||
|> Repo.all()
|
||||
|> Enum.filter(¬_whitelisted?(&1, whitelisted_groups, whitelisted_keys))
|
||||
|
||||
if not Enum.empty?(filtered) do
|
||||
shell_info("The following settings will be removed from ConfigDB:\n")
|
||||
Enum.each(filtered, &dump(&1))
|
||||
|
||||
if force or shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
|
||||
filtered_ids = Enum.map(filtered, fn %{id: id} -> id end)
|
||||
|
||||
Repo.delete_all(from(c in ConfigDB, where: c.id in ^filtered_ids))
|
||||
else
|
||||
shell_error("No changes made.")
|
||||
end
|
||||
else
|
||||
shell_error("No unwanted settings in ConfigDB. No changes made.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec migrate_to_db(Path.t() | nil) :: any()
|
||||
def migrate_to_db(file_path \\ nil) do
|
||||
with :ok <- Pleroma.Config.DeprecationWarnings.warn() do
|
||||
|
|
@ -301,7 +385,13 @@ defmodule Mix.Tasks.Pleroma.Config do
|
|||
|> Enum.each(&write_and_delete(&1, file, opts[:delete]))
|
||||
|
||||
:ok = File.close(file)
|
||||
System.cmd("mix", ["format", path])
|
||||
|
||||
# Ensure `mix format` runs in the same env as the current task and doesn't
|
||||
# emit config-time stderr noise (e.g. dev secret warnings) into `mix test`.
|
||||
System.cmd("mix", ["format", path],
|
||||
env: [{"MIX_ENV", to_string(Mix.env())}],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
end
|
||||
|
||||
defp config_header, do: "import Config\r\n\r\n"
|
||||
|
|
@ -399,4 +489,9 @@ defmodule Mix.Tasks.Pleroma.Config do
|
|||
Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;")
|
||||
Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;")
|
||||
end
|
||||
|
||||
defp not_whitelisted?(%{group: group, key: key}, whitelisted_groups, whitelisted_keys) do
|
||||
not Enum.member?(whitelisted_groups, group) and
|
||||
not Enum.member?(whitelisted_keys, {group, key})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -226,7 +226,12 @@ defmodule Mix.Tasks.Pleroma.Database do
|
|||
DELETE FROM hashtags AS ht
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM hashtags_objects hto
|
||||
WHERE ht.id = hto.hashtag_id)
|
||||
WHERE ht.id = hto.hashtag_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_follows_hashtag ufh
|
||||
WHERE ht.id = ufh.hashtag_id
|
||||
)
|
||||
"""
|
||||
|> Repo.query()
|
||||
|
||||
|
|
@ -295,10 +300,12 @@ defmodule Mix.Tasks.Pleroma.Database do
|
|||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> Timex.shift(days: days)
|
||||
|
||||
Pleroma.Workers.PurgeExpiredActivity.enqueue(%{
|
||||
activity_id: activity.id,
|
||||
expires_at: expires_at
|
||||
})
|
||||
Pleroma.Workers.PurgeExpiredActivity.enqueue(
|
||||
%{
|
||||
activity_id: activity.id
|
||||
},
|
||||
scheduled_at: expires_at
|
||||
)
|
||||
end)
|
||||
end)
|
||||
|> Stream.run()
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do
|
|||
)
|
||||
|
||||
files = fetch_and_decode!(files_loc)
|
||||
files_to_unzip = for({_, f} <- files, do: f)
|
||||
|
||||
IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name]))
|
||||
|
||||
|
|
@ -103,17 +104,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do
|
|||
pack_name
|
||||
])
|
||||
|
||||
files_to_unzip =
|
||||
Enum.map(
|
||||
files,
|
||||
fn {_, f} -> to_charlist(f) end
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
:zip.unzip(binary_archive,
|
||||
cwd: String.to_charlist(pack_path),
|
||||
file_list: files_to_unzip
|
||||
)
|
||||
{:ok, _} = Pleroma.SafeZip.unzip_data(binary_archive, pack_path, files_to_unzip)
|
||||
|
||||
IO.puts(IO.ANSI.format(["Writing pack.json for ", :bright, pack_name]))
|
||||
|
||||
|
|
@ -201,7 +192,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do
|
|||
|
||||
tmp_pack_dir = Path.join(System.tmp_dir!(), "emoji-pack-#{name}")
|
||||
|
||||
{:ok, _} = :zip.unzip(binary_archive, cwd: String.to_charlist(tmp_pack_dir))
|
||||
{:ok, _} = Pleroma.SafeZip.unzip_data(binary_archive, tmp_pack_dir)
|
||||
|
||||
emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts)
|
||||
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ defmodule Mix.Tasks.Pleroma.Instance do
|
|||
[config_dir, psql_dir, static_dir, uploads_dir]
|
||||
|> Enum.reject(&File.exists?/1)
|
||||
|> Enum.each(fn dir ->
|
||||
File.mkdir_p!(dir)
|
||||
Pleroma.Backports.mkdir_p!(dir)
|
||||
File.chmod!(dir, 0o700)
|
||||
end)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mix.Tasks.Pleroma.OpenapiSpec do
|
|||
else
|
||||
{_, errors} ->
|
||||
IO.puts(IO.ANSI.format([:red, :bright, "Spec check failed, errors:"]))
|
||||
Enum.map(errors, &IO.puts/1)
|
||||
Enum.each(errors, &IO.puts/1)
|
||||
|
||||
raise "Spec check failed"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mix.Tasks.Pleroma.RobotsTxt do
|
|||
static_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static/")
|
||||
|
||||
if !File.exists?(static_dir) do
|
||||
File.mkdir_p!(static_dir)
|
||||
Pleroma.Backports.mkdir_p!(static_dir)
|
||||
end
|
||||
|
||||
robots_txt_path = Path.join(static_dir, "robots.txt")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
|
|||
import Ecto.Query
|
||||
|
||||
import Pleroma.Search.Meilisearch,
|
||||
only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete: 1]
|
||||
only: [meili_put: 2, meili_get: 1, meili_delete: 1]
|
||||
|
||||
def run(["index"]) do
|
||||
start_pleroma()
|
||||
|
|
@ -28,7 +28,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
|
|||
end
|
||||
|
||||
{:ok, _} =
|
||||
meili_post(
|
||||
meili_put(
|
||||
"/indexes/objects/settings/ranking-rules",
|
||||
[
|
||||
"published:desc",
|
||||
|
|
@ -42,7 +42,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
|
|||
)
|
||||
|
||||
{:ok, _} =
|
||||
meili_post(
|
||||
meili_put(
|
||||
"/indexes/objects/settings/searchable-attributes",
|
||||
[
|
||||
"content"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ defmodule Mix.Tasks.Pleroma.TestRunner do
|
|||
use Mix.Task
|
||||
|
||||
def run(args \\ []) do
|
||||
case System.cmd("mix", ["test"] ++ args, into: IO.stream(:stdio, :line)) do
|
||||
case System.cmd("mix", ["test", "--warnings-as-errors"] ++ args,
|
||||
into: IO.stream(:stdio, :line)
|
||||
) do
|
||||
{_, 0} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ defmodule Pleroma.Activity.HTML do
|
|||
|
||||
def invalidate_cache_for(activity_id) do
|
||||
keys = get_cache_keys_for(activity_id)
|
||||
Enum.map(keys, &@cachex.del(:scrubber_cache, &1))
|
||||
Enum.each(keys, &@cachex.del(:scrubber_cache, &1))
|
||||
@cachex.del(:scrubber_management_cache, activity_id)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule Pleroma.Activity.Queries do
|
|||
Contains queries for Activity.
|
||||
"""
|
||||
|
||||
import Ecto.Query, only: [from: 2, where: 3]
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
@type query :: Ecto.Queryable.t() | Pleroma.Activity.t()
|
||||
|
||||
|
|
@ -70,22 +70,6 @@ defmodule Pleroma.Activity.Queries do
|
|||
)
|
||||
end
|
||||
|
||||
@spec by_object_in_reply_to_id(query, String.t(), keyword()) :: query
|
||||
def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do
|
||||
query =
|
||||
if opts[:skip_preloading] do
|
||||
Activity.with_joined_object(query)
|
||||
else
|
||||
Activity.with_preloaded_object(query)
|
||||
end
|
||||
|
||||
where(
|
||||
query,
|
||||
[activity, object: o],
|
||||
fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id))
|
||||
)
|
||||
end
|
||||
|
||||
@spec by_type(query, String.t()) :: query
|
||||
def by_type(query \\ Activity, activity_type) do
|
||||
from(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ defmodule Pleroma.Application do
|
|||
@name Mix.Project.config()[:name]
|
||||
@version Mix.Project.config()[:version]
|
||||
@repository Mix.Project.config()[:source_url]
|
||||
@compile_env Mix.env()
|
||||
|
||||
def name, do: @name
|
||||
def version, do: @version
|
||||
|
|
@ -44,20 +43,20 @@ defmodule Pleroma.Application do
|
|||
# every time the application is restarted, so we disable module
|
||||
# conflicts at runtime
|
||||
Code.compiler_options(ignore_module_conflict: true)
|
||||
# Disable warnings_as_errors at runtime, it breaks Phoenix live reload
|
||||
# due to protocol consolidation warnings
|
||||
Code.compiler_options(warnings_as_errors: false)
|
||||
Pleroma.Telemetry.Logger.attach()
|
||||
Config.Holder.save_default()
|
||||
Pleroma.HTML.compile_scrubbers()
|
||||
Pleroma.Config.Oban.warn()
|
||||
Config.DeprecationWarnings.warn()
|
||||
|
||||
if @compile_env != :test do
|
||||
if Config.get([Pleroma.Web.Plugs.HTTPSecurityPlug, :enable], true) do
|
||||
Pleroma.Web.Plugs.HTTPSecurityPlug.warn_if_disabled()
|
||||
end
|
||||
|
||||
Pleroma.ApplicationRequirements.verify!()
|
||||
if Config.get(:env) != :test do
|
||||
Pleroma.ApplicationRequirements.verify!()
|
||||
end
|
||||
|
||||
load_custom_modules()
|
||||
Pleroma.Docs.JSON.compile()
|
||||
limiters_setup()
|
||||
|
|
@ -69,32 +68,18 @@ defmodule Pleroma.Application do
|
|||
Finch.start_link(name: MyFinch)
|
||||
end
|
||||
|
||||
if adapter == Tesla.Adapter.Gun do
|
||||
if version = Pleroma.OTPVersion.version() do
|
||||
[major, minor] =
|
||||
version
|
||||
|> String.split(".")
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Enum.take(2)
|
||||
|
||||
if (major == 22 and minor < 2) or major < 22 do
|
||||
raise "
|
||||
!!!OTP VERSION WARNING!!!
|
||||
You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. Please update your Erlang/OTP to at least 22.2.
|
||||
"
|
||||
end
|
||||
else
|
||||
raise "
|
||||
!!!OTP VERSION WARNING!!!
|
||||
To support correct handling of unordered certificates chains - OTP version must be > 22.2.
|
||||
"
|
||||
end
|
||||
# Disable warnings_as_errors at runtime, it breaks Phoenix live reload
|
||||
# due to protocol consolidation warnings
|
||||
# :warnings_as_errors is deprecated via Code.compiler_options/2 since 1.18
|
||||
if Version.compare(System.version(), "1.18.0") == :lt do
|
||||
Code.compiler_options(warnings_as_errors: false)
|
||||
end
|
||||
|
||||
# Define workers and child supervisors to be supervised
|
||||
children =
|
||||
[
|
||||
Pleroma.PromEx,
|
||||
Pleroma.LDAP,
|
||||
Pleroma.Repo,
|
||||
Config.TransferTask,
|
||||
Pleroma.Emoji,
|
||||
|
|
@ -169,7 +154,8 @@ defmodule Pleroma.Application do
|
|||
limit: 500_000
|
||||
),
|
||||
build_cachex("rel_me", limit: 2500),
|
||||
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5000)
|
||||
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5_000),
|
||||
build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000)
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -189,7 +189,40 @@ defmodule Pleroma.ApplicationRequirements do
|
|||
false
|
||||
end
|
||||
|
||||
if Enum.all?([preview_proxy_commands_status | filter_commands_statuses], & &1) do
|
||||
language_detector_commands_status =
|
||||
if Pleroma.Language.LanguageDetector.missing_dependencies() == [] do
|
||||
true
|
||||
else
|
||||
Logger.error(
|
||||
"The following dependencies required by the currently enabled " <>
|
||||
"language detection provider are not installed: " <>
|
||||
inspect(Pleroma.Language.LanguageDetector.missing_dependencies())
|
||||
)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
translation_commands_status =
|
||||
if Pleroma.Language.Translation.missing_dependencies() == [] do
|
||||
true
|
||||
else
|
||||
Logger.error(
|
||||
"The following dependencies required by the currently enabled " <>
|
||||
"translation provider are not installed: " <>
|
||||
inspect(Pleroma.Language.Translation.missing_dependencies())
|
||||
)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
if Enum.all?(
|
||||
[
|
||||
preview_proxy_commands_status,
|
||||
language_detector_commands_status,
|
||||
translation_commands_status | filter_commands_statuses
|
||||
],
|
||||
& &1
|
||||
) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
|
|
@ -241,10 +274,9 @@ defmodule Pleroma.ApplicationRequirements do
|
|||
|
||||
missing_mrfs =
|
||||
Enum.reduce(mrfs, [], fn x, acc ->
|
||||
if Code.ensure_compiled(x) do
|
||||
acc
|
||||
else
|
||||
acc ++ [x]
|
||||
case Code.ensure_compiled(x) do
|
||||
{:module, _} -> acc
|
||||
{:error, _} -> acc ++ [x]
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
|
|||
72
lib/pleroma/backports.ex
Normal file
72
lib/pleroma/backports.ex
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Copyright 2012 Plataformatec
|
||||
# Copyright 2021 The Elixir Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
defmodule Pleroma.Backports do
|
||||
import File, only: [dir?: 1]
|
||||
|
||||
# <https://github.com/elixir-lang/elixir/pull/14242>
|
||||
# To be removed when we require Elixir 1.19
|
||||
@doc """
|
||||
Tries to create the directory `path`.
|
||||
|
||||
Missing parent directories are created. Returns `:ok` if successful, or
|
||||
`{:error, reason}` if an error occurs.
|
||||
|
||||
Typical error reasons are:
|
||||
|
||||
* `:eacces` - missing search or write permissions for the parent
|
||||
directories of `path`
|
||||
* `:enospc` - there is no space left on the device
|
||||
* `:enotdir` - a component of `path` is not a directory
|
||||
|
||||
"""
|
||||
@spec mkdir_p(Path.t()) :: :ok | {:error, File.posix() | :badarg}
|
||||
def mkdir_p(path) do
|
||||
do_mkdir_p(IO.chardata_to_string(path))
|
||||
end
|
||||
|
||||
defp do_mkdir_p("/") do
|
||||
:ok
|
||||
end
|
||||
|
||||
defp do_mkdir_p(path) do
|
||||
parent = Path.dirname(path)
|
||||
|
||||
if parent == path do
|
||||
:ok
|
||||
else
|
||||
case do_mkdir_p(parent) do
|
||||
:ok ->
|
||||
case :file.make_dir(path) do
|
||||
{:error, :eexist} ->
|
||||
if dir?(path), do: :ok, else: {:error, :enotdir}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
|
||||
e ->
|
||||
e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Same as `mkdir_p/1`, but raises a `File.Error` exception in case of failure.
|
||||
Otherwise `:ok`.
|
||||
"""
|
||||
@spec mkdir_p!(Path.t()) :: :ok
|
||||
def mkdir_p!(path) do
|
||||
case mkdir_p(path) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
raise File.Error,
|
||||
reason: reason,
|
||||
action: "make directory (with -p)",
|
||||
path: IO.chardata_to_string(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -19,7 +19,7 @@ defmodule Pleroma.Bookmark do
|
|||
schema "bookmarks" do
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:folder, BookmarkFolder, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:folder, BookmarkFolder, type: FlakeId.Ecto.Type)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ defmodule Pleroma.Bookmark do
|
|||
|> validate_required([:user_id, :activity_id])
|
||||
|> unique_constraint(:activity_id, name: :bookmarks_user_id_activity_id_index)
|
||||
|> Repo.insert(
|
||||
on_conflict: [set: [folder_id: folder_id]],
|
||||
on_conflict: [set: [folder_id: folder_id, updated_at: NaiveDateTime.utc_now()]],
|
||||
conflict_target: [:user_id, :activity_id]
|
||||
)
|
||||
end
|
||||
|
|
@ -76,11 +76,4 @@ defmodule Pleroma.Bookmark do
|
|||
|> Repo.one()
|
||||
|> Repo.delete()
|
||||
end
|
||||
|
||||
def set_folder(bookmark, folder_id) do
|
||||
bookmark
|
||||
|> cast(%{folder_id: folder_id}, [:folder_id])
|
||||
|> validate_required([:folder_id])
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule Pleroma.BookmarkFolder do
|
|||
alias Pleroma.User
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
@primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true}
|
||||
@primary_key {:id, FlakeId.Ecto.Type, autogenerate: true}
|
||||
|
||||
schema "bookmark_folders" do
|
||||
field(:name, :string)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ defmodule Pleroma.Chat do
|
|||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
field(:recipient, :string)
|
||||
|
||||
field(:pinned, :boolean)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
|
@ -94,4 +96,16 @@ defmodule Pleroma.Chat do
|
|||
order_by: [desc: c.updated_at]
|
||||
)
|
||||
end
|
||||
|
||||
def pin(%__MODULE__{} = chat) do
|
||||
chat
|
||||
|> cast(%{pinned: true}, [:pinned])
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def unpin(%__MODULE__{} = chat) do
|
||||
chat
|
||||
|> cast(%{pinned: false}, [:pinned])
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ defmodule Pleroma.Config do
|
|||
Application.get_env(:pleroma, key, default)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def get!(key) do
|
||||
value = get(key, nil)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
defmodule Pleroma.Config.Getting do
|
||||
@callback get(any()) :: any()
|
||||
@callback get(any(), any()) :: any()
|
||||
@callback get!(any()) :: any()
|
||||
|
||||
def get(key), do: get(key, nil)
|
||||
def get(key, default), do: impl().get(key, default)
|
||||
|
||||
def get!(key), do: impl().get!(key)
|
||||
|
||||
def impl do
|
||||
Application.get_env(:pleroma, :config_impl, Pleroma.Config)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ defmodule Pleroma.Config.TransferTask do
|
|||
{:pleroma, :markup},
|
||||
{:pleroma, :streamer},
|
||||
{:pleroma, :pools},
|
||||
{:pleroma, :connections_pool}
|
||||
{:pleroma, :connections_pool},
|
||||
{:pleroma, :ldap}
|
||||
]
|
||||
|
||||
defp reboot_time_subkeys,
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ defmodule Pleroma.ConfigDB do
|
|||
end
|
||||
|
||||
def to_elixir_types(%{"tuple" => entity}) do
|
||||
Enum.reduce(entity, {}, &Tuple.append(&2, to_elixir_types(&1)))
|
||||
Enum.reduce(entity, {}, &Tuple.insert_at(&2, tuple_size(&2), to_elixir_types(&1)))
|
||||
end
|
||||
|
||||
def to_elixir_types(entity) when is_map(entity) do
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ defmodule Pleroma.Constants do
|
|||
"deleted_activity_id",
|
||||
"pleroma_internal",
|
||||
"generator",
|
||||
"rules"
|
||||
"rules",
|
||||
"language",
|
||||
"voters",
|
||||
"assigned_account"
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -36,10 +39,12 @@ defmodule Pleroma.Constants do
|
|||
"updated",
|
||||
"emoji",
|
||||
"content",
|
||||
"contentMap",
|
||||
"summary",
|
||||
"sensitive",
|
||||
"attachment",
|
||||
"generator"
|
||||
"generator",
|
||||
"language"
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -85,12 +90,57 @@ defmodule Pleroma.Constants do
|
|||
]
|
||||
)
|
||||
|
||||
const(activity_types,
|
||||
do: [
|
||||
"Block",
|
||||
"Create",
|
||||
"Update",
|
||||
"Delete",
|
||||
"Follow",
|
||||
"Accept",
|
||||
"Reject",
|
||||
"Add",
|
||||
"Remove",
|
||||
"Like",
|
||||
"Dislike",
|
||||
"Announce",
|
||||
"Undo",
|
||||
"Flag",
|
||||
"EmojiReact",
|
||||
"Listen"
|
||||
]
|
||||
)
|
||||
|
||||
const(allowed_activity_types_from_strangers,
|
||||
do: [
|
||||
"Block",
|
||||
"Create",
|
||||
"Flag",
|
||||
"Follow",
|
||||
"Like",
|
||||
"Dislike",
|
||||
"EmojiReact",
|
||||
"Announce"
|
||||
]
|
||||
)
|
||||
|
||||
const(object_types,
|
||||
do: ~w[Event Question Answer Audio Video Image Article Note Page ChatMessage]
|
||||
)
|
||||
|
||||
# basic regex, just there to weed out potential mistakes
|
||||
# https://datatracker.ietf.org/doc/html/rfc2045#section-5.1
|
||||
const(mime_regex,
|
||||
do: ~r/^[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+\/[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+(; .*)?$/
|
||||
)
|
||||
|
||||
# List of allowed chars in the path segment of a URI
|
||||
# unreserved, sub-delims, ":", "@" and "/" allowed as the separator in path
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986
|
||||
const(uri_path_allowed_reserved_chars,
|
||||
do: ~c"!$&'()*+,;=/:@"
|
||||
)
|
||||
|
||||
const(upload_object_types, do: ["Document", "Image"])
|
||||
|
||||
const(activity_json_canonical_mime_type,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ defmodule Pleroma.Conversation.Participation do
|
|||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
schema "conversation_participations" do
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:conversation, Conversation)
|
||||
|
|
|
|||
3
lib/pleroma/date_time.ex
Normal file
3
lib/pleroma/date_time.ex
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
defmodule Pleroma.DateTime do
|
||||
@callback utc_now() :: NaiveDateTime.t()
|
||||
end
|
||||
6
lib/pleroma/date_time/impl.ex
Normal file
6
lib/pleroma/date_time/impl.ex
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
defmodule Pleroma.DateTime.Impl do
|
||||
@behaviour Pleroma.DateTime
|
||||
|
||||
@impl true
|
||||
def utc_now, do: NaiveDateTime.utc_now()
|
||||
end
|
||||
|
|
@ -27,11 +27,3 @@ defenum(Pleroma.DataMigration.State,
|
|||
failed: 4,
|
||||
manual: 5
|
||||
)
|
||||
|
||||
defenum(Pleroma.User.Backup.State,
|
||||
pending: 1,
|
||||
running: 2,
|
||||
complete: 3,
|
||||
failed: 4,
|
||||
invalid: 5
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ContentLanguageMap do
|
||||
use Ecto.Type
|
||||
|
||||
import Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCode,
|
||||
only: [good_locale_code?: 1]
|
||||
|
||||
def type, do: :map
|
||||
|
||||
def cast(%{} = object) do
|
||||
with {status, %{} = data} when status in [:modified, :ok] <- validate_map(object) do
|
||||
{:ok, data}
|
||||
else
|
||||
{_, nil} -> {:ok, nil}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def cast(_), do: :error
|
||||
|
||||
def dump(data), do: {:ok, data}
|
||||
|
||||
def load(data), do: {:ok, data}
|
||||
|
||||
defp validate_map(%{} = object) do
|
||||
{status, data} =
|
||||
object
|
||||
|> Enum.reduce({:ok, %{}}, fn
|
||||
{lang, value}, {status, acc} when is_binary(lang) and is_binary(value) ->
|
||||
if good_locale_code?(lang) do
|
||||
{status, Map.put(acc, lang, value)}
|
||||
else
|
||||
{:modified, acc}
|
||||
end
|
||||
|
||||
_, {_status, acc} ->
|
||||
{:modified, acc}
|
||||
end)
|
||||
|
||||
if data == %{} do
|
||||
{status, nil}
|
||||
else
|
||||
{status, data}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCode do
|
||||
use Ecto.Type
|
||||
|
||||
def type, do: :string
|
||||
|
||||
def cast(language) when is_binary(language) do
|
||||
if good_locale_code?(language) do
|
||||
{:ok, language}
|
||||
else
|
||||
{:error, :invalid_language}
|
||||
end
|
||||
end
|
||||
|
||||
def cast(_), do: :error
|
||||
|
||||
def dump(data), do: {:ok, data}
|
||||
|
||||
def load(data), do: {:ok, data}
|
||||
|
||||
def good_locale_code?(code) when is_binary(code), do: code =~ ~r<^[a-zA-Z0-9\-]+\z$>
|
||||
|
||||
def good_locale_code?(_code), do: false
|
||||
end
|
||||
|
|
@ -25,7 +25,8 @@ defmodule Pleroma.Emails.Mailer do
|
|||
|> :erlang.term_to_binary()
|
||||
|> Base.encode64()
|
||||
|
||||
MailerWorker.enqueue("email", %{"encoded_email" => encoded_email, "config" => config})
|
||||
MailerWorker.new(%{"op" => "email", "encoded_email" => encoded_email, "config" => config})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
@doc "callback to perform send email from queue"
|
||||
|
|
|
|||
|
|
@ -345,37 +345,22 @@ defmodule Pleroma.Emails.UserEmail do
|
|||
Router.Helpers.subscription_url(Endpoint, :unsubscribe, token)
|
||||
end
|
||||
|
||||
def backup_is_ready_email(backup, admin_user_id \\ nil) do
|
||||
def backup_is_ready_email(backup) do
|
||||
%{user: user} = Pleroma.Repo.preload(backup, :user)
|
||||
|
||||
Gettext.with_locale_or_default user.language do
|
||||
download_url = Pleroma.Web.PleromaAPI.BackupView.download_url(backup)
|
||||
|
||||
html_body =
|
||||
if is_nil(admin_user_id) do
|
||||
Gettext.dpgettext(
|
||||
"static_pages",
|
||||
"account archive email body - self-requested",
|
||||
"""
|
||||
<p>You requested a full backup of your Pleroma account. It's ready for download:</p>
|
||||
<p><a href="%{download_url}">%{download_url}</a></p>
|
||||
""",
|
||||
download_url: download_url
|
||||
)
|
||||
else
|
||||
admin = Pleroma.Repo.get(User, admin_user_id)
|
||||
|
||||
Gettext.dpgettext(
|
||||
"static_pages",
|
||||
"account archive email body - admin requested",
|
||||
"""
|
||||
<p>Admin @%{admin_nickname} requested a full backup of your Pleroma account. It's ready for download:</p>
|
||||
<p><a href="%{download_url}">%{download_url}</a></p>
|
||||
""",
|
||||
admin_nickname: admin.nickname,
|
||||
download_url: download_url
|
||||
)
|
||||
end
|
||||
Gettext.dpgettext(
|
||||
"static_pages",
|
||||
"account archive email body",
|
||||
"""
|
||||
<p>A full backup of your Pleroma account was requested. It's ready for download:</p>
|
||||
<p><a href="%{download_url}">%{download_url}</a></p>
|
||||
""",
|
||||
download_url: download_url
|
||||
)
|
||||
|
||||
new()
|
||||
|> to(recipient(user))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ defmodule Pleroma.Emoji do
|
|||
|
||||
alias Pleroma.Emoji.Combinations
|
||||
alias Pleroma.Emoji.Loader
|
||||
alias Pleroma.Utils.URIEncoding
|
||||
alias Pleroma.Web.Endpoint
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -189,6 +191,34 @@ defmodule Pleroma.Emoji do
|
|||
|
||||
def emoji_url(_), do: nil
|
||||
|
||||
@spec local_url(String.t() | nil) :: String.t() | nil
|
||||
def local_url(nil), do: nil
|
||||
|
||||
def local_url("http" <> _ = url) do
|
||||
URIEncoding.encode_url(url)
|
||||
end
|
||||
|
||||
def local_url("/" <> _ = path) do
|
||||
path = URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true)
|
||||
Endpoint.url() <> path
|
||||
end
|
||||
|
||||
def local_url(path) when is_binary(path) do
|
||||
local_url("/" <> path)
|
||||
end
|
||||
|
||||
def build_emoji_tag({name, url}) do
|
||||
url = URIEncoding.encode_url(url)
|
||||
|
||||
%{
|
||||
"icon" => %{"url" => "#{url}", "type" => "Image"},
|
||||
"name" => ":" <> name <> ":",
|
||||
"type" => "Emoji",
|
||||
"updated" => "1970-01-01T00:00:00Z",
|
||||
"id" => url
|
||||
}
|
||||
end
|
||||
|
||||
def emoji_name_with_instance(name, url) do
|
||||
url = url |> URI.parse() |> Map.get(:host)
|
||||
"#{name}@#{url}"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
defmodule Pleroma.Emoji.Formatter do
|
||||
alias Pleroma.Emoji
|
||||
alias Pleroma.HTML
|
||||
alias Pleroma.Web.Endpoint
|
||||
alias Pleroma.Web.MediaProxy
|
||||
|
||||
def emojify(text) do
|
||||
|
|
@ -44,7 +43,7 @@ defmodule Pleroma.Emoji.Formatter do
|
|||
Emoji.get_all()
|
||||
|> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end)
|
||||
|> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc ->
|
||||
Map.put(acc, name, to_string(URI.merge(Endpoint.url(), file)))
|
||||
Map.put(acc, name, Emoji.local_url(file))
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ defmodule Pleroma.Emoji.Pack do
|
|||
|
||||
alias Pleroma.Emoji
|
||||
alias Pleroma.Emoji.Pack
|
||||
alias Pleroma.SafeZip
|
||||
alias Pleroma.Utils
|
||||
|
||||
@spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
|
||||
def create(name) do
|
||||
with :ok <- validate_not_empty([name]),
|
||||
dir <- Path.join(emoji_path(), name),
|
||||
dir <- path_join_name_safe(emoji_path(), name),
|
||||
:ok <- File.mkdir(dir) do
|
||||
save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")})
|
||||
end
|
||||
|
|
@ -65,43 +66,21 @@ defmodule Pleroma.Emoji.Pack do
|
|||
{:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values}
|
||||
def delete(name) do
|
||||
with :ok <- validate_not_empty([name]),
|
||||
pack_path <- Path.join(emoji_path(), name) do
|
||||
pack_path <- path_join_name_safe(emoji_path(), name) do
|
||||
File.rm_rf(pack_path)
|
||||
end
|
||||
end
|
||||
|
||||
@spec unpack_zip_emojies(list(tuple())) :: list(map())
|
||||
defp unpack_zip_emojies(zip_files) do
|
||||
Enum.reduce(zip_files, [], fn
|
||||
{_, path, s, _, _, _}, acc when elem(s, 2) == :regular ->
|
||||
with(
|
||||
filename <- Path.basename(path),
|
||||
shortcode <- Path.basename(filename, Path.extname(filename)),
|
||||
false <- Emoji.exist?(shortcode)
|
||||
) do
|
||||
[%{path: path, filename: path, shortcode: shortcode} | acc]
|
||||
else
|
||||
_ -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
@spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) ::
|
||||
{:ok, t()}
|
||||
| {:error, File.posix() | atom()}
|
||||
def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do
|
||||
with {:ok, zip_files} <- :zip.table(to_charlist(file.path)),
|
||||
[_ | _] = emojies <- unpack_zip_emojies(zip_files),
|
||||
with {:ok, zip_files} <- SafeZip.list_dir_file(file.path),
|
||||
[_ | _] = emojies <- map_zip_emojies(zip_files),
|
||||
{:ok, tmp_dir} <- Utils.tmp_dir("emoji") do
|
||||
try do
|
||||
{:ok, _emoji_files} =
|
||||
:zip.unzip(
|
||||
to_charlist(file.path),
|
||||
[{:file_list, Enum.map(emojies, & &1[:path])}, {:cwd, String.to_charlist(tmp_dir)}]
|
||||
)
|
||||
SafeZip.unzip_file(file.path, tmp_dir, Enum.map(emojies, & &1[:path]))
|
||||
|
||||
{_, updated_pack} =
|
||||
Enum.map_reduce(emojies, pack, fn item, emoji_pack ->
|
||||
|
|
@ -246,6 +225,97 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
end
|
||||
|
||||
def download_zip(name, opts \\ %{}) do
|
||||
with :ok <- validate_not_empty([name]),
|
||||
:ok <- validate_new_pack(name),
|
||||
{:ok, archive_data} <- fetch_archive_data(opts),
|
||||
pack_path <- path_join_name_safe(emoji_path(), name),
|
||||
:ok <- create_pack_dir(pack_path),
|
||||
:ok <- safe_unzip(archive_data, pack_path) do
|
||||
ensure_pack_json(pack_path, archive_data, opts)
|
||||
else
|
||||
{:error, :empty_values} -> {:error, "Pack name cannot be empty"}
|
||||
{:error, reason} when is_binary(reason) -> {:error, reason}
|
||||
_ -> {:error, "Could not process pack"}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_pack_dir(pack_path) do
|
||||
case File.mkdir_p(pack_path) do
|
||||
:ok -> :ok
|
||||
{:error, _} -> {:error, "Could not create the pack directory"}
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_unzip(archive_data, pack_path) do
|
||||
case SafeZip.unzip_data(archive_data, pack_path) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} when is_binary(reason) -> {:error, reason}
|
||||
_ -> {:error, "Could not unzip pack"}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_new_pack(name) do
|
||||
pack_path = path_join_name_safe(emoji_path(), name)
|
||||
|
||||
if File.exists?(pack_path) do
|
||||
{:error, "Pack already exists, refusing to import #{name}"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_archive_data(%{url: url}) do
|
||||
case Pleroma.HTTP.get(url) do
|
||||
{:ok, %{status: 200, body: data}} -> {:ok, data}
|
||||
_ -> {:error, "Could not download pack"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_archive_data(%{file: %Plug.Upload{path: path}}) do
|
||||
case File.read(path) do
|
||||
{:ok, data} -> {:ok, data}
|
||||
_ -> {:error, "Could not read the uploaded pack file"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_archive_data(_) do
|
||||
{:error, "Neither file nor URL was present in the request"}
|
||||
end
|
||||
|
||||
defp ensure_pack_json(pack_path, archive_data, opts) do
|
||||
pack_json_path = Path.join(pack_path, "pack.json")
|
||||
|
||||
if not File.exists?(pack_json_path) do
|
||||
create_pack_json(pack_path, pack_json_path, archive_data, opts)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp create_pack_json(pack_path, pack_json_path, archive_data, opts) do
|
||||
emoji_map =
|
||||
Pleroma.Emoji.Loader.make_shortcode_to_file_map(
|
||||
pack_path,
|
||||
Map.get(opts, :exts, [".png", ".gif", ".jpg"])
|
||||
)
|
||||
|
||||
archive_sha = :crypto.hash(:sha256, archive_data) |> Base.encode16()
|
||||
|
||||
pack_json = %{
|
||||
pack: %{
|
||||
license: Map.get(opts, :license, ""),
|
||||
homepage: Map.get(opts, :homepage, ""),
|
||||
description: Map.get(opts, :description, ""),
|
||||
src: Map.get(opts, :url),
|
||||
src_sha256: archive_sha
|
||||
},
|
||||
files: emoji_map
|
||||
}
|
||||
|
||||
File.write!(pack_json_path, Jason.encode!(pack_json, pretty: true))
|
||||
end
|
||||
|
||||
@spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()}
|
||||
def download(name, url, as) do
|
||||
uri = url |> String.trim() |> URI.parse()
|
||||
|
|
@ -292,7 +362,7 @@ defmodule Pleroma.Emoji.Pack do
|
|||
@spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()}
|
||||
def load_pack(name) do
|
||||
name = Path.basename(name)
|
||||
pack_file = Path.join([emoji_path(), name, "pack.json"])
|
||||
pack_file = path_join_name_safe(emoji_path(), name) |> Path.join("pack.json")
|
||||
|
||||
with {:ok, _} <- File.stat(pack_file),
|
||||
{:ok, pack_data} <- File.read(pack_file) do
|
||||
|
|
@ -416,10 +486,9 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
|
||||
defp create_archive_and_cache(pack, hash) do
|
||||
files = [~c"pack.json" | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)]
|
||||
|
||||
{:ok, {_, result}} =
|
||||
:zip.zip(~c"#{pack.name}.zip", files, [:memory, cwd: to_charlist(pack.path)])
|
||||
pack_file_list = Enum.into(pack.files, [], fn {_, f} -> f end)
|
||||
files = ["pack.json" | pack_file_list]
|
||||
{:ok, {_, result}} = SafeZip.zip("#{pack.name}.zip", files, pack.path, true)
|
||||
|
||||
ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
|
||||
overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files))
|
||||
|
|
@ -478,7 +547,7 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
|
||||
defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do
|
||||
file_path = Path.join(pack.path, filename)
|
||||
file_path = path_join_safe(pack.path, filename)
|
||||
create_subdirs(file_path)
|
||||
|
||||
with {:ok, _} <- File.copy(upload_path, file_path) do
|
||||
|
|
@ -497,8 +566,8 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
|
||||
defp rename_file(pack, filename, new_filename) do
|
||||
old_path = Path.join(pack.path, filename)
|
||||
new_path = Path.join(pack.path, new_filename)
|
||||
old_path = path_join_safe(pack.path, filename)
|
||||
new_path = path_join_safe(pack.path, new_filename)
|
||||
create_subdirs(new_path)
|
||||
|
||||
with :ok <- File.rename(old_path, new_path) do
|
||||
|
|
@ -510,13 +579,13 @@ defmodule Pleroma.Emoji.Pack do
|
|||
with true <- String.contains?(file_path, "/"),
|
||||
path <- Path.dirname(file_path),
|
||||
false <- File.exists?(path) do
|
||||
File.mkdir_p!(path)
|
||||
Pleroma.Backports.mkdir_p!(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_file(pack, shortcode) do
|
||||
with {:ok, filename} <- get_filename(pack, shortcode),
|
||||
emoji <- Path.join(pack.path, filename),
|
||||
emoji <- path_join_safe(pack.path, filename),
|
||||
:ok <- File.rm(emoji) do
|
||||
remove_dir_if_empty(emoji, filename)
|
||||
end
|
||||
|
|
@ -534,7 +603,7 @@ defmodule Pleroma.Emoji.Pack do
|
|||
|
||||
defp get_filename(pack, shortcode) do
|
||||
with %{^shortcode => filename} when is_binary(filename) <- pack.files,
|
||||
file_path <- Path.join(pack.path, filename),
|
||||
file_path <- path_join_safe(pack.path, filename),
|
||||
{:ok, _} <- File.stat(file_path) do
|
||||
{:ok, filename}
|
||||
else
|
||||
|
|
@ -558,7 +627,7 @@ defmodule Pleroma.Emoji.Pack do
|
|||
emoji_path = emoji_path()
|
||||
# Create the directory first if it does not exist. This is probably the first request made
|
||||
# with the API so it should be sufficient
|
||||
with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)},
|
||||
with {:create_dir, :ok} <- {:create_dir, Pleroma.Backports.mkdir_p(emoji_path)},
|
||||
{:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do
|
||||
{:ok, Enum.sort(results)}
|
||||
else
|
||||
|
|
@ -583,12 +652,11 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
|
||||
defp unzip(archive, pack_info, remote_pack, local_pack) do
|
||||
with :ok <- File.mkdir_p!(local_pack.path) do
|
||||
files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end)
|
||||
with :ok <- Pleroma.Backports.mkdir_p!(local_pack.path) do
|
||||
files = Enum.map(remote_pack["files"], fn {_, path} -> path end)
|
||||
# Fallback cannot contain a pack.json file
|
||||
files = if pack_info[:fallback], do: files, else: [~c"pack.json" | files]
|
||||
|
||||
:zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files)
|
||||
files = if pack_info[:fallback], do: files, else: ["pack.json" | files]
|
||||
SafeZip.unzip_data(archive, local_pack.path, files)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -649,13 +717,43 @@ defmodule Pleroma.Emoji.Pack do
|
|||
end
|
||||
|
||||
defp validate_has_all_files(pack, zip) do
|
||||
with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do
|
||||
# Check if all files from the pack.json are in the archive
|
||||
pack.files
|
||||
|> Enum.all?(fn {_, from_manifest} ->
|
||||
List.keyfind(f_list, to_charlist(from_manifest), 0)
|
||||
# Check if all files from the pack.json are in the archive
|
||||
eset =
|
||||
Enum.reduce(pack.files, MapSet.new(), fn
|
||||
{_, file}, s -> MapSet.put(s, to_charlist(file))
|
||||
end)
|
||||
|> if(do: :ok, else: {:error, :incomplete})
|
||||
|
||||
if SafeZip.contains_all_data?(zip, eset),
|
||||
do: :ok,
|
||||
else: {:error, :incomplete}
|
||||
end
|
||||
|
||||
defp path_join_name_safe(dir, name) do
|
||||
if to_string(name) != Path.basename(name) or name in ["..", ".", ""] do
|
||||
raise "Invalid or malicious pack name: #{name}"
|
||||
else
|
||||
Path.join(dir, name)
|
||||
end
|
||||
end
|
||||
|
||||
defp path_join_safe(dir, path) do
|
||||
{:ok, safe_path} = Path.safe_relative(path)
|
||||
Path.join(dir, safe_path)
|
||||
end
|
||||
|
||||
defp map_zip_emojies(zip_files) do
|
||||
Enum.reduce(zip_files, [], fn path, acc ->
|
||||
with(
|
||||
filename <- Path.basename(path),
|
||||
shortcode <- Path.basename(filename, Path.extname(filename)),
|
||||
# note: this only checks the shortcode, if an emoji already exists on the same path, but
|
||||
# with a different shortcode, the existing one will be degraded to an alias of the new
|
||||
false <- Emoji.exist?(shortcode)
|
||||
) do
|
||||
[%{path: path, filename: path, shortcode: shortcode} | acc]
|
||||
else
|
||||
_ -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -133,10 +133,13 @@ defmodule Pleroma.Filter do
|
|||
defp maybe_add_expires_at(changeset, _), do: changeset
|
||||
|
||||
defp maybe_add_expiration_job(%{expires_at: %NaiveDateTime{} = expires_at} = filter) do
|
||||
Pleroma.Workers.PurgeExpiredFilter.enqueue(%{
|
||||
filter_id: filter.id,
|
||||
expires_at: DateTime.from_naive!(expires_at, "Etc/UTC")
|
||||
})
|
||||
Pleroma.Workers.PurgeExpiredFilter.new(
|
||||
%{
|
||||
filter_id: filter.id
|
||||
},
|
||||
scheduled_at: DateTime.from_naive!(expires_at, "Etc/UTC")
|
||||
)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp maybe_add_expiration_job(_), do: {:ok, nil}
|
||||
|
|
|
|||
|
|
@ -147,14 +147,22 @@ defmodule Pleroma.FollowingRelationship do
|
|||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
def get_follow_requests(%User{id: id}) do
|
||||
def get_follow_requests_query(%User{id: id}) do
|
||||
__MODULE__
|
||||
|> join(:inner, [r], f in assoc(r, :follower))
|
||||
|> join(:inner, [r], f in assoc(r, :follower), as: :follower)
|
||||
|> where([r], r.state == ^:follow_pending)
|
||||
|> where([r], r.following_id == ^id)
|
||||
|> where([r, f], f.is_active == true)
|
||||
|> select([r, f], f)
|
||||
|> Repo.all()
|
||||
|> where([r, follower: f], f.is_active == true)
|
||||
|> select([r, follower: f], f)
|
||||
end
|
||||
|
||||
def get_outgoing_follow_requests_query(%User{id: id}) do
|
||||
__MODULE__
|
||||
|> join(:inner, [r], f in assoc(r, :following), as: :following)
|
||||
|> where([r], r.state == ^:follow_pending)
|
||||
|> where([r], r.follower_id == ^id)
|
||||
|> where([r, following: f], f.is_active == true)
|
||||
|> select([r, following: f], f)
|
||||
end
|
||||
|
||||
def following?(%User{id: follower_id}, %User{id: followed_id}) do
|
||||
|
|
@ -199,8 +207,8 @@ defmodule Pleroma.FollowingRelationship do
|
|||
|> preload([:follower])
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn following_relationship ->
|
||||
Pleroma.Web.CommonAPI.follow(following_relationship.follower, target)
|
||||
Pleroma.Web.CommonAPI.unfollow(following_relationship.follower, origin)
|
||||
Pleroma.Web.CommonAPI.follow(target, following_relationship.follower)
|
||||
Pleroma.Web.CommonAPI.unfollow(origin, following_relationship.follower)
|
||||
end)
|
||||
|> case do
|
||||
[] ->
|
||||
|
|
|
|||
|
|
@ -43,10 +43,6 @@ defmodule Pleroma.Frontend do
|
|||
{:download_or_unzip, _} ->
|
||||
Logger.info("Could not download or unzip the frontend")
|
||||
{:error, "Could not download or unzip the frontend"}
|
||||
|
||||
_e ->
|
||||
Logger.info("Could not install the frontend")
|
||||
{:error, "Could not install the frontend"}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -69,21 +65,12 @@ defmodule Pleroma.Frontend do
|
|||
end
|
||||
|
||||
def unzip(zip, dest) do
|
||||
with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do
|
||||
File.rm_rf!(dest)
|
||||
File.mkdir_p!(dest)
|
||||
File.rm_rf!(dest)
|
||||
Pleroma.Backports.mkdir_p!(dest)
|
||||
|
||||
Enum.each(unzipped, fn {filename, data} ->
|
||||
path = filename
|
||||
|
||||
new_file_path = Path.join(dest, path)
|
||||
|
||||
new_file_path
|
||||
|> Path.dirname()
|
||||
|> File.mkdir_p!()
|
||||
|
||||
File.write!(new_file_path, data)
|
||||
end)
|
||||
case Pleroma.SafeZip.unzip_data(zip, dest) do
|
||||
{:ok, _} -> :ok
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -103,7 +90,7 @@ defmodule Pleroma.Frontend do
|
|||
defp install_frontend(frontend_info, source, dest) do
|
||||
from = frontend_info["build_dir"] || "dist"
|
||||
File.rm_rf!(dest)
|
||||
File.mkdir_p!(dest)
|
||||
Pleroma.Backports.mkdir_p!(dest)
|
||||
File.cp_r!(Path.join([source, from]), dest)
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,17 +21,28 @@ defmodule Pleroma.Gopher.Server do
|
|||
|
||||
def init([ip, port]) do
|
||||
Logger.info("Starting gopher server on #{port}")
|
||||
Process.flag(:trap_exit, true)
|
||||
|
||||
:ranch.start_listener(
|
||||
:gopher,
|
||||
100,
|
||||
:ranch_tcp,
|
||||
[ip: ip, port: port],
|
||||
__MODULE__.ProtocolHandler,
|
||||
[]
|
||||
)
|
||||
listener = :gopher
|
||||
|
||||
{:ok, %{ip: ip, port: port}}
|
||||
{:ok, _pid} =
|
||||
:ranch.start_listener(
|
||||
listener,
|
||||
:ranch_tcp,
|
||||
%{
|
||||
num_acceptors: 100,
|
||||
max_connections: 100,
|
||||
socket_opts: [ip: ip, port: port]
|
||||
},
|
||||
__MODULE__.ProtocolHandler,
|
||||
[]
|
||||
)
|
||||
|
||||
{:ok, %{ip: ip, port: port, listener: listener}}
|
||||
end
|
||||
|
||||
def terminate(_reason, state) do
|
||||
:ranch.stop_listener(state.listener)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -43,13 +54,13 @@ defmodule Pleroma.Gopher.Server.ProtocolHandler do
|
|||
alias Pleroma.Web.ActivityPub.ActivityPub
|
||||
alias Pleroma.Web.ActivityPub.Visibility
|
||||
|
||||
def start_link(ref, socket, transport, opts) do
|
||||
pid = spawn_link(__MODULE__, :init, [ref, socket, transport, opts])
|
||||
def start_link(ref, transport, opts) do
|
||||
pid = spawn_link(__MODULE__, :init, [ref, transport, opts])
|
||||
{:ok, pid}
|
||||
end
|
||||
|
||||
def init(ref, socket, transport, [] = _Opts) do
|
||||
:ok = :ranch.accept_ack(ref)
|
||||
def init(ref, transport, opts \\ []) do
|
||||
{:ok, socket} = :ranch.handshake(ref, opts)
|
||||
loop(socket, transport)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ defmodule Pleroma.Hashtag do
|
|||
alias Pleroma.Hashtag
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User.HashtagFollow
|
||||
|
||||
schema "hashtags" do
|
||||
field(:name, :string)
|
||||
|
|
@ -27,6 +28,14 @@ defmodule Pleroma.Hashtag do
|
|||
|> String.trim()
|
||||
end
|
||||
|
||||
def get_by_id(id) do
|
||||
Repo.get(Hashtag, id)
|
||||
end
|
||||
|
||||
def get_by_name(name) do
|
||||
Repo.get_by(Hashtag, name: normalize_name(name))
|
||||
end
|
||||
|
||||
def get_or_create_by_name(name) do
|
||||
changeset = changeset(%Hashtag{}, %{name: name})
|
||||
|
||||
|
|
@ -103,4 +112,84 @@ defmodule Pleroma.Hashtag do
|
|||
{:ok, deleted_count}
|
||||
end
|
||||
end
|
||||
|
||||
def get_followers(%Hashtag{id: hashtag_id}) do
|
||||
from(hf in HashtagFollow)
|
||||
|> where([hf], hf.hashtag_id == ^hashtag_id)
|
||||
|> join(:inner, [hf], u in assoc(hf, :user))
|
||||
|> select([hf, u], u.id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}})
|
||||
when is_list(tags) do
|
||||
tags
|
||||
|> Enum.map(&get_followers/1)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
def get_recipients_for_activity(_activity), do: []
|
||||
|
||||
def search(query, options \\ []) do
|
||||
limit = Keyword.get(options, :limit, 20)
|
||||
offset = Keyword.get(options, :offset, 0)
|
||||
|
||||
search_terms =
|
||||
query
|
||||
|> String.downcase()
|
||||
|> String.trim()
|
||||
|> String.split(~r/\s+/)
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.map(&String.trim_leading(&1, "#"))
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|
||||
if Enum.empty?(search_terms) do
|
||||
[]
|
||||
else
|
||||
# Use PostgreSQL's ANY operator with array for efficient multi-term search
|
||||
# This is much more efficient than multiple OR clauses
|
||||
search_patterns = Enum.map(search_terms, &"%#{&1}%")
|
||||
|
||||
# Create ranking query that prioritizes exact matches and closer matches
|
||||
# Use a subquery to properly handle computed columns in ORDER BY
|
||||
base_query =
|
||||
from(ht in Hashtag,
|
||||
where: fragment("LOWER(?) LIKE ANY(?)", ht.name, ^search_patterns),
|
||||
select: %{
|
||||
name: ht.name,
|
||||
# Ranking: exact matches get highest priority (0)
|
||||
# then prefix matches (1), then contains (2)
|
||||
match_rank:
|
||||
fragment(
|
||||
"""
|
||||
CASE
|
||||
WHEN LOWER(?) = ANY(?) THEN 0
|
||||
WHEN LOWER(?) LIKE ANY(?) THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
""",
|
||||
ht.name,
|
||||
^search_terms,
|
||||
ht.name,
|
||||
^Enum.map(search_terms, &"#{&1}%")
|
||||
),
|
||||
# Secondary sort by name length (shorter names first)
|
||||
name_length: fragment("LENGTH(?)", ht.name)
|
||||
}
|
||||
)
|
||||
|
||||
from(result in subquery(base_query),
|
||||
order_by: [
|
||||
asc: result.match_rank,
|
||||
asc: result.name_length,
|
||||
asc: result.name
|
||||
],
|
||||
limit: ^limit,
|
||||
offset: ^offset
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(& &1.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ defmodule Pleroma.HTTP do
|
|||
|
||||
adapter = Application.get_env(:tesla, :adapter)
|
||||
|
||||
client = Tesla.client(adapter_middlewares(adapter), adapter)
|
||||
extra_middleware = options[:tesla_middleware] || []
|
||||
|
||||
client = Tesla.client(adapter_middlewares(adapter, extra_middleware), adapter)
|
||||
|
||||
maybe_limit(
|
||||
fn ->
|
||||
|
|
@ -102,20 +104,31 @@ defmodule Pleroma.HTTP do
|
|||
fun.()
|
||||
end
|
||||
|
||||
defp adapter_middlewares(Tesla.Adapter.Gun) do
|
||||
[Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.ConnectionPool]
|
||||
defp adapter_middlewares(Tesla.Adapter.Gun, extra_middleware) do
|
||||
default_middleware() ++
|
||||
[Pleroma.Tesla.Middleware.ConnectionPool] ++
|
||||
extra_middleware
|
||||
end
|
||||
|
||||
defp adapter_middlewares({Tesla.Adapter.Finch, _}) do
|
||||
[Tesla.Middleware.FollowRedirects]
|
||||
end
|
||||
defp adapter_middlewares(_, extra_middleware) do
|
||||
# A lot of tests are written expecting unencoded URLs
|
||||
# and the burden of fixing that is high. Also it makes
|
||||
# them hard to read. Tests will opt-in when we want to validate
|
||||
# the encoding is being done correctly.
|
||||
cond do
|
||||
Pleroma.Config.get(:env) == :test and Pleroma.Config.get(:test_url_encoding) ->
|
||||
default_middleware()
|
||||
|
||||
defp adapter_middlewares(_) do
|
||||
if Pleroma.Config.get(:env) == :test do
|
||||
# Emulate redirects in test env, which are handled by adapters in other environments
|
||||
[Tesla.Middleware.FollowRedirects]
|
||||
else
|
||||
[]
|
||||
Pleroma.Config.get(:env) == :test ->
|
||||
# Emulate redirects in test env, which are handled by adapters in other environments
|
||||
[Tesla.Middleware.FollowRedirects]
|
||||
|
||||
# Hackney and Finch
|
||||
true ->
|
||||
default_middleware() ++ extra_middleware
|
||||
end
|
||||
end
|
||||
|
||||
defp default_middleware,
|
||||
do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ defmodule Pleroma.HTTP.AdapterHelper do
|
|||
case adapter() do
|
||||
Tesla.Adapter.Gun -> AdapterHelper.Gun
|
||||
Tesla.Adapter.Hackney -> AdapterHelper.Hackney
|
||||
{Tesla.Adapter.Finch, _} -> AdapterHelper.Finch
|
||||
_ -> AdapterHelper.Default
|
||||
end
|
||||
end
|
||||
|
|
@ -118,4 +119,13 @@ defmodule Pleroma.HTTP.AdapterHelper do
|
|||
host_charlist
|
||||
end
|
||||
end
|
||||
|
||||
@spec can_stream? :: bool()
|
||||
def can_stream? do
|
||||
case Application.get_env(:tesla, :adapter) do
|
||||
Tesla.Adapter.Gun -> true
|
||||
{Tesla.Adapter.Finch, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
33
lib/pleroma/http/adapter_helper/finch.ex
Normal file
33
lib/pleroma/http/adapter_helper/finch.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.HTTP.AdapterHelper.Finch do
|
||||
@behaviour Pleroma.HTTP.AdapterHelper
|
||||
|
||||
alias Pleroma.Config
|
||||
alias Pleroma.HTTP.AdapterHelper
|
||||
|
||||
@spec options(keyword(), URI.t()) :: keyword()
|
||||
def options(incoming_opts \\ [], %URI{} = _uri) do
|
||||
proxy =
|
||||
[:http, :proxy_url]
|
||||
|> Config.get()
|
||||
|> AdapterHelper.format_proxy()
|
||||
|
||||
config_opts = Config.get([:http, :adapter], [])
|
||||
|
||||
config_opts
|
||||
|> Keyword.merge(incoming_opts)
|
||||
|> AdapterHelper.maybe_add_proxy(proxy)
|
||||
|> maybe_stream()
|
||||
end
|
||||
|
||||
# Finch uses [response: :stream]
|
||||
defp maybe_stream(opts) do
|
||||
case Keyword.pop(opts, :stream, nil) do
|
||||
{true, opts} -> Keyword.put(opts, :response, :stream)
|
||||
{_, opts} -> opts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -32,6 +32,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do
|
|||
|> AdapterHelper.maybe_add_proxy(proxy)
|
||||
|> Keyword.merge(incoming_opts)
|
||||
|> put_timeout()
|
||||
|> maybe_stream()
|
||||
end
|
||||
|
||||
defp add_scheme_opts(opts, %{scheme: "http"}), do: opts
|
||||
|
|
@ -47,6 +48,14 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do
|
|||
Keyword.put(opts, :timeout, recv_timeout)
|
||||
end
|
||||
|
||||
# Gun uses [body_as: :stream]
|
||||
defp maybe_stream(opts) do
|
||||
case Keyword.pop(opts, :stream, nil) do
|
||||
{true, opts} -> Keyword.put(opts, :body_as, :stream)
|
||||
{_, opts} -> opts
|
||||
end
|
||||
end
|
||||
|
||||
@spec pool_timeout(pool()) :: non_neg_integer()
|
||||
def pool_timeout(pool) do
|
||||
default = Config.get([:pools, :default, :recv_timeout], 5_000)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do
|
|||
@behaviour Pleroma.HTTP.AdapterHelper
|
||||
|
||||
@defaults [
|
||||
follow_redirect: true,
|
||||
force_redirect: true
|
||||
follow_redirect: false,
|
||||
force_redirect: false,
|
||||
with_body: true
|
||||
]
|
||||
|
||||
@spec options(keyword(), URI.t()) :: keyword()
|
||||
|
|
@ -16,7 +17,12 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do
|
|||
|
||||
config_opts = Pleroma.Config.get([:http, :adapter], [])
|
||||
|
||||
url_encoding =
|
||||
Keyword.new()
|
||||
|> Keyword.put(:path_encode_fun, fn path -> path end)
|
||||
|
||||
@defaults
|
||||
|> Keyword.merge(url_encoding)
|
||||
|> Keyword.merge(config_opts)
|
||||
|> Keyword.merge(connection_opts)
|
||||
|> add_scheme_opts(uri)
|
||||
|
|
|
|||
|
|
@ -15,25 +15,7 @@ defmodule Pleroma.Instances do
|
|||
|
||||
defdelegate set_unreachable(url_or_host, unreachable_since \\ nil), to: Instance
|
||||
|
||||
defdelegate get_consistently_unreachable, to: Instance
|
||||
|
||||
def set_consistently_unreachable(url_or_host),
|
||||
do: set_unreachable(url_or_host, reachability_datetime_threshold())
|
||||
|
||||
def reachability_datetime_threshold do
|
||||
federation_reachability_timeout_days =
|
||||
Pleroma.Config.get([:instance, :federation_reachability_timeout_days], 0)
|
||||
|
||||
if federation_reachability_timeout_days > 0 do
|
||||
NaiveDateTime.add(
|
||||
NaiveDateTime.utc_now(),
|
||||
-federation_reachability_timeout_days * 24 * 3600,
|
||||
:second
|
||||
)
|
||||
else
|
||||
~N[0000-01-01 00:00:00]
|
||||
end
|
||||
end
|
||||
defdelegate get_unreachable, to: Instance
|
||||
|
||||
def host(url_or_host) when is_binary(url_or_host) do
|
||||
if url_or_host =~ ~r/^http/i do
|
||||
|
|
@ -42,4 +24,21 @@ defmodule Pleroma.Instances do
|
|||
url_or_host
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Schedules reachability checks for all unreachable instances"
|
||||
def check_all_unreachable do
|
||||
get_unreachable()
|
||||
|> Enum.each(fn {domain, _} ->
|
||||
Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain})
|
||||
|> Oban.insert()
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Deletes all users and activities for unreachable instances"
|
||||
def delete_all_unreachable do
|
||||
get_unreachable()
|
||||
|> Enum.each(fn {domain, _} ->
|
||||
Instance.delete(domain)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ defmodule Pleroma.Instances.Instance do
|
|||
alias Pleroma.Instances.Instance
|
||||
alias Pleroma.Maps
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.Workers.BackgroundWorker
|
||||
alias Pleroma.Workers.DeleteWorker
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
|
|
@ -51,7 +50,7 @@ defmodule Pleroma.Instances.Instance do
|
|||
|> cast(params, [:software_name, :software_version, :software_repository])
|
||||
end
|
||||
|
||||
def filter_reachable([]), do: %{}
|
||||
def filter_reachable([]), do: []
|
||||
|
||||
def filter_reachable(urls_or_hosts) when is_list(urls_or_hosts) do
|
||||
hosts =
|
||||
|
|
@ -68,19 +67,15 @@ defmodule Pleroma.Instances.Instance do
|
|||
)
|
||||
|> Map.new(& &1)
|
||||
|
||||
reachability_datetime_threshold = Instances.reachability_datetime_threshold()
|
||||
|
||||
for entry <- Enum.filter(urls_or_hosts, &is_binary/1) do
|
||||
host = host(entry)
|
||||
unreachable_since = unreachable_since_by_host[host]
|
||||
|
||||
if !unreachable_since ||
|
||||
NaiveDateTime.compare(unreachable_since, reachability_datetime_threshold) == :gt do
|
||||
{entry, unreachable_since}
|
||||
if is_nil(unreachable_since) do
|
||||
entry
|
||||
end
|
||||
end
|
||||
|> Enum.filter(& &1)
|
||||
|> Map.new(& &1)
|
||||
end
|
||||
|
||||
def reachable?(url_or_host) when is_binary(url_or_host) do
|
||||
|
|
@ -88,7 +83,7 @@ defmodule Pleroma.Instances.Instance do
|
|||
from(i in Instance,
|
||||
where:
|
||||
i.host == ^host(url_or_host) and
|
||||
i.unreachable_since <= ^Instances.reachability_datetime_threshold(),
|
||||
not is_nil(i.unreachable_since),
|
||||
select: true
|
||||
)
|
||||
)
|
||||
|
|
@ -97,9 +92,16 @@ defmodule Pleroma.Instances.Instance do
|
|||
def reachable?(url_or_host) when is_binary(url_or_host), do: true
|
||||
|
||||
def set_reachable(url_or_host) when is_binary(url_or_host) do
|
||||
%Instance{host: host(url_or_host)}
|
||||
|> changeset(%{unreachable_since: nil})
|
||||
|> Repo.insert(on_conflict: {:replace, [:unreachable_since]}, conflict_target: :host)
|
||||
host = host(url_or_host)
|
||||
|
||||
result =
|
||||
%Instance{host: host}
|
||||
|> changeset(%{unreachable_since: nil})
|
||||
|> Repo.insert(on_conflict: {:replace, [:unreachable_since]}, conflict_target: :host)
|
||||
|
||||
Pleroma.Workers.ReachabilityWorker.delete_jobs_for_host(host)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def set_reachable(_), do: {:error, nil}
|
||||
|
|
@ -132,11 +134,9 @@ defmodule Pleroma.Instances.Instance do
|
|||
|
||||
def set_unreachable(_, _), do: {:error, nil}
|
||||
|
||||
def get_consistently_unreachable do
|
||||
reachability_datetime_threshold = Instances.reachability_datetime_threshold()
|
||||
|
||||
def get_unreachable do
|
||||
from(i in Instance,
|
||||
where: ^reachability_datetime_threshold > i.unreachable_since,
|
||||
where: not is_nil(i.unreachable_since),
|
||||
order_by: i.unreachable_since,
|
||||
select: {i.host, i.unreachable_since}
|
||||
)
|
||||
|
|
@ -296,19 +296,14 @@ defmodule Pleroma.Instances.Instance do
|
|||
Deletes all users from an instance in a background task, thus also deleting
|
||||
all of those users' activities and notifications.
|
||||
"""
|
||||
def delete_users_and_activities(host) when is_binary(host) do
|
||||
BackgroundWorker.enqueue("delete_instance", %{"host" => host})
|
||||
def delete(host) when is_binary(host) do
|
||||
DeleteWorker.new(%{"op" => "delete_instance", "host" => host})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
def perform(:delete_instance, host) when is_binary(host) do
|
||||
User.Query.build(%{nickname: "@#{host}"})
|
||||
|> Repo.chunk_stream(100, :batches)
|
||||
|> Stream.each(fn users ->
|
||||
users
|
||||
|> Enum.each(fn user ->
|
||||
User.perform(:delete, user)
|
||||
end)
|
||||
end)
|
||||
|> Stream.run()
|
||||
@doc "Schedules reachability check for instance"
|
||||
def check_unreachable(domain) when is_binary(domain) do
|
||||
Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain})
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
59
lib/pleroma/language/language_detector.ex
Normal file
59
lib/pleroma/language/language_detector.ex
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.LanguageDetector do
|
||||
import Pleroma.EctoType.ActivityPub.ObjectValidators.LanguageCode,
|
||||
only: [good_locale_code?: 1]
|
||||
|
||||
@words_threshold 4
|
||||
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
|
||||
|
||||
def configured? do
|
||||
provider = get_provider()
|
||||
|
||||
!!provider and provider.configured?()
|
||||
end
|
||||
|
||||
def missing_dependencies do
|
||||
provider = get_provider()
|
||||
|
||||
if provider do
|
||||
provider.missing_dependencies()
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Strip tags from text, etc.
|
||||
defp prepare_text(text) do
|
||||
text
|
||||
|> Floki.parse_fragment!()
|
||||
|> Floki.filter_out(
|
||||
".h-card, .mention, .hashtag, .u-url, .quote-inline, .recipients-inline, code, pre"
|
||||
)
|
||||
|> Floki.text()
|
||||
end
|
||||
|
||||
def detect(text) do
|
||||
provider = get_provider()
|
||||
|
||||
text = prepare_text(text)
|
||||
word_count = text |> String.split(~r/\s+/) |> Enum.count()
|
||||
|
||||
if word_count < @words_threshold or !provider or !provider.configured?() do
|
||||
nil
|
||||
else
|
||||
with language <- provider.detect(text),
|
||||
true <- good_locale_code?(language) do
|
||||
language
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_provider do
|
||||
@config_impl.get([__MODULE__, :provider])
|
||||
end
|
||||
end
|
||||
47
lib/pleroma/language/language_detector/fasttext.ex
Normal file
47
lib/pleroma/language/language_detector/fasttext.ex
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.LanguageDetector.Fasttext do
|
||||
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
|
||||
|
||||
alias Pleroma.Language.LanguageDetector.Provider
|
||||
|
||||
@behaviour Provider
|
||||
|
||||
@impl Provider
|
||||
def missing_dependencies do
|
||||
if Pleroma.Utils.command_available?("fasttext") do
|
||||
[]
|
||||
else
|
||||
["fasttext"]
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def configured?, do: not_empty_string(get_model())
|
||||
|
||||
@impl Provider
|
||||
def detect(text) do
|
||||
text_path = Path.join(System.tmp_dir!(), "fasttext-#{Ecto.UUID.generate()}")
|
||||
|
||||
File.write(text_path, text |> String.replace(~r/\s+/, " "))
|
||||
|
||||
detected_language =
|
||||
case System.cmd("fasttext", ["predict", get_model(), text_path]) do
|
||||
{"__label__" <> language, _} ->
|
||||
language |> String.trim()
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
File.rm(text_path)
|
||||
|
||||
detected_language
|
||||
end
|
||||
|
||||
defp get_model do
|
||||
Pleroma.Config.get([__MODULE__, :model])
|
||||
end
|
||||
end
|
||||
11
lib/pleroma/language/language_detector/provider.ex
Normal file
11
lib/pleroma/language/language_detector/provider.ex
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.LanguageDetector.Provider do
|
||||
@callback missing_dependencies() :: [String.t()]
|
||||
|
||||
@callback configured?() :: boolean()
|
||||
|
||||
@callback detect(text :: String.t()) :: String.t() | nil
|
||||
end
|
||||
127
lib/pleroma/language/translation.ex
Normal file
127
lib/pleroma/language/translation.ex
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation do
|
||||
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
|
||||
|
||||
def configured? do
|
||||
provider = get_provider()
|
||||
|
||||
!!provider and provider.configured?()
|
||||
end
|
||||
|
||||
def missing_dependencies do
|
||||
provider = get_provider()
|
||||
|
||||
if provider do
|
||||
provider.missing_dependencies()
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def translate(text, source_language, target_language) do
|
||||
cache_key = get_cache_key(text, source_language, target_language)
|
||||
|
||||
case @cachex.get(:translations_cache, cache_key) do
|
||||
{:ok, nil} ->
|
||||
provider = get_provider()
|
||||
|
||||
result =
|
||||
if !configured?() do
|
||||
{:error, :not_found}
|
||||
else
|
||||
provider.translate(text, source_language, target_language)
|
||||
|> scrub_html()
|
||||
end
|
||||
|
||||
store_result(result, cache_key)
|
||||
|
||||
result
|
||||
|
||||
{:ok, result} ->
|
||||
{:ok, result}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
def supported_languages(type) when type in [:source, :target] do
|
||||
provider = get_provider()
|
||||
|
||||
cache_key = "#{type}_languages/#{provider.name()}"
|
||||
|
||||
case @cachex.get(:translations_cache, cache_key) do
|
||||
{:ok, nil} ->
|
||||
result =
|
||||
if !configured?() do
|
||||
{:error, :not_found}
|
||||
else
|
||||
provider.supported_languages(type)
|
||||
end
|
||||
|
||||
store_result(result, cache_key)
|
||||
|
||||
result
|
||||
|
||||
{:ok, result} ->
|
||||
{:ok, result}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
def languages_matrix do
|
||||
provider = get_provider()
|
||||
|
||||
cache_key = "languages_matrix/#{provider.name()}"
|
||||
|
||||
case @cachex.get(:translations_cache, cache_key) do
|
||||
{:ok, nil} ->
|
||||
result =
|
||||
if !configured?() do
|
||||
{:error, :not_found}
|
||||
else
|
||||
provider.languages_matrix()
|
||||
end
|
||||
|
||||
store_result(result, cache_key)
|
||||
|
||||
result
|
||||
|
||||
{:ok, result} ->
|
||||
{:ok, result}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_provider, do: Pleroma.Config.get([__MODULE__, :provider])
|
||||
|
||||
defp get_cache_key(text, source_language, target_language) do
|
||||
"#{source_language}/#{target_language}/#{content_hash(text)}"
|
||||
end
|
||||
|
||||
defp store_result({:ok, result}, cache_key) do
|
||||
@cachex.put(:translations_cache, cache_key, result)
|
||||
end
|
||||
|
||||
defp store_result(_, _), do: nil
|
||||
|
||||
defp content_hash(text), do: :crypto.hash(:sha256, text) |> Base.encode64()
|
||||
|
||||
defp scrub_html({:ok, %{content: content} = result}) when is_binary(content) do
|
||||
scrubbers = Pleroma.Config.get([:markup, :scrub_policy])
|
||||
|
||||
content
|
||||
|> Pleroma.HTML.filter_tags(scrubbers)
|
||||
|
||||
{:ok, %{result | content: content}}
|
||||
end
|
||||
|
||||
defp scrub_html(result), do: result
|
||||
end
|
||||
121
lib/pleroma/language/translation/deepl.ex
Normal file
121
lib/pleroma/language/translation/deepl.ex
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation.Deepl do
|
||||
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
|
||||
|
||||
alias Pleroma.Language.Translation.Provider
|
||||
|
||||
use Provider
|
||||
|
||||
@behaviour Provider
|
||||
|
||||
@name "DeepL"
|
||||
|
||||
@impl Provider
|
||||
def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key())
|
||||
|
||||
@impl Provider
|
||||
def translate(content, source_language, target_language) do
|
||||
endpoint =
|
||||
base_url()
|
||||
|> URI.merge("/v2/translate")
|
||||
|> URI.to_string()
|
||||
|
||||
case Pleroma.HTTP.post(
|
||||
endpoint,
|
||||
Jason.encode!(%{
|
||||
text: [content],
|
||||
source_lang: source_language |> String.upcase(),
|
||||
target_lang: target_language,
|
||||
tag_handling: "html"
|
||||
}),
|
||||
[
|
||||
{"Content-Type", "application/json"},
|
||||
{"Authorization", "DeepL-Auth-Key #{api_key()}"}
|
||||
]
|
||||
) do
|
||||
{:ok, %{status: 429}} ->
|
||||
{:error, :too_many_requests}
|
||||
|
||||
{:ok, %{status: 456}} ->
|
||||
{:error, :quota_exceeded}
|
||||
|
||||
{:ok, %{status: 200} = res} ->
|
||||
%{
|
||||
"translations" => [
|
||||
%{"text" => content, "detected_source_language" => detected_source_language}
|
||||
]
|
||||
} = Jason.decode!(res.body)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
content: content,
|
||||
detected_source_language: detected_source_language,
|
||||
provider: @name
|
||||
}}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def supported_languages(type) when type in [:source, :target] do
|
||||
endpoint =
|
||||
base_url()
|
||||
|> URI.merge("/v2/languages")
|
||||
|> URI.to_string()
|
||||
|
||||
case Pleroma.HTTP.post(
|
||||
endpoint <> "?" <> URI.encode_query(%{type: type}),
|
||||
"",
|
||||
[
|
||||
{"Content-Type", "application/x-www-form-urlencoded"},
|
||||
{"Authorization", "DeepL-Auth-Key #{api_key()}"}
|
||||
]
|
||||
) do
|
||||
{:ok, %{status: 200} = res} ->
|
||||
languages =
|
||||
Jason.decode!(res.body)
|
||||
|> Enum.map(fn %{"language" => language} -> language |> String.downcase() end)
|
||||
|> Enum.map(fn language ->
|
||||
if String.contains?(language, "-") do
|
||||
[language, language |> String.split("-") |> Enum.at(0)]
|
||||
else
|
||||
language
|
||||
end
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
|
||||
{:ok, languages}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def languages_matrix do
|
||||
with {:ok, source_languages} <- supported_languages(:source),
|
||||
{:ok, target_languages} <- supported_languages(:target) do
|
||||
{:ok,
|
||||
Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def name, do: @name
|
||||
|
||||
defp base_url do
|
||||
Pleroma.Config.get([__MODULE__, :base_url])
|
||||
end
|
||||
|
||||
defp api_key do
|
||||
Pleroma.Config.get([__MODULE__, :api_key])
|
||||
end
|
||||
end
|
||||
93
lib/pleroma/language/translation/libretranslate.ex
Normal file
93
lib/pleroma/language/translation/libretranslate.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation.Libretranslate do
|
||||
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
|
||||
|
||||
alias Pleroma.Language.Translation.Provider
|
||||
|
||||
use Provider
|
||||
|
||||
@behaviour Provider
|
||||
|
||||
@name "LibreTranslate"
|
||||
|
||||
@impl Provider
|
||||
def configured?, do: not_empty_string(base_url()) and not_empty_string(api_key())
|
||||
|
||||
@impl Provider
|
||||
def translate(content, source_language, target_language) do
|
||||
case Pleroma.HTTP.post(
|
||||
base_url() <> "/translate",
|
||||
Jason.encode!(%{
|
||||
q: content,
|
||||
source: source_language |> String.upcase(),
|
||||
target: target_language,
|
||||
format: "html",
|
||||
api_key: api_key()
|
||||
}),
|
||||
[
|
||||
{"Content-Type", "application/json"}
|
||||
]
|
||||
) do
|
||||
{:ok, %{status: 429}} ->
|
||||
{:error, :too_many_requests}
|
||||
|
||||
{:ok, %{status: 403}} ->
|
||||
{:error, :quota_exceeded}
|
||||
|
||||
{:ok, %{status: 200} = res} ->
|
||||
%{
|
||||
"translatedText" => content
|
||||
} = Jason.decode!(res.body)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
content: content,
|
||||
detected_source_language: source_language,
|
||||
provider: @name
|
||||
}}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def supported_languages(_) do
|
||||
case Pleroma.HTTP.get(base_url() <> "/languages") do
|
||||
{:ok, %{status: 200} = res} ->
|
||||
languages =
|
||||
Jason.decode!(res.body)
|
||||
|> Enum.map(fn %{"code" => code} -> code end)
|
||||
|
||||
{:ok, languages}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def languages_matrix do
|
||||
with {:ok, source_languages} <- supported_languages(:source),
|
||||
{:ok, target_languages} <- supported_languages(:target) do
|
||||
{:ok,
|
||||
Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def name, do: @name
|
||||
|
||||
defp base_url do
|
||||
Pleroma.Config.get([__MODULE__, :base_url])
|
||||
end
|
||||
|
||||
defp api_key do
|
||||
Pleroma.Config.get([__MODULE__, :api_key], "")
|
||||
end
|
||||
end
|
||||
109
lib/pleroma/language/translation/mozhi.ex
Normal file
109
lib/pleroma/language/translation/mozhi.ex
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation.Mozhi do
|
||||
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
|
||||
|
||||
alias Pleroma.Language.Translation.Provider
|
||||
|
||||
use Provider
|
||||
|
||||
@behaviour Provider
|
||||
|
||||
@name "Mozhi"
|
||||
|
||||
@impl Provider
|
||||
def configured?, do: not_empty_string(base_url()) and not_empty_string(engine())
|
||||
|
||||
@impl Provider
|
||||
def translate(content, source_language, target_language) do
|
||||
endpoint =
|
||||
base_url()
|
||||
|> URI.merge("/api/translate")
|
||||
|> URI.to_string()
|
||||
|
||||
case Pleroma.HTTP.get(
|
||||
endpoint <>
|
||||
"?" <>
|
||||
URI.encode_query(%{
|
||||
engine: engine(),
|
||||
text: content,
|
||||
from: source_language,
|
||||
to: target_language
|
||||
}),
|
||||
[{"Accept", "application/json"}]
|
||||
) do
|
||||
{:ok, %{status: 200} = res} ->
|
||||
%{
|
||||
"translated-text" => content,
|
||||
"source_language" => source_language
|
||||
} = Jason.decode!(res.body)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
content: content,
|
||||
detected_source_language: source_language,
|
||||
provider: @name
|
||||
}}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def supported_languages(type) when type in [:source, :target] do
|
||||
path =
|
||||
case type do
|
||||
:source -> "/api/source_languages"
|
||||
:target -> "/api/target_languages"
|
||||
end
|
||||
|
||||
endpoint =
|
||||
base_url()
|
||||
|> URI.merge(path)
|
||||
|> URI.to_string()
|
||||
|
||||
case Pleroma.HTTP.get(
|
||||
endpoint <>
|
||||
"?" <>
|
||||
URI.encode_query(%{
|
||||
engine: engine()
|
||||
}),
|
||||
[{"Accept", "application/json"}]
|
||||
) do
|
||||
{:ok, %{status: 200} = res} ->
|
||||
languages =
|
||||
Jason.decode!(res.body)
|
||||
|> Enum.map(fn %{"Id" => language} -> language end)
|
||||
|
||||
{:ok, languages}
|
||||
|
||||
_ ->
|
||||
{:error, :internal_server_error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def languages_matrix do
|
||||
with {:ok, source_languages} <- supported_languages(:source),
|
||||
{:ok, target_languages} <- supported_languages(:target) do
|
||||
{:ok,
|
||||
Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)}
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def name, do: @name
|
||||
|
||||
defp base_url do
|
||||
Pleroma.Config.get([__MODULE__, :base_url])
|
||||
end
|
||||
|
||||
defp engine do
|
||||
Pleroma.Config.get([__MODULE__, :engine])
|
||||
end
|
||||
end
|
||||
40
lib/pleroma/language/translation/provider.ex
Normal file
40
lib/pleroma/language/translation/provider.ex
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation.Provider do
|
||||
alias Pleroma.Language.Translation.Provider
|
||||
|
||||
@callback missing_dependencies() :: [String.t()]
|
||||
|
||||
@callback configured?() :: boolean()
|
||||
|
||||
@callback translate(
|
||||
content :: String.t(),
|
||||
source_language :: String.t(),
|
||||
target_language :: String.t()
|
||||
) ::
|
||||
{:ok,
|
||||
%{
|
||||
content: String.t(),
|
||||
detected_source_language: String.t(),
|
||||
provider: String.t()
|
||||
}}
|
||||
| {:error, atom()}
|
||||
|
||||
@callback supported_languages(type :: :string | :target) ::
|
||||
{:ok, [String.t()]} | {:error, atom()}
|
||||
|
||||
@callback languages_matrix() :: {:ok, map()} | {:error, atom()}
|
||||
|
||||
@callback name() :: String.t()
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@impl Provider
|
||||
def missing_dependencies, do: []
|
||||
|
||||
defoverridable missing_dependencies: 0
|
||||
end
|
||||
end
|
||||
end
|
||||
129
lib/pleroma/language/translation/translate_locally.ex
Normal file
129
lib/pleroma/language/translation/translate_locally.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Language.Translation.TranslateLocally do
|
||||
alias Pleroma.Language.Translation.Provider
|
||||
|
||||
use Provider
|
||||
|
||||
@behaviour Provider
|
||||
|
||||
@name "translateLocally"
|
||||
|
||||
@impl Provider
|
||||
def missing_dependencies do
|
||||
if Pleroma.Utils.command_available?("translateLocally") do
|
||||
[]
|
||||
else
|
||||
["translateLocally"]
|
||||
end
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def configured?, do: is_map(models())
|
||||
|
||||
@impl Provider
|
||||
def translate(content, source_language, target_language) do
|
||||
model =
|
||||
models()
|
||||
|> Map.get(source_language, %{})
|
||||
|> Map.get(target_language)
|
||||
|
||||
models =
|
||||
if model do
|
||||
[model]
|
||||
else
|
||||
[
|
||||
models()
|
||||
|> Map.get(source_language, %{})
|
||||
|> Map.get(intermediary_language()),
|
||||
models()
|
||||
|> Map.get(intermediary_language(), %{})
|
||||
|> Map.get(target_language)
|
||||
]
|
||||
end
|
||||
|
||||
translated_content =
|
||||
Enum.reduce(models, content, fn model, content ->
|
||||
text_path = Path.join(System.tmp_dir!(), "translateLocally-#{Ecto.UUID.generate()}")
|
||||
|
||||
File.write(text_path, content)
|
||||
|
||||
translated_content =
|
||||
case System.cmd("translateLocally", ["-m", model, "-i", text_path, "--html"]) do
|
||||
{content, _} -> content
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
File.rm(text_path)
|
||||
|
||||
translated_content
|
||||
end)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
content: translated_content,
|
||||
detected_source_language: source_language,
|
||||
provider: @name
|
||||
}}
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def supported_languages(:source) do
|
||||
languages =
|
||||
languages_matrix()
|
||||
|> elem(1)
|
||||
|> Map.keys()
|
||||
|
||||
{:ok, languages}
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def supported_languages(:target) do
|
||||
languages =
|
||||
languages_matrix()
|
||||
|> elem(1)
|
||||
|> Map.values()
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
|
||||
{:ok, languages}
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def languages_matrix do
|
||||
languages =
|
||||
models()
|
||||
|> Map.to_list()
|
||||
|> Enum.map(fn {key, value} -> {key, Map.keys(value)} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
matrix =
|
||||
if intermediary_language() do
|
||||
languages
|
||||
|> Map.to_list()
|
||||
|> Enum.map(fn {key, value} ->
|
||||
with_intermediary =
|
||||
(((value ++ languages[intermediary_language()])
|
||||
|> Enum.uniq()) --
|
||||
[key])
|
||||
|> Enum.sort()
|
||||
|
||||
{key, with_intermediary}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
else
|
||||
languages
|
||||
end
|
||||
|
||||
{:ok, matrix}
|
||||
end
|
||||
|
||||
@impl Provider
|
||||
def name, do: @name
|
||||
|
||||
defp models, do: Pleroma.Config.get([__MODULE__, :models])
|
||||
|
||||
defp intermediary_language, do: Pleroma.Config.get([__MODULE__, :intermediary_language])
|
||||
end
|
||||
271
lib/pleroma/ldap.ex
Normal file
271
lib/pleroma/ldap.ex
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
defmodule Pleroma.LDAP do
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pleroma.Config
|
||||
alias Pleroma.User
|
||||
|
||||
import Pleroma.Web.Auth.Helpers, only: [fetch_user: 1]
|
||||
|
||||
@connection_timeout 2_000
|
||||
@search_timeout 2_000
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
def bind_user(name, password) do
|
||||
GenServer.call(__MODULE__, {:bind_user, name, password})
|
||||
end
|
||||
|
||||
def change_password(name, password, new_password) do
|
||||
GenServer.call(__MODULE__, {:change_password, name, password, new_password})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
case {Config.get(Pleroma.Web.Auth.Authenticator), Config.get([:ldap, :enabled])} do
|
||||
{Pleroma.Web.Auth.LDAPAuthenticator, true} ->
|
||||
{:ok, state, {:continue, :connect}}
|
||||
|
||||
{Pleroma.Web.Auth.LDAPAuthenticator, false} ->
|
||||
Logger.error(
|
||||
"LDAP Authenticator enabled but :pleroma, :ldap is not enabled. Auth will not work."
|
||||
)
|
||||
|
||||
{:ok, state}
|
||||
|
||||
{_, true} ->
|
||||
Logger.warning(
|
||||
":pleroma, :ldap is enabled but Pleroma.Web.Authenticator is not set to the LDAPAuthenticator. LDAP will not be used."
|
||||
)
|
||||
|
||||
{:ok, state}
|
||||
|
||||
_ ->
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_continue(:connect, _state), do: do_handle_connect()
|
||||
|
||||
@impl true
|
||||
def handle_info(:connect, _state), do: do_handle_connect()
|
||||
|
||||
def handle_info({:bind_after_reconnect, name, password, from}, state) do
|
||||
result = do_bind_user(state[:handle], name, password)
|
||||
|
||||
GenServer.reply(from, result)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:bind_user, name, password}, from, state) do
|
||||
case do_bind_user(state[:handle], name, password) do
|
||||
:needs_reconnect ->
|
||||
Process.send(self(), {:bind_after_reconnect, name, password, from}, [])
|
||||
{:noreply, state, {:continue, :connect}}
|
||||
|
||||
result ->
|
||||
{:reply, result, state, :hibernate}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:change_password, name, password, new_password}, _from, state) do
|
||||
result = change_password(state[:handle], name, password, new_password)
|
||||
|
||||
{:reply, result, state, :hibernate}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_, state) do
|
||||
handle = Keyword.get(state, :handle)
|
||||
|
||||
if not is_nil(handle) do
|
||||
:eldap.close(handle)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp do_handle_connect do
|
||||
state =
|
||||
case connect() do
|
||||
{:ok, handle} ->
|
||||
:eldap.controlling_process(handle, self())
|
||||
Process.link(handle)
|
||||
[handle: handle]
|
||||
|
||||
_ ->
|
||||
Logger.error("Failed to connect to LDAP. Retrying in 5000ms")
|
||||
Process.send_after(self(), :connect, 5_000)
|
||||
[]
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp connect do
|
||||
ldap = Config.get(:ldap, [])
|
||||
host = Keyword.get(ldap, :host, "localhost")
|
||||
port = Keyword.get(ldap, :port, 389)
|
||||
ssl = Keyword.get(ldap, :ssl, false)
|
||||
tls = Keyword.get(ldap, :tls, false)
|
||||
cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path()
|
||||
|
||||
if ssl, do: Application.ensure_all_started(:ssl)
|
||||
|
||||
default_secure_opts = [
|
||||
verify: :verify_peer,
|
||||
cacerts: decode_certfile(cacertfile),
|
||||
customize_hostname_check: [
|
||||
fqdn_fun: fn _ -> to_charlist(host) end
|
||||
]
|
||||
]
|
||||
|
||||
sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, []))
|
||||
tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, []))
|
||||
|
||||
default_options = [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}]
|
||||
|
||||
# :sslopts can only be included in :eldap.open/2 when {ssl: true}
|
||||
# or the connection will fail
|
||||
options =
|
||||
if ssl do
|
||||
default_options ++ [{:sslopts, sslopts}]
|
||||
else
|
||||
default_options
|
||||
end
|
||||
|
||||
case :eldap.open([to_charlist(host)], options) do
|
||||
{:ok, handle} ->
|
||||
try do
|
||||
cond do
|
||||
tls ->
|
||||
case :eldap.start_tls(
|
||||
handle,
|
||||
tlsopts,
|
||||
@connection_timeout
|
||||
) do
|
||||
:ok ->
|
||||
{:ok, handle}
|
||||
|
||||
error ->
|
||||
Logger.error("Could not start TLS: #{inspect(error)}")
|
||||
:eldap.close(handle)
|
||||
end
|
||||
|
||||
true ->
|
||||
{:ok, handle}
|
||||
end
|
||||
after
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Could not open LDAP connection: #{inspect(error)}")
|
||||
{:error, {:ldap_connection_error, error}}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_bind_user(handle, name, password) do
|
||||
dn = make_dn(name)
|
||||
|
||||
case :eldap.simple_bind(handle, dn, password) do
|
||||
:ok ->
|
||||
case fetch_user(name) do
|
||||
%User{} = user ->
|
||||
user
|
||||
|
||||
_ ->
|
||||
register_user(handle, ldap_base(), ldap_uid(), name)
|
||||
end
|
||||
|
||||
# eldap does not inform us of socket closure
|
||||
# until it is used
|
||||
{:error, {:gen_tcp_error, :closed}} ->
|
||||
:eldap.close(handle)
|
||||
:needs_reconnect
|
||||
|
||||
{:error, error} = e ->
|
||||
Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}")
|
||||
e
|
||||
end
|
||||
end
|
||||
|
||||
defp register_user(handle, base, uid, name) do
|
||||
case :eldap.search(handle, [
|
||||
{:base, to_charlist(base)},
|
||||
{:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))},
|
||||
{:scope, :eldap.wholeSubtree()},
|
||||
{:timeout, @search_timeout}
|
||||
]) do
|
||||
# The :eldap_search_result record structure changed in OTP 24.3 and added a controls field
|
||||
# https://github.com/erlang/otp/pull/5538
|
||||
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals}} ->
|
||||
try_register(name, attributes)
|
||||
|
||||
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals, _controls}} ->
|
||||
try_register(name, attributes)
|
||||
|
||||
error ->
|
||||
Logger.error("Couldn't register user because LDAP search failed: #{inspect(error)}")
|
||||
{:error, {:ldap_search_error, error}}
|
||||
end
|
||||
end
|
||||
|
||||
defp try_register(name, attributes) do
|
||||
mail_attribute = Config.get([:ldap, :mail])
|
||||
|
||||
params = %{
|
||||
name: name,
|
||||
nickname: name,
|
||||
password: nil
|
||||
}
|
||||
|
||||
params =
|
||||
case List.keyfind(attributes, to_charlist(mail_attribute), 0) do
|
||||
{_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail))
|
||||
_ -> params
|
||||
end
|
||||
|
||||
changeset = User.register_changeset_ldap(%User{}, params)
|
||||
|
||||
case User.register(changeset) do
|
||||
{:ok, user} -> user
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp change_password(handle, name, password, new_password) do
|
||||
dn = make_dn(name)
|
||||
|
||||
with :ok <- :eldap.simple_bind(handle, dn, password) do
|
||||
:eldap.modify_password(handle, dn, to_charlist(new_password), to_charlist(password))
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_certfile(file) do
|
||||
with {:ok, data} <- File.read(file) do
|
||||
data
|
||||
|> :public_key.pem_decode()
|
||||
|> Enum.map(fn {_, b, _} -> b end)
|
||||
else
|
||||
_ ->
|
||||
Logger.error("Unable to read certfile: #{file}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp ldap_uid, do: to_charlist(Config.get([:ldap, :uid], "cn"))
|
||||
defp ldap_base, do: to_charlist(Config.get([:ldap, :base]))
|
||||
|
||||
defp make_dn(name) do
|
||||
uid = ldap_uid()
|
||||
base = ldap_base()
|
||||
~c"#{uid}=#{name},#{base}"
|
||||
end
|
||||
end
|
||||
|
|
@ -17,13 +17,14 @@ defmodule Pleroma.List do
|
|||
field(:title, :string)
|
||||
field(:following, {:array, :string}, default: [])
|
||||
field(:ap_id, :string)
|
||||
field(:exclusive, :boolean, default: false)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def title_changeset(list, attrs \\ %{}) do
|
||||
def update_changeset(list, attrs \\ %{}) do
|
||||
list
|
||||
|> cast(attrs, [:title])
|
||||
|> cast(attrs, [:title, :exclusive])
|
||||
|> validate_required([:title])
|
||||
end
|
||||
|
||||
|
|
@ -91,14 +92,14 @@ defmodule Pleroma.List do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
def rename(%Pleroma.List{} = list, title) do
|
||||
def update(%Pleroma.List{} = list, params) do
|
||||
list
|
||||
|> title_changeset(%{title: title})
|
||||
|> update_changeset(params)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def create(title, %User{} = creator) do
|
||||
changeset = title_changeset(%Pleroma.List{user_id: creator.id}, %{title: title})
|
||||
def create(params, %User{} = creator) do
|
||||
changeset = update_changeset(%Pleroma.List{user_id: creator.id}, params)
|
||||
|
||||
if changeset.valid? do
|
||||
Repo.transaction(fn ->
|
||||
|
|
@ -149,4 +150,14 @@ defmodule Pleroma.List do
|
|||
end
|
||||
|
||||
def member?(_, _), do: false
|
||||
|
||||
def get_exclusive_list_members(%User{id: user_id}) do
|
||||
Pleroma.List
|
||||
|> where([l], l.user_id == ^user_id)
|
||||
|> where([l], l.exclusive == true)
|
||||
|> select([l], l.following)
|
||||
|> Repo.all()
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,15 +20,13 @@ defmodule Pleroma.Maps do
|
|||
end
|
||||
|
||||
def filter_empty_values(data) do
|
||||
# TODO: Change to Map.filter in Elixir 1.13+
|
||||
data
|
||||
|> Enum.filter(fn
|
||||
|> Map.filter(fn
|
||||
{_k, nil} -> false
|
||||
{_k, ""} -> false
|
||||
{_k, []} -> false
|
||||
{_k, %{} = v} -> Map.keys(v) != []
|
||||
{_k, _v} -> true
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ defmodule Pleroma.Marker do
|
|||
|
||||
defp get_marker(user, timeline) do
|
||||
case Repo.find_resource(get_query(user, timeline)) do
|
||||
{:ok, marker} -> %__MODULE__{marker | user: user}
|
||||
{:ok, %__MODULE__{} = marker} -> %{marker | user: user}
|
||||
_ -> %__MODULE__{timeline: timeline, user_id: user.id}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ defmodule Pleroma.MFA.Changeset do
|
|||
alias Pleroma.User
|
||||
|
||||
def disable(%Ecto.Changeset{} = changeset, force \\ false) do
|
||||
settings =
|
||||
%Settings{} =
|
||||
settings =
|
||||
changeset
|
||||
|> Ecto.Changeset.apply_changes()
|
||||
|> MFA.fetch_settings()
|
||||
|
|
@ -20,20 +21,20 @@ defmodule Pleroma.MFA.Changeset do
|
|||
end
|
||||
end
|
||||
|
||||
def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
|
||||
def disable_totp(%User{multi_factor_authentication_settings: %Settings{} = settings} = user) do
|
||||
user
|
||||
|> put_change(%Settings{settings | totp: %Settings.TOTP{}})
|
||||
end
|
||||
|
||||
def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
|
||||
totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
|
||||
def confirm_totp(%User{multi_factor_authentication_settings: %Settings{} = settings} = user) do
|
||||
totp_settings = %Settings.TOTP{(%Settings.TOTP{} = settings.totp) | confirmed: true}
|
||||
|
||||
user
|
||||
|> put_change(%Settings{settings | totp: totp_settings, enabled: true})
|
||||
end
|
||||
|
||||
def setup_totp(%User{} = user, attrs) do
|
||||
mfa_settings = MFA.fetch_settings(user)
|
||||
%Settings{} = mfa_settings = MFA.fetch_settings(user)
|
||||
|
||||
totp_settings =
|
||||
%Settings.TOTP{}
|
||||
|
|
@ -46,7 +47,7 @@ defmodule Pleroma.MFA.Changeset do
|
|||
def cast_backup_codes(%User{} = user, codes) do
|
||||
user
|
||||
|> put_change(%Settings{
|
||||
user.multi_factor_authentication_settings
|
||||
(%Settings{} = user.multi_factor_authentication_settings)
|
||||
| backup_codes: codes
|
||||
})
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,11 +52,14 @@ defmodule Pleroma.MFA.Token do
|
|||
@spec create(User.t(), Authorization.t() | nil) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
|
||||
def create(user, authorization \\ nil) do
|
||||
with {:ok, token} <- do_create(user, authorization) do
|
||||
Pleroma.Workers.PurgeExpiredToken.enqueue(%{
|
||||
token_id: token.id,
|
||||
valid_until: DateTime.from_naive!(token.valid_until, "Etc/UTC"),
|
||||
mod: __MODULE__
|
||||
})
|
||||
Pleroma.Workers.PurgeExpiredToken.new(
|
||||
%{
|
||||
token_id: token.id,
|
||||
mod: __MODULE__
|
||||
},
|
||||
scheduled_at: DateTime.from_naive!(token.valid_until, "Etc/UTC")
|
||||
)
|
||||
|> Oban.insert()
|
||||
|
||||
{:ok, token}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -132,11 +132,18 @@ defmodule Pleroma.ModerationLog do
|
|||
end
|
||||
|
||||
def insert_log(%{actor: %User{}, action: action, subject: %Activity{} = subject} = attrs)
|
||||
when action in ["report_note_delete", "report_update", "report_note"] do
|
||||
when action in [
|
||||
"report_note_delete",
|
||||
"report_update",
|
||||
"report_note",
|
||||
"report_unassigned",
|
||||
"report_assigned"
|
||||
] do
|
||||
data =
|
||||
attrs
|
||||
|> prepare_log_data
|
||||
|> Pleroma.Maps.put_if_present("text", attrs[:text])
|
||||
|> Pleroma.Maps.put_if_present("assigned_account", attrs[:assigned_account])
|
||||
|> Map.merge(%{"subject" => report_to_map(subject)})
|
||||
|
||||
insert_log_entry_with_message(%ModerationLog{data: data})
|
||||
|
|
@ -441,6 +448,35 @@ defmodule Pleroma.ModerationLog do
|
|||
" with '#{state}' state"
|
||||
end
|
||||
|
||||
def get_log_entry_message(
|
||||
%ModerationLog{
|
||||
data: %{
|
||||
"actor" => %{"nickname" => actor_nickname},
|
||||
"action" => "report_assigned",
|
||||
"subject" => %{"id" => subject_id, "type" => "report"},
|
||||
"assigned_account" => assigned_account
|
||||
}
|
||||
} = log
|
||||
) do
|
||||
"@#{actor_nickname} assigned report ##{subject_id}" <>
|
||||
subject_actor_nickname(log, " (on user ", ")") <>
|
||||
" to user #{assigned_account}"
|
||||
end
|
||||
|
||||
def get_log_entry_message(
|
||||
%ModerationLog{
|
||||
data: %{
|
||||
"actor" => %{"nickname" => actor_nickname},
|
||||
"action" => "report_unassigned",
|
||||
"subject" => %{"id" => subject_id, "type" => "report"}
|
||||
}
|
||||
} = log
|
||||
) do
|
||||
"@#{actor_nickname} unassigned report ##{subject_id}" <>
|
||||
subject_actor_nickname(log, " (on user ", ")") <>
|
||||
" from a user"
|
||||
end
|
||||
|
||||
def get_log_entry_message(
|
||||
%ModerationLog{
|
||||
data: %{
|
||||
|
|
@ -575,6 +611,12 @@ defmodule Pleroma.ModerationLog do
|
|||
"@#{actor_nickname} requested account backup for @#{user_nickname}"
|
||||
end
|
||||
|
||||
def get_log_entry_message(%ModerationLog{data: data}) do
|
||||
actor_name = get_in(data, ["actor", "nickname"]) || "unknown"
|
||||
action = data["action"] || "unknown"
|
||||
"@#{actor_name} performed action #{action}"
|
||||
end
|
||||
|
||||
defp nicknames_to_string(nicknames) do
|
||||
nicknames
|
||||
|> Enum.map(&"@#{&1}")
|
||||
|
|
|
|||
15
lib/pleroma/mogrify_behaviour.ex
Normal file
15
lib/pleroma/mogrify_behaviour.ex
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.MogrifyBehaviour do
|
||||
@moduledoc """
|
||||
Behaviour for Mogrify operations.
|
||||
This module defines the interface for Mogrify operations that can be mocked in tests.
|
||||
"""
|
||||
|
||||
@callback open(binary()) :: map()
|
||||
@callback custom(map(), binary()) :: map()
|
||||
@callback custom(map(), binary(), binary()) :: map()
|
||||
@callback save(map(), keyword()) :: map()
|
||||
end
|
||||
30
lib/pleroma/mogrify_wrapper.ex
Normal file
30
lib/pleroma/mogrify_wrapper.ex
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.MogrifyWrapper do
|
||||
@moduledoc """
|
||||
Default implementation of MogrifyBehaviour that delegates to Mogrify.
|
||||
"""
|
||||
@behaviour Pleroma.MogrifyBehaviour
|
||||
|
||||
@impl true
|
||||
def open(file) do
|
||||
Mogrify.open(file)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def custom(image, action) do
|
||||
Mogrify.custom(image, action)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def custom(image, action, options) do
|
||||
Mogrify.custom(image, action, options)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def save(image, opts) do
|
||||
Mogrify.save(image, opts)
|
||||
end
|
||||
end
|
||||
|
|
@ -74,6 +74,7 @@ defmodule Pleroma.Notification do
|
|||
reblog
|
||||
poll
|
||||
status
|
||||
update
|
||||
}
|
||||
|
||||
def changeset(%Notification{} = notification, attrs) do
|
||||
|
|
@ -281,10 +282,15 @@ defmodule Pleroma.Notification do
|
|||
select: n.id
|
||||
)
|
||||
|
||||
Multi.new()
|
||||
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|
||||
|> Marker.multi_set_last_read_id(user, "notifications")
|
||||
|> Repo.transaction()
|
||||
{:ok, %{marker: marker}} =
|
||||
Multi.new()
|
||||
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|
||||
|> Marker.multi_set_last_read_id(user, "notifications")
|
||||
|> Repo.transaction()
|
||||
|
||||
Streamer.stream(["user", "user:notification"], marker)
|
||||
|
||||
{:ok, %{marker: marker}}
|
||||
end
|
||||
|
||||
@spec read_one(User.t(), String.t()) ::
|
||||
|
|
@ -525,9 +531,7 @@ defmodule Pleroma.Notification do
|
|||
%Activity{data: %{"type" => "Create"}} = activity,
|
||||
local_only
|
||||
) do
|
||||
notification_enabled_ap_ids =
|
||||
[]
|
||||
|> Utils.maybe_notify_subscribers(activity)
|
||||
notification_enabled_ap_ids = Utils.get_notified_subscribers(activity)
|
||||
|
||||
potential_receivers =
|
||||
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)
|
||||
|
|
@ -734,7 +738,7 @@ defmodule Pleroma.Notification do
|
|||
|
||||
def mark_as_read?(activity, target_user) do
|
||||
user = Activity.user_actor(activity)
|
||||
User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(target_user, activity)
|
||||
User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(activity, target_user)
|
||||
end
|
||||
|
||||
def for_user_and_activity(user, activity) do
|
||||
|
|
|
|||
|
|
@ -99,24 +99,6 @@ defmodule Pleroma.Object do
|
|||
def get_by_id(nil), do: nil
|
||||
def get_by_id(id), do: Repo.get(Object, id)
|
||||
|
||||
def get_by_id_and_maybe_refetch(id, opts \\ []) do
|
||||
%{updated_at: updated_at} = object = get_by_id(id)
|
||||
|
||||
if opts[:interval] &&
|
||||
NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do
|
||||
case Fetcher.refetch_object(object) do
|
||||
{:ok, %Object{} = object} ->
|
||||
object
|
||||
|
||||
e ->
|
||||
Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}")
|
||||
object
|
||||
end
|
||||
else
|
||||
object
|
||||
end
|
||||
end
|
||||
|
||||
def get_by_ap_id(nil), do: nil
|
||||
|
||||
def get_by_ap_id(ap_id) do
|
||||
|
|
@ -144,7 +126,7 @@ defmodule Pleroma.Object do
|
|||
Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}")
|
||||
end
|
||||
|
||||
def normalize(_, options \\ [fetch: false, id_only: false])
|
||||
def normalize(_, options \\ [fetch: false])
|
||||
|
||||
# If we pass an Activity to Object.normalize(), we can try to use the preloaded object.
|
||||
# Use this whenever possible, especially when walking graphs in an O(N) loop!
|
||||
|
|
@ -173,9 +155,6 @@ defmodule Pleroma.Object do
|
|||
|
||||
def normalize(ap_id, options) when is_binary(ap_id) do
|
||||
cond do
|
||||
Keyword.get(options, :id_only) ->
|
||||
ap_id
|
||||
|
||||
Keyword.get(options, :fetch) ->
|
||||
case Fetcher.fetch_object_from_id(ap_id, options) do
|
||||
{:ok, object} -> object
|
||||
|
|
@ -252,7 +231,8 @@ defmodule Pleroma.Object do
|
|||
@spec cleanup_attachments(boolean(), Object.t()) ::
|
||||
{:ok, Oban.Job.t() | nil}
|
||||
def cleanup_attachments(true, %Object{} = object) do
|
||||
AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{"object" => object})
|
||||
AttachmentsCleanupWorker.new(%{"op" => "cleanup_attachments", "object" => object})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
def cleanup_attachments(_, _), do: {:ok, nil}
|
||||
|
|
@ -418,28 +398,6 @@ defmodule Pleroma.Object do
|
|||
String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
|
||||
end
|
||||
|
||||
def replies(object, opts \\ []) do
|
||||
object = Object.normalize(object, fetch: false)
|
||||
|
||||
query =
|
||||
Object
|
||||
|> where(
|
||||
[o],
|
||||
fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
|
||||
)
|
||||
|> order_by([o], asc: o.id)
|
||||
|
||||
if opts[:self_only] do
|
||||
actor = object.data["actor"]
|
||||
where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
def self_replies(object, opts \\ []),
|
||||
do: replies(object, Keyword.put(opts, :self_only, true))
|
||||
|
||||
def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
|
||||
|
||||
def tags(_), do: []
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ defmodule Pleroma.Object.Containment do
|
|||
defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok
|
||||
defp compare_uris(_id_uri, _other_uri), do: :error
|
||||
|
||||
@doc """
|
||||
Checks whether an URL to fetch from is from the local server.
|
||||
|
||||
We never want to fetch from ourselves; if it's not in the database
|
||||
it can't be authentic and must be a counterfeit.
|
||||
"""
|
||||
def contain_local_fetch(id) do
|
||||
case compare_uris(URI.parse(id), Pleroma.Web.Endpoint.struct_url()) do
|
||||
:ok -> :error
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks that an imported AP object's actor matches the host it came from.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
defmodule Pleroma.Object.Fetcher do
|
||||
alias Pleroma.HTTP
|
||||
alias Pleroma.Instances
|
||||
alias Pleroma.Maps
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Object.Containment
|
||||
|
|
@ -58,7 +57,12 @@ defmodule Pleroma.Object.Fetcher do
|
|||
end
|
||||
end
|
||||
|
||||
@typep fetcher_errors ::
|
||||
:error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier
|
||||
|
||||
# Note: will create a Create activity, which we need internally at the moment.
|
||||
@spec fetch_object_from_id(String.t(), list()) ::
|
||||
{:ok, Object.t()} | {fetcher_errors(), any()} | Pipeline.errors()
|
||||
def fetch_object_from_id(id, options \\ []) do
|
||||
with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)},
|
||||
{_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])},
|
||||
|
|
@ -72,50 +76,22 @@ defmodule Pleroma.Object.Fetcher do
|
|||
{:object, data, Object.normalize(activity, fetch: false)} do
|
||||
{:ok, object}
|
||||
else
|
||||
{:allowed_depth, false} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:error, :allowed_depth}
|
||||
|
||||
{:containment, reason} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:error, reason}
|
||||
|
||||
{:transmogrifier, {:error, {:reject, reason}}} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:reject, reason}
|
||||
|
||||
{:transmogrifier, {:reject, reason}} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:reject, reason}
|
||||
|
||||
{:transmogrifier, reason} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:error, reason}
|
||||
|
||||
{:object, data, nil} ->
|
||||
reinject_object(%Object{}, data)
|
||||
|
||||
{:normalize, object = %Object{}} ->
|
||||
{:ok, object}
|
||||
|
||||
{:fetch_object, %Object{} = object} ->
|
||||
{:ok, object}
|
||||
|
||||
{:fetch, {:error, reason}} = e ->
|
||||
log_fetch_error(id, e)
|
||||
{:error, reason}
|
||||
{:object, data, nil} ->
|
||||
reinject_object(%Object{}, data)
|
||||
|
||||
e ->
|
||||
log_fetch_error(id, e)
|
||||
{:error, e}
|
||||
Logger.metadata(object: id)
|
||||
Logger.error("Object rejected while fetching #{id} #{inspect(e)}")
|
||||
e
|
||||
end
|
||||
end
|
||||
|
||||
defp log_fetch_error(id, error) do
|
||||
Logger.metadata(object: id)
|
||||
Logger.error("Object rejected while fetching #{id} #{inspect(error)}")
|
||||
end
|
||||
|
||||
defp prepare_activity_params(data) do
|
||||
%{
|
||||
"type" => "Create",
|
||||
|
|
@ -168,21 +144,25 @@ defmodule Pleroma.Object.Fetcher do
|
|||
Logger.debug("Fetching object #{id} via AP")
|
||||
|
||||
with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")},
|
||||
{_, true} <- {:mrf, MRF.id_filter(id)},
|
||||
{_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)},
|
||||
{:ok, body} <- get_object(id),
|
||||
{:ok, data} <- safe_json_decode(body),
|
||||
:ok <- Containment.contain_origin_from_id(id, data) do
|
||||
if not Instances.reachable?(id) do
|
||||
Instances.set_reachable(id)
|
||||
end
|
||||
|
||||
{:ok, data}
|
||||
else
|
||||
{:scheme, _} ->
|
||||
{:error, "Unsupported URI scheme"}
|
||||
|
||||
{:local_fetch, _} ->
|
||||
{:error, "Trying to fetch local resource"}
|
||||
|
||||
{:error, e} ->
|
||||
{:error, e}
|
||||
|
||||
{:mrf, false} ->
|
||||
{:error, {:reject, "Filtered by id"}}
|
||||
|
||||
e ->
|
||||
{:error, e}
|
||||
end
|
||||
|
|
@ -191,6 +171,14 @@ defmodule Pleroma.Object.Fetcher do
|
|||
def fetch_and_contain_remote_object_from_id(_id),
|
||||
do: {:error, "id must be a string"}
|
||||
|
||||
defp check_crossdomain_redirect(final_host, _original_url) when is_nil(final_host) do
|
||||
{:cross_domain_redirect, false}
|
||||
end
|
||||
|
||||
defp check_crossdomain_redirect(final_host, original_url) do
|
||||
{:cross_domain_redirect, final_host != URI.parse(original_url).host}
|
||||
end
|
||||
|
||||
defp get_object(id) do
|
||||
date = Pleroma.Signature.signed_date()
|
||||
|
||||
|
|
@ -200,19 +188,29 @@ defmodule Pleroma.Object.Fetcher do
|
|||
|> sign_fetch(id, date)
|
||||
|
||||
case HTTP.get(id, headers) do
|
||||
{:ok, %{body: body, status: code, headers: headers, url: final_url}}
|
||||
when code in 200..299 ->
|
||||
remote_host = if final_url, do: URI.parse(final_url).host, else: nil
|
||||
|
||||
with {:cross_domain_redirect, false} <- check_crossdomain_redirect(remote_host, id),
|
||||
{_, content_type} <- List.keyfind(headers, "content-type", 0),
|
||||
{:ok, _media_type} <- verify_content_type(content_type) do
|
||||
{:ok, body}
|
||||
else
|
||||
{:cross_domain_redirect, true} ->
|
||||
{:error, {:cross_domain_redirect, true}}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
|
||||
# Handle the case where URL is not in the response (older HTTP library versions)
|
||||
{:ok, %{body: body, status: code, headers: headers}} when code in 200..299 ->
|
||||
case List.keyfind(headers, "content-type", 0) do
|
||||
{_, content_type} ->
|
||||
case Plug.Conn.Utils.media_type(content_type) do
|
||||
{:ok, "application", "activity+json", _} ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, "application", "ld+json",
|
||||
%{"profile" => "https://www.w3.org/ns/activitystreams"}} ->
|
||||
{:ok, body}
|
||||
|
||||
_ ->
|
||||
{:error, {:content_type, content_type}}
|
||||
case verify_content_type(content_type) do
|
||||
{:ok, _} -> {:ok, body}
|
||||
error -> error
|
||||
end
|
||||
|
||||
_ ->
|
||||
|
|
@ -235,4 +233,17 @@ defmodule Pleroma.Object.Fetcher do
|
|||
|
||||
defp safe_json_decode(nil), do: {:ok, nil}
|
||||
defp safe_json_decode(json), do: Jason.decode(json)
|
||||
|
||||
defp verify_content_type(content_type) do
|
||||
case Plug.Conn.Utils.media_type(content_type) do
|
||||
{:ok, "application", "activity+json", _} ->
|
||||
{:ok, :activity_json}
|
||||
|
||||
{:ok, "application", "ld+json", %{"profile" => "https://www.w3.org/ns/activitystreams"}} ->
|
||||
{:ok, :ld_json}
|
||||
|
||||
_ ->
|
||||
{:error, {:content_type, content_type}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
defmodule Pleroma.Object.Updater do
|
||||
require Pleroma.Constants
|
||||
|
||||
alias Pleroma.Maps
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Repo
|
||||
|
||||
|
|
@ -115,6 +116,7 @@ defmodule Pleroma.Object.Updater do
|
|||
# Choices are the same, but counts are different
|
||||
to_be_updated
|
||||
|> Map.put(key, updated_object[key])
|
||||
|> Maps.put_if_present("votersCount", updated_object["votersCount"])
|
||||
else
|
||||
# Choices (or vote type) have changed, do not allow this
|
||||
_ -> to_be_updated
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.OTPVersion do
|
||||
@spec version() :: String.t() | nil
|
||||
def version do
|
||||
# OTP Version https://erlang.org/doc/system_principles/versions.html#otp-version
|
||||
[
|
||||
Path.join(:code.root_dir(), "OTP_VERSION"),
|
||||
Path.join([:code.root_dir(), "releases", :erlang.system_info(:otp_release), "OTP_VERSION"])
|
||||
]
|
||||
|> get_version_from_files()
|
||||
end
|
||||
|
||||
@spec get_version_from_files([Path.t()]) :: String.t() | nil
|
||||
def get_version_from_files([]), do: nil
|
||||
|
||||
def get_version_from_files([path | paths]) do
|
||||
if File.exists?(path) do
|
||||
path
|
||||
|> File.read!()
|
||||
|> String.replace(~r/\r|\n|\s/, "")
|
||||
else
|
||||
get_version_from_files(paths)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -89,19 +89,36 @@ defmodule Pleroma.Pagination do
|
|||
|
||||
defp cast_params(params) do
|
||||
param_types = %{
|
||||
min_id: :string,
|
||||
since_id: :string,
|
||||
max_id: :string,
|
||||
min_id: params[:id_type] || :string,
|
||||
since_id: params[:id_type] || :string,
|
||||
max_id: params[:id_type] || :string,
|
||||
offset: :integer,
|
||||
limit: :integer,
|
||||
skip_extra_order: :boolean,
|
||||
skip_order: :boolean
|
||||
skip_order: :boolean,
|
||||
order_asc: :boolean
|
||||
}
|
||||
|
||||
changeset = cast({%{}, param_types}, params, Map.keys(param_types))
|
||||
changeset.changes
|
||||
end
|
||||
|
||||
defp order_statement(query, table_binding, :asc) do
|
||||
order_by(
|
||||
query,
|
||||
[{u, table_position(query, table_binding)}],
|
||||
fragment("? asc nulls last", u.id)
|
||||
)
|
||||
end
|
||||
|
||||
defp order_statement(query, table_binding, :desc) do
|
||||
order_by(
|
||||
query,
|
||||
[{u, table_position(query, table_binding)}],
|
||||
fragment("? desc nulls last", u.id)
|
||||
)
|
||||
end
|
||||
|
||||
defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do
|
||||
where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id)
|
||||
end
|
||||
|
|
@ -119,19 +136,16 @@ defmodule Pleroma.Pagination do
|
|||
defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query
|
||||
|
||||
defp restrict(query, :order, %{min_id: _}, table_binding) do
|
||||
order_by(
|
||||
query,
|
||||
[{u, table_position(query, table_binding)}],
|
||||
fragment("? asc nulls last", u.id)
|
||||
)
|
||||
order_statement(query, table_binding, :asc)
|
||||
end
|
||||
|
||||
defp restrict(query, :order, _options, table_binding) do
|
||||
order_by(
|
||||
query,
|
||||
[{u, table_position(query, table_binding)}],
|
||||
fragment("? desc nulls last", u.id)
|
||||
)
|
||||
defp restrict(query, :order, %{max_id: _}, table_binding) do
|
||||
order_statement(query, table_binding, :desc)
|
||||
end
|
||||
|
||||
defp restrict(query, :order, options, table_binding) do
|
||||
dir = if options[:order_asc], do: :asc, else: :desc
|
||||
order_statement(query, table_binding, dir)
|
||||
end
|
||||
|
||||
defp restrict(query, :offset, %{offset: offset}, _table_binding) do
|
||||
|
|
@ -151,11 +165,9 @@ defmodule Pleroma.Pagination do
|
|||
|
||||
defp restrict(query, _, _, _), do: query
|
||||
|
||||
defp enforce_order(result, %{min_id: _}) do
|
||||
result
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp enforce_order(result, %{min_id: _, order_asc: true}), do: result
|
||||
defp enforce_order(result, %{min_id: _}), do: Enum.reverse(result)
|
||||
defp enforce_order(result, %{max_id: _, order_asc: true}), do: Enum.reverse(result)
|
||||
defp enforce_order(result, _), do: result
|
||||
|
||||
defp table_position(%Ecto.Query{} = query, binding_name) do
|
||||
|
|
|
|||
|
|
@ -16,17 +16,24 @@ defmodule Pleroma.ReleaseTasks do
|
|||
end
|
||||
end
|
||||
|
||||
def find_module(task) do
|
||||
module_name =
|
||||
task
|
||||
|> String.split(".")
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> then(fn x -> [Mix, Tasks, Pleroma] ++ x end)
|
||||
|> Module.concat()
|
||||
|
||||
case Code.ensure_loaded(module_name) do
|
||||
{:module, _} -> module_name
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp mix_task(task, args) do
|
||||
Application.load(:pleroma)
|
||||
{:ok, modules} = :application.get_key(:pleroma, :modules)
|
||||
|
||||
module =
|
||||
Enum.find(modules, fn module ->
|
||||
module = Module.split(module)
|
||||
|
||||
match?(["Mix", "Tasks", "Pleroma" | _], module) and
|
||||
String.downcase(List.last(module)) == task
|
||||
end)
|
||||
module = find_module(task)
|
||||
|
||||
if module do
|
||||
module.run(args)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.ReverseProxy do
|
||||
alias Pleroma.Utils.URIEncoding
|
||||
|
||||
@range_headers ~w(range if-range)
|
||||
@keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++
|
||||
~w(if-unmodified-since if-none-match) ++ @range_headers
|
||||
|
|
@ -17,6 +19,8 @@ defmodule Pleroma.ReverseProxy do
|
|||
@failed_request_ttl :timer.seconds(60)
|
||||
@methods ~w(GET HEAD)
|
||||
|
||||
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
|
||||
|
||||
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
|
||||
|
||||
def max_read_duration_default, do: @max_read_duration
|
||||
|
|
@ -153,9 +157,12 @@ defmodule Pleroma.ReverseProxy do
|
|||
end
|
||||
|
||||
defp request(method, url, headers, opts) do
|
||||
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
|
||||
method = method |> String.downcase() |> String.to_existing_atom()
|
||||
|
||||
url = maybe_encode_url(url)
|
||||
|
||||
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
|
||||
|
||||
case client().request(method, url, headers, "", opts) do
|
||||
{:ok, code, headers, client} when code in @valid_resp_codes ->
|
||||
{:ok, code, downcase_headers(headers), client}
|
||||
|
|
@ -301,10 +308,26 @@ defmodule Pleroma.ReverseProxy do
|
|||
headers
|
||||
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|
||||
|> build_resp_cache_headers(opts)
|
||||
|> sanitise_content_type()
|
||||
|> build_resp_content_disposition_header(opts)
|
||||
|> Keyword.merge(Keyword.get(opts, :resp_headers, []))
|
||||
end
|
||||
|
||||
defp sanitise_content_type(headers) do
|
||||
original_ct = get_content_type(headers)
|
||||
|
||||
safe_ct =
|
||||
Pleroma.Web.Plugs.Utils.get_safe_mime_type(
|
||||
%{allowed_mime_types: @allowed_mime_types},
|
||||
original_ct
|
||||
)
|
||||
|
||||
[
|
||||
{"content-type", safe_ct}
|
||||
| Enum.filter(headers, fn {k, _v} -> k != "content-type" end)
|
||||
]
|
||||
end
|
||||
|
||||
defp build_resp_cache_headers(headers, _opts) do
|
||||
has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
|
||||
|
||||
|
|
@ -431,4 +454,18 @@ defmodule Pleroma.ReverseProxy do
|
|||
_ -> 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
|
||||
# EncodeUrl Tesla middleware
|
||||
# Also do it for test environment
|
||||
defp maybe_encode_url(url) do
|
||||
case Application.get_env(:tesla, :adapter) do
|
||||
Tesla.Adapter.Hackney -> URIEncoding.encode_url(url)
|
||||
{Tesla.Adapter.Finch, _} -> URIEncoding.encode_url(url)
|
||||
Tesla.Mock -> URIEncoding.encode_url(url)
|
||||
_ -> url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,9 +5,59 @@
|
|||
defmodule Pleroma.ReverseProxy.Client.Hackney do
|
||||
@behaviour Pleroma.ReverseProxy.Client
|
||||
|
||||
# In-app redirect handler to avoid Hackney redirect bugs:
|
||||
# - https://github.com/benoitc/hackney/issues/527 (relative/protocol-less redirects can crash Hackney)
|
||||
# - https://github.com/benoitc/hackney/issues/273 (redirects not followed when using HTTP proxy)
|
||||
#
|
||||
# Based on a redirect handler from Pleb, slightly modified to work with Hackney:
|
||||
# https://declin.eu/objects/d4f38e62-5429-4614-86d1-e8fc16e6bf33
|
||||
@redirect_statuses [301, 302, 303, 307, 308]
|
||||
defp absolute_redirect_url(original_url, resp_headers) do
|
||||
location =
|
||||
Enum.find(resp_headers, fn {header, _location} ->
|
||||
String.downcase(header) == "location"
|
||||
end)
|
||||
|
||||
URI.merge(original_url, elem(location, 1))
|
||||
|> URI.to_string()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def request(method, url, headers, body, opts \\ []) do
|
||||
:hackney.request(method, url, headers, body, opts)
|
||||
opts =
|
||||
Keyword.put_new(opts, :path_encode_fun, fn path ->
|
||||
path
|
||||
end)
|
||||
|
||||
if opts[:follow_redirect] != false do
|
||||
{_state, req_opts} = Access.get_and_update(opts, :follow_redirect, fn a -> {a, false} end)
|
||||
res = :hackney.request(method, url, headers, body, req_opts)
|
||||
|
||||
case res do
|
||||
{:ok, code, resp_headers, _client} when code in @redirect_statuses ->
|
||||
:hackney.request(
|
||||
method,
|
||||
absolute_redirect_url(url, resp_headers),
|
||||
headers,
|
||||
body,
|
||||
req_opts
|
||||
)
|
||||
|
||||
{:ok, code, resp_headers} when code in @redirect_statuses ->
|
||||
:hackney.request(
|
||||
method,
|
||||
absolute_redirect_url(url, resp_headers),
|
||||
headers,
|
||||
body,
|
||||
req_opts
|
||||
)
|
||||
|
||||
_ ->
|
||||
res
|
||||
end
|
||||
else
|
||||
:hackney.request(method, url, headers, body, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
212
lib/pleroma/safe_zip.ex
Normal file
212
lib/pleroma/safe_zip.ex
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# Akkoma: Magically expressive social media
|
||||
# Copyright © 2024 Akkoma Authors <https://akkoma.dev/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.SafeZip do
|
||||
@moduledoc """
|
||||
Wraps the subset of Erlang's zip module we’d like to use
|
||||
but enforces path-traversal safety everywhere and other checks.
|
||||
|
||||
For convenience almost all functions accept both elixir strings and charlists,
|
||||
but output elixir strings themselves. However, this means the input parameter type
|
||||
can no longer be used to distinguish archive file paths from archive binary data in memory,
|
||||
thus where needed both a _data and _file variant are provided.
|
||||
"""
|
||||
|
||||
@type text() :: String.t() | [char()]
|
||||
|
||||
defp safe_path?(path) do
|
||||
# Path accepts elixir’s chardata()
|
||||
case Path.safe_relative(path) do
|
||||
{:ok, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_type?(file_type) do
|
||||
if file_type in [:regular, :directory] do
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_file(_type, _path_charlist, nil), do: nil
|
||||
|
||||
defp maybe_add_file(:regular, path_charlist, file_list),
|
||||
do: [to_string(path_charlist) | file_list]
|
||||
|
||||
defp maybe_add_file(_type, _path_charlist, file_list), do: file_list
|
||||
|
||||
@spec check_safe_archive_and_maybe_list_files(binary() | [char()], [term()], boolean()) ::
|
||||
{:ok, [String.t()]} | {:error, reason :: term()}
|
||||
defp check_safe_archive_and_maybe_list_files(archive, opts, list) do
|
||||
acc = if list, do: [], else: nil
|
||||
|
||||
with {:ok, table} <- :zip.table(archive, opts) do
|
||||
Enum.reduce_while(table, {:ok, acc}, fn
|
||||
# ZIP comment
|
||||
{:zip_comment, _}, acc ->
|
||||
{:cont, acc}
|
||||
|
||||
# File entry
|
||||
{:zip_file, path, info, _comment, _offset, _comp_size}, {:ok, fl} ->
|
||||
with {_, type} <- {:get_type, elem(info, 2)},
|
||||
{_, true} <- {:type, safe_type?(type)},
|
||||
{_, true} <- {:safe_path, safe_path?(path)} do
|
||||
{:cont, {:ok, maybe_add_file(type, path, fl)}}
|
||||
else
|
||||
{:type, _} ->
|
||||
{:halt, {:error, "Potentially unsafe file type in ZIP at: #{path}"}}
|
||||
|
||||
{:safe_path, _} ->
|
||||
{:halt, {:error, "Unsafe path in ZIP: #{path}"}}
|
||||
end
|
||||
|
||||
# new OTP version?
|
||||
_, _acc ->
|
||||
{:halt, {:error, "Unknown ZIP record type"}}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@spec check_safe_archive_and_list_files(binary() | [char()], [term()]) ::
|
||||
{:ok, [String.t()]} | {:error, reason :: term()}
|
||||
defp check_safe_archive_and_list_files(archive, opts \\ []) do
|
||||
check_safe_archive_and_maybe_list_files(archive, opts, true)
|
||||
end
|
||||
|
||||
@spec check_safe_archive(binary() | [char()], [term()]) :: :ok | {:error, reason :: term()}
|
||||
defp check_safe_archive(archive, opts \\ []) do
|
||||
case check_safe_archive_and_maybe_list_files(archive, opts, false) do
|
||||
{:ok, _} -> :ok
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@spec check_safe_file_list([text()], text()) :: :ok | {:error, term()}
|
||||
defp check_safe_file_list([], _), do: :ok
|
||||
|
||||
defp check_safe_file_list([path | tail], cwd) do
|
||||
with {_, true} <- {:path, safe_path?(path)},
|
||||
{_, {:ok, fstat}} <- {:stat, File.stat(Path.expand(path, cwd))},
|
||||
{_, true} <- {:type, safe_type?(fstat.type)} do
|
||||
check_safe_file_list(tail, cwd)
|
||||
else
|
||||
{:path, _} ->
|
||||
{:error, "Unsafe path escaping cwd: #{path}"}
|
||||
|
||||
{:stat, e} ->
|
||||
{:error, "Unable to check file type of #{path}: #{inspect(e)}"}
|
||||
|
||||
{:type, _} ->
|
||||
{:error, "Unsafe type at #{path}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_safe_file_list(_, _), do: {:error, "Malformed file_list"}
|
||||
|
||||
@doc """
|
||||
Checks whether the archive data contais file entries for all paths from fset
|
||||
|
||||
Note this really only accepts entries corresponding to regular _files_,
|
||||
if a path is contained as for example an directory, this does not count as a match.
|
||||
"""
|
||||
@spec contains_all_data?(binary(), MapSet.t()) :: true | false
|
||||
def contains_all_data?(archive_data, fset) do
|
||||
with {:ok, table} <- :zip.table(archive_data) do
|
||||
remaining =
|
||||
Enum.reduce(table, fset, fn
|
||||
{:zip_file, path, info, _comment, _offset, _comp_size}, fset ->
|
||||
if elem(info, 2) == :regular do
|
||||
MapSet.delete(fset, path)
|
||||
else
|
||||
fset
|
||||
end
|
||||
|
||||
_, _ ->
|
||||
fset
|
||||
end)
|
||||
|> MapSet.size()
|
||||
|
||||
if remaining == 0, do: true, else: false
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
List all file entries in ZIP, or error if invalid or unsafe.
|
||||
|
||||
Note this really only lists regular files, no directories, ZIP comments or other types!
|
||||
"""
|
||||
@spec list_dir_file(text()) :: {:ok, [String.t()]} | {:error, reason :: term()}
|
||||
def list_dir_file(archive) do
|
||||
path = to_charlist(archive)
|
||||
check_safe_archive_and_list_files(path)
|
||||
end
|
||||
|
||||
defp stringify_zip({:ok, {fname, data}}), do: {:ok, {to_string(fname), data}}
|
||||
defp stringify_zip({:ok, fname}), do: {:ok, to_string(fname)}
|
||||
defp stringify_zip(ret), do: ret
|
||||
|
||||
@spec zip(text(), text(), [text()], boolean()) ::
|
||||
{:ok, file_name :: String.t()}
|
||||
| {:ok, {file_name :: String.t(), file_data :: binary()}}
|
||||
| {:error, reason :: term()}
|
||||
def zip(name, file_list, cwd, memory \\ false) do
|
||||
opts = [{:cwd, to_charlist(cwd)}]
|
||||
opts = if memory, do: [:memory | opts], else: opts
|
||||
|
||||
with :ok <- check_safe_file_list(file_list, cwd) do
|
||||
file_list = for f <- file_list, do: to_charlist(f)
|
||||
name = to_charlist(name)
|
||||
stringify_zip(:zip.zip(name, file_list, opts))
|
||||
end
|
||||
end
|
||||
|
||||
@spec unzip_file(text(), text(), [text()] | nil) ::
|
||||
{:ok, [String.t()]}
|
||||
| {:error, reason :: term()}
|
||||
| {:error, {name :: text(), reason :: term()}}
|
||||
def unzip_file(archive, target_dir, file_list \\ nil) do
|
||||
do_unzip(to_charlist(archive), to_charlist(target_dir), file_list)
|
||||
end
|
||||
|
||||
@spec unzip_data(binary(), text(), [text()] | nil) ::
|
||||
{:ok, [String.t()]}
|
||||
| {:error, reason :: term()}
|
||||
| {:error, {name :: text(), reason :: term()}}
|
||||
def unzip_data(archive, target_dir, file_list \\ nil) do
|
||||
do_unzip(archive, to_charlist(target_dir), file_list)
|
||||
end
|
||||
|
||||
defp stringify_unzip({:ok, [{_fname, _data} | _] = filebinlist}),
|
||||
do: {:ok, Enum.map(filebinlist, fn {fname, data} -> {to_string(fname), data} end)}
|
||||
|
||||
defp stringify_unzip({:ok, [_fname | _] = filelist}),
|
||||
do: {:ok, Enum.map(filelist, fn fname -> to_string(fname) end)}
|
||||
|
||||
defp stringify_unzip({:error, {fname, term}}), do: {:error, {to_string(fname), term}}
|
||||
defp stringify_unzip(ret), do: ret
|
||||
|
||||
@spec do_unzip(binary() | [char()], text(), [text()] | nil) ::
|
||||
{:ok, [String.t()]}
|
||||
| {:error, reason :: term()}
|
||||
| {:error, {name :: text(), reason :: term()}}
|
||||
defp do_unzip(archive, target_dir, file_list) do
|
||||
opts =
|
||||
if file_list != nil do
|
||||
[
|
||||
file_list: for(f <- file_list, do: to_charlist(f)),
|
||||
cwd: target_dir
|
||||
]
|
||||
else
|
||||
[cwd: target_dir]
|
||||
end
|
||||
|
||||
with :ok <- check_safe_archive(archive) do
|
||||
stringify_unzip(:zip.unzip(archive, opts))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,17 +4,14 @@ defmodule Pleroma.Search do
|
|||
alias Pleroma.Web.ActivityPub.Visibility
|
||||
alias Pleroma.Workers.SearchIndexingWorker
|
||||
|
||||
@spec add_to_index(Activity.t()) :: :ok | :error
|
||||
@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)},
|
||||
{:ok, %Oban.Job{}} <-
|
||||
SearchIndexingWorker.enqueue("add_to_index", %{"activity" => activity_id}) do
|
||||
:ok
|
||||
{_, "public"} <- {:visibility, Visibility.get_visibility(object)} do
|
||||
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => activity_id})
|
||||
|> Oban.insert()
|
||||
else
|
||||
{:indexable, false} -> :ok
|
||||
{:visibility, _} -> :ok
|
||||
_ -> :error
|
||||
_ -> {:ok, :noop}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -25,12 +22,10 @@ defmodule Pleroma.Search do
|
|||
end
|
||||
end
|
||||
|
||||
@spec remove_from_index(Object.t()) :: :ok | :error
|
||||
@spec remove_from_index(Object.t()) :: {:ok, Oban.Job.t()} | {:error, Oban.Job.changeset()}
|
||||
def remove_from_index(%Pleroma.Object{id: object_id}) do
|
||||
case SearchIndexingWorker.enqueue("remove_from_index", %{"object" => object_id}) do
|
||||
{:ok, %Oban.Job{}} -> :ok
|
||||
_ -> :error
|
||||
end
|
||||
SearchIndexingWorker.new(%{"op" => "remove_from_index", "object" => object_id})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
def search(query, options) do
|
||||
|
|
@ -40,7 +35,7 @@ defmodule Pleroma.Search do
|
|||
|
||||
def healthcheck_endpoints do
|
||||
search_module = Pleroma.Config.get([Pleroma.Search, :module])
|
||||
search_module.healthcheck_endpoints
|
||||
search_module.healthcheck_endpoints()
|
||||
end
|
||||
|
||||
defp indexable?(%Activity{data: %{"type" => "Create"}}), do: true
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ defmodule Pleroma.Search.DatabaseSearch do
|
|||
^tsc,
|
||||
o.data,
|
||||
^search_query
|
||||
)
|
||||
),
|
||||
order_by: [desc: :inserted_at]
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ defmodule Pleroma.Search.Meilisearch do
|
|||
# Only index public or unlisted Notes
|
||||
if not is_nil(object) and object.data["type"] == "Note" and
|
||||
not is_nil(object.data["content"]) and
|
||||
not is_nil(object.data["published"]) and
|
||||
(Pleroma.Constants.as_public() in object.data["to"] or
|
||||
Pleroma.Constants.as_public() in object.data["cc"]) and
|
||||
object.data["content"] not in ["", "."] do
|
||||
|
|
|
|||
|
|
@ -153,26 +153,55 @@ defmodule Pleroma.Search.QdrantSearch do
|
|||
end
|
||||
|
||||
defmodule Pleroma.Search.QdrantSearch.OpenAIClient do
|
||||
use Tesla
|
||||
alias Pleroma.Config.Getting, as: Config
|
||||
|
||||
plug(Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :openai_url]))
|
||||
plug(Tesla.Middleware.JSON)
|
||||
def post(path, body) do
|
||||
Tesla.post(client(), path, body)
|
||||
end
|
||||
|
||||
plug(Tesla.Middleware.Headers, [
|
||||
{"Authorization",
|
||||
"Bearer #{Pleroma.Config.get([Pleroma.Search.QdrantSearch, :openai_api_key])}"}
|
||||
])
|
||||
defp client do
|
||||
Tesla.client(middleware())
|
||||
end
|
||||
|
||||
defp middleware do
|
||||
[
|
||||
{Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :openai_url])},
|
||||
Tesla.Middleware.JSON,
|
||||
{Tesla.Middleware.Headers,
|
||||
[
|
||||
{"Authorization", "Bearer #{Config.get([Pleroma.Search.QdrantSearch, :openai_api_key])}"}
|
||||
]}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Pleroma.Search.QdrantSearch.QdrantClient do
|
||||
use Tesla
|
||||
alias Pleroma.Config.Getting, as: Config
|
||||
|
||||
plug(Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :qdrant_url]))
|
||||
plug(Tesla.Middleware.JSON)
|
||||
def delete(path) do
|
||||
Tesla.delete(client(), path)
|
||||
end
|
||||
|
||||
plug(Tesla.Middleware.Headers, [
|
||||
{"api-key", Pleroma.Config.get([Pleroma.Search.QdrantSearch, :qdrant_api_key])}
|
||||
])
|
||||
def post(path, body) do
|
||||
Tesla.post(client(), path, body)
|
||||
end
|
||||
|
||||
def put(path, body) do
|
||||
Tesla.put(client(), path, body)
|
||||
end
|
||||
|
||||
defp client do
|
||||
Tesla.client(middleware())
|
||||
end
|
||||
|
||||
defp middleware do
|
||||
[
|
||||
{Tesla.Middleware.BaseUrl, Config.get([Pleroma.Search.QdrantSearch, :qdrant_url])},
|
||||
Tesla.Middleware.JSON,
|
||||
{Tesla.Middleware.Headers,
|
||||
[
|
||||
{"api-key", Pleroma.Config.get([Pleroma.Search.QdrantSearch, :qdrant_api_key])}
|
||||
]}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Signature do
|
||||
@behaviour Pleroma.Signature.API
|
||||
@behaviour HTTPSignatures.Adapter
|
||||
|
||||
alias Pleroma.EctoType.ActivityPub.ObjectValidators
|
||||
|
|
@ -10,6 +11,14 @@ defmodule Pleroma.Signature do
|
|||
alias Pleroma.User
|
||||
alias Pleroma.Web.ActivityPub.ActivityPub
|
||||
|
||||
import Plug.Conn, only: [put_req_header: 3]
|
||||
|
||||
@http_signatures_impl Application.compile_env(
|
||||
:pleroma,
|
||||
[__MODULE__, :http_signatures_impl],
|
||||
HTTPSignatures
|
||||
)
|
||||
|
||||
@known_suffixes ["/publickey", "/main-key"]
|
||||
|
||||
def key_id_to_actor_id(key_id) do
|
||||
|
|
@ -45,7 +54,7 @@ defmodule Pleroma.Signature do
|
|||
|
||||
def fetch_public_key(conn) do
|
||||
with {:ok, actor_id} <- get_actor_id(conn),
|
||||
{:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do
|
||||
{:ok, public_key} <- User.get_or_fetch_public_key_for_ap_id(actor_id) do
|
||||
{:ok, public_key}
|
||||
else
|
||||
e ->
|
||||
|
|
@ -85,4 +94,48 @@ defmodule Pleroma.Signature do
|
|||
def signed_date(%NaiveDateTime{} = date) do
|
||||
Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
|
||||
end
|
||||
|
||||
@spec validate_signature(Plug.Conn.t(), String.t()) :: boolean()
|
||||
def validate_signature(%Plug.Conn{} = conn, request_target) do
|
||||
# Newer drafts for HTTP signatures now use @request-target instead of the
|
||||
# old (request-target). We'll now support both for incoming signatures.
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("(request-target)", request_target)
|
||||
|> put_req_header("@request-target", request_target)
|
||||
|
||||
@http_signatures_impl.validate_conn(conn)
|
||||
end
|
||||
|
||||
@spec validate_signature(Plug.Conn.t()) :: boolean()
|
||||
def validate_signature(%Plug.Conn{} = conn) do
|
||||
# This (request-target) is non-standard, but many implementations do it
|
||||
# this way due to a misinterpretation of
|
||||
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-06
|
||||
# "path" was interpreted as not having the query, though later examples
|
||||
# show that it must be the absolute path + query. This behavior is kept to
|
||||
# make sure most software (Pleroma itself, Mastodon, and probably others)
|
||||
# do not break.
|
||||
request_target = Enum.join([String.downcase(conn.method), conn.request_path], " ")
|
||||
|
||||
# This is the proper way to build the @request-target, as expected by
|
||||
# many HTTP signature libraries, clarified in the following draft:
|
||||
# https://www.ietf.org/archive/id/draft-ietf-httpbis-message-signatures-11.html#section-2.2.6
|
||||
# It is the same as before, but containing the query part as well.
|
||||
proper_target = Enum.join([request_target, "?", conn.query_string], "")
|
||||
|
||||
cond do
|
||||
# Normal, non-standard behavior but expected by Pleroma and more.
|
||||
validate_signature(conn, request_target) ->
|
||||
true
|
||||
|
||||
# Has query string and the previous one failed: let's try the standard.
|
||||
conn.query_string != "" ->
|
||||
validate_signature(conn, proper_target)
|
||||
|
||||
# If there's no query string and signature fails, it's rotten.
|
||||
true ->
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
14
lib/pleroma/signature/api.ex
Normal file
14
lib/pleroma/signature/api.ex
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Signature.API do
|
||||
@moduledoc """
|
||||
Behaviour for signing requests and producing HTTP Date headers.
|
||||
|
||||
This is used to allow tests to replace the signing implementation with Mox.
|
||||
"""
|
||||
|
||||
@callback sign(user :: Pleroma.User.t(), headers :: map()) :: String.t()
|
||||
@callback signed_date() :: String.t()
|
||||
end
|
||||
|
|
@ -39,7 +39,7 @@ defmodule Pleroma.Telemetry.Logger do
|
|||
_,
|
||||
_
|
||||
) do
|
||||
Logger.error(fn ->
|
||||
Logger.debug(fn ->
|
||||
"Connection pool failed to reclaim any connections due to all of them being in use. It will have to drop requests for opening connections to new hosts"
|
||||
end)
|
||||
end
|
||||
|
|
@ -70,7 +70,7 @@ defmodule Pleroma.Telemetry.Logger do
|
|||
%{key: key},
|
||||
_
|
||||
) do
|
||||
Logger.warning(fn ->
|
||||
Logger.debug(fn ->
|
||||
"Pool worker for #{key}: Client #{inspect(client_pid)} died before releasing the connection with #{inspect(reason)}"
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
29
lib/pleroma/tesla/middleware/encode_url.ex
Normal file
29
lib/pleroma/tesla/middleware/encode_url.ex
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2025 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Tesla.Middleware.EncodeUrl do
|
||||
@moduledoc """
|
||||
Middleware to encode URLs properly
|
||||
|
||||
We must decode and then re-encode to ensure correct encoding.
|
||||
If we only encode it will re-encode each % as %25 causing a space
|
||||
already encoded as %20 to be %2520.
|
||||
|
||||
Similar problem for query parameters which need spaces to be the + character
|
||||
"""
|
||||
|
||||
@behaviour Tesla.Middleware
|
||||
|
||||
@impl Tesla.Middleware
|
||||
def call(%Tesla.Env{url: url} = env, next, _) do
|
||||
url = Pleroma.Utils.URIEncoding.encode_url(url)
|
||||
|
||||
env = %{env | url: url}
|
||||
|
||||
case Tesla.run(env, next) do
|
||||
{:ok, env} -> {:ok, env}
|
||||
err -> err
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -35,6 +35,7 @@ defmodule Pleroma.Upload do
|
|||
"""
|
||||
alias Ecto.UUID
|
||||
alias Pleroma.Maps
|
||||
alias Pleroma.Utils.URIEncoding
|
||||
alias Pleroma.Web.ActivityPub.Utils
|
||||
require Logger
|
||||
|
||||
|
|
@ -92,7 +93,7 @@ defmodule Pleroma.Upload do
|
|||
def store(upload, opts \\ []) do
|
||||
opts = get_opts(opts)
|
||||
|
||||
with {:ok, upload} <- prepare_upload(upload, opts),
|
||||
with {:ok, %__MODULE__{} = upload} <- prepare_upload(upload, opts),
|
||||
upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"},
|
||||
{:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload),
|
||||
description = get_description(upload),
|
||||
|
|
@ -230,11 +231,18 @@ defmodule Pleroma.Upload do
|
|||
tmp_path
|
||||
end
|
||||
|
||||
# Encoding the whole path here is fine since the path is in a
|
||||
# UUID/<file name> form.
|
||||
# The file at this point isn't %-encoded, so the path shouldn't
|
||||
# be decoded first like Pleroma.Utils.URIEncoding.encode_url/1 does.
|
||||
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
|
||||
encode_opts = [bypass_decode: true, bypass_parse: true]
|
||||
|
||||
path =
|
||||
URI.encode(path, &char_unescaped?/1) <>
|
||||
URIEncoding.encode_url(path, encode_opts) <>
|
||||
if Pleroma.Config.get([__MODULE__, :link_name], false) do
|
||||
"?name=#{URI.encode(name, &char_unescaped?/1)}"
|
||||
enum = %{name: name}
|
||||
"?#{URI.encode_query(enum)}"
|
||||
else
|
||||
""
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,9 +90,13 @@ defmodule Pleroma.Upload.Filter.AnalyzeMetadata do
|
|||
{:ok, rgb} =
|
||||
if Image.has_alpha?(resized_image) do
|
||||
# remove alpha channel
|
||||
resized_image
|
||||
|> Operation.extract_band!(0, n: 3)
|
||||
|> Image.write_to_binary()
|
||||
case Operation.extract_band(resized_image, 0, n: 3) do
|
||||
{:ok, data} ->
|
||||
Image.write_to_binary(data)
|
||||
|
||||
_ ->
|
||||
Image.write_to_binary(resized_image)
|
||||
end
|
||||
else
|
||||
Image.write_to_binary(resized_image)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ defmodule Pleroma.Upload.Filter.AnonymizeFilename do
|
|||
"""
|
||||
@behaviour Pleroma.Upload.Filter
|
||||
|
||||
alias Pleroma.Config
|
||||
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
|
||||
alias Pleroma.Upload
|
||||
|
||||
def filter(%Upload{name: name} = upload) do
|
||||
|
|
@ -23,7 +23,7 @@ defmodule Pleroma.Upload.Filter.AnonymizeFilename do
|
|||
|
||||
@spec predefined_name(String.t()) :: String.t() | nil
|
||||
defp predefined_name(extension) do
|
||||
with name when not is_nil(name) <- Config.get([__MODULE__, :text]),
|
||||
with name when not is_nil(name) <- @config_impl.get([__MODULE__, :text]),
|
||||
do: String.replace(name, "{extension}", extension)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,16 @@ defmodule Pleroma.Upload.Filter.Dedupe do
|
|||
|> Base.encode16(case: :lower)
|
||||
|
||||
filename = shasum <> "." <> extension
|
||||
{:ok, :filtered, %Upload{upload | id: shasum, path: filename}}
|
||||
|
||||
{:ok, :filtered, %Upload{upload | id: shasum, path: shard_path(filename)}}
|
||||
end
|
||||
|
||||
def filter(_), do: {:ok, :noop}
|
||||
|
||||
@spec shard_path(String.t()) :: String.t()
|
||||
def shard_path(
|
||||
<<a::binary-size(2), b::binary-size(2), c::binary-size(2), _::binary>> = filename
|
||||
) do
|
||||
Path.join([a, b, c, filename])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,22 +29,26 @@ defmodule Pleroma.Upload.Filter.Exiftool.ReadDescription do
|
|||
do: current_description
|
||||
|
||||
defp read_when_empty(_, file, tag) do
|
||||
try do
|
||||
{tag_content, 0} =
|
||||
System.cmd("exiftool", ["-b", "-s3", tag, file],
|
||||
stderr_to_stdout: false,
|
||||
parallelism: true
|
||||
)
|
||||
if File.exists?(file) do
|
||||
try do
|
||||
{tag_content, 0} =
|
||||
System.cmd("exiftool", ["-m", "-b", "-s3", tag, file],
|
||||
stderr_to_stdout: false,
|
||||
parallelism: true
|
||||
)
|
||||
|
||||
tag_content = String.trim(tag_content)
|
||||
tag_content = String.trim(tag_content)
|
||||
|
||||
if tag_content != "" and
|
||||
String.length(tag_content) <=
|
||||
Pleroma.Config.get([:instance, :description_limit]),
|
||||
do: tag_content,
|
||||
else: nil
|
||||
rescue
|
||||
_ in ErlangError -> nil
|
||||
if tag_content != "" and
|
||||
String.length(tag_content) <=
|
||||
Pleroma.Config.get([:instance, :description_limit]),
|
||||
do: tag_content,
|
||||
else: nil
|
||||
rescue
|
||||
_ in ErlangError -> nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@ defmodule Pleroma.Upload.Filter.Exiftool.StripLocation do
|
|||
|
||||
def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do
|
||||
try do
|
||||
case System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) do
|
||||
case System.cmd("exiftool", ["-m", "-overwrite_original", "-gps:all=", "-png:all=", file],
|
||||
stderr_to_stdout: true,
|
||||
parallelism: true
|
||||
) do
|
||||
{_response, 0} -> {:ok, :filtered}
|
||||
{error, 1} -> {:error, error}
|
||||
{error, _} -> {:error, error}
|
||||
end
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@ defmodule Pleroma.Upload.Filter.Mogrify do
|
|||
@type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()}
|
||||
@type conversions :: conversion() | [conversion()]
|
||||
|
||||
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
|
||||
@mogrify_impl Application.compile_env(
|
||||
:pleroma,
|
||||
[__MODULE__, :mogrify_impl],
|
||||
Pleroma.MogrifyWrapper
|
||||
)
|
||||
|
||||
def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do
|
||||
try do
|
||||
do_filter(file, Pleroma.Config.get!([__MODULE__, :args]))
|
||||
do_filter(file, @config_impl.get!([__MODULE__, :args]))
|
||||
{:ok, :filtered}
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
|
|
@ -22,9 +29,9 @@ defmodule Pleroma.Upload.Filter.Mogrify do
|
|||
|
||||
def do_filter(file, filters) do
|
||||
file
|
||||
|> Mogrify.open()
|
||||
|> @mogrify_impl.open()
|
||||
|> mogrify_filter(filters)
|
||||
|> Mogrify.save(in_place: true)
|
||||
|> @mogrify_impl.save(in_place: true)
|
||||
end
|
||||
|
||||
defp mogrify_filter(mogrify, nil), do: mogrify
|
||||
|
|
@ -38,10 +45,10 @@ defmodule Pleroma.Upload.Filter.Mogrify do
|
|||
defp mogrify_filter(mogrify, []), do: mogrify
|
||||
|
||||
defp mogrify_filter(mogrify, {action, options}) do
|
||||
Mogrify.custom(mogrify, action, options)
|
||||
@mogrify_impl.custom(mogrify, action, options)
|
||||
end
|
||||
|
||||
defp mogrify_filter(mogrify, action) when is_binary(action) do
|
||||
Mogrify.custom(mogrify, action)
|
||||
@mogrify_impl.custom(mogrify, action)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ defmodule Pleroma.Uploaders.Local do
|
|||
|
||||
[file | folders] ->
|
||||
path = Path.join([upload_path()] ++ Enum.reverse(folders))
|
||||
File.mkdir_p!(path)
|
||||
Pleroma.Backports.mkdir_p!(path)
|
||||
{path, file}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ defmodule Pleroma.User do
|
|||
alias Pleroma.Emoji
|
||||
alias Pleroma.FollowingRelationship
|
||||
alias Pleroma.Formatter
|
||||
alias Pleroma.Hashtag
|
||||
alias Pleroma.HTML
|
||||
alias Pleroma.Keys
|
||||
alias Pleroma.MFA
|
||||
|
|
@ -27,6 +28,7 @@ defmodule Pleroma.User do
|
|||
alias Pleroma.Registration
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.User.HashtagFollow
|
||||
alias Pleroma.UserRelationship
|
||||
alias Pleroma.Web.ActivityPub.ActivityPub
|
||||
alias Pleroma.Web.ActivityPub.Builder
|
||||
|
|
@ -38,6 +40,7 @@ defmodule Pleroma.User do
|
|||
alias Pleroma.Web.OAuth
|
||||
alias Pleroma.Web.RelMe
|
||||
alias Pleroma.Workers.BackgroundWorker
|
||||
alias Pleroma.Workers.DeleteWorker
|
||||
alias Pleroma.Workers.UserRefreshWorker
|
||||
|
||||
require Logger
|
||||
|
|
@ -147,7 +150,7 @@ defmodule Pleroma.User do
|
|||
field(:allow_following_move, :boolean, default: true)
|
||||
field(:skip_thread_containment, :boolean, default: false)
|
||||
field(:actor_type, :string, default: "Person")
|
||||
field(:also_known_as, {:array, ObjectValidators.ObjectID}, default: [])
|
||||
field(:also_known_as, {:array, ObjectValidators.BareUri}, default: [])
|
||||
field(:inbox, :string)
|
||||
field(:shared_inbox, :string)
|
||||
field(:accepts_chat_messages, :boolean, default: nil)
|
||||
|
|
@ -173,6 +176,12 @@ defmodule Pleroma.User do
|
|||
has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id)
|
||||
has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id)
|
||||
|
||||
many_to_many(:followed_hashtags, Hashtag,
|
||||
on_replace: :delete,
|
||||
on_delete: :delete_all,
|
||||
join_through: HashtagFollow
|
||||
)
|
||||
|
||||
for {relationship_type,
|
||||
[
|
||||
{outgoing_relation, outgoing_relation_target},
|
||||
|
|
@ -224,8 +233,8 @@ defmodule Pleroma.User do
|
|||
for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <-
|
||||
@user_relationships_config do
|
||||
# `def blocked_users_relation/2`, `def muted_users_relation/2`,
|
||||
# `def reblog_muted_users_relation/2`, `def notification_muted_users/2`,
|
||||
# `def subscriber_users/2`, `def endorsed_users_relation/2`
|
||||
# `def reblog_muted_users_relation/2`, `def notification_muted_users_relation/2`,
|
||||
# `def subscriber_users_relation/2`, `def endorsed_users_relation/2`
|
||||
def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do
|
||||
target_users_query = assoc(user, unquote(outgoing_relation_target))
|
||||
|
||||
|
|
@ -278,7 +287,14 @@ defmodule Pleroma.User do
|
|||
defdelegate following(user), to: FollowingRelationship
|
||||
defdelegate following?(follower, followed), to: FollowingRelationship
|
||||
defdelegate following_ap_ids(user), to: FollowingRelationship
|
||||
defdelegate get_follow_requests(user), to: FollowingRelationship
|
||||
defdelegate get_follow_requests_query(user), to: FollowingRelationship
|
||||
defdelegate get_outgoing_follow_requests_query(user), to: FollowingRelationship
|
||||
|
||||
def get_follow_requests(user) do
|
||||
get_follow_requests_query(user)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defdelegate search(query, opts \\ []), to: User.Search
|
||||
|
||||
@doc """
|
||||
|
|
@ -299,7 +315,7 @@ defmodule Pleroma.User do
|
|||
|
||||
def binary_id(%User{} = user), do: binary_id(user.id)
|
||||
|
||||
@doc "Returns status account"
|
||||
@doc "Returns account status"
|
||||
@spec account_status(User.t()) :: account_status()
|
||||
def account_status(%User{is_active: false}), do: :deactivated
|
||||
def account_status(%User{password_reset_pending: true}), do: :password_reset_pending
|
||||
|
|
@ -418,6 +434,11 @@ defmodule Pleroma.User do
|
|||
end
|
||||
end
|
||||
|
||||
def image_description(image, default \\ "")
|
||||
|
||||
def image_description(%{"name" => name}, _default), do: name
|
||||
def image_description(_, default), do: default
|
||||
|
||||
# Should probably be renamed or removed
|
||||
@spec ap_id(User.t()) :: String.t()
|
||||
def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}"
|
||||
|
|
@ -462,6 +483,7 @@ defmodule Pleroma.User do
|
|||
def remote_user_changeset(struct \\ %User{local: false}, params) do
|
||||
bio_limit = Config.get([:instance, :user_bio_length], 5000)
|
||||
name_limit = Config.get([:instance, :user_name_length], 100)
|
||||
fields_limit = Config.get([:instance, :max_remote_account_fields], 0)
|
||||
|
||||
name =
|
||||
case params[:name] do
|
||||
|
|
@ -475,6 +497,7 @@ defmodule Pleroma.User do
|
|||
|> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now())
|
||||
|> truncate_if_exists(:name, name_limit)
|
||||
|> truncate_if_exists(:bio, bio_limit)
|
||||
|> Map.update(:fields, [], &Enum.take(&1, fields_limit))
|
||||
|> truncate_fields_param()
|
||||
|> fix_follower_address()
|
||||
|
||||
|
|
@ -583,16 +606,26 @@ defmodule Pleroma.User do
|
|||
|> validate_length(:bio, max: bio_limit)
|
||||
|> validate_length(:name, min: 1, max: name_limit)
|
||||
|> validate_inclusion(:actor_type, Pleroma.Constants.allowed_user_actor_types())
|
||||
|> validate_image_description(:avatar_description, params)
|
||||
|> validate_image_description(:header_description, params)
|
||||
|> put_fields()
|
||||
|> put_emoji()
|
||||
|> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)})
|
||||
|> put_change_if_present(:avatar, &put_upload(&1, :avatar))
|
||||
|> put_change_if_present(:banner, &put_upload(&1, :banner))
|
||||
|> put_change_if_present(
|
||||
:avatar,
|
||||
&put_upload(&1, :avatar, Map.get(params, :avatar_description))
|
||||
)
|
||||
|> put_change_if_present(
|
||||
:banner,
|
||||
&put_upload(&1, :banner, Map.get(params, :header_description))
|
||||
)
|
||||
|> put_change_if_present(:background, &put_upload(&1, :background))
|
||||
|> put_change_if_present(
|
||||
:pleroma_settings_store,
|
||||
&{:ok, Map.merge(struct.pleroma_settings_store, &1)}
|
||||
)
|
||||
|> maybe_update_image_description(:avatar, Map.get(params, :avatar_description))
|
||||
|> maybe_update_image_description(:banner, Map.get(params, :header_description))
|
||||
|> validate_fields(false)
|
||||
end
|
||||
|
||||
|
|
@ -671,13 +704,41 @@ defmodule Pleroma.User do
|
|||
end
|
||||
end
|
||||
|
||||
defp put_upload(value, type) do
|
||||
defp put_upload(value, type, description \\ nil) do
|
||||
with %Plug.Upload{} <- value,
|
||||
{:ok, object} <- ActivityPub.upload(value, type: type) do
|
||||
{:ok, object} <- ActivityPub.upload(value, type: type, description: description) do
|
||||
{:ok, object.data}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_image_description(changeset, key, params) do
|
||||
description_limit = Config.get([:instance, :description_limit], 5_000)
|
||||
description = Map.get(params, key)
|
||||
|
||||
if is_binary(description) and String.length(description) > description_limit do
|
||||
changeset
|
||||
|> add_error(key, "#{key} is too long")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_image_description(changeset, image_field, description)
|
||||
when is_binary(description) do
|
||||
with {:image_missing, true} <- {:image_missing, not changed?(changeset, image_field)},
|
||||
{:existing_image, %{"id" => id}} <-
|
||||
{:existing_image, Map.get(changeset.data, image_field)},
|
||||
{:object, %Object{} = object} <- {:object, Object.get_by_ap_id(id)},
|
||||
{:ok, object} <- Object.update_data(object, %{"name" => description}) do
|
||||
put_change(changeset, image_field, object.data)
|
||||
else
|
||||
{:description_too_long, true} -> {:error}
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_image_description(changeset, _, _), do: changeset
|
||||
|
||||
def update_as_admin_changeset(struct, params) do
|
||||
struct
|
||||
|> update_changeset(params)
|
||||
|
|
@ -735,7 +796,8 @@ defmodule Pleroma.User do
|
|||
end
|
||||
|
||||
def force_password_reset_async(user) do
|
||||
BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id})
|
||||
BackgroundWorker.new(%{"op" => "force_password_reset", "user_id" => user.id})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
@spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
||||
|
|
@ -746,13 +808,6 @@ defmodule Pleroma.User do
|
|||
when is_nil(password) do
|
||||
params = Map.put_new(params, :accepts_chat_messages, true)
|
||||
|
||||
params =
|
||||
if Map.has_key?(params, :email) do
|
||||
Map.put_new(params, :email, params[:email])
|
||||
else
|
||||
params
|
||||
end
|
||||
|
||||
struct
|
||||
|> cast(params, [
|
||||
:name,
|
||||
|
|
@ -840,7 +895,7 @@ defmodule Pleroma.User do
|
|||
end)
|
||||
end
|
||||
|
||||
def validate_email_not_in_blacklisted_domain(changeset, field) do
|
||||
defp validate_email_not_in_blacklisted_domain(changeset, field) do
|
||||
validate_change(changeset, field, fn _, value ->
|
||||
valid? =
|
||||
Config.get([User, :email_blacklist])
|
||||
|
|
@ -857,9 +912,9 @@ defmodule Pleroma.User do
|
|||
end)
|
||||
end
|
||||
|
||||
def maybe_validate_required_email(changeset, true), do: changeset
|
||||
defp maybe_validate_required_email(changeset, true), do: changeset
|
||||
|
||||
def maybe_validate_required_email(changeset, _) do
|
||||
defp maybe_validate_required_email(changeset, _) do
|
||||
if Config.get([:instance, :account_activation_required]) do
|
||||
validate_required(changeset, [:email])
|
||||
else
|
||||
|
|
@ -1054,15 +1109,15 @@ defmodule Pleroma.User do
|
|||
|
||||
defp maybe_send_registration_email(_), do: {:ok, :noop}
|
||||
|
||||
def needs_update?(%User{local: true}), do: false
|
||||
defp needs_update?(%User{local: true}), do: false
|
||||
|
||||
def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true
|
||||
defp needs_update?(%User{local: false, last_refreshed_at: nil}), do: true
|
||||
|
||||
def needs_update?(%User{local: false} = user) do
|
||||
defp needs_update?(%User{local: false} = user) do
|
||||
NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400
|
||||
end
|
||||
|
||||
def needs_update?(_), do: true
|
||||
defp needs_update?(_), do: true
|
||||
|
||||
@spec maybe_direct_follow(User.t(), User.t()) ::
|
||||
{:ok, User.t(), User.t()} | {:error, String.t()}
|
||||
|
|
@ -1217,7 +1272,8 @@ defmodule Pleroma.User do
|
|||
def update_and_set_cache(changeset) do
|
||||
with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
|
||||
if get_change(changeset, :raw_fields) do
|
||||
BackgroundWorker.enqueue("verify_fields_links", %{"user_id" => user.id})
|
||||
BackgroundWorker.new(%{"op" => "verify_fields_links", "user_id" => user.id})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
set_cache(user)
|
||||
|
|
@ -1308,7 +1364,7 @@ defmodule Pleroma.User do
|
|||
@spec get_by_nickname(String.t()) :: User.t() | nil
|
||||
def get_by_nickname(nickname) do
|
||||
Repo.get_by(User, nickname: nickname) ||
|
||||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
|
||||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()}$)i, nickname) do
|
||||
Repo.get_by(User, nickname: local_nickname(nickname))
|
||||
end
|
||||
end
|
||||
|
|
@ -1588,11 +1644,11 @@ defmodule Pleroma.User do
|
|||
)) ||
|
||||
{:ok, nil} do
|
||||
if duration > 0 do
|
||||
Pleroma.Workers.MuteExpireWorker.enqueue(
|
||||
"unmute_user",
|
||||
%{"muter_id" => muter.id, "mutee_id" => mutee.id},
|
||||
Pleroma.Workers.MuteExpireWorker.new(
|
||||
%{"op" => "unmute_user", "muter_id" => muter.id, "mutee_id" => mutee.id},
|
||||
scheduled_at: expires_at
|
||||
)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
@cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}")
|
||||
|
|
@ -1652,7 +1708,9 @@ defmodule Pleroma.User do
|
|||
end
|
||||
end
|
||||
|
||||
def block(%User{} = blocker, %User{} = blocked) do
|
||||
def block(blocker, blocked, params \\ %{})
|
||||
|
||||
def block(%User{} = blocker, %User{} = blocked, params) do
|
||||
# sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)
|
||||
blocker =
|
||||
if following?(blocker, blocked) do
|
||||
|
|
@ -1682,12 +1740,33 @@ defmodule Pleroma.User do
|
|||
|
||||
{:ok, blocker} = update_follower_count(blocker)
|
||||
{:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked)
|
||||
add_to_block(blocker, blocked)
|
||||
|
||||
duration = Map.get(params, :duration, 0)
|
||||
|
||||
expires_at =
|
||||
if duration > 0 do
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(duration)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
user_block = add_to_block(blocker, blocked, expires_at)
|
||||
|
||||
if duration > 0 do
|
||||
Pleroma.Workers.MuteExpireWorker.new(
|
||||
%{"op" => "unblock_user", "blocker_id" => blocker.id, "blocked_id" => blocked.id},
|
||||
scheduled_at: expires_at
|
||||
)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
user_block
|
||||
end
|
||||
|
||||
# helper to handle the block given only an actor's AP id
|
||||
def block(%User{} = blocker, %{ap_id: ap_id}) do
|
||||
block(blocker, get_cached_by_ap_id(ap_id))
|
||||
def block(%User{} = blocker, %{ap_id: ap_id}, params) do
|
||||
block(blocker, get_cached_by_ap_id(ap_id), params)
|
||||
end
|
||||
|
||||
def unblock(%User{} = blocker, %User{} = blocked) do
|
||||
|
|
@ -1835,7 +1914,8 @@ defmodule Pleroma.User do
|
|||
defp maybe_filter_on_ap_id(query, _ap_ids), do: query
|
||||
|
||||
def set_activation_async(user, status \\ true) do
|
||||
BackgroundWorker.enqueue("user_activation", %{"user_id" => user.id, "status" => status})
|
||||
BackgroundWorker.new(%{"op" => "user_activation", "user_id" => user.id, "status" => status})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
@spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
||||
|
|
@ -1927,7 +2007,7 @@ defmodule Pleroma.User do
|
|||
end
|
||||
|
||||
@spec purge_user_changeset(User.t()) :: Ecto.Changeset.t()
|
||||
def purge_user_changeset(user) do
|
||||
defp purge_user_changeset(user) do
|
||||
# "Right to be forgotten"
|
||||
# https://gdpr.eu/right-to-be-forgotten/
|
||||
change(user, %{
|
||||
|
|
@ -1982,7 +2062,9 @@ defmodule Pleroma.User do
|
|||
def delete(%User{} = user) do
|
||||
# Purge the user immediately
|
||||
purge(user)
|
||||
BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id})
|
||||
|
||||
DeleteWorker.new(%{"op" => "delete_user", "user_id" => user.id})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
# *Actually* delete the user from the DB
|
||||
|
|
@ -2097,7 +2179,7 @@ defmodule Pleroma.User do
|
|||
Repo.all(query)
|
||||
end
|
||||
|
||||
def delete_notifications_from_user_activities(%User{ap_id: ap_id}) do
|
||||
defp delete_notifications_from_user_activities(%User{ap_id: ap_id}) do
|
||||
Notification
|
||||
|> join(:inner, [n], activity in assoc(n, :activity))
|
||||
|> where([n, a], fragment("? = ?", a.actor, ^ap_id))
|
||||
|
|
@ -2232,6 +2314,15 @@ defmodule Pleroma.User do
|
|||
|
||||
def public_key(_), do: {:error, "key not found"}
|
||||
|
||||
def get_or_fetch_public_key_for_ap_id(ap_id) do
|
||||
with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id),
|
||||
{:ok, public_key} <- public_key(user) do
|
||||
{:ok, public_key}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def get_public_key_for_ap_id(ap_id) do
|
||||
with %User{} = user <- get_cached_by_ap_id(ap_id),
|
||||
{:ok, public_key} <- public_key(user) do
|
||||
|
|
@ -2556,7 +2647,7 @@ defmodule Pleroma.User do
|
|||
end
|
||||
end
|
||||
|
||||
# Internal function; public one is `deactivate/2`
|
||||
# Internal function; public one is `set_activation/2`
|
||||
defp set_activation_status(user, status) do
|
||||
user
|
||||
|> cast(%{is_active: status}, [:is_active])
|
||||
|
|
@ -2575,7 +2666,7 @@ defmodule Pleroma.User do
|
|||
|> update_and_set_cache()
|
||||
end
|
||||
|
||||
def validate_fields(changeset, remote? \\ false) do
|
||||
defp validate_fields(changeset, remote?) do
|
||||
limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields
|
||||
limit = Config.get([:instance, limit_name], 0)
|
||||
|
||||
|
|
@ -2720,10 +2811,10 @@ defmodule Pleroma.User do
|
|||
set_domain_blocks(user, List.delete(user.domain_blocks, domain_blocked))
|
||||
end
|
||||
|
||||
@spec add_to_block(User.t(), User.t()) ::
|
||||
@spec add_to_block(User.t(), User.t(), integer() | nil) ::
|
||||
{:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()}
|
||||
defp add_to_block(%User{} = user, %User{} = blocked) do
|
||||
with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do
|
||||
defp add_to_block(%User{} = user, %User{} = blocked, expires_at) do
|
||||
with {:ok, relationship} <- UserRelationship.create_block(user, blocked, expires_at) do
|
||||
@cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
|
||||
{:ok, relationship}
|
||||
end
|
||||
|
|
@ -2810,4 +2901,54 @@ defmodule Pleroma.User do
|
|||
birthday_month: month
|
||||
})
|
||||
end
|
||||
|
||||
defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
|
||||
when is_list(follows),
|
||||
do: user
|
||||
|
||||
defp maybe_load_followed_hashtags(%User{} = user) do
|
||||
followed_hashtags = HashtagFollow.get_by_user(user)
|
||||
%{user | followed_hashtags: followed_hashtags}
|
||||
end
|
||||
|
||||
def followed_hashtags(%User{followed_hashtags: follows})
|
||||
when is_list(follows),
|
||||
do: follows
|
||||
|
||||
def followed_hashtags(%User{} = user) do
|
||||
{:ok, user} =
|
||||
user
|
||||
|> maybe_load_followed_hashtags()
|
||||
|> set_cache()
|
||||
|
||||
user.followed_hashtags
|
||||
end
|
||||
|
||||
def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
|
||||
Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
|
||||
user = maybe_load_followed_hashtags(user)
|
||||
|
||||
with {:ok, _} <- HashtagFollow.new(user, hashtag),
|
||||
follows <- HashtagFollow.get_by_user(user),
|
||||
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
|
||||
user
|
||||
|> set_cache()
|
||||
end
|
||||
end
|
||||
|
||||
def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
|
||||
Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
|
||||
user = maybe_load_followed_hashtags(user)
|
||||
|
||||
with {:ok, _} <- HashtagFollow.delete(user, hashtag),
|
||||
follows <- HashtagFollow.get_by_user(user),
|
||||
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
|
||||
user
|
||||
|> set_cache()
|
||||
end
|
||||
end
|
||||
|
||||
def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
|
||||
not is_nil(HashtagFollow.get(user, hashtag))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ defmodule Pleroma.User.Backup do
|
|||
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Bookmark
|
||||
alias Pleroma.Config
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.SafeZip
|
||||
alias Pleroma.Uploaders.Uploader
|
||||
alias Pleroma.User
|
||||
alias Pleroma.User.Backup.State
|
||||
alias Pleroma.Web.ActivityPub.ActivityPub
|
||||
alias Pleroma.Web.ActivityPub.Transmogrifier
|
||||
alias Pleroma.Web.ActivityPub.UserView
|
||||
|
|
@ -29,71 +31,107 @@ defmodule Pleroma.User.Backup do
|
|||
field(:file_name, :string)
|
||||
field(:file_size, :integer, default: 0)
|
||||
field(:processed, :boolean, default: false)
|
||||
field(:state, State, default: :invalid)
|
||||
field(:processed_number, :integer, default: 0)
|
||||
field(:tempdir, :string)
|
||||
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
|
||||
@doc """
|
||||
Schedules a job to backup a user if the number of backup requests has not exceeded the limit.
|
||||
|
||||
def create(user, admin_id \\ nil) do
|
||||
with :ok <- validate_limit(user, admin_id),
|
||||
{:ok, backup} <- user |> new() |> Repo.insert() do
|
||||
BackupWorker.process(backup, admin_id)
|
||||
Admins can directly call new/1 and schedule_backup/1 to bypass the limit.
|
||||
"""
|
||||
@spec user(User.t()) :: {:ok, t()} | {:error, any()}
|
||||
def user(user) do
|
||||
days = Config.get([__MODULE__, :limit_days])
|
||||
|
||||
with true <- permitted?(user),
|
||||
%__MODULE__{} = backup <- new(user),
|
||||
{:ok, inserted_backup} <- Repo.insert(backup),
|
||||
{:ok, %Oban.Job{}} <- schedule_backup(inserted_backup) do
|
||||
{:ok, inserted_backup}
|
||||
else
|
||||
false ->
|
||||
{:error,
|
||||
dngettext(
|
||||
"errors",
|
||||
"Last export was less than a day ago",
|
||||
"Last export was less than %{days} days ago",
|
||||
days,
|
||||
days: days
|
||||
)}
|
||||
|
||||
e ->
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Generates a %Backup{} for a user with a random file name"
|
||||
@spec new(User.t()) :: t()
|
||||
def new(user) do
|
||||
rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now())
|
||||
name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip"
|
||||
|
||||
%__MODULE__{
|
||||
user_id: user.id,
|
||||
content_type: "application/zip",
|
||||
file_name: name,
|
||||
state: :pending
|
||||
tempdir: tempdir(),
|
||||
user: user
|
||||
}
|
||||
end
|
||||
|
||||
def delete(backup) do
|
||||
uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
|
||||
@doc "Schedules the execution of the provided backup"
|
||||
@spec schedule_backup(t()) :: {:ok, Oban.Job.t()} | {:error, any()}
|
||||
def schedule_backup(backup) do
|
||||
with false <- is_nil(backup.id) do
|
||||
%{"op" => "process", "backup_id" => backup.id}
|
||||
|> BackupWorker.new()
|
||||
|> Oban.insert()
|
||||
else
|
||||
true ->
|
||||
{:error, "Backup is missing id. Please insert it into the Repo first."}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Deletes the backup archive file and removes the database record"
|
||||
@spec delete_archive(t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete_archive(backup) do
|
||||
uploader = Config.get([Pleroma.Upload, :uploader])
|
||||
|
||||
with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do
|
||||
Repo.delete(backup)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok
|
||||
@doc "Schedules a job to delete the backup archive"
|
||||
@spec schedule_delete(t()) :: {:ok, Oban.Job.t()} | {:error, any()}
|
||||
def schedule_delete(backup) do
|
||||
days = Config.get([__MODULE__, :purge_after_days])
|
||||
time = 60 * 60 * 24 * days
|
||||
scheduled_at = Calendar.NaiveDateTime.add!(backup.inserted_at, time)
|
||||
|
||||
defp validate_limit(user, nil) do
|
||||
case get_last(user.id) do
|
||||
%__MODULE__{inserted_at: inserted_at} ->
|
||||
days = Pleroma.Config.get([__MODULE__, :limit_days])
|
||||
diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days)
|
||||
%{"op" => "delete", "backup_id" => backup.id}
|
||||
|> BackupWorker.new(scheduled_at: scheduled_at)
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
if diff > days do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
dngettext(
|
||||
"errors",
|
||||
"Last export was less than a day ago",
|
||||
"Last export was less than %{days} days ago",
|
||||
days,
|
||||
days: days
|
||||
)}
|
||||
end
|
||||
defp permitted?(user) do
|
||||
with {_, %__MODULE__{inserted_at: inserted_at}} <- {:last, get_last(user)} do
|
||||
days = Config.get([__MODULE__, :limit_days])
|
||||
diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days)
|
||||
|
||||
nil ->
|
||||
:ok
|
||||
diff > days
|
||||
else
|
||||
{:last, nil} -> true
|
||||
end
|
||||
end
|
||||
|
||||
def get_last(user_id) do
|
||||
@doc "Returns last backup for the provided user"
|
||||
@spec get_last(User.t()) :: t()
|
||||
def get_last(%User{id: user_id}) do
|
||||
__MODULE__
|
||||
|> where(user_id: ^user_id)
|
||||
|> order_by(desc: :id)
|
||||
|
|
@ -101,6 +139,8 @@ defmodule Pleroma.User.Backup do
|
|||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc "Lists all existing backups for a user"
|
||||
@spec list(User.t()) :: [Ecto.Schema.t() | term()]
|
||||
def list(%User{id: user_id}) do
|
||||
__MODULE__
|
||||
|> where(user_id: ^user_id)
|
||||
|
|
@ -108,149 +148,113 @@ defmodule Pleroma.User.Backup do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do
|
||||
__MODULE__
|
||||
|> where(user_id: ^user_id)
|
||||
|> where([b], b.id != ^latest_id)
|
||||
|> Repo.all()
|
||||
|> Enum.each(&BackupWorker.delete/1)
|
||||
@doc "Schedules deletion of all but the the most recent backup"
|
||||
@spec remove_outdated(User.t()) :: :ok
|
||||
def remove_outdated(user) do
|
||||
with %__MODULE__{} = latest_backup <- get_last(user) do
|
||||
__MODULE__
|
||||
|> where(user_id: ^user.id)
|
||||
|> where([b], b.id != ^latest_backup.id)
|
||||
|> Repo.all()
|
||||
|> Enum.each(&schedule_delete/1)
|
||||
else
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
def get(id), do: Repo.get(__MODULE__, id)
|
||||
|
||||
defp set_state(backup, state, processed_number \\ nil) do
|
||||
struct =
|
||||
%{state: state}
|
||||
|> Pleroma.Maps.put_if_present(:processed_number, processed_number)
|
||||
def get_by_id(id), do: Repo.get(__MODULE__, id)
|
||||
|
||||
@doc "Generates changeset for %Pleroma.User.Backup{}"
|
||||
@spec changeset(%__MODULE__{}, map()) :: %Ecto.Changeset{}
|
||||
def changeset(backup \\ %__MODULE__{}, attrs) do
|
||||
backup
|
||||
|> cast(struct, [:state, :processed_number])
|
||||
|> cast(attrs, [:content_type, :file_name, :file_size, :processed, :tempdir])
|
||||
end
|
||||
|
||||
@doc "Updates the backup record"
|
||||
@spec update_record(%__MODULE__{}, map()) :: {:ok, %__MODULE__{}} | {:error, %Ecto.Changeset{}}
|
||||
def update_record(%__MODULE__{} = backup, attrs) do
|
||||
backup
|
||||
|> changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def process(
|
||||
%__MODULE__{} = backup,
|
||||
processor_module \\ __MODULE__.Processor
|
||||
) do
|
||||
set_state(backup, :running, 0)
|
||||
|
||||
current_pid = self()
|
||||
|
||||
task =
|
||||
Task.Supervisor.async_nolink(
|
||||
Pleroma.TaskSupervisor,
|
||||
processor_module,
|
||||
:do_process,
|
||||
[backup, current_pid]
|
||||
)
|
||||
|
||||
wait_backup(backup, backup.processed_number, task)
|
||||
end
|
||||
|
||||
defp wait_backup(backup, current_processed, task) do
|
||||
wait_time = @config_impl.get([__MODULE__, :process_wait_time])
|
||||
|
||||
receive do
|
||||
{:progress, new_processed} ->
|
||||
total_processed = current_processed + new_processed
|
||||
|
||||
set_state(backup, :running, total_processed)
|
||||
wait_backup(backup, total_processed, task)
|
||||
|
||||
{:DOWN, _ref, _proc, _pid, reason} ->
|
||||
backup = get(backup.id)
|
||||
|
||||
if reason != :normal do
|
||||
Logger.error("Backup #{backup.id} process ended abnormally: #{inspect(reason)}")
|
||||
|
||||
{:ok, backup} = set_state(backup, :failed)
|
||||
|
||||
cleanup(backup)
|
||||
|
||||
{:error,
|
||||
%{
|
||||
backup: backup,
|
||||
reason: :exit,
|
||||
details: reason
|
||||
}}
|
||||
else
|
||||
{:ok, backup}
|
||||
end
|
||||
after
|
||||
wait_time ->
|
||||
Logger.error(
|
||||
"Backup #{backup.id} timed out after no response for #{wait_time}ms, terminating"
|
||||
)
|
||||
|
||||
Task.Supervisor.terminate_child(Pleroma.TaskSupervisor, task.pid)
|
||||
|
||||
{:ok, backup} = set_state(backup, :failed)
|
||||
|
||||
cleanup(backup)
|
||||
|
||||
{:error,
|
||||
%{
|
||||
backup: backup,
|
||||
reason: :timeout
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
@files [
|
||||
~c"actor.json",
|
||||
~c"outbox.json",
|
||||
~c"likes.json",
|
||||
~c"bookmarks.json",
|
||||
~c"followers.json",
|
||||
~c"following.json"
|
||||
"actor.json",
|
||||
"outbox.json",
|
||||
"likes.json",
|
||||
"bookmarks.json",
|
||||
"followers.json",
|
||||
"following.json"
|
||||
]
|
||||
@spec export(Pleroma.User.Backup.t(), pid()) :: {:ok, String.t()} | :error
|
||||
def export(%__MODULE__{} = backup, caller_pid) do
|
||||
backup = Repo.preload(backup, :user)
|
||||
dir = backup_tempdir(backup)
|
||||
|
||||
with :ok <- File.mkdir(dir),
|
||||
:ok <- actor(dir, backup.user, caller_pid),
|
||||
:ok <- statuses(dir, backup.user, caller_pid),
|
||||
:ok <- likes(dir, backup.user, caller_pid),
|
||||
:ok <- bookmarks(dir, backup.user, caller_pid),
|
||||
:ok <- followers(dir, backup.user, caller_pid),
|
||||
:ok <- following(dir, backup.user, caller_pid),
|
||||
{:ok, zip_path} <- :zip.create(backup.file_name, @files, cwd: dir),
|
||||
{:ok, _} <- File.rm_rf(dir) do
|
||||
{:ok, zip_path}
|
||||
@spec run(t()) :: {:ok, t()} | {:error, :failed}
|
||||
def run(%__MODULE__{} = backup) do
|
||||
backup = Repo.preload(backup, :user)
|
||||
tempfile = Path.join([backup.tempdir, backup.file_name])
|
||||
|
||||
with {_, :ok} <- {:mkdir, Pleroma.Backports.mkdir_p(backup.tempdir)},
|
||||
{_, :ok} <- {:actor, actor(backup.tempdir, backup.user)},
|
||||
{_, :ok} <- {:statuses, statuses(backup.tempdir, backup.user)},
|
||||
{_, :ok} <- {:likes, likes(backup.tempdir, backup.user)},
|
||||
{_, :ok} <- {:bookmarks, bookmarks(backup.tempdir, backup.user)},
|
||||
{_, :ok} <- {:followers, followers(backup.tempdir, backup.user)},
|
||||
{_, :ok} <- {:following, following(backup.tempdir, backup.user)},
|
||||
{_, {:ok, _zip_path}} <-
|
||||
{:zip, SafeZip.zip(tempfile, @files, backup.tempdir)},
|
||||
{_, {:ok, %File.Stat{size: zip_size}}} <- {:filestat, File.stat(tempfile)},
|
||||
{:ok, updated_backup} <- update_record(backup, %{file_size: zip_size}) do
|
||||
{:ok, updated_backup}
|
||||
else
|
||||
_ -> :error
|
||||
_ ->
|
||||
File.rm_rf(backup.tempdir)
|
||||
{:error, :failed}
|
||||
end
|
||||
end
|
||||
|
||||
def dir(name) do
|
||||
dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!()
|
||||
Path.join(dir, name)
|
||||
defp tempdir do
|
||||
rand = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
|
||||
subdir = "backup-#{rand}"
|
||||
|
||||
case Config.get([__MODULE__, :tempdir]) do
|
||||
nil ->
|
||||
Path.join([System.tmp_dir!(), subdir])
|
||||
|
||||
path ->
|
||||
Path.join([path, subdir])
|
||||
end
|
||||
end
|
||||
|
||||
def upload(%__MODULE__{} = backup, zip_path) do
|
||||
uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
|
||||
@doc "Uploads the completed backup and marks it as processed"
|
||||
@spec upload(t()) :: {:ok, t()}
|
||||
def upload(%__MODULE__{tempdir: tempdir} = backup) when is_binary(tempdir) do
|
||||
uploader = Config.get([Pleroma.Upload, :uploader])
|
||||
|
||||
upload = %Pleroma.Upload{
|
||||
name: backup.file_name,
|
||||
tempfile: zip_path,
|
||||
tempfile: Path.join([tempdir, backup.file_name]),
|
||||
content_type: backup.content_type,
|
||||
path: Path.join("backups", backup.file_name)
|
||||
}
|
||||
|
||||
with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
|
||||
:ok <- File.rm(zip_path) do
|
||||
{:ok, upload}
|
||||
with {:ok, _} <- Uploader.put_file(uploader, upload),
|
||||
{:ok, uploaded_backup} <- update_record(backup, %{processed: true}),
|
||||
{:ok, _} <- File.rm_rf(tempdir) do
|
||||
{:ok, uploaded_backup}
|
||||
end
|
||||
end
|
||||
|
||||
defp actor(dir, user, caller_pid) do
|
||||
defp actor(dir, user) do
|
||||
with {:ok, json} <-
|
||||
UserView.render("user.json", %{user: user})
|
||||
|> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
|
||||
|> Map.merge(%{
|
||||
"bookmarks" => "bookmarks.json",
|
||||
"likes" => "likes.json",
|
||||
"outbox" => "outbox.json",
|
||||
"followers" => "followers.json",
|
||||
"following" => "following.json"
|
||||
})
|
||||
|> Jason.encode() do
|
||||
send(caller_pid, {:progress, 1})
|
||||
File.write(Path.join(dir, "actor.json"), json)
|
||||
end
|
||||
end
|
||||
|
|
@ -269,22 +273,10 @@ defmodule Pleroma.User.Backup do
|
|||
)
|
||||
end
|
||||
|
||||
defp should_report?(num, chunk_size), do: rem(num, chunk_size) == 0
|
||||
|
||||
defp backup_tempdir(backup) do
|
||||
name = String.trim_trailing(backup.file_name, ".zip")
|
||||
dir(name)
|
||||
end
|
||||
|
||||
defp cleanup(backup) do
|
||||
dir = backup_tempdir(backup)
|
||||
File.rm_rf(dir)
|
||||
end
|
||||
|
||||
defp write(query, dir, name, fun, caller_pid) do
|
||||
defp write(query, dir, name, fun) do
|
||||
path = Path.join(dir, "#{name}.json")
|
||||
|
||||
chunk_size = Pleroma.Config.get([__MODULE__, :process_chunk_size])
|
||||
chunk_size = Config.get([__MODULE__, :process_chunk_size])
|
||||
|
||||
with {:ok, file} <- File.open(path, [:write, :utf8]),
|
||||
:ok <- write_header(file, name) do
|
||||
|
|
@ -300,10 +292,6 @@ defmodule Pleroma.User.Backup do
|
|||
end),
|
||||
{:ok, str} <- Jason.encode(data),
|
||||
:ok <- IO.write(file, str <> ",\n") do
|
||||
if should_report?(acc + 1, chunk_size) do
|
||||
send(caller_pid, {:progress, chunk_size})
|
||||
end
|
||||
|
||||
acc + 1
|
||||
else
|
||||
{:error, e} ->
|
||||
|
|
@ -312,37 +300,32 @@ defmodule Pleroma.User.Backup do
|
|||
)
|
||||
|
||||
acc
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
send(caller_pid, {:progress, rem(total, chunk_size)})
|
||||
|
||||
with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
|
||||
File.close(file)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp bookmarks(dir, %{id: user_id} = _user, caller_pid) do
|
||||
defp bookmarks(dir, %{id: user_id} = _user) do
|
||||
Bookmark
|
||||
|> where(user_id: ^user_id)
|
||||
|> join(:inner, [b], activity in assoc(b, :activity))
|
||||
|> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
|
||||
|> write(dir, "bookmarks", fn a -> {:ok, a.object} end, caller_pid)
|
||||
|> write(dir, "bookmarks", fn a -> {:ok, a.object} end)
|
||||
end
|
||||
|
||||
defp likes(dir, user, caller_pid) do
|
||||
defp likes(dir, user) do
|
||||
user.ap_id
|
||||
|> Activity.Queries.by_actor()
|
||||
|> Activity.Queries.by_type("Like")
|
||||
|> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
|
||||
|> write(dir, "likes", fn a -> {:ok, a.object} end, caller_pid)
|
||||
|> write(dir, "likes", fn a -> {:ok, a.object} end)
|
||||
end
|
||||
|
||||
defp statuses(dir, user, caller_pid) do
|
||||
defp statuses(dir, user) do
|
||||
opts =
|
||||
%{}
|
||||
|> Map.put(:type, ["Create", "Announce"])
|
||||
|
|
@ -359,55 +342,20 @@ defmodule Pleroma.User.Backup do
|
|||
dir,
|
||||
"outbox",
|
||||
fn a ->
|
||||
with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
|
||||
with {:ok, activity} <- Transmogrifier.prepare_activity(a.data) do
|
||||
{:ok, Map.delete(activity, "@context")}
|
||||
end
|
||||
end,
|
||||
caller_pid
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp followers(dir, user, caller_pid) do
|
||||
defp followers(dir, user) do
|
||||
User.get_followers_query(user)
|
||||
|> write(dir, "followers", fn a -> {:ok, a.ap_id} end, caller_pid)
|
||||
|> write(dir, "followers", fn a -> {:ok, a.ap_id} end)
|
||||
end
|
||||
|
||||
defp following(dir, user, caller_pid) do
|
||||
defp following(dir, user) do
|
||||
User.get_friends_query(user)
|
||||
|> write(dir, "following", fn a -> {:ok, a.ap_id} end, caller_pid)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Pleroma.User.Backup.ProcessorAPI do
|
||||
@callback do_process(%Pleroma.User.Backup{}, pid()) ::
|
||||
{:ok, %Pleroma.User.Backup{}} | {:error, any()}
|
||||
end
|
||||
|
||||
defmodule Pleroma.User.Backup.Processor do
|
||||
@behaviour Pleroma.User.Backup.ProcessorAPI
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User.Backup
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@impl true
|
||||
def do_process(backup, current_pid) do
|
||||
with {:ok, zip_file} <- Backup.export(backup, current_pid),
|
||||
{:ok, %{size: size}} <- File.stat(zip_file),
|
||||
{:ok, _upload} <- Backup.upload(backup, zip_file) do
|
||||
backup
|
||||
|> cast(
|
||||
%{
|
||||
file_size: size,
|
||||
processed: true,
|
||||
state: :complete
|
||||
},
|
||||
[:file_size, :processed, :state]
|
||||
)
|
||||
|> Repo.update()
|
||||
else
|
||||
e -> {:error, e}
|
||||
end
|
||||
|> write(dir, "following", fn a -> {:ok, a.ap_id} end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
55
lib/pleroma/user/hashtag_follow.ex
Normal file
55
lib/pleroma/user/hashtag_follow.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Pleroma.User.HashtagFollow do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Pleroma.Hashtag
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
|
||||
schema "user_follows_hashtag" do
|
||||
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
|
||||
belongs_to(:hashtag, Hashtag)
|
||||
end
|
||||
|
||||
def changeset(%__MODULE__{} = user_hashtag_follow, attrs) do
|
||||
user_hashtag_follow
|
||||
|> cast(attrs, [:user_id, :hashtag_id])
|
||||
|> unique_constraint(:hashtag_id,
|
||||
name: :user_hashtag_follows_user_id_hashtag_id_index,
|
||||
message: "already following"
|
||||
)
|
||||
|> validate_required([:user_id, :hashtag_id])
|
||||
end
|
||||
|
||||
def new(%User{} = user, %Hashtag{} = hashtag) do
|
||||
%__MODULE__{}
|
||||
|> changeset(%{user_id: user.id, hashtag_id: hashtag.id})
|
||||
|> Repo.insert(on_conflict: :nothing)
|
||||
end
|
||||
|
||||
def delete(%User{} = user, %Hashtag{} = hashtag) do
|
||||
with %__MODULE__{} = user_hashtag_follow <- get(user, hashtag) do
|
||||
Repo.delete(user_hashtag_follow)
|
||||
else
|
||||
_ -> {:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
def get(%User{} = user, %Hashtag{} = hashtag) do
|
||||
from(hf in __MODULE__)
|
||||
|> where([hf], hf.user_id == ^user.id and hf.hashtag_id == ^hashtag.id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_by_user(%User{} = user) do
|
||||
user
|
||||
|> followed_hashtags_query()
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def followed_hashtags_query(%User{} = user) do
|
||||
Ecto.assoc(user, :followed_hashtags)
|
||||
|> Ecto.Query.order_by([h], desc: h.id)
|
||||
end
|
||||
end
|
||||
|
|
@ -5,81 +5,107 @@
|
|||
defmodule Pleroma.User.Import do
|
||||
use Ecto.Schema
|
||||
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.Web.CommonAPI
|
||||
alias Pleroma.Workers.BackgroundWorker
|
||||
|
||||
require Logger
|
||||
|
||||
@spec perform(atom(), User.t(), list()) :: :ok | list() | {:error, any()}
|
||||
def perform(:mutes_import, %User{} = user, [_ | _] = identifiers) do
|
||||
Enum.map(
|
||||
identifiers,
|
||||
fn identifier ->
|
||||
with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier),
|
||||
{:ok, _} <- User.mute(user, muted_user) do
|
||||
muted_user
|
||||
else
|
||||
error -> handle_error(:mutes_import, identifier, error)
|
||||
end
|
||||
end
|
||||
)
|
||||
@spec perform(atom(), User.t(), String.t()) :: :ok | {:error, any()}
|
||||
def perform(:mute_import, %User{} = user, actor) do
|
||||
with {:ok, %User{} = muted_user} <- User.get_or_fetch(actor),
|
||||
{_, false} <- {:existing_mute, User.mutes_user?(user, muted_user)},
|
||||
{:ok, _} <- User.mute(user, muted_user) do
|
||||
{:ok, muted_user}
|
||||
else
|
||||
{:existing_mute, true} -> :ok
|
||||
error -> handle_error(:mutes_import, actor, error)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(:blocks_import, %User{} = blocker, [_ | _] = identifiers) do
|
||||
Enum.map(
|
||||
identifiers,
|
||||
fn identifier ->
|
||||
with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier),
|
||||
{:ok, _block} <- CommonAPI.block(blocker, blocked) do
|
||||
blocked
|
||||
else
|
||||
error -> handle_error(:blocks_import, identifier, error)
|
||||
end
|
||||
end
|
||||
)
|
||||
def perform(:block_import, %User{} = user, actor) do
|
||||
with {:ok, %User{} = blocked} <- User.get_or_fetch(actor),
|
||||
{_, false} <- {:existing_block, User.blocks_user?(user, blocked)},
|
||||
{:ok, _block} <- CommonAPI.block(blocked, user) do
|
||||
{:ok, blocked}
|
||||
else
|
||||
{:existing_block, true} -> :ok
|
||||
error -> handle_error(:blocks_import, actor, error)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(:follow_import, %User{} = follower, [_ | _] = identifiers) do
|
||||
Enum.map(
|
||||
identifiers,
|
||||
fn identifier ->
|
||||
with {:ok, %User{} = followed} <- User.get_or_fetch(identifier),
|
||||
{:ok, follower, followed} <- User.maybe_direct_follow(follower, followed),
|
||||
{:ok, _, _, _} <- CommonAPI.follow(follower, followed) do
|
||||
followed
|
||||
else
|
||||
error -> handle_error(:follow_import, identifier, error)
|
||||
end
|
||||
end
|
||||
)
|
||||
def perform(:follow_import, %User{} = user, actor) do
|
||||
with {:ok, %User{} = followed} <- User.get_or_fetch(actor),
|
||||
{_, false} <- {:existing_follow, User.following?(user, followed)},
|
||||
{:ok, user, followed} <- User.maybe_direct_follow(user, followed),
|
||||
{:ok, _, _, _} <- CommonAPI.follow(followed, user) do
|
||||
{:ok, followed}
|
||||
else
|
||||
{:existing_follow, true} -> :ok
|
||||
error -> handle_error(:follow_import, actor, error)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(_, _, _), do: :ok
|
||||
|
||||
defp handle_error(op, user_id, error) do
|
||||
Logger.debug("#{op} failed for #{user_id} with: #{inspect(error)}")
|
||||
error
|
||||
{:error, error}
|
||||
end
|
||||
|
||||
def blocks_import(%User{} = blocker, [_ | _] = identifiers) do
|
||||
BackgroundWorker.enqueue(
|
||||
"blocks_import",
|
||||
%{"user_id" => blocker.id, "identifiers" => identifiers}
|
||||
)
|
||||
def blocks_import(%User{} = user, [_ | _] = actors) do
|
||||
jobs =
|
||||
Repo.checkout(fn ->
|
||||
Enum.reduce(actors, [], fn actor, acc ->
|
||||
{:ok, job} =
|
||||
BackgroundWorker.new(%{
|
||||
"op" => "block_import",
|
||||
"user_id" => user.id,
|
||||
"actor" => actor
|
||||
})
|
||||
|> Oban.insert()
|
||||
|
||||
acc ++ [job]
|
||||
end)
|
||||
end)
|
||||
|
||||
{:ok, jobs}
|
||||
end
|
||||
|
||||
def follow_import(%User{} = follower, [_ | _] = identifiers) do
|
||||
BackgroundWorker.enqueue(
|
||||
"follow_import",
|
||||
%{"user_id" => follower.id, "identifiers" => identifiers}
|
||||
)
|
||||
def follows_import(%User{} = user, [_ | _] = actors) do
|
||||
jobs =
|
||||
Repo.checkout(fn ->
|
||||
Enum.reduce(actors, [], fn actor, acc ->
|
||||
{:ok, job} =
|
||||
BackgroundWorker.new(%{
|
||||
"op" => "follow_import",
|
||||
"user_id" => user.id,
|
||||
"actor" => actor
|
||||
})
|
||||
|> Oban.insert()
|
||||
|
||||
acc ++ [job]
|
||||
end)
|
||||
end)
|
||||
|
||||
{:ok, jobs}
|
||||
end
|
||||
|
||||
def mutes_import(%User{} = user, [_ | _] = identifiers) do
|
||||
BackgroundWorker.enqueue(
|
||||
"mutes_import",
|
||||
%{"user_id" => user.id, "identifiers" => identifiers}
|
||||
)
|
||||
def mutes_import(%User{} = user, [_ | _] = actors) do
|
||||
jobs =
|
||||
Repo.checkout(fn ->
|
||||
Enum.reduce(actors, [], fn actor, acc ->
|
||||
{:ok, job} =
|
||||
BackgroundWorker.new(%{
|
||||
"op" => "mute_import",
|
||||
"user_id" => user.id,
|
||||
"actor" => actor
|
||||
})
|
||||
|> Oban.insert()
|
||||
|
||||
acc ++ [job]
|
||||
end)
|
||||
end)
|
||||
|
||||
{:ok, jobs}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ defmodule Pleroma.User.Search do
|
|||
following = Keyword.get(opts, :following, false)
|
||||
result_limit = Keyword.get(opts, :limit, @limit)
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
capabilities = Keyword.get(opts, :capabilities, [])
|
||||
|
||||
for_user = Keyword.get(opts, :for_user)
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ defmodule Pleroma.User.Search do
|
|||
|
||||
results =
|
||||
query_string
|
||||
|> search_query(for_user, following, top_user_ids)
|
||||
|> search_query(for_user, following, top_user_ids, capabilities)
|
||||
|> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset)
|
||||
|
||||
results
|
||||
|
|
@ -80,7 +81,7 @@ defmodule Pleroma.User.Search do
|
|||
end
|
||||
end
|
||||
|
||||
defp search_query(query_string, for_user, following, top_user_ids) do
|
||||
defp search_query(query_string, for_user, following, top_user_ids, capabilities) do
|
||||
for_user
|
||||
|> base_query(following)
|
||||
|> filter_blocked_user(for_user)
|
||||
|
|
@ -94,6 +95,7 @@ defmodule Pleroma.User.Search do
|
|||
|> subquery()
|
||||
|> order_by(desc: :search_rank)
|
||||
|> maybe_restrict_local(for_user)
|
||||
|> maybe_restrict_accepting_chat_messages(capabilities)
|
||||
|> filter_deactivated_users()
|
||||
end
|
||||
|
||||
|
|
@ -214,6 +216,14 @@ defmodule Pleroma.User.Search do
|
|||
end
|
||||
end
|
||||
|
||||
defp maybe_restrict_accepting_chat_messages(query, capabilities) do
|
||||
if "accepts_chat_messages" in capabilities do
|
||||
from(q in query, where: q.accepts_chat_messages == true)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated)
|
||||
|
||||
defp restrict_local(q), do: where(q, [u], u.local == true)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ defmodule Pleroma.UserRelationship do
|
|||
do: exists?(unquote(relationship_type), source, target)
|
||||
|
||||
# `def get_block_expire_date/2`, `def get_mute_expire_date/2`,
|
||||
# `def get_reblog_mute_expire_date/2`, `def get_notification_mute_exists?/2`,
|
||||
# `def get_reblog_mute_expire_date/2`, `def get_notification_mute_expire_date/2`,
|
||||
# `def get_inverse_subscription_expire_date/2`, `def get_inverse_endorsement_expire_date/2`
|
||||
def unquote(:"get_#{relationship_type}_expire_date")(source, target),
|
||||
do: get_expire_date(unquote(relationship_type), source, target)
|
||||
|
|
@ -55,9 +55,13 @@ defmodule Pleroma.UserRelationship do
|
|||
|
||||
def user_relationship_mappings, do: Pleroma.UserRelationship.Type.__enum_map__()
|
||||
|
||||
def datetime_impl do
|
||||
Application.get_env(:pleroma, :datetime_impl, Pleroma.DateTime.Impl)
|
||||
end
|
||||
|
||||
def changeset(%UserRelationship{} = user_relationship, params \\ %{}) do
|
||||
user_relationship
|
||||
|> cast(params, [:relationship_type, :source_id, :target_id, :expires_at])
|
||||
|> cast(params, [:relationship_type, :source_id, :target_id, :expires_at, :inserted_at])
|
||||
|> validate_required([:relationship_type, :source_id, :target_id])
|
||||
|> unique_constraint(:relationship_type,
|
||||
name: :user_relationships_source_id_relationship_type_target_id_index
|
||||
|
|
@ -65,6 +69,7 @@ defmodule Pleroma.UserRelationship do
|
|||
|> validate_not_self_relationship()
|
||||
end
|
||||
|
||||
@spec exists?(any(), Pleroma.User.t(), Pleroma.User.t()) :: boolean()
|
||||
def exists?(relationship_type, %User{} = source, %User{} = target) do
|
||||
UserRelationship
|
||||
|> where(relationship_type: ^relationship_type, source_id: ^source.id, target_id: ^target.id)
|
||||
|
|
@ -90,7 +95,8 @@ defmodule Pleroma.UserRelationship do
|
|||
relationship_type: relationship_type,
|
||||
source_id: source.id,
|
||||
target_id: target.id,
|
||||
expires_at: expires_at
|
||||
expires_at: expires_at,
|
||||
inserted_at: datetime_impl().utc_now()
|
||||
})
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace_all_except, [:id, :inserted_at]},
|
||||
|
|
@ -187,7 +193,8 @@ defmodule Pleroma.UserRelationship do
|
|||
{[:mute], []}
|
||||
|
||||
nil ->
|
||||
{[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]}
|
||||
{[:block, :mute, :notification_mute, :reblog_mute, :endorsement],
|
||||
[:block, :inverse_subscription]}
|
||||
|
||||
unknown ->
|
||||
raise "Unsupported :subset option value: #{inspect(unknown)}"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule Pleroma.Utils do
|
|||
dir
|
||||
|> File.ls!()
|
||||
|> Enum.map(&Path.join(dir, &1))
|
||||
|> Kernel.ParallelCompiler.compile()
|
||||
|> Kernel.ParallelCompiler.compile(return_diagnostics: true)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
142
lib/pleroma/utils/uri_encoding.ex
Normal file
142
lib/pleroma/utils/uri_encoding.ex
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2025 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Utils.URIEncoding do
|
||||
@moduledoc """
|
||||
Utility functions for dealing with URI encoding of paths and queries
|
||||
with support for query-encoding quirks.
|
||||
"""
|
||||
require Pleroma.Constants
|
||||
|
||||
# We don't always want to decode the path first, like is the case in
|
||||
# Pleroma.Upload.url_from_spec/3.
|
||||
@doc """
|
||||
Wraps URI encoding/decoding functions from Elixir's standard library to fix usually unintended side-effects.
|
||||
|
||||
Supports two URL processing options in the optional 2nd argument with the default being `false`:
|
||||
|
||||
* `bypass_parse` - Bypasses `URI.parse` stage, useful when it's not desirable to parse to URL first
|
||||
before encoding it. Supports only encoding as the Path segment of a URI.
|
||||
* `bypass_decode` - Bypasses `URI.decode` stage for the Path segment of a URI. Used when a URL
|
||||
has to be double %-encoded for internal reasons.
|
||||
|
||||
Options must be specified as a Keyword with tuples with booleans, otherwise
|
||||
`{:error, :invalid_opts}` is returned. Example:
|
||||
`encode_url(url, [bypass_parse: true, bypass_decode: true])`
|
||||
"""
|
||||
@spec encode_url(String.t(), Keyword.t()) :: String.t() | {:error, :invalid_opts}
|
||||
def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do
|
||||
bypass_parse = Keyword.get(opts, :bypass_parse, false)
|
||||
bypass_decode = Keyword.get(opts, :bypass_decode, false)
|
||||
|
||||
with true <- is_boolean(bypass_parse),
|
||||
true <- is_boolean(bypass_decode) do
|
||||
cond do
|
||||
bypass_parse ->
|
||||
encode_path(url, bypass_decode)
|
||||
|
||||
true ->
|
||||
URI.parse(url)
|
||||
|> then(fn parsed ->
|
||||
path = encode_path(parsed.path, bypass_decode)
|
||||
|
||||
query = encode_query(parsed.query, parsed.host)
|
||||
|
||||
%{parsed | path: path, query: query}
|
||||
end)
|
||||
|> URI.to_string()
|
||||
end
|
||||
else
|
||||
_ -> {:error, :invalid_opts}
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_path(nil, _bypass_decode), do: nil
|
||||
|
||||
# URI.encode/2 deliberately does not encode all chars that are forbidden
|
||||
# in the path component of a URI. It only encodes chars that are forbidden
|
||||
# in the whole URI. A predicate in the 2nd argument is used to fix that here.
|
||||
# URI.encode/2 uses the predicate function to determine whether each byte
|
||||
# (in an integer representation) should be encoded or not.
|
||||
defp encode_path(path, bypass_decode) when is_binary(path) do
|
||||
path =
|
||||
cond do
|
||||
bypass_decode ->
|
||||
path
|
||||
|
||||
true ->
|
||||
URI.decode(path)
|
||||
end
|
||||
|
||||
path
|
||||
|> URI.encode(fn byte ->
|
||||
URI.char_unreserved?(byte) ||
|
||||
Enum.any?(
|
||||
Pleroma.Constants.uri_path_allowed_reserved_chars(),
|
||||
fn char ->
|
||||
char == byte
|
||||
end
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
# Order of kv pairs in query is not preserved when using URI.decode_query.
|
||||
# URI.query_decoder/2 returns a stream which so far appears to not change order.
|
||||
# Immediately switch to a list to prevent breakage for sites that expect
|
||||
# the order of query keys to be always the same.
|
||||
defp encode_query(query, host) when is_binary(query) do
|
||||
query
|
||||
|> URI.query_decoder()
|
||||
|> Enum.to_list()
|
||||
|> do_encode_query(host)
|
||||
end
|
||||
|
||||
defp encode_query(nil, _), do: nil
|
||||
|
||||
# Always uses www_form encoding.
|
||||
# Taken from Elixir's URI module.
|
||||
defp do_encode_query(enumerable, host) do
|
||||
Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1, host))
|
||||
end
|
||||
|
||||
# https://git.pleroma.social/pleroma/pleroma/-/issues/1055
|
||||
defp maybe_apply_query_quirk({key, value}, "i.guim.co.uk" = _host) do
|
||||
case key do
|
||||
"precrop" ->
|
||||
query_encode_kv_pair({key, value}, ~c":,")
|
||||
|
||||
key ->
|
||||
query_encode_kv_pair({key, value})
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_apply_query_quirk({key, value}, _), do: query_encode_kv_pair({key, value})
|
||||
|
||||
# Taken from Elixir's URI module and modified to support quirks.
|
||||
defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do
|
||||
cond do
|
||||
length(rules) > 0 ->
|
||||
# URI.encode_query/2 does not appear to follow spec and encodes all parts
|
||||
# of our URI path Constant. This appears to work outside of edge-cases
|
||||
# like The Guardian Rich Media Cards, keeping behavior same as with
|
||||
# URI.encode_query/2 unless otherwise specified via rules.
|
||||
(URI.encode_www_form(Kernel.to_string(key)) <>
|
||||
"=" <>
|
||||
URI.encode(value, fn byte ->
|
||||
URI.char_unreserved?(byte) ||
|
||||
Enum.any?(
|
||||
rules,
|
||||
fn char ->
|
||||
char == byte
|
||||
end
|
||||
)
|
||||
end))
|
||||
|> String.replace("%20", "+")
|
||||
|
||||
true ->
|
||||
URI.encode_www_form(Kernel.to_string(key)) <>
|
||||
"=" <> URI.encode_www_form(Kernel.to_string(value))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -163,7 +163,7 @@ defmodule Pleroma.Web do
|
|||
"""
|
||||
def safe_render_many(collection, view, template, assigns \\ %{}) do
|
||||
Enum.map(collection, fn resource ->
|
||||
as = Map.get(assigns, :as) || view.__resource__
|
||||
as = Map.get(assigns, :as) || view.__resource__()
|
||||
assigns = Map.put(assigns, as, resource)
|
||||
safe_render(view, template, assigns)
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -222,10 +222,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
%{data: %{"expires_at" => %DateTime{} = expires_at}} = activity
|
||||
) do
|
||||
with {:ok, _job} <-
|
||||
Pleroma.Workers.PurgeExpiredActivity.enqueue(%{
|
||||
activity_id: activity.id,
|
||||
expires_at: expires_at
|
||||
}) do
|
||||
Pleroma.Workers.PurgeExpiredActivity.enqueue(
|
||||
%{
|
||||
activity_id: activity.id
|
||||
},
|
||||
scheduled_at: expires_at
|
||||
) do
|
||||
{:ok, activity}
|
||||
end
|
||||
end
|
||||
|
|
@ -412,10 +414,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|
||||
with flag_data <- make_flag_data(params, additional),
|
||||
{:ok, activity} <- insert(flag_data, local),
|
||||
{:ok, stripped_activity} <- strip_report_status_data(activity),
|
||||
_ <- notify_and_stream(activity),
|
||||
:ok <-
|
||||
maybe_federate(stripped_activity) do
|
||||
:ok <- maybe_federate(activity) do
|
||||
User.all_users_with_privilege(:reports_manage_reports)
|
||||
|> Enum.filter(fn user -> user.ap_id != actor end)
|
||||
|> Enum.filter(fn user -> not is_nil(user.email) end)
|
||||
|
|
@ -446,10 +446,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
_ <- notify_and_stream(activity) do
|
||||
maybe_federate(activity)
|
||||
|
||||
BackgroundWorker.enqueue("move_following", %{
|
||||
BackgroundWorker.new(%{
|
||||
"op" => "move_following",
|
||||
"origin_id" => origin.id,
|
||||
"target_id" => target.id
|
||||
})
|
||||
|> Oban.insert()
|
||||
|
||||
{:ok, activity}
|
||||
else
|
||||
|
|
@ -497,6 +499,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
def fetch_objects_for_replies_collection(parent_ap_id, opts \\ %{}) do
|
||||
opts =
|
||||
opts
|
||||
|> Map.put(:order_asc, true)
|
||||
|> Map.put(:id_type, :integer)
|
||||
|
||||
from(o in Object,
|
||||
where:
|
||||
fragment("?->>'inReplyTo' = ?", o.data, ^parent_ap_id) and
|
||||
fragment(
|
||||
"(?->'to' \\? ?::text OR ?->'cc' \\? ?::text)",
|
||||
o.data,
|
||||
^Pleroma.Constants.as_public(),
|
||||
o.data,
|
||||
^Pleroma.Constants.as_public()
|
||||
) and
|
||||
fragment("?->>'type' <> 'Answer'", o.data),
|
||||
select: %{id: o.id, ap_id: fragment("?->>'id'", o.data)}
|
||||
)
|
||||
|> Pagination.fetch_paginated(opts, :keyset)
|
||||
end
|
||||
|
||||
@spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) ::
|
||||
Ecto.UUID.t() | nil
|
||||
def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do
|
||||
|
|
@ -920,6 +944,31 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
)
|
||||
end
|
||||
|
||||
# Essentially, either look for activities addressed to `recipients`, _OR_ ones
|
||||
# that reference a hashtag that the user follows
|
||||
# Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't
|
||||
# follow any
|
||||
defp restrict_recipients_or_hashtags(query, recipients, user, nil) do
|
||||
restrict_recipients(query, recipients, user)
|
||||
end
|
||||
|
||||
defp restrict_recipients_or_hashtags(query, recipients, user, []) do
|
||||
restrict_recipients(query, recipients, user)
|
||||
end
|
||||
|
||||
defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do
|
||||
from([activity, object] in query)
|
||||
|> join(:left, [activity, object], hto in "hashtags_objects",
|
||||
on: hto.object_id == object.id,
|
||||
as: :hto
|
||||
)
|
||||
|> where(
|
||||
[activity, object, hto: hto],
|
||||
(hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or
|
||||
fragment("? && ?", ^recipients, activity.recipients)
|
||||
)
|
||||
end
|
||||
|
||||
defp restrict_local(query, %{local_only: true}) do
|
||||
from(activity in query, where: activity.local == true)
|
||||
end
|
||||
|
|
@ -954,6 +1003,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|
||||
defp restrict_state(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)
|
||||
)
|
||||
end
|
||||
|
||||
defp restrict_assigned_account(query, _), do: query
|
||||
|
||||
defp restrict_favorited_by(query, %{favorited_by: ap_id}) do
|
||||
from(
|
||||
[_activity, object] in query,
|
||||
|
|
@ -1036,6 +1093,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data))
|
||||
end
|
||||
|
||||
defp restrict_reblogs(query, %{only_reblogs: true}) do
|
||||
from(activity in query, where: fragment("?->>'type' = 'Announce'", activity.data))
|
||||
end
|
||||
|
||||
defp restrict_reblogs(query, _), do: query
|
||||
|
||||
defp restrict_muted(query, %{with_muted: true}), do: query
|
||||
|
|
@ -1410,7 +1471,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|> maybe_preload_report_notes(opts)
|
||||
|> maybe_set_thread_muted_field(opts)
|
||||
|> maybe_order(opts)
|
||||
|> restrict_recipients(recipients, opts[:user])
|
||||
|> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
|
||||
|> restrict_replies(opts)
|
||||
|> restrict_since(opts)
|
||||
|> restrict_local(opts)
|
||||
|
|
@ -1418,6 +1479,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|> restrict_actor(opts)
|
||||
|> restrict_type(opts)
|
||||
|> restrict_state(opts)
|
||||
|> restrict_assigned_account(opts)
|
||||
|> restrict_favorited_by(opts)
|
||||
|> restrict_blocked(restrict_blocked_opts)
|
||||
|> restrict_blockers_visibility(opts)
|
||||
|
|
@ -1538,16 +1600,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
|
||||
defp get_actor_url(_url), do: nil
|
||||
|
||||
defp normalize_image(%{"url" => url}) do
|
||||
defp normalize_image(%{"url" => url} = data) when is_binary(url) do
|
||||
%{
|
||||
"type" => "Image",
|
||||
"url" => [%{"href" => url}]
|
||||
}
|
||||
|> maybe_put_description(data)
|
||||
end
|
||||
|
||||
defp normalize_image(%{"url" => urls}) when is_list(urls) do
|
||||
url = urls |> List.first()
|
||||
|
||||
%{"url" => url}
|
||||
|> normalize_image()
|
||||
end
|
||||
|
||||
defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image()
|
||||
defp normalize_image(_), do: nil
|
||||
|
||||
defp normalize_also_known_as(urls) when is_list(urls), do: urls
|
||||
defp normalize_also_known_as(url) when is_binary(url), do: [url]
|
||||
defp normalize_also_known_as(nil), do: []
|
||||
|
||||
defp maybe_put_description(map, %{"name" => description}) when is_binary(description) do
|
||||
Map.put(map, "name", description)
|
||||
end
|
||||
|
||||
defp maybe_put_description(map, _), do: map
|
||||
|
||||
defp object_to_user_data(data, additional) do
|
||||
fields =
|
||||
data
|
||||
|
|
@ -1617,7 +1697,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
featured_address: featured_address,
|
||||
bio: data["summary"] || "",
|
||||
actor_type: actor_type,
|
||||
also_known_as: Map.get(data, "alsoKnownAs", []),
|
||||
also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
|
||||
public_key: public_key,
|
||||
inbox: data["inbox"],
|
||||
shared_inbox: shared_inbox,
|
||||
|
|
@ -1661,7 +1741,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
}}
|
||||
else
|
||||
{:error, _} = e -> e
|
||||
e -> {:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1798,10 +1877,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|
|||
# enqueue a task to fetch all pinned objects
|
||||
Enum.each(pins, fn {ap_id, _} ->
|
||||
if is_nil(Object.get_cached_by_ap_id(ap_id)) do
|
||||
Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{
|
||||
Pleroma.Workers.RemoteFetcherWorker.new(%{
|
||||
"op" => "fetch_remote",
|
||||
"id" => ap_id,
|
||||
"depth" => 1
|
||||
})
|
||||
|> Oban.insert()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue