Merge pull request 'metrics' (#375) from stats into develop
[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_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} <-
134 :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: String.to_charlist(dir)),
135 {:ok, _} <- File.rm_rf(dir) do
136 {:ok, to_string(zip_path)}
137 end
138 end
139
140 def dir(name) do
141 dir = Pleroma.Config.get([__MODULE__, :dir]) || System.tmp_dir!()
142 Path.join(dir, name)
143 end
144
145 def upload(%__MODULE__{} = backup, zip_path) do
146 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
147
148 upload = %Pleroma.Upload{
149 name: backup.file_name,
150 tempfile: zip_path,
151 content_type: backup.content_type,
152 path: Path.join("backups", backup.file_name)
153 }
154
155 with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
156 :ok <- File.rm(zip_path) do
157 {:ok, upload}
158 end
159 end
160
161 defp actor(dir, user) do
162 with {:ok, json} <-
163 UserView.render("user.json", %{user: user})
164 |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
165 |> Jason.encode() do
166 File.write(Path.join(dir, "actor.json"), json)
167 end
168 end
169
170 defp write_header(file, name) do
171 IO.write(
172 file,
173 """
174 {
175 "@context": "https://www.w3.org/ns/activitystreams",
176 "id": "#{name}.json",
177 "type": "OrderedCollection",
178 "orderedItems": [
179
180 """
181 )
182 end
183
184 defp write(query, dir, name, fun) do
185 path = Path.join(dir, "#{name}.json")
186
187 with {:ok, file} <- File.open(path, [:write, :utf8]),
188 :ok <- write_header(file, name) do
189 total =
190 query
191 |> Pleroma.Repo.chunk_stream(100)
192 |> Enum.reduce(0, fn i, acc ->
193 with {:ok, data} <- fun.(i),
194 {:ok, str} <- Jason.encode(data),
195 :ok <- IO.write(file, str <> ",\n") do
196 acc + 1
197 else
198 _ -> acc
199 end
200 end)
201
202 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
203 File.close(file)
204 end
205 end
206 end
207
208 defp bookmarks(dir, %{id: user_id} = _user) do
209 Bookmark
210 |> where(user_id: ^user_id)
211 |> join(:inner, [b], activity in assoc(b, :activity))
212 |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
213 |> write(dir, "bookmarks", fn a -> {:ok, a.object} end)
214 end
215
216 defp likes(dir, user) do
217 user.ap_id
218 |> Activity.Queries.by_actor()
219 |> Activity.Queries.by_type("Like")
220 |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
221 |> write(dir, "likes", fn a -> {:ok, a.object} end)
222 end
223
224 defp statuses(dir, user) do
225 opts =
226 %{}
227 |> Map.put(:type, ["Create", "Announce"])
228 |> Map.put(:actor_id, user.ap_id)
229
230 [
231 [Pleroma.Constants.as_public(), user.ap_id],
232 User.following(user),
233 Pleroma.List.memberships(user)
234 ]
235 |> Enum.concat()
236 |> ActivityPub.fetch_activities_query(opts)
237 |> write(dir, "outbox", fn a ->
238 with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
239 {:ok, Map.delete(activity, "@context")}
240 end
241 end)
242 end
243 end