1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.User.Backup do
10 import Pleroma.Web.Gettext
12 require Pleroma.Constants
14 alias Pleroma.Activity
15 alias Pleroma.Bookmark
18 alias Pleroma.Web.ActivityPub.ActivityPub
19 alias Pleroma.Web.ActivityPub.Transmogrifier
20 alias Pleroma.Web.ActivityPub.UserView
21 alias Pleroma.Workers.BackupWorker
24 field(:content_type, :string)
25 field(:file_name, :string)
26 field(:file_size, :integer, default: 0)
27 field(:processed, :boolean, default: false)
29 belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
34 def create(user, admin_id \\ nil) do
35 with :ok <- validate_email_enabled(),
36 :ok <- validate_user_email(user),
37 :ok <- validate_limit(user, admin_id),
38 {:ok, backup} <- user |> new() |> Repo.insert() do
39 BackupWorker.process(backup, admin_id)
44 rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
45 datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now())
46 name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip"
50 content_type: "application/zip",
56 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
58 with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do
63 defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok
65 defp validate_limit(user, nil) do
66 case get_last(user.id) do
67 %__MODULE__{inserted_at: inserted_at} ->
68 days = Pleroma.Config.get([__MODULE__, :limit_days])
69 diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days)
77 "Last export was less than a day ago",
78 "Last export was less than %{days} days ago",
89 defp validate_email_enabled do
90 if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do
93 {:error, dgettext("errors", "Backups require enabled email")}
97 defp validate_user_email(%User{email: nil}) do
98 {:error, dgettext("errors", "Email is required")}
101 defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok
103 def get_last(user_id) do
105 |> where(user_id: ^user_id)
106 |> order_by(desc: :id)
111 def list(%User{id: user_id}) do
113 |> where(user_id: ^user_id)
114 |> order_by(desc: :id)
118 def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do
120 |> where(user_id: ^user_id)
121 |> where([b], b.id != ^latest_id)
123 |> Enum.each(&BackupWorker.delete/1)
126 def get(id), do: Repo.get(__MODULE__, id)
128 def process(%__MODULE__{} = backup) do
129 with {:ok, zip_file} <- export(backup),
130 {:ok, %{size: size}} <- File.stat(zip_file),
131 {:ok, _upload} <- upload(backup, zip_file) do
133 |> cast(%{file_size: size, processed: true}, [:file_size, :processed])
138 @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json']
139 def export(%__MODULE__{} = backup) do
140 backup = Repo.preload(backup, :user)
141 name = String.trim_trailing(backup.file_name, ".zip")
144 with :ok <- File.mkdir(dir),
145 :ok <- actor(dir, backup.user),
146 :ok <- statuses(dir, backup.user),
147 :ok <- likes(dir, backup.user),
148 :ok <- bookmarks(dir, backup.user),
149 {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir),
150 {:ok, _} <- File.rm_rf(dir) do
151 {:ok, to_string(zip_path)}
156 dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!()
160 def upload(%__MODULE__{} = backup, zip_path) do
161 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
163 upload = %Pleroma.Upload{
164 name: backup.file_name,
166 content_type: backup.content_type,
167 path: Path.join("backups", backup.file_name)
170 with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
171 :ok <- File.rm(zip_path) do
176 defp actor(dir, user) do
178 UserView.render("user.json", %{user: user})
179 |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
181 File.write(Path.join(dir, "actor.json"), json)
185 defp write_header(file, name) do
190 "@context": "https://www.w3.org/ns/activitystreams",
191 "id": "#{name}.json",
192 "type": "OrderedCollection",
199 defp write(query, dir, name, fun) do
200 path = Path.join(dir, "#{name}.json")
202 with {:ok, file} <- File.open(path, [:write, :utf8]),
203 :ok <- write_header(file, name) do
206 |> Pleroma.Repo.chunk_stream(100)
207 |> Enum.reduce(0, fn i, acc ->
208 with {:ok, data} <- fun.(i),
209 {:ok, str} <- Jason.encode(data),
210 :ok <- IO.write(file, str <> ",\n") do
217 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
223 defp bookmarks(dir, %{id: user_id} = _user) do
225 |> where(user_id: ^user_id)
226 |> join(:inner, [b], activity in assoc(b, :activity))
227 |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
228 |> write(dir, "bookmarks", fn a -> {:ok, a.object} end)
231 defp likes(dir, user) do
233 |> Activity.Queries.by_actor()
234 |> Activity.Queries.by_type("Like")
235 |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
236 |> write(dir, "likes", fn a -> {:ok, a.object} end)
239 defp statuses(dir, user) do
242 |> Map.put(:type, ["Create", "Announce"])
243 |> Map.put(:actor_id, user.ap_id)
246 [Pleroma.Constants.as_public(), user.ap_id],
247 User.following(user),
248 Pleroma.List.memberships(user)
251 |> ActivityPub.fetch_activities_query(opts)
252 |> write(dir, "outbox", fn a ->
253 with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
254 {:ok, Map.delete(activity, "@context")}