Bump Copyright to 2021
[akkoma] / lib / pleroma / user / backup.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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_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)
40 end
41 end
42
43 def new(user) do
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"
47
48 %__MODULE__{
49 user_id: user.id,
50 content_type: "application/zip",
51 file_name: name
52 }
53 end
54
55 def delete(backup) do
56 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
57
58 with :ok <- uploader.delete_file(Path.join("backups", backup.file_name)) do
59 Repo.delete(backup)
60 end
61 end
62
63 defp validate_limit(_user, admin_id) when is_binary(admin_id), do: :ok
64
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)
70
71 if diff > days do
72 :ok
73 else
74 {:error,
75 dngettext(
76 "errors",
77 "Last export was less than a day ago",
78 "Last export was less than %{days} days ago",
79 days,
80 days: days
81 )}
82 end
83
84 nil ->
85 :ok
86 end
87 end
88
89 defp validate_email_enabled do
90 if Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do
91 :ok
92 else
93 {:error, dgettext("errors", "Backups require enabled email")}
94 end
95 end
96
97 defp validate_user_email(%User{email: nil}) do
98 {:error, dgettext("errors", "Email is required")}
99 end
100
101 defp validate_user_email(%User{email: email}) when is_binary(email), do: :ok
102
103 def get_last(user_id) do
104 __MODULE__
105 |> where(user_id: ^user_id)
106 |> order_by(desc: :id)
107 |> limit(1)
108 |> Repo.one()
109 end
110
111 def list(%User{id: user_id}) do
112 __MODULE__
113 |> where(user_id: ^user_id)
114 |> order_by(desc: :id)
115 |> Repo.all()
116 end
117
118 def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do
119 __MODULE__
120 |> where(user_id: ^user_id)
121 |> where([b], b.id != ^latest_id)
122 |> Repo.all()
123 |> Enum.each(&BackupWorker.delete/1)
124 end
125
126 def get(id), do: Repo.get(__MODULE__, id)
127
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
132 backup
133 |> cast(%{file_size: size, processed: true}, [:file_size, :processed])
134 |> Repo.update()
135 end
136 end
137
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")
142 dir = dir(name)
143
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)}
152 end
153 end
154
155 def dir(name) do
156 dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!()
157 Path.join(dir, name)
158 end
159
160 def upload(%__MODULE__{} = backup, zip_path) do
161 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
162
163 upload = %Pleroma.Upload{
164 name: backup.file_name,
165 tempfile: zip_path,
166 content_type: backup.content_type,
167 path: Path.join("backups", backup.file_name)
168 }
169
170 with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
171 :ok <- File.rm(zip_path) do
172 {:ok, upload}
173 end
174 end
175
176 defp actor(dir, user) do
177 with {:ok, json} <-
178 UserView.render("user.json", %{user: user})
179 |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
180 |> Jason.encode() do
181 File.write(Path.join(dir, "actor.json"), json)
182 end
183 end
184
185 defp write_header(file, name) do
186 IO.write(
187 file,
188 """
189 {
190 "@context": "https://www.w3.org/ns/activitystreams",
191 "id": "#{name}.json",
192 "type": "OrderedCollection",
193 "orderedItems": [
194
195 """
196 )
197 end
198
199 defp write(query, dir, name, fun) do
200 path = Path.join(dir, "#{name}.json")
201
202 with {:ok, file} <- File.open(path, [:write, :utf8]),
203 :ok <- write_header(file, name) do
204 total =
205 query
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
211 acc + 1
212 else
213 _ -> acc
214 end
215 end)
216
217 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
218 File.close(file)
219 end
220 end
221 end
222
223 defp bookmarks(dir, %{id: user_id} = _user) do
224 Bookmark
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)
229 end
230
231 defp likes(dir, user) do
232 user.ap_id
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)
237 end
238
239 defp statuses(dir, user) do
240 opts =
241 %{}
242 |> Map.put(:type, ["Create", "Announce"])
243 |> Map.put(:actor_id, user.ap_id)
244
245 [
246 [Pleroma.Constants.as_public(), user.ap_id],
247 User.following(user),
248 Pleroma.List.memberships(user)
249 ]
250 |> Enum.concat()
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")}
255 end
256 end)
257 end
258 end