Move emoji reloading to admin api
[akkoma] / lib / pleroma / web / pleroma_api / emoji_api_controller.ex
1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
3
4 require Logger
5
6 @emoji_dir_path Path.join(
7 Pleroma.Config.get!([:instance, :static_dir]),
8 "emoji"
9 )
10
11 @cache_seconds_per_file Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
12
13 @doc """
14 Lists the packs available on the instance as JSON.
15
16 The information is public and does not require authentification. The format is
17 a map of "pack directory name" to pack.json contents.
18 """
19 def list_packs(conn, _params) do
20 with {:ok, results} <- File.ls(@emoji_dir_path) do
21 pack_infos =
22 results
23 |> Enum.filter(&has_pack_json?/1)
24 |> Enum.map(&load_pack/1)
25 # Check if all the files are in place and can be sent
26 |> Enum.map(&validate_pack/1)
27 # Transform into a map of pack-name => pack-data
28 |> Enum.into(%{})
29
30 json(conn, pack_infos)
31 end
32 end
33
34 defp has_pack_json?(file) do
35 dir_path = Path.join(@emoji_dir_path, file)
36 # Filter to only use the pack.json packs
37 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
38 end
39
40 defp load_pack(pack_name) do
41 pack_path = Path.join(@emoji_dir_path, pack_name)
42 pack_file = Path.join(pack_path, "pack.json")
43
44 {pack_name, Jason.decode!(File.read!(pack_file))}
45 end
46
47 defp validate_pack({name, pack}) do
48 pack_path = Path.join(@emoji_dir_path, name)
49
50 if can_download?(pack, pack_path) do
51 archive_for_sha = make_archive(name, pack, pack_path)
52 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
53
54 pack =
55 pack
56 |> put_in(["pack", "can-download"], true)
57 |> put_in(["pack", "download-sha256"], archive_sha)
58
59 {name, pack}
60 else
61 {name, put_in(pack, ["pack", "can-download"], false)}
62 end
63 end
64
65 defp can_download?(pack, pack_path) do
66 # If the pack is set as shared, check if it can be downloaded
67 # That means that when asked, the pack can be packed and sent to the remote
68 # Otherwise, they'd have to download it from external-src
69 pack["pack"]["share-files"] &&
70 Enum.all?(pack["files"], fn {_, path} ->
71 File.exists?(Path.join(pack_path, path))
72 end)
73 end
74
75 defp create_archive_and_cache(name, pack, pack_dir, md5) do
76 files =
77 ['pack.json'] ++
78 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
79
80 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
81
82 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
83
84 Cachex.put!(
85 :emoji_packs_cache,
86 name,
87 # if pack.json MD5 changes, the cache is not valid anymore
88 %{pack_json_md5: md5, pack_data: zip_result},
89 # Add a minute to cache time for every file in the pack
90 ttl: cache_ms
91 )
92
93 Logger.debug("Created an archive for the '#{name}' emoji pack, \
94 keeping it in cache for #{div(cache_ms, 1000)}s")
95
96 zip_result
97 end
98
99 defp make_archive(name, pack, pack_dir) do
100 # Having a different pack.json md5 invalidates cache
101 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
102
103 case Cachex.get!(:emoji_packs_cache, name) do
104 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
105 Logger.debug("Using cache for the '#{name}' shared emoji pack")
106 zip_result
107
108 _ ->
109 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
110 end
111 end
112
113 @doc """
114 An endpoint for other instances (via admin UI) or users (via browser)
115 to download packs that the instance shares.
116 """
117 def download_shared(conn, %{"name" => name}) do
118 pack_dir = Path.join(@emoji_dir_path, name)
119 pack_file = Path.join(pack_dir, "pack.json")
120
121 with {_, true} <- {:exists?, File.exists?(pack_file)},
122 pack = Jason.decode!(File.read!(pack_file)),
123 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
124 zip_result = make_archive(name, pack, pack_dir)
125 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
126 else
127 {:can_download?, _} ->
128 conn
129 |> put_status(:forbidden)
130 |> json(%{
131 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
132 was disabled for this pack or some files are missing"
133 })
134
135 {:exists?, _} ->
136 conn
137 |> put_status(:not_found)
138 |> json(%{error: "Pack #{name} does not exist"})
139 end
140 end
141
142 @doc """
143 An admin endpoint to request downloading a pack named `pack_name` from the instance
144 `instance_address`.
145
146 If the requested instance's admin chose to share the pack, it will be downloaded
147 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
148 """
149 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
150 shareable_packs_available =
151 "#{address}/nodeinfo/2.1.json"
152 |> Tesla.get!()
153 |> Map.get(:body)
154 |> Jason.decode!()
155 |> Map.get("features")
156 |> Enum.member?("shareable_emoji_packs")
157
158 if shareable_packs_available do
159 full_pack =
160 "#{address}/api/pleroma/emoji/packs/list"
161 |> Tesla.get!()
162 |> Map.get(:body)
163 |> Jason.decode!()
164 |> Map.get(name)
165
166 pack_info_res =
167 case full_pack["pack"] do
168 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
169 {:ok,
170 %{
171 sha: sha,
172 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
173 }}
174
175 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
176 {:ok,
177 %{
178 sha: sha,
179 uri: src,
180 fallback: true
181 }}
182
183 _ ->
184 {:error,
185 "The pack was not set as shared and there is no fallback src to download from"}
186 end
187
188 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
189 %{body: emoji_archive} <- Tesla.get!(uri),
190 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
191 local_name = data["as"] || name
192 pack_dir = Path.join(@emoji_dir_path, local_name)
193 File.mkdir_p!(pack_dir)
194
195 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
196 # Fallback cannot contain a pack.json file
197 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
198
199 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
200
201 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
202 # in it to depend on itself
203 if pinfo[:fallback] do
204 pack_file_path = Path.join(pack_dir, "pack.json")
205
206 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
207 end
208
209 json(conn, "ok")
210 else
211 {:error, e} ->
212 conn |> put_status(:internal_server_error) |> json(%{error: e})
213
214 {:checksum, _} ->
215 conn
216 |> put_status(:internal_server_error)
217 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
218 end
219 else
220 conn
221 |> put_status(:internal_server_error)
222 |> json(%{error: "The requested instance does not support sharing emoji packs"})
223 end
224 end
225
226 @doc """
227 Creates an empty pack named `name` which then can be updated via the admin UI.
228 """
229 def create(conn, %{"name" => name}) do
230 pack_dir = Path.join(@emoji_dir_path, name)
231
232 if not File.exists?(pack_dir) do
233 File.mkdir_p!(pack_dir)
234
235 pack_file_p = Path.join(pack_dir, "pack.json")
236
237 File.write!(
238 pack_file_p,
239 Jason.encode!(%{pack: %{}, files: %{}})
240 )
241
242 conn |> json("ok")
243 else
244 conn
245 |> put_status(:conflict)
246 |> json(%{error: "A pack named \"#{name}\" already exists"})
247 end
248 end
249
250 @doc """
251 Deletes the pack `name` and all it's files.
252 """
253 def delete(conn, %{"name" => name}) do
254 pack_dir = Path.join(@emoji_dir_path, name)
255
256 case File.rm_rf(pack_dir) do
257 {:ok, _} ->
258 conn |> json("ok")
259
260 {:error, _} ->
261 conn
262 |> put_status(:internal_server_error)
263 |> json(%{error: "Couldn't delete the pack #{name}"})
264 end
265 end
266
267 @doc """
268 An endpoint to update `pack_names`'s metadata.
269
270 `new_data` is the new metadata for the pack, that will replace the old metadata.
271 """
272 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
273 pack_file_p = Path.join([@emoji_dir_path, name, "pack.json"])
274
275 full_pack = Jason.decode!(File.read!(pack_file_p))
276
277 # The new fallback-src is in the new data and it's not the same as it was in the old data
278 should_update_fb_sha =
279 not is_nil(new_data["fallback-src"]) and
280 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
281
282 with {_, true} <- {:should_update?, should_update_fb_sha},
283 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
284 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
285 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
286 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
287
288 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
289 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
290 else
291 {:should_update?, _} ->
292 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
293
294 {:has_all_files?, _} ->
295 conn
296 |> put_status(:bad_request)
297 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
298 end
299 end
300
301 # Check if all files from the pack.json are in the archive
302 defp has_all_files?(%{"files" => files}, flist) do
303 Enum.all?(files, fn {_, from_manifest} ->
304 Enum.find(flist, fn {from_archive, _} ->
305 to_string(from_archive) == from_manifest
306 end)
307 end)
308 end
309
310 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
311 full_pack = Map.put(full_pack, "pack", new_data)
312 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
313
314 # Send new data back with fallback sha filled
315 json(conn, new_data)
316 end
317
318 defp get_filename(%{"filename" => filename}), do: filename
319
320 defp get_filename(%{"file" => file}) do
321 case file do
322 %Plug.Upload{filename: filename} -> filename
323 url when is_binary(url) -> Path.basename(url)
324 end
325 end
326
327 defp empty?(str), do: String.trim(str) == ""
328
329 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
330 # Write the emoji pack file
331 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
332
333 # Return the modified file list
334 json(conn, updated_full_pack["files"])
335 end
336
337 @doc """
338 Updates a file in a pack.
339
340 Updating can mean three things:
341
342 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
343 that means that the emoji file needs to be uploaded with the request
344 (thus requiring it to be a multipart request) and be named `file`.
345 There can also be an optional `filename` that will be the new emoji file name
346 (if it's not there, the name will be taken from the uploaded file).
347 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
348 (from the current filename to `new_filename`)
349 - `remove` removes the emoji named `shortcode` and it's associated file
350 """
351
352 # Add
353 def update_file(
354 conn,
355 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
356 ) do
357 pack_dir = Path.join(@emoji_dir_path, pack_name)
358 pack_file_p = Path.join(pack_dir, "pack.json")
359
360 full_pack = Jason.decode!(File.read!(pack_file_p))
361
362 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
363 filename <- get_filename(params),
364 false <- empty?(shortcode),
365 false <- empty?(filename) do
366 file_path = Path.join(pack_dir, filename)
367
368 # If the name contains directories, create them
369 if String.contains?(file_path, "/") do
370 File.mkdir_p!(Path.dirname(file_path))
371 end
372
373 case params["file"] do
374 %Plug.Upload{path: upload_path} ->
375 # Copy the uploaded file from the temporary directory
376 File.copy!(upload_path, file_path)
377
378 url when is_binary(url) ->
379 # Download and write the file
380 file_contents = Tesla.get!(url).body
381 File.write!(file_path, file_contents)
382 end
383
384 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
385 update_file_and_send(conn, updated_full_pack, pack_file_p)
386 else
387 {:has_shortcode, _} ->
388 conn
389 |> put_status(:conflict)
390 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
391
392 true ->
393 conn
394 |> put_status(:bad_request)
395 |> json(%{error: "shortcode or filename cannot be empty"})
396 end
397 end
398
399 # Remove
400 def update_file(conn, %{
401 "pack_name" => pack_name,
402 "action" => "remove",
403 "shortcode" => shortcode
404 }) do
405 pack_dir = Path.join(@emoji_dir_path, pack_name)
406 pack_file_p = Path.join(pack_dir, "pack.json")
407
408 full_pack = Jason.decode!(File.read!(pack_file_p))
409
410 if Map.has_key?(full_pack["files"], shortcode) do
411 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
412
413 emoji_file_path = Path.join(pack_dir, emoji_file_path)
414
415 # Delete the emoji file
416 File.rm!(emoji_file_path)
417
418 # If the old directory has no more files, remove it
419 if String.contains?(emoji_file_path, "/") do
420 dir = Path.dirname(emoji_file_path)
421
422 if Enum.empty?(File.ls!(dir)) do
423 File.rmdir!(dir)
424 end
425 end
426
427 update_file_and_send(conn, updated_full_pack, pack_file_p)
428 else
429 conn
430 |> put_status(:bad_request)
431 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
432 end
433 end
434
435 # Update
436 def update_file(
437 conn,
438 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
439 ) do
440 pack_dir = Path.join(@emoji_dir_path, pack_name)
441 pack_file_p = Path.join(pack_dir, "pack.json")
442
443 full_pack = Jason.decode!(File.read!(pack_file_p))
444
445 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
446 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
447 false <- empty?(new_shortcode),
448 false <- empty?(new_filename) do
449 # First, remove the old shortcode, saving the old path
450 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
451 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
452 new_emoji_file_path = Path.join(pack_dir, new_filename)
453
454 # If the name contains directories, create them
455 if String.contains?(new_emoji_file_path, "/") do
456 File.mkdir_p!(Path.dirname(new_emoji_file_path))
457 end
458
459 # Move/Rename the old filename to a new filename
460 # These are probably on the same filesystem, so just rename should work
461 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
462
463 # If the old directory has no more files, remove it
464 if String.contains?(old_emoji_file_path, "/") do
465 dir = Path.dirname(old_emoji_file_path)
466
467 if Enum.empty?(File.ls!(dir)) do
468 File.rmdir!(dir)
469 end
470 end
471
472 # Then, put in the new shortcode with the new path
473 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
474 update_file_and_send(conn, updated_full_pack, pack_file_p)
475 else
476 {:has_shortcode, _} ->
477 conn
478 |> put_status(:bad_request)
479 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
480
481 true ->
482 conn
483 |> put_status(:bad_request)
484 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
485
486 _ ->
487 conn
488 |> put_status(:bad_request)
489 |> json(%{error: "new_shortcode or new_file were not specified"})
490 end
491 end
492
493 def update_file(conn, %{"action" => action}) do
494 conn
495 |> put_status(:bad_request)
496 |> json(%{error: "Unknown action: #{action}"})
497 end
498
499 @doc """
500 Imports emoji from the filesystem.
501
502 Importing means checking all the directories in the
503 `$instance_static/emoji/` for directories which do not have
504 `pack.json`. If one has an emoji.txt file, that file will be used
505 to create a `pack.json` file with it's contents. If the directory has
506 neither, all the files with specific configured extenstions will be
507 assumed to be emojis and stored in the new `pack.json` file.
508 """
509 def import_from_fs(conn, _params) do
510 with {:ok, results} <- File.ls(@emoji_dir_path) do
511 imported_pack_names =
512 results
513 |> Enum.filter(fn file ->
514 dir_path = Path.join(@emoji_dir_path, file)
515 # Find the directories that do NOT have pack.json
516 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
517 end)
518 |> Enum.map(&write_pack_json_contents/1)
519
520 json(conn, imported_pack_names)
521 else
522 {:error, _} ->
523 conn
524 |> put_status(:internal_server_error)
525 |> json(%{error: "Error accessing emoji pack directory"})
526 end
527 end
528
529 defp write_pack_json_contents(dir) do
530 dir_path = Path.join(@emoji_dir_path, dir)
531 emoji_txt_path = Path.join(dir_path, "emoji.txt")
532
533 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
534 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
535
536 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
537
538 dir
539 end
540
541 defp files_for_pack(emoji_txt_path, dir_path) do
542 if File.exists?(emoji_txt_path) do
543 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
544 # Make a pack.json file from the contents of that emoji.txt fileh
545
546 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
547
548 # Create a map of shortcodes to filenames from emoji.txt
549 File.read!(emoji_txt_path)
550 |> String.split("\n")
551 |> Enum.map(&String.trim/1)
552 |> Enum.map(fn line ->
553 case String.split(line, ~r/,\s*/) do
554 # This matches both strings with and without tags
555 # and we don't care about tags here
556 [name, file | _] -> {name, file}
557 _ -> nil
558 end
559 end)
560 |> Enum.filter(fn x -> not is_nil(x) end)
561 |> Enum.into(%{})
562 else
563 # If there's no emoji.txt, assume all files
564 # that are of certain extensions from the config are emojis and import them all
565 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
566 Pleroma.Emoji.make_shortcode_to_file_map(dir_path, pack_extensions)
567 end
568 end
569 end