1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
6 @emoji_dir_path Path.join(
7 Pleroma.Config.get!([:instance, :static_dir]),
11 @cache_seconds_per_file Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
14 Lists the packs available on the instance as JSON.
16 The information is public and does not require authentification. The format is
17 a map of "pack directory name" to pack.json contents.
19 def list_packs(conn, _params) do
20 # Create the directory first if it does not exist. This is probably the first request made
21 # with the API so it should be sufficient
22 with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(@emoji_dir_path)},
23 {:ls, {:ok, results}} <- {:ls, File.ls(@emoji_dir_path)} do
26 |> Enum.filter(&has_pack_json?/1)
27 |> Enum.map(&load_pack/1)
28 # Check if all the files are in place and can be sent
29 |> Enum.map(&validate_pack/1)
30 # Transform into a map of pack-name => pack-data
33 json(conn, pack_infos)
35 {:create_dir, {:error, e}} ->
37 |> put_status(:internal_server_error)
38 |> json(%{error: "Failed to create the emoji pack directory at #{@emoji_dir_path}: #{e}"})
42 |> put_status(:internal_server_error)
45 "Failed to get the contents of the emoji pack directory at #{@emoji_dir_path}: #{e}"
50 defp has_pack_json?(file) do
51 dir_path = Path.join(@emoji_dir_path, file)
52 # Filter to only use the pack.json packs
53 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
56 defp load_pack(pack_name) do
57 pack_path = Path.join(@emoji_dir_path, pack_name)
58 pack_file = Path.join(pack_path, "pack.json")
60 {pack_name, Jason.decode!(File.read!(pack_file))}
63 defp validate_pack({name, pack}) do
64 pack_path = Path.join(@emoji_dir_path, name)
66 if can_download?(pack, pack_path) do
67 archive_for_sha = make_archive(name, pack, pack_path)
68 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
72 |> put_in(["pack", "can-download"], true)
73 |> put_in(["pack", "download-sha256"], archive_sha)
77 {name, put_in(pack, ["pack", "can-download"], false)}
81 defp can_download?(pack, pack_path) do
82 # If the pack is set as shared, check if it can be downloaded
83 # That means that when asked, the pack can be packed and sent to the remote
84 # Otherwise, they'd have to download it from external-src
85 pack["pack"]["share-files"] &&
86 Enum.all?(pack["files"], fn {_, path} ->
87 File.exists?(Path.join(pack_path, path))
91 defp create_archive_and_cache(name, pack, pack_dir, md5) do
94 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
96 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
98 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
103 # if pack.json MD5 changes, the cache is not valid anymore
104 %{pack_json_md5: md5, pack_data: zip_result},
105 # Add a minute to cache time for every file in the pack
109 Logger.debug("Created an archive for the '#{name}' emoji pack, \
110 keeping it in cache for #{div(cache_ms, 1000)}s")
115 defp make_archive(name, pack, pack_dir) do
116 # Having a different pack.json md5 invalidates cache
117 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
119 case Cachex.get!(:emoji_packs_cache, name) do
120 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
121 Logger.debug("Using cache for the '#{name}' shared emoji pack")
125 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
130 An endpoint for other instances (via admin UI) or users (via browser)
131 to download packs that the instance shares.
133 def download_shared(conn, %{"name" => name}) do
134 pack_dir = Path.join(@emoji_dir_path, name)
135 pack_file = Path.join(pack_dir, "pack.json")
137 with {_, true} <- {:exists?, File.exists?(pack_file)},
138 pack = Jason.decode!(File.read!(pack_file)),
139 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
140 zip_result = make_archive(name, pack, pack_dir)
141 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
143 {:can_download?, _} ->
145 |> put_status(:forbidden)
147 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
148 was disabled for this pack or some files are missing"
153 |> put_status(:not_found)
154 |> json(%{error: "Pack #{name} does not exist"})
159 An admin endpoint to request downloading a pack named `pack_name` from the instance
162 If the requested instance's admin chose to share the pack, it will be downloaded
163 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
165 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
166 shareable_packs_available =
167 "#{address}/.well-known/nodeinfo"
173 # Get the actual nodeinfo address and fetch it
177 |> get_in(["metadata", "features"])
178 |> Enum.member?("shareable_emoji_packs")
180 if shareable_packs_available do
182 "#{address}/api/pleroma/emoji/packs/list"
189 case full_pack["pack"] do
190 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
194 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
197 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
207 "The pack was not set as shared and there is no fallback src to download from"}
210 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
211 %{body: emoji_archive} <- Tesla.get!(uri),
212 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
213 local_name = data["as"] || name
214 pack_dir = Path.join(@emoji_dir_path, local_name)
215 File.mkdir_p!(pack_dir)
217 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
218 # Fallback cannot contain a pack.json file
219 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
221 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
223 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
224 # in it to depend on itself
225 if pinfo[:fallback] do
226 pack_file_path = Path.join(pack_dir, "pack.json")
228 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
234 conn |> put_status(:internal_server_error) |> json(%{error: e})
238 |> put_status(:internal_server_error)
239 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
243 |> put_status(:internal_server_error)
244 |> json(%{error: "The requested instance does not support sharing emoji packs"})
249 Creates an empty pack named `name` which then can be updated via the admin UI.
251 def create(conn, %{"name" => name}) do
252 pack_dir = Path.join(@emoji_dir_path, name)
254 if not File.exists?(pack_dir) do
255 File.mkdir_p!(pack_dir)
257 pack_file_p = Path.join(pack_dir, "pack.json")
261 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
267 |> put_status(:conflict)
268 |> json(%{error: "A pack named \"#{name}\" already exists"})
273 Deletes the pack `name` and all it's files.
275 def delete(conn, %{"name" => name}) do
276 pack_dir = Path.join(@emoji_dir_path, name)
278 case File.rm_rf(pack_dir) do
284 |> put_status(:internal_server_error)
285 |> json(%{error: "Couldn't delete the pack #{name}"})
290 An endpoint to update `pack_names`'s metadata.
292 `new_data` is the new metadata for the pack, that will replace the old metadata.
294 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
295 pack_file_p = Path.join([@emoji_dir_path, name, "pack.json"])
297 full_pack = Jason.decode!(File.read!(pack_file_p))
299 # The new fallback-src is in the new data and it's not the same as it was in the old data
300 should_update_fb_sha =
301 not is_nil(new_data["fallback-src"]) and
302 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
304 with {_, true} <- {:should_update?, should_update_fb_sha},
305 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
306 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
307 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
308 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
310 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
311 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
313 {:should_update?, _} ->
314 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
316 {:has_all_files?, _} ->
318 |> put_status(:bad_request)
319 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
323 # Check if all files from the pack.json are in the archive
324 defp has_all_files?(%{"files" => files}, flist) do
325 Enum.all?(files, fn {_, from_manifest} ->
326 Enum.find(flist, fn {from_archive, _} ->
327 to_string(from_archive) == from_manifest
332 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
333 full_pack = Map.put(full_pack, "pack", new_data)
334 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
336 # Send new data back with fallback sha filled
340 defp get_filename(%{"filename" => filename}), do: filename
342 defp get_filename(%{"file" => file}) do
344 %Plug.Upload{filename: filename} -> filename
345 url when is_binary(url) -> Path.basename(url)
349 defp empty?(str), do: String.trim(str) == ""
351 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
352 # Write the emoji pack file
353 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
355 # Return the modified file list
356 json(conn, updated_full_pack["files"])
360 Updates a file in a pack.
362 Updating can mean three things:
364 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
365 that means that the emoji file needs to be uploaded with the request
366 (thus requiring it to be a multipart request) and be named `file`.
367 There can also be an optional `filename` that will be the new emoji file name
368 (if it's not there, the name will be taken from the uploaded file).
369 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
370 (from the current filename to `new_filename`)
371 - `remove` removes the emoji named `shortcode` and it's associated file
377 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
379 pack_dir = Path.join(@emoji_dir_path, pack_name)
380 pack_file_p = Path.join(pack_dir, "pack.json")
382 full_pack = Jason.decode!(File.read!(pack_file_p))
384 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
385 filename <- get_filename(params),
386 false <- empty?(shortcode),
387 false <- empty?(filename) do
388 file_path = Path.join(pack_dir, filename)
390 # If the name contains directories, create them
391 if String.contains?(file_path, "/") do
392 File.mkdir_p!(Path.dirname(file_path))
395 case params["file"] do
396 %Plug.Upload{path: upload_path} ->
397 # Copy the uploaded file from the temporary directory
398 File.copy!(upload_path, file_path)
400 url when is_binary(url) ->
401 # Download and write the file
402 file_contents = Tesla.get!(url).body
403 File.write!(file_path, file_contents)
406 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
407 update_file_and_send(conn, updated_full_pack, pack_file_p)
409 {:has_shortcode, _} ->
411 |> put_status(:conflict)
412 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
416 |> put_status(:bad_request)
417 |> json(%{error: "shortcode or filename cannot be empty"})
422 def update_file(conn, %{
423 "pack_name" => pack_name,
424 "action" => "remove",
425 "shortcode" => shortcode
427 pack_dir = Path.join(@emoji_dir_path, pack_name)
428 pack_file_p = Path.join(pack_dir, "pack.json")
430 full_pack = Jason.decode!(File.read!(pack_file_p))
432 if Map.has_key?(full_pack["files"], shortcode) do
433 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
435 emoji_file_path = Path.join(pack_dir, emoji_file_path)
437 # Delete the emoji file
438 File.rm!(emoji_file_path)
440 # If the old directory has no more files, remove it
441 if String.contains?(emoji_file_path, "/") do
442 dir = Path.dirname(emoji_file_path)
444 if Enum.empty?(File.ls!(dir)) do
449 update_file_and_send(conn, updated_full_pack, pack_file_p)
452 |> put_status(:bad_request)
453 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
460 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
462 pack_dir = Path.join(@emoji_dir_path, pack_name)
463 pack_file_p = Path.join(pack_dir, "pack.json")
465 full_pack = Jason.decode!(File.read!(pack_file_p))
467 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
468 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
469 false <- empty?(new_shortcode),
470 false <- empty?(new_filename) do
471 # First, remove the old shortcode, saving the old path
472 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
473 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
474 new_emoji_file_path = Path.join(pack_dir, new_filename)
476 # If the name contains directories, create them
477 if String.contains?(new_emoji_file_path, "/") do
478 File.mkdir_p!(Path.dirname(new_emoji_file_path))
481 # Move/Rename the old filename to a new filename
482 # These are probably on the same filesystem, so just rename should work
483 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
485 # If the old directory has no more files, remove it
486 if String.contains?(old_emoji_file_path, "/") do
487 dir = Path.dirname(old_emoji_file_path)
489 if Enum.empty?(File.ls!(dir)) do
494 # Then, put in the new shortcode with the new path
495 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
496 update_file_and_send(conn, updated_full_pack, pack_file_p)
498 {:has_shortcode, _} ->
500 |> put_status(:bad_request)
501 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
505 |> put_status(:bad_request)
506 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
510 |> put_status(:bad_request)
511 |> json(%{error: "new_shortcode or new_file were not specified"})
515 def update_file(conn, %{"action" => action}) do
517 |> put_status(:bad_request)
518 |> json(%{error: "Unknown action: #{action}"})
522 Imports emoji from the filesystem.
524 Importing means checking all the directories in the
525 `$instance_static/emoji/` for directories which do not have
526 `pack.json`. If one has an emoji.txt file, that file will be used
527 to create a `pack.json` file with it's contents. If the directory has
528 neither, all the files with specific configured extenstions will be
529 assumed to be emojis and stored in the new `pack.json` file.
531 def import_from_fs(conn, _params) do
532 with {:ok, results} <- File.ls(@emoji_dir_path) do
533 imported_pack_names =
535 |> Enum.filter(fn file ->
536 dir_path = Path.join(@emoji_dir_path, file)
537 # Find the directories that do NOT have pack.json
538 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
540 |> Enum.map(&write_pack_json_contents/1)
542 json(conn, imported_pack_names)
546 |> put_status(:internal_server_error)
547 |> json(%{error: "Error accessing emoji pack directory"})
551 defp write_pack_json_contents(dir) do
552 dir_path = Path.join(@emoji_dir_path, dir)
553 emoji_txt_path = Path.join(dir_path, "emoji.txt")
555 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
556 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
558 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
563 defp files_for_pack(emoji_txt_path, dir_path) do
564 if File.exists?(emoji_txt_path) do
565 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
566 # Make a pack.json file from the contents of that emoji.txt fileh
568 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
570 # Create a map of shortcodes to filenames from emoji.txt
571 File.read!(emoji_txt_path)
572 |> String.split("\n")
573 |> Enum.map(&String.trim/1)
574 |> Enum.map(fn line ->
575 case String.split(line, ~r/,\s*/) do
576 # This matches both strings with and without tags
577 # and we don't care about tags here
578 [name, file | _] -> {name, file}
582 |> Enum.filter(fn x -> not is_nil(x) end)
585 # If there's no emoji.txt, assume all files
586 # that are of certain extensions from the config are emojis and import them all
587 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
588 Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)