1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
4 alias Pleroma.Plugs.OAuthScopesPlug
10 %{scopes: ["write"], admin: true}
22 plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
26 Pleroma.Config.get!([:instance, :static_dir]),
32 Lists packs from the remote instance.
34 Since JS cannot ask remote instances for their packs due to CPS, it has to
37 def list_from(conn, %{"instance_address" => address}) do
38 address = String.trim(address)
40 if shareable_packs_available(address) do
42 "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
47 |> put_status(:internal_server_error)
48 |> json(%{error: "The requested instance does not support sharing emoji packs"})
53 Lists the packs available on the instance as JSON.
55 The information is public and does not require authentification. The format is
56 a map of "pack directory name" to pack.json contents.
58 def list_packs(conn, _params) do
59 # Create the directory first if it does not exist. This is probably the first request made
60 # with the API so it should be sufficient
61 with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())},
62 {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do
65 |> Enum.filter(&has_pack_json?/1)
66 |> Enum.map(&load_pack/1)
67 # Check if all the files are in place and can be sent
68 |> Enum.map(&validate_pack/1)
69 # Transform into a map of pack-name => pack-data
72 json(conn, pack_infos)
74 {:create_dir, {:error, e}} ->
76 |> put_status(:internal_server_error)
77 |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
81 |> put_status(:internal_server_error)
84 "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
89 defp has_pack_json?(file) do
90 dir_path = Path.join(emoji_dir_path(), file)
91 # Filter to only use the pack.json packs
92 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
95 defp load_pack(pack_name) do
96 pack_path = Path.join(emoji_dir_path(), pack_name)
97 pack_file = Path.join(pack_path, "pack.json")
99 {pack_name, Jason.decode!(File.read!(pack_file))}
102 defp validate_pack({name, pack}) do
103 pack_path = Path.join(emoji_dir_path(), name)
105 if can_download?(pack, pack_path) do
106 archive_for_sha = make_archive(name, pack, pack_path)
107 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
111 |> put_in(["pack", "can-download"], true)
112 |> put_in(["pack", "download-sha256"], archive_sha)
116 {name, put_in(pack, ["pack", "can-download"], false)}
120 defp can_download?(pack, pack_path) do
121 # If the pack is set as shared, check if it can be downloaded
122 # That means that when asked, the pack can be packed and sent to the remote
123 # Otherwise, they'd have to download it from external-src
124 pack["pack"]["share-files"] &&
125 Enum.all?(pack["files"], fn {_, path} ->
126 File.exists?(Path.join(pack_path, path))
130 defp create_archive_and_cache(name, pack, pack_dir, md5) do
133 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
135 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
137 cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
138 cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files))
143 # if pack.json MD5 changes, the cache is not valid anymore
144 %{pack_json_md5: md5, pack_data: zip_result},
145 # Add a minute to cache time for every file in the pack
149 Logger.debug("Created an archive for the '#{name}' emoji pack, \
150 keeping it in cache for #{div(cache_ms, 1000)}s")
155 defp make_archive(name, pack, pack_dir) do
156 # Having a different pack.json md5 invalidates cache
157 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
159 case Cachex.get!(:emoji_packs_cache, name) do
160 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
161 Logger.debug("Using cache for the '#{name}' shared emoji pack")
165 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
170 An endpoint for other instances (via admin UI) or users (via browser)
171 to download packs that the instance shares.
173 def download_shared(conn, %{"name" => name}) do
174 pack_dir = Path.join(emoji_dir_path(), name)
175 pack_file = Path.join(pack_dir, "pack.json")
177 with {_, true} <- {:exists?, File.exists?(pack_file)},
178 pack = Jason.decode!(File.read!(pack_file)),
179 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
180 zip_result = make_archive(name, pack, pack_dir)
181 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
183 {:can_download?, _} ->
185 |> put_status(:forbidden)
187 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
188 was disabled for this pack or some files are missing"
193 |> put_status(:not_found)
194 |> json(%{error: "Pack #{name} does not exist"})
198 defp shareable_packs_available(address) do
199 "#{address}/.well-known/nodeinfo"
206 # Get the actual nodeinfo address and fetch it
210 |> get_in(["metadata", "features"])
211 |> Enum.member?("shareable_emoji_packs")
215 An admin endpoint to request downloading a pack named `pack_name` from the instance
218 If the requested instance's admin chose to share the pack, it will be downloaded
219 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
221 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
222 address = String.trim(address)
224 if shareable_packs_available(address) do
226 "#{address}/api/pleroma/emoji/packs/list"
233 case full_pack["pack"] do
234 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
238 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
241 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
251 "The pack was not set as shared and there is no fallback src to download from"}
254 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
255 %{body: emoji_archive} <- Tesla.get!(uri),
256 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
257 local_name = data["as"] || name
258 pack_dir = Path.join(emoji_dir_path(), local_name)
259 File.mkdir_p!(pack_dir)
261 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
262 # Fallback cannot contain a pack.json file
263 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
265 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
267 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
268 # in it to depend on itself
269 if pinfo[:fallback] do
270 pack_file_path = Path.join(pack_dir, "pack.json")
272 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
278 conn |> put_status(:internal_server_error) |> json(%{error: e})
282 |> put_status(:internal_server_error)
283 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
287 |> put_status(:internal_server_error)
288 |> json(%{error: "The requested instance does not support sharing emoji packs"})
293 Creates an empty pack named `name` which then can be updated via the admin UI.
295 def create(conn, %{"name" => name}) do
296 pack_dir = Path.join(emoji_dir_path(), name)
298 if not File.exists?(pack_dir) do
299 File.mkdir_p!(pack_dir)
301 pack_file_p = Path.join(pack_dir, "pack.json")
305 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
311 |> put_status(:conflict)
312 |> json(%{error: "A pack named \"#{name}\" already exists"})
317 Deletes the pack `name` and all it's files.
319 def delete(conn, %{"name" => name}) do
320 pack_dir = Path.join(emoji_dir_path(), name)
322 case File.rm_rf(pack_dir) do
328 |> put_status(:internal_server_error)
329 |> json(%{error: "Couldn't delete the pack #{name}"})
334 An endpoint to update `pack_names`'s metadata.
336 `new_data` is the new metadata for the pack, that will replace the old metadata.
338 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
339 pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"])
341 full_pack = Jason.decode!(File.read!(pack_file_p))
343 # The new fallback-src is in the new data and it's not the same as it was in the old data
344 should_update_fb_sha =
345 not is_nil(new_data["fallback-src"]) and
346 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
348 with {_, true} <- {:should_update?, should_update_fb_sha},
349 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
350 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
351 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
352 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
354 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
355 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
357 {:should_update?, _} ->
358 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
360 {:has_all_files?, _} ->
362 |> put_status(:bad_request)
363 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
367 # Check if all files from the pack.json are in the archive
368 defp has_all_files?(%{"files" => files}, flist) do
369 Enum.all?(files, fn {_, from_manifest} ->
370 Enum.find(flist, fn {from_archive, _} ->
371 to_string(from_archive) == from_manifest
376 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
377 full_pack = Map.put(full_pack, "pack", new_data)
378 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
380 # Send new data back with fallback sha filled
384 defp get_filename(%{"filename" => filename}), do: filename
386 defp get_filename(%{"file" => file}) do
388 %Plug.Upload{filename: filename} -> filename
389 url when is_binary(url) -> Path.basename(url)
393 defp empty?(str), do: String.trim(str) == ""
395 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
396 # Write the emoji pack file
397 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
399 # Return the modified file list
400 json(conn, updated_full_pack["files"])
404 Updates a file in a pack.
406 Updating can mean three things:
408 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
409 that means that the emoji file needs to be uploaded with the request
410 (thus requiring it to be a multipart request) and be named `file`.
411 There can also be an optional `filename` that will be the new emoji file name
412 (if it's not there, the name will be taken from the uploaded file).
413 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
414 (from the current filename to `new_filename`)
415 - `remove` removes the emoji named `shortcode` and it's associated file
421 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
423 pack_dir = Path.join(emoji_dir_path(), pack_name)
424 pack_file_p = Path.join(pack_dir, "pack.json")
426 full_pack = Jason.decode!(File.read!(pack_file_p))
428 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
429 filename <- get_filename(params),
430 false <- empty?(shortcode),
431 false <- empty?(filename) do
432 file_path = Path.join(pack_dir, filename)
434 # If the name contains directories, create them
435 if String.contains?(file_path, "/") do
436 File.mkdir_p!(Path.dirname(file_path))
439 case params["file"] do
440 %Plug.Upload{path: upload_path} ->
441 # Copy the uploaded file from the temporary directory
442 File.copy!(upload_path, file_path)
444 url when is_binary(url) ->
445 # Download and write the file
446 file_contents = Tesla.get!(url).body
447 File.write!(file_path, file_contents)
450 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
451 update_file_and_send(conn, updated_full_pack, pack_file_p)
453 {:has_shortcode, _} ->
455 |> put_status(:conflict)
456 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
460 |> put_status(:bad_request)
461 |> json(%{error: "shortcode or filename cannot be empty"})
466 def update_file(conn, %{
467 "pack_name" => pack_name,
468 "action" => "remove",
469 "shortcode" => shortcode
471 pack_dir = Path.join(emoji_dir_path(), pack_name)
472 pack_file_p = Path.join(pack_dir, "pack.json")
474 full_pack = Jason.decode!(File.read!(pack_file_p))
476 if Map.has_key?(full_pack["files"], shortcode) do
477 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
479 emoji_file_path = Path.join(pack_dir, emoji_file_path)
481 # Delete the emoji file
482 File.rm!(emoji_file_path)
484 # If the old directory has no more files, remove it
485 if String.contains?(emoji_file_path, "/") do
486 dir = Path.dirname(emoji_file_path)
488 if Enum.empty?(File.ls!(dir)) do
493 update_file_and_send(conn, updated_full_pack, pack_file_p)
496 |> put_status(:bad_request)
497 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
504 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
506 pack_dir = Path.join(emoji_dir_path(), pack_name)
507 pack_file_p = Path.join(pack_dir, "pack.json")
509 full_pack = Jason.decode!(File.read!(pack_file_p))
511 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
512 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
513 false <- empty?(new_shortcode),
514 false <- empty?(new_filename) do
515 # First, remove the old shortcode, saving the old path
516 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
517 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
518 new_emoji_file_path = Path.join(pack_dir, new_filename)
520 # If the name contains directories, create them
521 if String.contains?(new_emoji_file_path, "/") do
522 File.mkdir_p!(Path.dirname(new_emoji_file_path))
525 # Move/Rename the old filename to a new filename
526 # These are probably on the same filesystem, so just rename should work
527 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
529 # If the old directory has no more files, remove it
530 if String.contains?(old_emoji_file_path, "/") do
531 dir = Path.dirname(old_emoji_file_path)
533 if Enum.empty?(File.ls!(dir)) do
538 # Then, put in the new shortcode with the new path
539 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
540 update_file_and_send(conn, updated_full_pack, pack_file_p)
542 {:has_shortcode, _} ->
544 |> put_status(:bad_request)
545 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
549 |> put_status(:bad_request)
550 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
554 |> put_status(:bad_request)
555 |> json(%{error: "new_shortcode or new_file were not specified"})
559 def update_file(conn, %{"action" => action}) do
561 |> put_status(:bad_request)
562 |> json(%{error: "Unknown action: #{action}"})
566 Imports emoji from the filesystem.
568 Importing means checking all the directories in the
569 `$instance_static/emoji/` for directories which do not have
570 `pack.json`. If one has an emoji.txt file, that file will be used
571 to create a `pack.json` file with it's contents. If the directory has
572 neither, all the files with specific configured extenstions will be
573 assumed to be emojis and stored in the new `pack.json` file.
575 def import_from_fs(conn, _params) do
576 with {:ok, results} <- File.ls(emoji_dir_path()) do
577 imported_pack_names =
579 |> Enum.filter(fn file ->
580 dir_path = Path.join(emoji_dir_path(), file)
581 # Find the directories that do NOT have pack.json
582 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
584 |> Enum.map(&write_pack_json_contents/1)
586 json(conn, imported_pack_names)
590 |> put_status(:internal_server_error)
591 |> json(%{error: "Error accessing emoji pack directory"})
595 defp write_pack_json_contents(dir) do
596 dir_path = Path.join(emoji_dir_path(), dir)
597 emoji_txt_path = Path.join(dir_path, "emoji.txt")
599 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
600 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
602 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
607 defp files_for_pack(emoji_txt_path, dir_path) do
608 if File.exists?(emoji_txt_path) do
609 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
610 # Make a pack.json file from the contents of that emoji.txt fileh
612 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
614 # Create a map of shortcodes to filenames from emoji.txt
615 File.read!(emoji_txt_path)
616 |> String.split("\n")
617 |> Enum.map(&String.trim/1)
618 |> Enum.map(fn line ->
619 case String.split(line, ~r/,\s*/) do
620 # This matches both strings with and without tags
621 # and we don't care about tags here
622 [name, file | _] -> {name, file}
626 |> Enum.filter(fn x -> not is_nil(x) end)
629 # If there's no emoji.txt, assume all files
630 # that are of certain extensions from the config are emojis and import them all
631 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
632 Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)