Note that openbsd needs erlang-wx
[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} <- :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