1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
8 Pleroma.Config.get!([:instance, :static_dir]),
14 Lists packs from the remote instance.
16 Since JS cannot ask remote instances for their packs due to CPS, it has to
19 def list_from(conn, %{"instance_address" => address}) do
20 address = String.trim(address)
22 if shareable_packs_available(address) do
24 "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
29 |> put_status(:internal_server_error)
30 |> json(%{error: "The requested instance does not support sharing emoji packs"})
35 Lists the packs available on the instance as JSON.
37 The information is public and does not require authentification. The format is
38 a map of "pack directory name" to pack.json contents.
40 def list_packs(conn, _params) do
41 # Create the directory first if it does not exist. This is probably the first request made
42 # with the API so it should be sufficient
43 with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())},
44 {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do
47 |> Enum.filter(&has_pack_json?/1)
48 |> Enum.map(&load_pack/1)
49 # Check if all the files are in place and can be sent
50 |> Enum.map(&validate_pack/1)
51 # Transform into a map of pack-name => pack-data
54 json(conn, pack_infos)
56 {:create_dir, {:error, e}} ->
58 |> put_status(:internal_server_error)
59 |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
63 |> put_status(:internal_server_error)
66 "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
71 defp has_pack_json?(file) do
72 dir_path = Path.join(emoji_dir_path(), file)
73 # Filter to only use the pack.json packs
74 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
77 defp load_pack(pack_name) do
78 pack_path = Path.join(emoji_dir_path(), pack_name)
79 pack_file = Path.join(pack_path, "pack.json")
81 {pack_name, Jason.decode!(File.read!(pack_file))}
84 defp validate_pack({name, pack}) do
85 pack_path = Path.join(emoji_dir_path(), name)
87 if can_download?(pack, pack_path) do
88 archive_for_sha = make_archive(name, pack, pack_path)
89 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
93 |> put_in(["pack", "can-download"], true)
94 |> put_in(["pack", "download-sha256"], archive_sha)
98 {name, put_in(pack, ["pack", "can-download"], false)}
102 defp can_download?(pack, pack_path) do
103 # If the pack is set as shared, check if it can be downloaded
104 # That means that when asked, the pack can be packed and sent to the remote
105 # Otherwise, they'd have to download it from external-src
106 pack["pack"]["share-files"] &&
107 Enum.all?(pack["files"], fn {_, path} ->
108 File.exists?(Path.join(pack_path, path))
112 defp create_archive_and_cache(name, pack, pack_dir, md5) do
115 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
117 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
119 cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
120 cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files))
125 # if pack.json MD5 changes, the cache is not valid anymore
126 %{pack_json_md5: md5, pack_data: zip_result},
127 # Add a minute to cache time for every file in the pack
131 Logger.debug("Created an archive for the '#{name}' emoji pack, \
132 keeping it in cache for #{div(cache_ms, 1000)}s")
137 defp make_archive(name, pack, pack_dir) do
138 # Having a different pack.json md5 invalidates cache
139 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
141 case Cachex.get!(:emoji_packs_cache, name) do
142 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
143 Logger.debug("Using cache for the '#{name}' shared emoji pack")
147 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
152 An endpoint for other instances (via admin UI) or users (via browser)
153 to download packs that the instance shares.
155 def download_shared(conn, %{"name" => name}) do
156 pack_dir = Path.join(emoji_dir_path(), name)
157 pack_file = Path.join(pack_dir, "pack.json")
159 with {_, true} <- {:exists?, File.exists?(pack_file)},
160 pack = Jason.decode!(File.read!(pack_file)),
161 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
162 zip_result = make_archive(name, pack, pack_dir)
163 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
165 {:can_download?, _} ->
167 |> put_status(:forbidden)
169 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
170 was disabled for this pack or some files are missing"
175 |> put_status(:not_found)
176 |> json(%{error: "Pack #{name} does not exist"})
180 defp shareable_packs_available(address) do
181 "#{address}/.well-known/nodeinfo"
188 # Get the actual nodeinfo address and fetch it
192 |> get_in(["metadata", "features"])
193 |> Enum.member?("shareable_emoji_packs")
197 An admin endpoint to request downloading a pack named `pack_name` from the instance
200 If the requested instance's admin chose to share the pack, it will be downloaded
201 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
203 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
204 address = String.trim(address)
206 if shareable_packs_available(address) do
208 "#{address}/api/pleroma/emoji/packs/list"
215 case full_pack["pack"] do
216 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
220 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
223 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
233 "The pack was not set as shared and there is no fallback src to download from"}
236 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
237 %{body: emoji_archive} <- Tesla.get!(uri),
238 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
239 local_name = data["as"] || name
240 pack_dir = Path.join(emoji_dir_path(), local_name)
241 File.mkdir_p!(pack_dir)
243 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
244 # Fallback cannot contain a pack.json file
245 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
247 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
249 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
250 # in it to depend on itself
251 if pinfo[:fallback] do
252 pack_file_path = Path.join(pack_dir, "pack.json")
254 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
260 conn |> put_status(:internal_server_error) |> json(%{error: e})
264 |> put_status(:internal_server_error)
265 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
269 |> put_status(:internal_server_error)
270 |> json(%{error: "The requested instance does not support sharing emoji packs"})
275 Creates an empty pack named `name` which then can be updated via the admin UI.
277 def create(conn, %{"name" => name}) do
278 pack_dir = Path.join(emoji_dir_path(), name)
280 if not File.exists?(pack_dir) do
281 File.mkdir_p!(pack_dir)
283 pack_file_p = Path.join(pack_dir, "pack.json")
287 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
293 |> put_status(:conflict)
294 |> json(%{error: "A pack named \"#{name}\" already exists"})
299 Deletes the pack `name` and all it's files.
301 def delete(conn, %{"name" => name}) do
302 pack_dir = Path.join(emoji_dir_path(), name)
304 case File.rm_rf(pack_dir) do
310 |> put_status(:internal_server_error)
311 |> json(%{error: "Couldn't delete the pack #{name}"})
316 An endpoint to update `pack_names`'s metadata.
318 `new_data` is the new metadata for the pack, that will replace the old metadata.
320 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
321 pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"])
323 full_pack = Jason.decode!(File.read!(pack_file_p))
325 # The new fallback-src is in the new data and it's not the same as it was in the old data
326 should_update_fb_sha =
327 not is_nil(new_data["fallback-src"]) and
328 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
330 with {_, true} <- {:should_update?, should_update_fb_sha},
331 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
332 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
333 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
334 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
336 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
337 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
339 {:should_update?, _} ->
340 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
342 {:has_all_files?, _} ->
344 |> put_status(:bad_request)
345 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
349 # Check if all files from the pack.json are in the archive
350 defp has_all_files?(%{"files" => files}, flist) do
351 Enum.all?(files, fn {_, from_manifest} ->
352 Enum.find(flist, fn {from_archive, _} ->
353 to_string(from_archive) == from_manifest
358 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
359 full_pack = Map.put(full_pack, "pack", new_data)
360 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
362 # Send new data back with fallback sha filled
366 defp get_filename(%{"filename" => filename}), do: filename
368 defp get_filename(%{"file" => file}) do
370 %Plug.Upload{filename: filename} -> filename
371 url when is_binary(url) -> Path.basename(url)
375 defp empty?(str), do: String.trim(str) == ""
377 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
378 # Write the emoji pack file
379 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
381 # Return the modified file list
382 json(conn, updated_full_pack["files"])
386 Updates a file in a pack.
388 Updating can mean three things:
390 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
391 that means that the emoji file needs to be uploaded with the request
392 (thus requiring it to be a multipart request) and be named `file`.
393 There can also be an optional `filename` that will be the new emoji file name
394 (if it's not there, the name will be taken from the uploaded file).
395 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
396 (from the current filename to `new_filename`)
397 - `remove` removes the emoji named `shortcode` and it's associated file
403 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
405 pack_dir = Path.join(emoji_dir_path(), pack_name)
406 pack_file_p = Path.join(pack_dir, "pack.json")
408 full_pack = Jason.decode!(File.read!(pack_file_p))
410 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
411 filename <- get_filename(params),
412 false <- empty?(shortcode),
413 false <- empty?(filename) do
414 file_path = Path.join(pack_dir, filename)
416 # If the name contains directories, create them
417 if String.contains?(file_path, "/") do
418 File.mkdir_p!(Path.dirname(file_path))
421 case params["file"] do
422 %Plug.Upload{path: upload_path} ->
423 # Copy the uploaded file from the temporary directory
424 File.copy!(upload_path, file_path)
426 url when is_binary(url) ->
427 # Download and write the file
428 file_contents = Tesla.get!(url).body
429 File.write!(file_path, file_contents)
432 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
433 update_file_and_send(conn, updated_full_pack, pack_file_p)
435 {:has_shortcode, _} ->
437 |> put_status(:conflict)
438 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
442 |> put_status(:bad_request)
443 |> json(%{error: "shortcode or filename cannot be empty"})
448 def update_file(conn, %{
449 "pack_name" => pack_name,
450 "action" => "remove",
451 "shortcode" => shortcode
453 pack_dir = Path.join(emoji_dir_path(), pack_name)
454 pack_file_p = Path.join(pack_dir, "pack.json")
456 full_pack = Jason.decode!(File.read!(pack_file_p))
458 if Map.has_key?(full_pack["files"], shortcode) do
459 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
461 emoji_file_path = Path.join(pack_dir, emoji_file_path)
463 # Delete the emoji file
464 File.rm!(emoji_file_path)
466 # If the old directory has no more files, remove it
467 if String.contains?(emoji_file_path, "/") do
468 dir = Path.dirname(emoji_file_path)
470 if Enum.empty?(File.ls!(dir)) do
475 update_file_and_send(conn, updated_full_pack, pack_file_p)
478 |> put_status(:bad_request)
479 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
486 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
488 pack_dir = Path.join(emoji_dir_path(), pack_name)
489 pack_file_p = Path.join(pack_dir, "pack.json")
491 full_pack = Jason.decode!(File.read!(pack_file_p))
493 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
494 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
495 false <- empty?(new_shortcode),
496 false <- empty?(new_filename) do
497 # First, remove the old shortcode, saving the old path
498 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
499 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
500 new_emoji_file_path = Path.join(pack_dir, new_filename)
502 # If the name contains directories, create them
503 if String.contains?(new_emoji_file_path, "/") do
504 File.mkdir_p!(Path.dirname(new_emoji_file_path))
507 # Move/Rename the old filename to a new filename
508 # These are probably on the same filesystem, so just rename should work
509 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
511 # If the old directory has no more files, remove it
512 if String.contains?(old_emoji_file_path, "/") do
513 dir = Path.dirname(old_emoji_file_path)
515 if Enum.empty?(File.ls!(dir)) do
520 # Then, put in the new shortcode with the new path
521 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
522 update_file_and_send(conn, updated_full_pack, pack_file_p)
524 {:has_shortcode, _} ->
526 |> put_status(:bad_request)
527 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
531 |> put_status(:bad_request)
532 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
536 |> put_status(:bad_request)
537 |> json(%{error: "new_shortcode or new_file were not specified"})
541 def update_file(conn, %{"action" => action}) do
543 |> put_status(:bad_request)
544 |> json(%{error: "Unknown action: #{action}"})
548 Imports emoji from the filesystem.
550 Importing means checking all the directories in the
551 `$instance_static/emoji/` for directories which do not have
552 `pack.json`. If one has an emoji.txt file, that file will be used
553 to create a `pack.json` file with it's contents. If the directory has
554 neither, all the files with specific configured extenstions will be
555 assumed to be emojis and stored in the new `pack.json` file.
557 def import_from_fs(conn, _params) do
558 with {:ok, results} <- File.ls(emoji_dir_path()) do
559 imported_pack_names =
561 |> Enum.filter(fn file ->
562 dir_path = Path.join(emoji_dir_path(), file)
563 # Find the directories that do NOT have pack.json
564 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
566 |> Enum.map(&write_pack_json_contents/1)
568 json(conn, imported_pack_names)
572 |> put_status(:internal_server_error)
573 |> json(%{error: "Error accessing emoji pack directory"})
577 defp write_pack_json_contents(dir) do
578 dir_path = Path.join(emoji_dir_path(), dir)
579 emoji_txt_path = Path.join(dir_path, "emoji.txt")
581 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
582 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
584 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
589 defp files_for_pack(emoji_txt_path, dir_path) do
590 if File.exists?(emoji_txt_path) do
591 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
592 # Make a pack.json file from the contents of that emoji.txt fileh
594 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
596 # Create a map of shortcodes to filenames from emoji.txt
597 File.read!(emoji_txt_path)
598 |> String.split("\n")
599 |> Enum.map(&String.trim/1)
600 |> Enum.map(fn line ->
601 case String.split(line, ~r/,\s*/) do
602 # This matches both strings with and without tags
603 # and we don't care about tags here
604 [name, file | _] -> {name, file}
608 |> Enum.filter(fn x -> not is_nil(x) end)
611 # If there's no emoji.txt, assume all files
612 # that are of certain extensions from the config are emojis and import them all
613 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
614 Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)