Implememt emoji pack file updating + write tests
[akkoma] / lib / pleroma / web / emoji_api / emoji_api_controller.ex
1 defmodule Pleroma.Web.EmojiAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
3
4 require Logger
5
6 def reload(conn, _params) do
7 Pleroma.Emoji.reload()
8
9 conn |> text("ok")
10 end
11
12 @emoji_dir_path Path.join(
13 Pleroma.Config.get!([:instance, :static_dir]),
14 "emoji"
15 )
16
17 @cache_seconds_per_file Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
18
19 def list_packs(conn, _params) do
20 pack_infos =
21 case File.ls(@emoji_dir_path) do
22 {:error, _} ->
23 %{}
24
25 {:ok, results} ->
26 results
27 |> Enum.filter(fn file ->
28 dir_path = Path.join(@emoji_dir_path, file)
29 # Filter to only use the pack.json packs
30 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
31 end)
32 |> Enum.map(fn pack_name ->
33 pack_path = Path.join(@emoji_dir_path, pack_name)
34 pack_file = Path.join(pack_path, "pack.json")
35
36 {pack_name, Jason.decode!(File.read!(pack_file))}
37 end)
38 # Transform into a map of pack-name => pack-data
39 # Check if all the files are in place and can be sent
40 |> Enum.map(fn {name, pack} ->
41 pack_path = Path.join(@emoji_dir_path, name)
42
43 if can_download?(pack, pack_path) do
44 archive_for_sha = make_archive(name, pack, pack_path)
45 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
46
47 {name,
48 pack
49 |> put_in(["pack", "can-download"], true)
50 |> put_in(["pack", "download-sha256"], archive_sha)}
51 else
52 {name,
53 pack
54 |> put_in(["pack", "can-download"], false)}
55 end
56 end)
57 |> Enum.into(%{})
58 end
59
60 conn |> json(pack_infos)
61 end
62
63 defp can_download?(pack, pack_path) do
64 # If the pack is set as shared, check if it can be downloaded
65 # That means that when asked, the pack can be packed and sent to the remote
66 # Otherwise, they'd have to download it from external-src
67 pack["pack"]["share-files"] and
68 Enum.all?(pack["files"], fn {_, path} ->
69 File.exists?(Path.join(pack_path, path))
70 end)
71 end
72
73 defp create_archive_and_cache(name, pack, pack_dir, md5) do
74 files =
75 ['pack.json'] ++
76 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
77
78 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
79
80 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
81
82 Cachex.put!(
83 :emoji_packs_cache,
84 name,
85 # if pack.json MD5 changes, the cache is not valid anymore
86 %{pack_json_md5: md5, pack_data: zip_result},
87 # Add a minute to cache time for every file in the pack
88 ttl: cache_ms
89 )
90
91 Logger.debug("Create an archive for the '#{name}' emoji pack, \
92 keeping it in cache for #{div(cache_ms, 1000)}s")
93
94 zip_result
95 end
96
97 defp make_archive(name, pack, pack_dir) do
98 # Having a different pack.json md5 invalidates cache
99 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
100
101 maybe_cached_pack = Cachex.get!(:emoji_packs_cache, name)
102
103 zip_result =
104 if is_nil(maybe_cached_pack) do
105 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
106 else
107 if maybe_cached_pack[:pack_file_md5] == pack_file_md5 do
108 Logger.debug("Using cache for the '#{name}' shared emoji pack")
109
110 maybe_cached_pack[:pack_data]
111 else
112 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
113 end
114 end
115
116 zip_result
117 end
118
119 def download_shared(conn, %{"name" => name}) do
120 pack_dir = Path.join(@emoji_dir_path, name)
121 pack_file = Path.join(pack_dir, "pack.json")
122
123 if File.exists?(pack_file) do
124 pack = Jason.decode!(File.read!(pack_file))
125
126 if can_download?(pack, pack_dir) do
127 zip_result = make_archive(name, pack, pack_dir)
128
129 conn
130 |> send_download({:binary, zip_result}, filename: "#{name}.zip")
131 else
132 {:error,
133 conn
134 |> put_status(:forbidden)
135 |> text("Pack #{name} cannot be downloaded from this instance, either pack sharing\
136 was disabled for this pack or some files are missing")}
137 end
138 else
139 {:error,
140 conn
141 |> put_status(:not_found)
142 |> text("Pack #{name} does not exist")}
143 end
144 end
145
146 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
147 list_uri = "#{address}/api/pleroma/emoji/packs/list"
148
149 list = Tesla.get!(list_uri).body |> Jason.decode!()
150 full_pack = list[name]
151 pfiles = full_pack["files"]
152 pack = full_pack["pack"]
153
154 pack_info_res =
155 cond do
156 pack["share-files"] && pack["can-download"] ->
157 {:ok,
158 %{
159 sha: pack["download-sha256"],
160 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
161 }}
162
163 pack["fallback-src"] ->
164 {:ok,
165 %{
166 sha: pack["fallback-src-sha256"],
167 uri: pack["fallback-src"],
168 fallback: true
169 }}
170
171 true ->
172 {:error, "The pack was not set as shared and there is no fallback src to download from"}
173 end
174
175 case pack_info_res do
176 {:ok, %{sha: sha, uri: uri} = pinfo} ->
177 sha = Base.decode16!(sha)
178 emoji_archive = Tesla.get!(uri).body
179
180 got_sha = :crypto.hash(:sha256, emoji_archive)
181
182 if got_sha == sha do
183 local_name = data["as"] || name
184 pack_dir = Path.join(@emoji_dir_path, local_name)
185 File.mkdir_p!(pack_dir)
186
187 # Fallback cannot contain a pack.json file
188 files =
189 unless(pinfo[:fallback], do: ['pack.json'], else: []) ++
190 (pfiles |> Enum.map(fn {_, path} -> to_charlist(path) end))
191
192 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
193
194 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
195 # in it to depend on itself
196 if pinfo[:fallback] do
197 pack_file_path = Path.join(pack_dir, "pack.json")
198
199 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
200 end
201
202 conn |> text("ok")
203 else
204 conn
205 |> put_status(:internal_server_error)
206 |> text("SHA256 for the pack doesn't match the one sent by the server")
207 end
208
209 {:error, e} ->
210 conn |> put_status(:internal_server_error) |> text(e)
211 end
212 end
213
214 def delete(conn, %{"name" => name}) do
215 pack_dir = Path.join(@emoji_dir_path, name)
216
217 case File.rm_rf(pack_dir) do
218 {:ok, _} ->
219 conn |> text("ok")
220
221 {:error, _} ->
222 conn |> put_status(:internal_server_error) |> text("Couldn't delete the pack #{name}")
223 end
224 end
225
226 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
227 pack_dir = Path.join(@emoji_dir_path, name)
228 pack_file_p = Path.join(pack_dir, "pack.json")
229
230 full_pack = Jason.decode!(File.read!(pack_file_p))
231
232 # The new fallback-src is in the new data and it's not the same as it was in the old data
233 should_update_fb_sha =
234 not is_nil(new_data["fallback-src"]) and
235 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
236
237 new_data =
238 if should_update_fb_sha do
239 pack_arch = Tesla.get!(new_data["fallback-src"]).body
240
241 {:ok, flist} = :zip.unzip(pack_arch, [:memory])
242
243 # Check if all files from the pack.json are in the archive
244 has_all_files =
245 Enum.all?(full_pack["files"], fn {_, from_manifest} ->
246 Enum.find(flist, fn {from_archive, _} ->
247 to_string(from_archive) == from_manifest
248 end)
249 end)
250
251 unless has_all_files do
252 {:error,
253 conn
254 |> put_status(:bad_request)
255 |> text("The fallback archive does not have all files specified in pack.json")}
256 else
257 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
258
259 {:ok, new_data |> Map.put("fallback-src-sha256", fallback_sha)}
260 end
261 else
262 {:ok, new_data}
263 end
264
265 case new_data do
266 {:ok, new_data} ->
267 full_pack = Map.put(full_pack, "pack", new_data)
268 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
269
270 # Send new data back with fallback sha filled
271 conn |> json(new_data)
272
273 {:error, e} ->
274 e
275 end
276 end
277
278 def update_file(
279 conn,
280 %{"pack_name" => pack_name, "action" => action, "shortcode" => shortcode} = params
281 ) do
282 pack_dir = Path.join(@emoji_dir_path, pack_name)
283 pack_file_p = Path.join(pack_dir, "pack.json")
284
285 full_pack = Jason.decode!(File.read!(pack_file_p))
286
287 res =
288 case action do
289 "add" ->
290 unless Map.has_key?(full_pack["files"], shortcode) do
291 with %{"file" => %Plug.Upload{filename: filename, path: upload_path}} <- params do
292 # If there was a file name provided with the request, use it, otherwise just use the
293 # uploaded file name
294 filename =
295 if Map.has_key?(params, "filename") do
296 params["filename"]
297 else
298 filename
299 end
300
301 file_path = Path.join(pack_dir, filename)
302
303 # If the name contains directories, create them
304 if String.contains?(file_path, "/") do
305 File.mkdir_p!(Path.dirname(file_path))
306 end
307
308 # Copy the uploaded file from the temporary directory
309 File.copy!(upload_path, file_path)
310
311 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
312
313 {:ok, updated_full_pack}
314 else
315 _ -> {:error, conn |> put_status(:bad_request) |> text("\"file\" not provided")}
316 end
317 else
318 {:error,
319 conn
320 |> put_status(:conflict)
321 |> text("An emoji with the \"#{shortcode}\" shortcode already exists")}
322 end
323
324 "remove" ->
325 if Map.has_key?(full_pack["files"], shortcode) do
326 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
327
328 emoji_file_path = Path.join(pack_dir, emoji_file_path)
329
330 # Delete the emoji file
331 File.rm!(emoji_file_path)
332
333 # If the old directory has no more files, remove it
334 if String.contains?(emoji_file_path, "/") do
335 dir = Path.dirname(emoji_file_path)
336
337 if Enum.empty?(File.ls!(dir)) do
338 File.rmdir!(dir)
339 end
340 end
341
342 {:ok, updated_full_pack}
343 else
344 {:error,
345 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
346 end
347
348 "update" ->
349 if Map.has_key?(full_pack["files"], shortcode) do
350 with %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params do
351 # First, remove the old shortcode, saving the old path
352 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
353 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
354 new_emoji_file_path = Path.join(pack_dir, new_filename)
355
356 # If the name contains directories, create them
357 if String.contains?(new_emoji_file_path, "/") do
358 File.mkdir_p!(Path.dirname(new_emoji_file_path))
359 end
360
361 # Move/Rename the old filename to a new filename
362 # These are probably on the same filesystem, so just rename should work
363 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
364
365 # If the old directory has no more files, remove it
366 if String.contains?(old_emoji_file_path, "/") do
367 dir = Path.dirname(old_emoji_file_path)
368
369 if Enum.empty?(File.ls!(dir)) do
370 File.rmdir!(dir)
371 end
372 end
373
374 # Then, put in the new shortcode with the new path
375 updated_full_pack =
376 put_in(updated_full_pack, ["files", new_shortcode], new_filename)
377
378 {:ok, updated_full_pack}
379 else
380 _ ->
381 {:error,
382 conn
383 |> put_status(:bad_request)
384 |> text("new_shortcode or new_file were not specified")}
385 end
386 else
387 {:error,
388 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
389 end
390
391 _ ->
392 {:error, conn |> put_status(:bad_request) |> text("Unknown action: #{action}")}
393 end
394
395 case res do
396 {:ok, updated_full_pack} ->
397 # Write the emoji pack file
398 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
399
400 # Return the modified file list
401 conn |> json(updated_full_pack["files"])
402
403 {:error, e} ->
404 e
405 end
406 end
407 end