e384b6b0080aaf5886fc2d2673d77feb72702c05
[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 alias Pleroma.Activity
12 alias Pleroma.Bookmark
13 alias Pleroma.Repo
14 alias Pleroma.User
15 alias Pleroma.Web.ActivityPub.ActivityPub
16 alias Pleroma.Web.ActivityPub.Transmogrifier
17 alias Pleroma.Web.ActivityPub.UserView
18
19 schema "backups" do
20 field(:content_type, :string)
21 field(:file_name, :string)
22 field(:file_size, :integer, default: 0)
23 field(:processed, :boolean, default: false)
24
25 belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
26
27 timestamps()
28 end
29
30 def create(user) do
31 with :ok <- validate_limit(user),
32 {:ok, backup} <- user |> new() |> Repo.insert() do
33 Pleroma.Workers.BackupWorker.enqueue("process", %{"backup_id" => backup.id})
34 end
35 end
36
37 def new(user) do
38 rand_str = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
39 datetime = Calendar.NaiveDateTime.Format.iso8601_basic(NaiveDateTime.utc_now())
40 name = "archive-#{user.nickname}-#{datetime}-#{rand_str}.zip"
41
42 %__MODULE__{
43 user_id: user.id,
44 content_type: "application/zip",
45 file_name: name
46 }
47 end
48
49 defp validate_limit(user) do
50 case get_last(user.id) do
51 %__MODULE__{inserted_at: inserted_at} ->
52 days = Pleroma.Config.get([Pleroma.Backup, :limit_days])
53 diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days)
54
55 if diff > days do
56 :ok
57 else
58 {:error, "Last export was less than #{days} days ago"}
59 end
60
61 nil ->
62 :ok
63 end
64 end
65
66 def get_last(user_id) do
67 __MODULE__
68 |> where(user_id: ^user_id)
69 |> order_by(desc: :id)
70 |> limit(1)
71 |> Repo.one()
72 end
73
74 def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do
75 __MODULE__
76 |> where(user_id: ^user_id)
77 |> where([b], b.id != ^latest_id)
78 |> Repo.delete_all()
79 end
80
81 def get(id), do: Repo.get(__MODULE__, id)
82
83 def process(%__MODULE__{} = backup) do
84 with {:ok, zip_file} <- zip(backup),
85 {:ok, %{size: size}} <- File.stat(zip_file),
86 {:ok, _upload} <- upload(backup, zip_file) do
87 backup
88 |> cast(%{file_size: size, processed: true}, [:file_size, :processed])
89 |> Repo.update()
90 end
91 end
92
93 @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json']
94 def zip(%__MODULE__{} = backup) do
95 backup = Repo.preload(backup, :user)
96 name = String.trim_trailing(backup.file_name, ".zip")
97 dir = Path.join(System.tmp_dir!(), name)
98
99 with :ok <- File.mkdir(dir),
100 :ok <- actor(dir, backup.user),
101 :ok <- statuses(dir, backup.user),
102 :ok <- likes(dir, backup.user),
103 :ok <- bookmarks(dir, backup.user),
104 {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir),
105 {:ok, _} <- File.rm_rf(dir) do
106 {:ok, :binary.list_to_bin(zip_path)}
107 end
108 end
109
110 def upload(%__MODULE__{} = backup, zip_path) do
111 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
112
113 upload = %Pleroma.Upload{
114 name: backup.file_name,
115 tempfile: zip_path,
116 content_type: backup.content_type,
117 path: "backups/" <> backup.file_name
118 }
119
120 with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
121 :ok <- File.rm(zip_path) do
122 {:ok, upload}
123 end
124 end
125
126 defp actor(dir, user) do
127 with {:ok, json} <-
128 UserView.render("user.json", %{user: user})
129 |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
130 |> Jason.encode() do
131 File.write(dir <> "/actor.json", json)
132 end
133 end
134
135 defp write_header(file, name) do
136 IO.write(
137 file,
138 """
139 {
140 "@context": "https://www.w3.org/ns/activitystreams",
141 "id": "#{name}.json",
142 "type": "OrderedCollection",
143 "orderedItems": [
144 """
145 )
146 end
147
148 defp write(query, dir, name, fun) do
149 path = dir <> "/#{name}.json"
150
151 with {:ok, file} <- File.open(path, [:write, :utf8]),
152 :ok <- write_header(file, name) do
153 counter = :counters.new(1, [])
154
155 query
156 |> Pleroma.RepoStreamer.chunk_stream(100)
157 |> Stream.each(fn items ->
158 Enum.each(items, fn i ->
159 with {:ok, str} <- fun.(i),
160 :ok <- IO.write(file, str <> ",\n") do
161 :counters.add(counter, 1, 1)
162 end
163 end)
164 end)
165 |> Stream.run()
166
167 total = :counters.get(counter, 1)
168
169 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
170 File.close(file)
171 end
172 end
173 end
174
175 defp bookmarks(dir, %{id: user_id} = _user) do
176 Bookmark
177 |> where(user_id: ^user_id)
178 |> join(:inner, [b], activity in assoc(b, :activity))
179 |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
180 |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end)
181 end
182
183 defp likes(dir, user) do
184 user.ap_id
185 |> Activity.Queries.by_actor()
186 |> Activity.Queries.by_type("Like")
187 |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
188 |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end)
189 end
190
191 defp statuses(dir, user) do
192 opts =
193 %{}
194 |> Map.put(:type, ["Create", "Announce"])
195 |> Map.put(:blocking_user, user)
196 |> Map.put(:muting_user, user)
197 |> Map.put(:reply_filtering_user, user)
198 |> Map.put(:announce_filtering_user, user)
199 |> Map.put(:user, user)
200
201 [[user.ap_id], User.following(user), Pleroma.List.memberships(user)]
202 |> Enum.concat()
203 |> ActivityPub.fetch_activities_query(opts)
204 |> write(dir, "outbox", fn a ->
205 with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
206 activity |> Map.delete("@context") |> Jason.encode()
207 end
208 end)
209 end
210 end