Use `Pleroma.Repo.chunk_stream/2` instead of `Pleroma.RepoStreamer.chunk_stream/2`
[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 total =
192 query
193 |> Pleroma.Repo.chunk_stream(100)
194 |> Enum.reduce(0, fn i, acc ->
195 with {:ok, str} <- fun.(i),
196 :ok <- IO.write(file, str <> ",\n") do
197 acc + 1
198 else
199 _ -> acc
200 end
201 end)
202
203 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
204 File.close(file)
205 end
206 end
207 end
208
209 defp bookmarks(dir, %{id: user_id} = _user) do
210 Bookmark
211 |> where(user_id: ^user_id)
212 |> join(:inner, [b], activity in assoc(b, :activity))
213 |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
214 |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end)
215 end
216
217 defp likes(dir, user) do
218 user.ap_id
219 |> Activity.Queries.by_actor()
220 |> Activity.Queries.by_type("Like")
221 |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
222 |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end)
223 end
224
225 defp statuses(dir, user) do
226 opts =
227 %{}
228 |> Map.put(:type, ["Create", "Announce"])
229 |> Map.put(:actor_id, user.ap_id)
230
231 [
232 [Pleroma.Constants.as_public(), user.ap_id],
233 User.following(user),
234 Pleroma.List.memberships(user)
235 ]
236 |> Enum.concat()
237 |> ActivityPub.fetch_activities_query(opts)
238 |> write(dir, "outbox", fn a ->
239 with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
240 activity |> Map.delete("@context") |> Jason.encode()
241 end
242 end)
243 end
244 end