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