First
[anni] / lib / pleroma / user / backup.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.User.Backup do
6   use Ecto.Schema
7
8   import Ecto.Changeset
9   import Ecto.Query
10   import Pleroma.Web.Gettext
11
12   require Pleroma.Constants
13
14   alias Pleroma.Activity
15   alias Pleroma.Bookmark
16   alias Pleroma.Repo
17   alias Pleroma.User
18   alias Pleroma.Web.ActivityPub.ActivityPub
19   alias Pleroma.Web.ActivityPub.Transmogrifier
20   alias Pleroma.Web.ActivityPub.UserView
21   alias Pleroma.Workers.BackupWorker
22
23   schema "backups" do
24     field(:content_type, :string)
25     field(:file_name, :string)
26     field(:file_size, :integer, default: 0)
27     field(:processed, :boolean, default: false)
28
29     belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
30
31     timestamps()
32   end
33
34   def create(user, admin_id \\ nil) do
35     with :ok <- validate_limit(user, admin_id),
36          {:ok, backup} <- user |> new() |> Repo.insert() do
37       BackupWorker.process(backup, admin_id)
38     end
39   end
40
41   def new(user) do
42     rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
43     datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now())
44     name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip"
45
46     %__MODULE__{
47       user_id: user.id,
48       content_type: "application/zip",
49       file_name: name
50     }
51   end
52
53   def delete(backup) do
54     uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
55
56     with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do
57       Repo.delete(backup)
58     end
59   end
60
61   defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok
62
63   defp validate_limit(user, nil) do
64     case get_last(user.id) do
65       %__MODULE__{inserted_at: inserted_at} ->
66         days = Pleroma.Config.get([__MODULE__, :limit_days])
67         diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days)
68
69         if diff > days do
70           :ok
71         else
72           {:error,
73            dngettext(
74              "errors",
75              "Last export was less than a day ago",
76              "Last export was less than %{days} days ago",
77              days,
78              days: days
79            )}
80         end
81
82       nil ->
83         :ok
84     end
85   end
86
87   def get_last(user_id) do
88     __MODULE__
89     |> where(user_id: ^user_id)
90     |> order_by(desc: :id)
91     |> limit(1)
92     |> Repo.one()
93   end
94
95   def list(%User{id: user_id}) do
96     __MODULE__
97     |> where(user_id: ^user_id)
98     |> order_by(desc: :id)
99     |> Repo.all()
100   end
101
102   def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do
103     __MODULE__
104     |> where(user_id: ^user_id)
105     |> where([b], b.id != ^latest_id)
106     |> Repo.all()
107     |> Enum.each(&BackupWorker.delete/1)
108   end
109
110   def get(id), do: Repo.get(__MODULE__, id)
111
112   def process(%__MODULE__{} = backup) do
113     with {:ok, zip_file} <- export(backup),
114          {:ok, %{size: size}} <- File.stat(zip_file),
115          {:ok, _upload} <- upload(backup, zip_file) do
116       backup
117       |> cast(%{file_size: size, processed: true}, [:file_size, :processed])
118       |> Repo.update()
119     end
120   end
121
122   @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json']
123   def export(%__MODULE__{} = backup) do
124     backup = Repo.preload(backup, :user)
125     name = String.trim_trailing(backup.file_name, ".zip")
126     dir = dir(name)
127
128     with :ok <- File.mkdir(dir),
129          :ok <- actor(dir, backup.user),
130          :ok <- statuses(dir, backup.user),
131          :ok <- likes(dir, backup.user),
132          :ok <- bookmarks(dir, backup.user),
133          {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir),
134          {:ok, _} <- File.rm_rf(dir) do
135       {:ok, to_string(zip_path)}
136     end
137   end
138
139   def dir(name) do
140     dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!()
141     Path.join(dir, name)
142   end
143
144   def upload(%__MODULE__{} = backup, zip_path) do
145     uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
146
147     upload = %Pleroma.Upload{
148       name: backup.file_name,
149       tempfile: zip_path,
150       content_type: backup.content_type,
151       path: Path.join("backups", backup.file_name)
152     }
153
154     with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
155          :ok <- File.rm(zip_path) do
156       {:ok, upload}
157     end
158   end
159
160   defp actor(dir, user) do
161     with {:ok, json} <-
162            UserView.render("user.json", %{user: user})
163            |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
164            |> Jason.encode() do
165       File.write(Path.join(dir, "actor.json"), json)
166     end
167   end
168
169   defp write_header(file, name) do
170     IO.write(
171       file,
172       """
173       {
174         "@context": "https://www.w3.org/ns/activitystreams",
175         "id": "#{name}.json",
176         "type": "OrderedCollection",
177         "orderedItems": [
178
179       """
180     )
181   end
182
183   defp write(query, dir, name, fun) do
184     path = Path.join(dir, "#{name}.json")
185
186     with {:ok, file} <- File.open(path, [:write, :utf8]),
187          :ok <- write_header(file, name) do
188       total =
189         query
190         |> Pleroma.Repo.chunk_stream(100)
191         |> Enum.reduce(0, fn i, acc ->
192           with {:ok, data} <- fun.(i),
193                {:ok, str} <- Jason.encode(data),
194                :ok <- IO.write(file, str <> ",\n") do
195             acc + 1
196           else
197             _ -> acc
198           end
199         end)
200
201       with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n  \"totalItems\": #{total}}") do
202         File.close(file)
203       end
204     end
205   end
206
207   defp bookmarks(dir, %{id: user_id} = _user) do
208     Bookmark
209     |> where(user_id: ^user_id)
210     |> join(:inner, [b], activity in assoc(b, :activity))
211     |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
212     |> write(dir, "bookmarks", fn a -> {:ok, a.object} end)
213   end
214
215   defp likes(dir, user) do
216     user.ap_id
217     |> Activity.Queries.by_actor()
218     |> Activity.Queries.by_type("Like")
219     |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
220     |> write(dir, "likes", fn a -> {:ok, a.object} end)
221   end
222
223   defp statuses(dir, user) do
224     opts =
225       %{}
226       |> Map.put(:type, ["Create", "Announce"])
227       |> Map.put(:actor_id, user.ap_id)
228
229     [
230       [Pleroma.Constants.as_public(), user.ap_id],
231       User.following(user),
232       Pleroma.List.memberships(user)
233     ]
234     |> Enum.concat()
235     |> ActivityPub.fetch_activities_query(opts)
236     |> write(dir, "outbox", fn a ->
237       with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
238         {:ok, Map.delete(activity, "@context")}
239       end
240     end)
241   end
242 end