Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85824349
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
59 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/backups-refactor.change b/changelog.d/backups-refactor.change
new file mode 100644
index 000000000..47fc74138
--- /dev/null
+++ b/changelog.d/backups-refactor.change
@@ -0,0 +1 @@
+Refactor the user backups code and improve test coverage
diff --git a/config/test.exs b/config/test.exs
index 410b9bbab..2a007a22d 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,196 +1,198 @@
import Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :pleroma, Pleroma.Web.Endpoint,
http: [port: 4001],
url: [port: 4001],
server: true
# Disable captha for tests
config :pleroma, Pleroma.Captcha,
# It should not be enabled for automatic tests
enabled: false,
# A fake captcha service for tests
method: Pleroma.Captcha.Mock
# Print only warnings and errors during test
config :logger, :console,
level: :warning,
format: "\n[$level] $message\n"
config :pleroma, :auth, oauth_consumer_strategies: []
config :pleroma, Pleroma.Upload,
filters: [],
link_name: false,
default_description: :filename
config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads"
config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Test, enabled: true
config :pleroma, :instance,
email: "admin@example.com",
notify_email: "noreply@example.com",
skip_thread_containment: false,
federating: false,
external_user_synchronization: false,
static_dir: "test/instance_static/"
config :pleroma, :activitypub, sign_object_fetches: false, follow_handshake_timeout: 0
# Configure your database
config :pleroma, Pleroma.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "pleroma_test",
hostname: System.get_env("DB_HOST") || "localhost",
port: System.get_env("DB_PORT") || "5432",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2,
log: false
config :pleroma, :dangerzone, override_repo_pool_size: true
# Reduce hash rounds for testing
config :pleroma, :password, iterations: 1
config :tesla, adapter: Tesla.Mock
config :pleroma, :rich_media,
enabled: false,
ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"],
max_body: 2_000_000
config :pleroma, :instance,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com",
public_key:
"BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4",
private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA"
config :pleroma, Oban, testing: :manual
config :pleroma, Pleroma.ScheduledActivity,
daily_user_limit: 2,
total_user_limit: 3,
enabled: false
config :pleroma, :rate_limit, %{}
config :pleroma, :http_security, report_uri: "https://endpoint.com"
config :pleroma, :http, send_user_agent: false
rum_enabled = System.get_env("RUM_ENABLED") == "true"
config :pleroma, :database, rum_enabled: rum_enabled
IO.puts("RUM enabled: #{rum_enabled}")
config :joken, default_signer: "yU8uHKq+yyAkZ11Hx//jcdacWc8yQ1bxAAGrplzB0Zwwjkp35v0RK9SO8WTPr6QZ"
config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock
config :pleroma, :modules, runtime_dir: "test/fixtures/modules"
config :pleroma, Pleroma.Gun, Pleroma.GunMock
config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true
config :pleroma, Pleroma.Web.Plugs.RemoteIp, enabled: false
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
config :tzdata, :autoupdate, :disabled
config :pleroma, :mrf, policies: []
config :pleroma, :pipeline,
object_validator: Pleroma.Web.ActivityPub.ObjectValidatorMock,
mrf: Pleroma.Web.ActivityPub.MRFMock,
activity_pub: Pleroma.Web.ActivityPub.ActivityPubMock,
side_effects: Pleroma.Web.ActivityPub.SideEffectsMock,
federator: Pleroma.Web.FederatorMock,
config: Pleroma.ConfigMock
config :pleroma, :cachex, provider: Pleroma.CachexMock
config :pleroma, Pleroma.Web.WebFinger, update_nickname_on_user_fetch: false
config :pleroma, :side_effects,
ap_streamer: Pleroma.Web.ActivityPub.ActivityPubMock,
logger: Pleroma.LoggerMock
config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch
config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", private_key: nil
# Reduce recompilation time
# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects
config :phoenix, :plug_init_mode, :runtime
config :pleroma, :config_impl, Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.PromEx, disabled: true
# Mox definitions. Only read during compile time.
config :pleroma, Pleroma.User.Backup, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Uploaders.S3, ex_aws_impl: Pleroma.Uploaders.S3.ExAwsMock
config :pleroma, Pleroma.Uploaders.S3, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Upload, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.ScheduledActivity, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Web.RichMedia.Helpers, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Uploaders.IPFS, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSecurityPlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSignaturePlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSignaturePlug,
http_signatures_impl: Pleroma.StubbedHTTPSignaturesMock
peer_module =
if String.to_integer(System.otp_release()) >= 25 do
:peer
else
:slave
end
config :pleroma, Pleroma.Cluster, peer_module: peer_module
config :pleroma, Pleroma.Application,
background_migrators: false,
internal_fetch: false,
load_custom_modules: false,
max_restarts: 100,
streamer_registry: false,
test_http_pools: true
config :pleroma, Pleroma.Web.Streaming, sync_streaming: true
config :pleroma, Pleroma.Uploaders.Uploader, timeout: 1_000
config :pleroma, Pleroma.Emoji.Loader, test_emoji: true
config :pleroma, Pleroma.Web.RichMedia.Backfill,
stream_out: Pleroma.Web.ActivityPub.ActivityPubMock
+config :pleroma, Pleroma.User.Backup, tempdir: "test/tmp"
+
if File.exists?("./config/test.secret.exs") do
import_config "test.secret.exs"
else
IO.puts(
"You may want to create test.secret.exs to declare custom database connection parameters."
)
end
diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex
index b346b39d6..a4890b489 100644
--- a/lib/pleroma/ecto_enums.ex
+++ b/lib/pleroma/ecto_enums.ex
@@ -1,37 +1,29 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
import EctoEnum
defenum(Pleroma.UserRelationship.Type,
block: 1,
mute: 2,
reblog_mute: 3,
notification_mute: 4,
inverse_subscription: 5,
suggestion_dismiss: 6,
endorsement: 7
)
defenum(Pleroma.FollowingRelationship.State,
follow_pending: 1,
follow_accept: 2,
follow_reject: 3
)
defenum(Pleroma.DataMigration.State,
pending: 1,
running: 2,
complete: 3,
failed: 4,
manual: 5
)
-
-defenum(Pleroma.User.Backup.State,
- pending: 1,
- running: 2,
- complete: 3,
- failed: 4,
- invalid: 5
-)
diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex
index 95b963764..10d89d2f3 100644
--- a/lib/pleroma/emails/user_email.ex
+++ b/lib/pleroma/emails/user_email.ex
@@ -1,393 +1,378 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Emails.UserEmail do
@moduledoc "User emails"
require Pleroma.Web.Gettext
alias Pleroma.Config
alias Pleroma.User
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Gettext
alias Pleroma.Web.Router
import Swoosh.Email
import Phoenix.Swoosh, except: [render_body: 3]
import Pleroma.Config.Helpers, only: [instance_name: 0, sender: 0]
def render_body(email, template, assigns \\ %{}) do
email
|> put_new_layout({Pleroma.Web.LayoutView, :email})
|> put_new_view(Pleroma.Web.EmailView)
|> Phoenix.Swoosh.render_body(template, assigns)
end
defp recipient(email, nil), do: email
defp recipient(email, name), do: {name, email}
defp recipient(%User{} = user), do: recipient(user.email, user.name)
@spec welcome(User.t(), map()) :: Swoosh.Email.t()
def welcome(user, opts \\ %{}) do
Gettext.with_locale_or_default user.language do
new()
|> to(recipient(user))
|> from(Map.get(opts, :sender, sender()))
|> subject(
Map.get(
opts,
:subject,
Gettext.dpgettext(
"static_pages",
"welcome email subject",
"Welcome to %{instance_name}!",
instance_name: instance_name()
)
)
)
|> html_body(
Map.get(
opts,
:html,
Gettext.dpgettext(
"static_pages",
"welcome email html body",
"Welcome to %{instance_name}!",
instance_name: instance_name()
)
)
)
|> text_body(
Map.get(
opts,
:text,
Gettext.dpgettext(
"static_pages",
"welcome email text body",
"Welcome to %{instance_name}!",
instance_name: instance_name()
)
)
)
end
end
def password_reset_email(user, token) when is_binary(token) do
Gettext.with_locale_or_default user.language do
password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token)
html_body =
Gettext.dpgettext(
"static_pages",
"password reset email body",
"""
<h3>Reset your password at %{instance_name}</h3>
<p>Someone has requested password change for your account at %{instance_name}.</p>
<p>If it was you, visit the following link to proceed: <a href="%{password_reset_url}">reset password</a>.</p>
<p>If it was someone else, nothing to worry about: your data is secure and your password has not been changed.</p>
""",
instance_name: instance_name(),
password_reset_url: password_reset_url
)
new()
|> to(recipient(user))
|> from(sender())
|> subject(
Gettext.dpgettext("static_pages", "password reset email subject", "Password reset")
)
|> html_body(html_body)
end
end
def user_invitation_email(
user,
%Pleroma.UserInviteToken{} = user_invite_token,
to_email,
to_name \\ nil
) do
Gettext.with_locale_or_default user.language do
registration_url =
Router.Helpers.redirect_url(
Endpoint,
:registration_page,
user_invite_token.token
)
html_body =
Gettext.dpgettext(
"static_pages",
"user invitation email body",
"""
<h3>You are invited to %{instance_name}</h3>
<p>%{inviter_name} invites you to join %{instance_name}, an instance of Pleroma federated social networking platform.</p>
<p>Click the following link to register: <a href="%{registration_url}">accept invitation</a>.</p>
""",
instance_name: instance_name(),
inviter_name: user.name,
registration_url: registration_url
)
new()
|> to(recipient(to_email, to_name))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"user invitation email subject",
"Invitation to %{instance_name}",
instance_name: instance_name()
)
)
|> html_body(html_body)
end
end
def account_confirmation_email(user) do
Gettext.with_locale_or_default user.language do
confirmation_url =
Router.Helpers.confirm_email_url(
Endpoint,
:confirm_email,
user.id,
to_string(user.confirmation_token)
)
html_body =
Gettext.dpgettext(
"static_pages",
"confirmation email body",
"""
<h3>Thank you for registering on %{instance_name}</h3>
<p>Email confirmation is required to activate the account.</p>
<p>Please click the following link to <a href="%{confirmation_url}">activate your account</a>.</p>
""",
instance_name: instance_name(),
confirmation_url: confirmation_url
)
new()
|> to(recipient(user))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"confirmation email subject",
"%{instance_name} account confirmation",
instance_name: instance_name()
)
)
|> html_body(html_body)
end
end
def approval_pending_email(user) do
Gettext.with_locale_or_default user.language do
html_body =
Gettext.dpgettext(
"static_pages",
"approval pending email body",
"""
<h3>Awaiting Approval</h3>
<p>Your account at %{instance_name} is being reviewed by staff. You will receive another email once your account is approved.</p>
""",
instance_name: instance_name()
)
new()
|> to(recipient(user))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"approval pending email subject",
"Your account is awaiting approval"
)
)
|> html_body(html_body)
end
end
def successful_registration_email(user) do
Gettext.with_locale_or_default user.language do
html_body =
Gettext.dpgettext(
"static_pages",
"successful registration email body",
"""
<h3>Hello @%{nickname},</h3>
<p>Your account at %{instance_name} has been registered successfully.</p>
<p>No further action is required to activate your account.</p>
""",
nickname: user.nickname,
instance_name: instance_name()
)
new()
|> to(recipient(user))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"successful registration email subject",
"Account registered on %{instance_name}",
instance_name: instance_name()
)
)
|> html_body(html_body)
end
end
@doc """
Email used in digest email notifications
Includes Mentions and New Followers data
If there are no mentions (even when new followers exist), the function will return nil
"""
@spec digest_email(User.t()) :: Swoosh.Email.t() | nil
def digest_email(user) do
Gettext.with_locale_or_default user.language do
notifications = Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at)
mentions =
notifications
|> Enum.filter(&(&1.activity.data["type"] == "Create"))
|> Enum.map(fn notification ->
object = Pleroma.Object.normalize(notification.activity, fetch: false)
if not is_nil(object) do
object = update_in(object.data["content"], &format_links/1)
%{
data: notification,
object: object,
from: User.get_by_ap_id(notification.activity.actor)
}
end
end)
|> Enum.filter(& &1)
followers =
notifications
|> Enum.filter(&(&1.activity.data["type"] == "Follow"))
|> Enum.map(fn notification ->
from = User.get_by_ap_id(notification.activity.actor)
if not is_nil(from) do
%{
data: notification,
object: Pleroma.Object.normalize(notification.activity, fetch: false),
from: User.get_by_ap_id(notification.activity.actor)
}
end
end)
|> Enum.filter(& &1)
unless Enum.empty?(mentions) do
styling = Config.get([__MODULE__, :styling])
logo = Config.get([__MODULE__, :logo])
html_data = %{
instance: instance_name(),
user: user,
mentions: mentions,
followers: followers,
unsubscribe_link: unsubscribe_url(user, "digest"),
styling: styling
}
logo_path =
if is_nil(logo) do
Path.join(:code.priv_dir(:pleroma), "static/static/logo.svg")
else
Path.join(Config.get([:instance, :static_dir]), logo)
end
new()
|> to(recipient(user))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"digest email subject",
"Your digest from %{instance_name}",
instance_name: instance_name()
)
)
|> put_layout(false)
|> render_body("digest.html", html_data)
|> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.svg", type: :inline))
end
end
end
defp format_links(str) do
re = ~r/<a.+href=['"].*>/iU
%{link_color: color} = Config.get([__MODULE__, :styling])
Regex.replace(re, str, fn link ->
String.replace(link, "<a", "<a style=\"color: #{color};text-decoration: none;\"")
end)
end
@doc """
Generate unsubscribe link for given user and notifications type.
The link contains JWT token with the data, and subscription can be modified without
authorization.
"""
@spec unsubscribe_url(User.t(), String.t()) :: String.t()
def unsubscribe_url(user, notifications_type) do
token =
%{"sub" => user.id, "act" => %{"unsubscribe" => notifications_type}, "exp" => false}
|> Pleroma.JWT.generate_and_sign!()
|> Base.encode64()
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))
|> from(sender())
|> subject(
Gettext.dpgettext(
"static_pages",
"account archive email subject",
"Your account archive is ready"
)
)
|> html_body(html_body)
end
end
end
diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex
index 1821de667..2568089a4 100644
--- a/lib/pleroma/user/backup.ex
+++ b/lib/pleroma/user/backup.ex
@@ -1,413 +1,359 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.User.Backup do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
import Pleroma.Web.Gettext
require Logger
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.Bookmark
+ alias Pleroma.Config
alias Pleroma.Repo
+ 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
alias Pleroma.Workers.BackupWorker
@type t :: %__MODULE__{}
schema "backups" do
field(:content_type, :string)
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."}
+
+ e ->
+ {:error, e}
+ 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
-
- 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)
-
- 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
+ @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)
- nil ->
- :ok
+ %{"op" => "delete", "backup_id" => backup.id}
+ |> BackupWorker.new(scheduled_at: scheduled_at)
+ |> Oban.insert()
+ end
+
+ defp permitted?(user) do
+ with {_, %__MODULE__{inserted_at: inserted_at}} <- {:last, get_last(user)},
+ days = Config.get([__MODULE__, :limit_days]),
+ diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days),
+ {_, true} <- {:diff, diff > days} do
+ true
+ else
+ {:last, nil} -> true
+ {:diff, false} -> false
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)
|> limit(1)
|> 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)
|> order_by(desc: :id)
|> 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])
- |> 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)
+ |> cast(attrs, [:content_type, :file_name, :file_size, :processed, :tempdir])
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
+ @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
@files [
~c"actor.json",
~c"outbox.json",
~c"likes.json",
~c"bookmarks.json",
~c"followers.json",
~c"following.json"
]
- @spec export(Pleroma.User.Backup.t(), pid()) :: {:ok, String.t()} | :error
- def export(%__MODULE__{} = backup, caller_pid) do
+
+ @spec run(t()) :: {:ok, t()} | {:error, :failed}
+ def run(%__MODULE__{} = backup) 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}
+ tempfile = Path.join([backup.tempdir, backup.file_name])
+
+ with {_, :ok} <- {:mkdir, File.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, :zip.create(to_charlist(tempfile), @files, cwd: to_charlist(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
+ case Config.get([__MODULE__, :tempdir]) do
+ nil ->
+ System.tmp_dir!()
+
+ path ->
+ rand = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
+ Path.join([path, rand])
+ 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"})
|> Jason.encode() do
- send(caller_pid, {:progress, 1})
File.write(Path.join(dir, "actor.json"), json)
end
end
defp write_header(file, name) do
IO.write(
file,
"""
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "#{name}.json",
"type": "OrderedCollection",
"orderedItems": [
"""
)
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
total =
query
|> Pleroma.Repo.chunk_stream(chunk_size, _returns_as = :one, timeout: :infinity)
|> Enum.reduce(0, fn i, acc ->
with {:ok, data} <-
(try do
fun.(i)
rescue
e -> {:error, e}
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} ->
Logger.warning(
"Error processing backup item: #{inspect(e)}\n The item is: #{inspect(i)}"
)
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"])
|> Map.put(:actor_id, user.ap_id)
[
[Pleroma.Constants.as_public(), user.ap_id],
User.following(user),
Pleroma.List.memberships(user)
]
|> Enum.concat()
|> ActivityPub.fetch_activities_query(opts)
|> write(
dir,
"outbox",
fn a ->
with {:ok, activity} <- Transmogrifier.prepare_outgoing(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
diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex
index 1894000ff..0f22dd538 100644
--- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex
+++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex
@@ -1,445 +1,448 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.AdminAPIController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [json_response: 3, fetch_integer_param: 3]
alias Pleroma.Config
alias Pleroma.MFA
alias Pleroma.ModerationLog
alias Pleroma.Stats
alias Pleroma.User
+ alias Pleroma.User.Backup
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.AdminAPI.ModerationLogView
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50
plug(
OAuthScopesPlug,
%{scopes: ["admin:read:accounts"]}
when action in [:right_get, :show_user_credentials, :create_backup]
)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:accounts"]}
when action in [
:get_password_reset,
:force_password_reset,
:tag_users,
:untag_users,
:right_add,
:right_add_multiple,
:right_delete,
:disable_mfa,
:right_delete_multiple,
:update_user_credentials
]
)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read:statuses"]}
when action in [:list_user_statuses]
)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read:chats"]}
when action in [:list_user_chats]
)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read"]}
when action in [
:list_log,
:stats,
:need_reboot
]
)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write"]}
when action in [
:restart,
:resend_confirmation_email,
:confirm_email,
:reload_emoji
]
)
action_fallback(AdminAPI.FallbackController)
def list_user_statuses(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do
with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true
godmode = params["godmode"] == "true" || params["godmode"] == true
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do
{page, page_size} = page_params(params)
result =
ActivityPub.fetch_user_activities(user, nil, %{
limit: page_size,
offset: (page - 1) * page_size,
godmode: godmode,
exclude_reblogs: not with_reblogs,
pagination_type: :offset,
total: true
})
conn
|> put_view(AdminAPI.StatusView)
|> render("index.json", %{total: result[:total], activities: result[:items], as: :activity})
else
_ -> {:error, :not_found}
end
end
def list_user_chats(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = _params) do
with %User{id: user_id} <- User.get_cached_by_nickname_or_id(nickname, for: admin) do
chats =
Pleroma.Chat.for_user_query(user_id)
|> Pleroma.Repo.all()
conn
|> put_view(AdminAPI.ChatView)
|> render("index.json", chats: chats)
else
_ -> {:error, :not_found}
end
end
def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do
with {:ok, _} <- User.tag(nicknames, tags) do
ModerationLog.insert_log(%{
actor: admin,
nicknames: nicknames,
tags: tags,
action: "tag"
})
json_response(conn, :no_content, "")
end
end
def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do
with {:ok, _} <- User.untag(nicknames, tags) do
ModerationLog.insert_log(%{
actor: admin,
nicknames: nicknames,
tags: tags,
action: "untag"
})
json_response(conn, :no_content, "")
end
end
def right_add_multiple(%{assigns: %{user: admin}} = conn, %{
"permission_group" => permission_group,
"nicknames" => nicknames
})
when permission_group in ["moderator", "admin"] do
update = %{:"is_#{permission_group}" => true}
users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
for u <- users, do: User.admin_api_update(u, update)
ModerationLog.insert_log(%{
action: "grant",
actor: admin,
subject: users,
permission: permission_group
})
json(conn, update)
end
def right_add_multiple(conn, _) do
render_error(conn, :not_found, "No such permission_group")
end
def right_add(%{assigns: %{user: admin}} = conn, %{
"permission_group" => permission_group,
"nickname" => nickname
})
when permission_group in ["moderator", "admin"] do
fields = %{:"is_#{permission_group}" => true}
{:ok, user} =
nickname
|> User.get_cached_by_nickname()
|> User.admin_api_update(fields)
ModerationLog.insert_log(%{
action: "grant",
actor: admin,
subject: [user],
permission: permission_group
})
json(conn, fields)
end
def right_add(conn, _) do
render_error(conn, :not_found, "No such permission_group")
end
def right_get(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname)
conn
|> json(%{
is_moderator: user.is_moderator,
is_admin: user.is_admin
})
end
def right_delete_multiple(
%{assigns: %{user: %{nickname: admin_nickname} = admin}} = conn,
%{
"permission_group" => permission_group,
"nicknames" => nicknames
}
)
when permission_group in ["moderator", "admin"] do
with false <- Enum.member?(nicknames, admin_nickname) do
update = %{:"is_#{permission_group}" => false}
users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
for u <- users, do: User.admin_api_update(u, update)
ModerationLog.insert_log(%{
action: "revoke",
actor: admin,
subject: users,
permission: permission_group
})
json(conn, update)
else
_ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.")
end
end
def right_delete_multiple(conn, _) do
render_error(conn, :not_found, "No such permission_group")
end
def right_delete(
%{assigns: %{user: admin}} = conn,
%{
"permission_group" => permission_group,
"nickname" => nickname
}
)
when permission_group in ["moderator", "admin"] do
fields = %{:"is_#{permission_group}" => false}
{:ok, user} =
nickname
|> User.get_cached_by_nickname()
|> User.admin_api_update(fields)
ModerationLog.insert_log(%{
action: "revoke",
actor: admin,
subject: [user],
permission: permission_group
})
json(conn, fields)
end
def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do
render_error(conn, :forbidden, "You can't revoke your own admin status.")
end
@doc "Get a password reset token (base64 string) for given nickname"
def get_password_reset(conn, %{"nickname" => nickname}) do
(%User{local: true} = user) = User.get_cached_by_nickname(nickname)
{:ok, token} = Pleroma.PasswordResetToken.create_token(user)
conn
|> json(%{
token: token.token,
link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token)
})
end
@doc "Force password reset for a given user"
def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
Enum.each(users, &User.force_password_reset_async/1)
ModerationLog.insert_log(%{
actor: admin,
subject: users,
action: "force_password_reset"
})
json_response(conn, :no_content, "")
end
@doc "Disable mfa for user's account."
def disable_mfa(conn, %{"nickname" => nickname}) do
case User.get_by_nickname(nickname) do
%User{} = user ->
MFA.disable(user)
json(conn, nickname)
_ ->
{:error, :not_found}
end
end
@doc "Show a given user's credentials"
def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do
conn
|> put_view(AccountView)
|> render("credentials.json", %{user: user, for: admin})
else
_ -> {:error, :not_found}
end
end
@doc "Updates a given user"
def update_user_credentials(
%{assigns: %{user: admin}} = conn,
%{"nickname" => nickname} = params
) do
with {_, %User{} = user} <- {:user, User.get_cached_by_nickname(nickname)},
{:ok, _user} <-
User.update_as_admin(user, params) do
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "updated_users"
})
if params["password"] do
User.force_password_reset_async(user)
end
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "force_password_reset"
})
json(conn, %{status: "success"})
else
{:error, changeset} ->
errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end)
{:errors, errors}
_ ->
{:error, :not_found}
end
end
def list_log(conn, params) do
{page, page_size} = page_params(params)
log =
ModerationLog.get_all(%{
page: page,
page_size: page_size,
start_date: params["start_date"],
end_date: params["end_date"],
user_id: params["user_id"],
search: params["search"]
})
conn
|> put_view(ModerationLogView)
|> render("index.json", %{log: log})
end
def restart(conn, _params) do
with :ok <- configurable_from_database() do
Restarter.Pleroma.restart(Config.get(:env), 50)
json(conn, %{})
end
end
def need_reboot(conn, _params) do
json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()})
end
defp configurable_from_database do
if Config.get(:configurable_from_database) do
:ok
else
{:error, "You must enable configurable_from_database in your config file."}
end
end
def reload_emoji(conn, _params) do
Pleroma.Emoji.reload()
json(conn, "ok")
end
def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
User.confirm(users)
ModerationLog.insert_log(%{actor: admin, subject: users, action: "confirm_email"})
json(conn, "")
end
def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
users =
Enum.map(nicknames, fn nickname ->
nickname
|> User.get_cached_by_nickname()
|> User.send_confirmation_email()
end)
ModerationLog.insert_log(%{actor: admin, subject: users, action: "resend_confirmation_email"})
json(conn, "")
end
def stats(conn, params) do
counters = Stats.get_status_visibility_count(params["instance"])
json(conn, %{"status_visibility" => counters})
end
def create_backup(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_by_nickname(nickname),
- {:ok, _} <- Pleroma.User.Backup.create(user, admin.id) do
+ %Backup{} = backup <- Backup.new(user),
+ {:ok, inserted_backup} <- Pleroma.Repo.insert(backup),
+ {:ok, %Oban.Job{}} <- Backup.schedule_backup(inserted_backup) do
ModerationLog.insert_log(%{actor: admin, subject: user, action: "create_backup"})
json(conn, "")
end
end
defp page_params(params) do
{
fetch_integer_param(params, "page", 1),
fetch_integer_param(params, "page_size", @users_page_size)
}
end
end
diff --git a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex
index 400f3825d..86f709515 100644
--- a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex
@@ -1,87 +1,81 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.PleromaBackupOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Backups"],
summary: "List backups",
security: [%{"oAuth" => ["read:backups"]}],
operationId: "PleromaAPI.BackupController.index",
responses: %{
200 =>
Operation.response(
"An array of backups",
"application/json",
%Schema{
type: :array,
items: backup()
}
),
400 => Operation.response("Bad Request", "application/json", ApiError)
}
}
end
def create_operation do
%Operation{
tags: ["Backups"],
summary: "Create a backup",
security: [%{"oAuth" => ["read:backups"]}],
operationId: "PleromaAPI.BackupController.create",
responses: %{
200 =>
Operation.response(
"An array of backups",
"application/json",
%Schema{
type: :array,
items: backup()
}
),
400 => Operation.response("Bad Request", "application/json", ApiError)
}
}
end
defp backup do
%Schema{
title: "Backup",
description: "Response schema for a backup",
type: :object,
properties: %{
inserted_at: %Schema{type: :string, format: :"date-time"},
content_type: %Schema{type: :string},
file_name: %Schema{type: :string},
file_size: %Schema{type: :integer},
processed: %Schema{type: :boolean, description: "whether this backup has succeeded"},
- state: %Schema{
- type: :string,
- description: "the state of the backup",
- enum: ["pending", "running", "complete", "failed"]
- },
- processed_number: %Schema{type: :integer, description: "the number of records processed"}
+ tempdir: %Schema{type: :string}
},
example: %{
"content_type" => "application/zip",
"file_name" =>
"https://cofe.fe:4000/media/backups/archive-foobar-20200908T164207-Yr7vuT5Wycv-sN3kSN2iJ0k-9pMo60j9qmvRCdDqIew.zip",
"file_size" => 4105,
"inserted_at" => "2020-09-08T16:42:07.000Z",
"processed" => true,
- "state" => "complete",
- "processed_number" => 20
+ "tempdir" => "/tmp/PZIMw40vmpM"
}
}
end
end
diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex
index b9daed22b..0115ec645 100644
--- a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex
+++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex
@@ -1,28 +1,28 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.BackupController do
use Pleroma.Web, :controller
alias Pleroma.User.Backup
alias Pleroma.Web.Plugs.OAuthScopesPlug
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["read:backups"]} when action in [:index, :create])
plug(Pleroma.Web.ApiSpec.CastAndValidate)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBackupOperation
def index(%{assigns: %{user: user}} = conn, _params) do
backups = Backup.list(user)
render(conn, "index.json", backups: backups)
end
def create(%{assigns: %{user: user}} = conn, _params) do
- with {:ok, _} <- Backup.create(user) do
+ with {:ok, _} <- Backup.user(user) do
backups = Backup.list(user)
render(conn, "index.json", backups: backups)
end
end
end
diff --git a/lib/pleroma/web/pleroma_api/views/backup_view.ex b/lib/pleroma/web/pleroma_api/views/backup_view.ex
index 20403aeee..d778590f0 100644
--- a/lib/pleroma/web/pleroma_api/views/backup_view.ex
+++ b/lib/pleroma/web/pleroma_api/views/backup_view.ex
@@ -1,39 +1,29 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.BackupView do
use Pleroma.Web, :view
alias Pleroma.User.Backup
alias Pleroma.Web.CommonAPI.Utils
def render("show.json", %{backup: %Backup{} = backup}) do
- # To deal with records before the migration
- state =
- if backup.state == :invalid do
- if backup.processed, do: :complete, else: :failed
- else
- backup.state
- end
-
%{
id: backup.id,
content_type: backup.content_type,
url: download_url(backup),
file_size: backup.file_size,
processed: backup.processed,
- state: to_string(state),
- processed_number: backup.processed_number,
inserted_at: Utils.to_masto_date(backup.inserted_at)
}
end
def render("index.json", %{backups: backups}) do
render_many(backups, __MODULE__, "show.json")
end
def download_url(%Backup{file_name: file_name}) do
Pleroma.Upload.base_url() <> "/backups/" <> file_name
end
end
diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex
index 54ac31a3c..41f404e69 100644
--- a/lib/pleroma/workers/backup_worker.ex
+++ b/lib/pleroma/workers/backup_worker.ex
@@ -1,74 +1,55 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.BackupWorker do
use Oban.Worker, queue: :slow, max_attempts: 1
alias Oban.Job
alias Pleroma.User.Backup
- def process(backup, admin_user_id \\ nil) do
- %{"op" => "process", "backup_id" => backup.id, "admin_user_id" => admin_user_id}
- |> new()
- |> Oban.insert()
- end
-
- def schedule_deletion(backup) do
- days = Pleroma.Config.get([Backup, :purge_after_days])
- time = 60 * 60 * 24 * days
- scheduled_at = Calendar.NaiveDateTime.add!(backup.inserted_at, time)
-
- %{"op" => "delete", "backup_id" => backup.id}
- |> new(scheduled_at: scheduled_at)
- |> Oban.insert()
- end
-
- def delete(backup) do
- %{"op" => "delete", "backup_id" => backup.id}
- |> new()
- |> Oban.insert()
- end
-
@impl Oban.Worker
def perform(%Job{
- args: %{"op" => "process", "backup_id" => backup_id, "admin_user_id" => admin_user_id}
+ args: %{"op" => "process", "backup_id" => backup_id}
}) do
- with {:ok, %Backup{} = backup} <-
- backup_id |> Backup.get() |> Backup.process(),
- {:ok, _job} <- schedule_deletion(backup),
- :ok <- Backup.remove_outdated(backup),
- :ok <- maybe_deliver_email(backup, admin_user_id) do
- {:ok, backup}
+ with {_, %Backup{} = backup} <- {:get, Backup.get_by_id(backup_id)},
+ {_, {:ok, updated_backup}} <- {:run, Backup.run(backup)},
+ {_, {:ok, uploaded_backup}} <- {:upload, Backup.upload(updated_backup)},
+ {_, {:ok, _job}} <- {:delete, Backup.schedule_delete(uploaded_backup)},
+ {_, :ok} <- {:outdated, Backup.remove_outdated(uploaded_backup.user)},
+ {_, :ok} <- {:email, maybe_deliver_email(uploaded_backup)} do
+ {:ok, uploaded_backup}
+ else
+ e -> {:error, e}
end
end
def perform(%Job{args: %{"op" => "delete", "backup_id" => backup_id}}) do
- case Backup.get(backup_id) do
- %Backup{} = backup -> Backup.delete(backup)
+ case Backup.get_by_id(backup_id) do
+ %Backup{} = backup -> Backup.delete_archive(backup)
nil -> :ok
end
end
@impl Oban.Worker
def timeout(_job), do: :infinity
defp has_email?(user) do
not is_nil(user.email) and user.email != ""
end
- defp maybe_deliver_email(backup, admin_user_id) do
+ defp maybe_deliver_email(backup) do
has_mailer = Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled])
backup = backup |> Pleroma.Repo.preload(:user)
if has_email?(backup.user) and has_mailer do
backup
- |> Pleroma.Emails.UserEmail.backup_is_ready_email(admin_user_id)
+ |> Pleroma.Emails.UserEmail.backup_is_ready_email()
|> Pleroma.Emails.Mailer.deliver()
:ok
else
:ok
end
end
end
diff --git a/priv/repo/migrations/20240622175346_backup_refactor.exs b/priv/repo/migrations/20240622175346_backup_refactor.exs
new file mode 100644
index 000000000..5dfc55789
--- /dev/null
+++ b/priv/repo/migrations/20240622175346_backup_refactor.exs
@@ -0,0 +1,19 @@
+defmodule Pleroma.Repo.Migrations.BackupRefactor do
+ use Ecto.Migration
+
+ def up do
+ alter table("backups") do
+ remove(:state)
+ remove(:processed_number)
+ add(:tempdir, :string)
+ end
+ end
+
+ def down do
+ alter table("backups") do
+ add(:state, :integer, default: 5)
+ add(:processed_number, :integer, default: 0)
+ remove(:tempdir)
+ end
+ end
+end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Oct 29, 2:46 PM (1 d, 7 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798216
Default Alt Text
(59 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline