1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
8 Pleroma.Config.get!([:instance, :static_dir]),
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_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
99 cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files))
104 # if pack.json MD5 changes, the cache is not valid anymore
105 %{pack_json_md5: md5, pack_data: zip_result},
106 # Add a minute to cache time for every file in the pack
110 Logger.debug("Created an archive for the '#{name}' emoji pack, \
111 keeping it in cache for #{div(cache_ms, 1000)}s")
116 defp make_archive(name, pack, pack_dir) do
117 # Having a different pack.json md5 invalidates cache
118 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
120 case Cachex.get!(:emoji_packs_cache, name) do
121 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
122 Logger.debug("Using cache for the '#{name}' shared emoji pack")
126 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
131 An endpoint for other instances (via admin UI) or users (via browser)
132 to download packs that the instance shares.
134 def download_shared(conn, %{"name" => name}) do
135 pack_dir = Path.join(emoji_dir_path(), name)
136 pack_file = Path.join(pack_dir, "pack.json")
138 with {_, true} <- {:exists?, File.exists?(pack_file)},
139 pack = Jason.decode!(File.read!(pack_file)),
140 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
141 zip_result = make_archive(name, pack, pack_dir)
142 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
144 {:can_download?, _} ->
146 |> put_status(:forbidden)
148 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
149 was disabled for this pack or some files are missing"
154 |> put_status(:not_found)
155 |> json(%{error: "Pack #{name} does not exist"})
160 An admin endpoint to request downloading a pack named `pack_name` from the instance
163 If the requested instance's admin chose to share the pack, it will be downloaded
164 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
166 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
167 shareable_packs_available =
168 "#{address}/.well-known/nodeinfo"
174 # Get the actual nodeinfo address and fetch it
178 |> get_in(["metadata", "features"])
179 |> Enum.member?("shareable_emoji_packs")
181 if shareable_packs_available do
183 "#{address}/api/pleroma/emoji/packs/list"
190 case full_pack["pack"] do
191 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
195 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
198 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
208 "The pack was not set as shared and there is no fallback src to download from"}
211 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
212 %{body: emoji_archive} <- Tesla.get!(uri),
213 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
214 local_name = data["as"] || name
215 pack_dir = Path.join(emoji_dir_path(), local_name)
216 File.mkdir_p!(pack_dir)
218 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
219 # Fallback cannot contain a pack.json file
220 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
222 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
224 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
225 # in it to depend on itself
226 if pinfo[:fallback] do
227 pack_file_path = Path.join(pack_dir, "pack.json")
229 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
235 conn |> put_status(:internal_server_error) |> json(%{error: e})
239 |> put_status(:internal_server_error)
240 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
244 |> put_status(:internal_server_error)
245 |> json(%{error: "The requested instance does not support sharing emoji packs"})
250 Creates an empty pack named `name` which then can be updated via the admin UI.
252 def create(conn, %{"name" => name}) do
253 pack_dir = Path.join(emoji_dir_path(), name)
255 if not File.exists?(pack_dir) do
256 File.mkdir_p!(pack_dir)
258 pack_file_p = Path.join(pack_dir, "pack.json")
262 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
268 |> put_status(:conflict)
269 |> json(%{error: "A pack named \"#{name}\" already exists"})
274 Deletes the pack `name` and all it's files.
276 def delete(conn, %{"name" => name}) do
277 pack_dir = Path.join(emoji_dir_path(), name)
279 case File.rm_rf(pack_dir) do
285 |> put_status(:internal_server_error)
286 |> json(%{error: "Couldn't delete the pack #{name}"})
291 An endpoint to update `pack_names`'s metadata.
293 `new_data` is the new metadata for the pack, that will replace the old metadata.
295 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
296 pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"])
298 full_pack = Jason.decode!(File.read!(pack_file_p))
300 # The new fallback-src is in the new data and it's not the same as it was in the old data
301 should_update_fb_sha =
302 not is_nil(new_data["fallback-src"]) and
303 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
305 with {_, true} <- {:should_update?, should_update_fb_sha},
306 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
307 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
308 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
309 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
311 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
312 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
314 {:should_update?, _} ->
315 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
317 {:has_all_files?, _} ->
319 |> put_status(:bad_request)
320 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
324 # Check if all files from the pack.json are in the archive
325 defp has_all_files?(%{"files" => files}, flist) do
326 Enum.all?(files, fn {_, from_manifest} ->
327 Enum.find(flist, fn {from_archive, _} ->
328 to_string(from_archive) == from_manifest
333 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
334 full_pack = Map.put(full_pack, "pack", new_data)
335 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
337 # Send new data back with fallback sha filled
341 defp get_filename(%{"filename" => filename}), do: filename
343 defp get_filename(%{"file" => file}) do
345 %Plug.Upload{filename: filename} -> filename
346 url when is_binary(url) -> Path.basename(url)
350 defp empty?(str), do: String.trim(str) == ""
352 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
353 # Write the emoji pack file
354 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
356 # Return the modified file list
357 json(conn, updated_full_pack["files"])
361 Updates a file in a pack.
363 Updating can mean three things:
365 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
366 that means that the emoji file needs to be uploaded with the request
367 (thus requiring it to be a multipart request) and be named `file`.
368 There can also be an optional `filename` that will be the new emoji file name
369 (if it's not there, the name will be taken from the uploaded file).
370 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
371 (from the current filename to `new_filename`)
372 - `remove` removes the emoji named `shortcode` and it's associated file
378 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
380 pack_dir = Path.join(emoji_dir_path(), pack_name)
381 pack_file_p = Path.join(pack_dir, "pack.json")
383 full_pack = Jason.decode!(File.read!(pack_file_p))
385 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
386 filename <- get_filename(params),
387 false <- empty?(shortcode),
388 false <- empty?(filename) do
389 file_path = Path.join(pack_dir, filename)
391 # If the name contains directories, create them
392 if String.contains?(file_path, "/") do
393 File.mkdir_p!(Path.dirname(file_path))
396 case params["file"] do
397 %Plug.Upload{path: upload_path} ->
398 # Copy the uploaded file from the temporary directory
399 File.copy!(upload_path, file_path)
401 url when is_binary(url) ->
402 # Download and write the file
403 file_contents = Tesla.get!(url).body
404 File.write!(file_path, file_contents)
407 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
408 update_file_and_send(conn, updated_full_pack, pack_file_p)
410 {:has_shortcode, _} ->
412 |> put_status(:conflict)
413 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
417 |> put_status(:bad_request)
418 |> json(%{error: "shortcode or filename cannot be empty"})
423 def update_file(conn, %{
424 "pack_name" => pack_name,
425 "action" => "remove",
426 "shortcode" => shortcode
428 pack_dir = Path.join(emoji_dir_path(), pack_name)
429 pack_file_p = Path.join(pack_dir, "pack.json")
431 full_pack = Jason.decode!(File.read!(pack_file_p))
433 if Map.has_key?(full_pack["files"], shortcode) do
434 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
436 emoji_file_path = Path.join(pack_dir, emoji_file_path)
438 # Delete the emoji file
439 File.rm!(emoji_file_path)
441 # If the old directory has no more files, remove it
442 if String.contains?(emoji_file_path, "/") do
443 dir = Path.dirname(emoji_file_path)
445 if Enum.empty?(File.ls!(dir)) do
450 update_file_and_send(conn, updated_full_pack, pack_file_p)
453 |> put_status(:bad_request)
454 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
461 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
463 pack_dir = Path.join(emoji_dir_path(), pack_name)
464 pack_file_p = Path.join(pack_dir, "pack.json")
466 full_pack = Jason.decode!(File.read!(pack_file_p))
468 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
469 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
470 false <- empty?(new_shortcode),
471 false <- empty?(new_filename) do
472 # First, remove the old shortcode, saving the old path
473 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
474 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
475 new_emoji_file_path = Path.join(pack_dir, new_filename)
477 # If the name contains directories, create them
478 if String.contains?(new_emoji_file_path, "/") do
479 File.mkdir_p!(Path.dirname(new_emoji_file_path))
482 # Move/Rename the old filename to a new filename
483 # These are probably on the same filesystem, so just rename should work
484 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
486 # If the old directory has no more files, remove it
487 if String.contains?(old_emoji_file_path, "/") do
488 dir = Path.dirname(old_emoji_file_path)
490 if Enum.empty?(File.ls!(dir)) do
495 # Then, put in the new shortcode with the new path
496 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
497 update_file_and_send(conn, updated_full_pack, pack_file_p)
499 {:has_shortcode, _} ->
501 |> put_status(:bad_request)
502 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
506 |> put_status(:bad_request)
507 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
511 |> put_status(:bad_request)
512 |> json(%{error: "new_shortcode or new_file were not specified"})
516 def update_file(conn, %{"action" => action}) do
518 |> put_status(:bad_request)
519 |> json(%{error: "Unknown action: #{action}"})
523 Imports emoji from the filesystem.
525 Importing means checking all the directories in the
526 `$instance_static/emoji/` for directories which do not have
527 `pack.json`. If one has an emoji.txt file, that file will be used
528 to create a `pack.json` file with it's contents. If the directory has
529 neither, all the files with specific configured extenstions will be
530 assumed to be emojis and stored in the new `pack.json` file.
532 def import_from_fs(conn, _params) do
533 with {:ok, results} <- File.ls(emoji_dir_path()) do
534 imported_pack_names =
536 |> Enum.filter(fn file ->
537 dir_path = Path.join(emoji_dir_path(), file)
538 # Find the directories that do NOT have pack.json
539 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
541 |> Enum.map(&write_pack_json_contents/1)
543 json(conn, imported_pack_names)
547 |> put_status(:internal_server_error)
548 |> json(%{error: "Error accessing emoji pack directory"})
552 defp write_pack_json_contents(dir) do
553 dir_path = Path.join(emoji_dir_path(), dir)
554 emoji_txt_path = Path.join(dir_path, "emoji.txt")
556 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
557 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
559 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
564 defp files_for_pack(emoji_txt_path, dir_path) do
565 if File.exists?(emoji_txt_path) do
566 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
567 # Make a pack.json file from the contents of that emoji.txt fileh
569 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
571 # Create a map of shortcodes to filenames from emoji.txt
572 File.read!(emoji_txt_path)
573 |> String.split("\n")
574 |> Enum.map(&String.trim/1)
575 |> Enum.map(fn line ->
576 case String.split(line, ~r/,\s*/) do
577 # This matches both strings with and without tags
578 # and we don't care about tags here
579 [name, file | _] -> {name, file}
583 |> Enum.filter(fn x -> not is_nil(x) end)
586 # If there's no emoji.txt, assume all files
587 # that are of certain extensions from the config are emojis and import them all
588 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
589 Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)