Add `backups` table
[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 {:ok, backup}
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 = 7
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 process(%__MODULE__{} = backup) do
75 with {:ok, zip_file} <- zip(backup),
76 {:ok, %{size: size}} <- File.stat(zip_file),
77 {:ok, _upload} <- upload(backup, zip_file) do
78 backup
79 |> cast(%{file_size: size, processed: true}, [:file_size, :processed])
80 |> Repo.update()
81 end
82 end
83
84 @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json']
85 def zip(%__MODULE__{} = backup) do
86 backup = Repo.preload(backup, :user)
87 name = String.trim_trailing(backup.file_name, ".zip")
88 dir = Path.join(System.tmp_dir!(), name)
89
90 with :ok <- File.mkdir(dir),
91 :ok <- actor(dir, backup.user),
92 :ok <- statuses(dir, backup.user),
93 :ok <- likes(dir, backup.user),
94 :ok <- bookmarks(dir, backup.user),
95 {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir),
96 {:ok, _} <- File.rm_rf(dir) do
97 {:ok, :binary.list_to_bin(zip_path)}
98 end
99 end
100
101 def upload(%__MODULE__{} = backup, zip_path) do
102 uploader = Pleroma.Config.get([Pleroma.Upload, :uploader])
103
104 upload = %Pleroma.Upload{
105 name: backup.file_name,
106 tempfile: zip_path,
107 content_type: backup.content_type,
108 path: "backups/" <> backup.file_name
109 }
110
111 with {:ok, _} <- Pleroma.Uploaders.Uploader.put_file(uploader, upload),
112 :ok <- File.rm(zip_path) do
113 {:ok, upload}
114 end
115 end
116
117 defp actor(dir, user) do
118 with {:ok, json} <-
119 UserView.render("user.json", %{user: user})
120 |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"})
121 |> Jason.encode() do
122 File.write(dir <> "/actor.json", json)
123 end
124 end
125
126 defp write_header(file, name) do
127 IO.write(
128 file,
129 """
130 {
131 "@context": "https://www.w3.org/ns/activitystreams",
132 "id": "#{name}.json",
133 "type": "OrderedCollection",
134 "orderedItems": [
135 """
136 )
137 end
138
139 defp write(query, dir, name, fun) do
140 path = dir <> "/#{name}.json"
141
142 with {:ok, file} <- File.open(path, [:write, :utf8]),
143 :ok <- write_header(file, name) do
144 counter = :counters.new(1, [])
145
146 query
147 |> Pleroma.RepoStreamer.chunk_stream(100)
148 |> Stream.each(fn items ->
149 Enum.each(items, fn i ->
150 with {:ok, str} <- fun.(i),
151 :ok <- IO.write(file, str <> ",\n") do
152 :counters.add(counter, 1, 1)
153 end
154 end)
155 end)
156 |> Stream.run()
157
158 total = :counters.get(counter, 1)
159
160 with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do
161 File.close(file)
162 end
163 end
164 end
165
166 defp bookmarks(dir, %{id: user_id} = _user) do
167 Bookmark
168 |> where(user_id: ^user_id)
169 |> join(:inner, [b], activity in assoc(b, :activity))
170 |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)})
171 |> write(dir, "bookmarks", fn a -> {:ok, "\"#{a.object}\""} end)
172 end
173
174 defp likes(dir, user) do
175 user.ap_id
176 |> Activity.Queries.by_actor()
177 |> Activity.Queries.by_type("Like")
178 |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)})
179 |> write(dir, "likes", fn a -> {:ok, "\"#{a.object}\""} end)
180 end
181
182 defp statuses(dir, user) do
183 opts =
184 %{}
185 |> Map.put(:type, ["Create", "Announce"])
186 |> Map.put(:blocking_user, user)
187 |> Map.put(:muting_user, user)
188 |> Map.put(:reply_filtering_user, user)
189 |> Map.put(:announce_filtering_user, user)
190 |> Map.put(:user, user)
191
192 [[user.ap_id], User.following(user), Pleroma.List.memberships(user)]
193 |> Enum.concat()
194 |> ActivityPub.fetch_activities_query(opts)
195 |> write(dir, "outbox", fn a ->
196 with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do
197 activity |> Map.delete("@context") |> Jason.encode()
198 end
199 end)
200 end
201 end