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 with {:ok, results} <- File.ls(@emoji_dir_path) do
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
30 json(conn, pack_infos)
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"))
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")
44 {pack_name, Jason.decode!(File.read!(pack_file))}
47 defp validate_pack({name, pack}) do
48 pack_path = Path.join(@emoji_dir_path, name)
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()
56 |> put_in(["pack", "can-download"], true)
57 |> put_in(["pack", "download-sha256"], archive_sha)
61 {name, put_in(pack, ["pack", "can-download"], false)}
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))
75 defp create_archive_and_cache(name, pack, pack_dir, md5) do
78 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
80 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
82 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
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
93 Logger.debug("Created an archive for the '#{name}' emoji pack, \
94 keeping it in cache for #{div(cache_ms, 1000)}s")
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")))
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")
109 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
114 An endpoint for other instances (via admin UI) or users (via browser)
115 to download packs that the instance shares.
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")
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")
127 {:can_download?, _} ->
129 |> put_status(:forbidden)
131 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
132 was disabled for this pack or some files are missing"
137 |> put_status(:not_found)
138 |> json(%{error: "Pack #{name} does not exist"})
143 An admin endpoint to request downloading a pack named `pack_name` from the instance
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.
149 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
150 shareable_packs_available =
151 "#{address}/nodeinfo/2.1.json"
155 |> get_in(["metadata", "features"])
156 |> Enum.member?("shareable_emoji_packs")
158 if shareable_packs_available do
160 "#{address}/api/pleroma/emoji/packs/list"
167 case full_pack["pack"] do
168 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
172 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
175 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
185 "The pack was not set as shared and there is no fallback src to download from"}
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)
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
199 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
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")
206 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
212 conn |> put_status(:internal_server_error) |> json(%{error: e})
216 |> put_status(:internal_server_error)
217 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
221 |> put_status(:internal_server_error)
222 |> json(%{error: "The requested instance does not support sharing emoji packs"})
227 Creates an empty pack named `name` which then can be updated via the admin UI.
229 def create(conn, %{"name" => name}) do
230 pack_dir = Path.join(@emoji_dir_path, name)
232 if not File.exists?(pack_dir) do
233 File.mkdir_p!(pack_dir)
235 pack_file_p = Path.join(pack_dir, "pack.json")
239 Jason.encode!(%{pack: %{}, files: %{}})
245 |> put_status(:conflict)
246 |> json(%{error: "A pack named \"#{name}\" already exists"})
251 Deletes the pack `name` and all it's files.
253 def delete(conn, %{"name" => name}) do
254 pack_dir = Path.join(@emoji_dir_path, name)
256 case File.rm_rf(pack_dir) do
262 |> put_status(:internal_server_error)
263 |> json(%{error: "Couldn't delete the pack #{name}"})
268 An endpoint to update `pack_names`'s metadata.
270 `new_data` is the new metadata for the pack, that will replace the old metadata.
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"])
275 full_pack = Jason.decode!(File.read!(pack_file_p))
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"]
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()
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)
291 {:should_update?, _} ->
292 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
294 {:has_all_files?, _} ->
296 |> put_status(:bad_request)
297 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
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
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))
314 # Send new data back with fallback sha filled
318 defp get_filename(%{"filename" => filename}), do: filename
320 defp get_filename(%{"file" => file}) do
322 %Plug.Upload{filename: filename} -> filename
323 url when is_binary(url) -> Path.basename(url)
327 defp empty?(str), do: String.trim(str) == ""
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))
333 # Return the modified file list
334 json(conn, updated_full_pack["files"])
338 Updates a file in a pack.
340 Updating can mean three things:
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
355 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
357 pack_dir = Path.join(@emoji_dir_path, pack_name)
358 pack_file_p = Path.join(pack_dir, "pack.json")
360 full_pack = Jason.decode!(File.read!(pack_file_p))
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)
368 # If the name contains directories, create them
369 if String.contains?(file_path, "/") do
370 File.mkdir_p!(Path.dirname(file_path))
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)
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)
384 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
385 update_file_and_send(conn, updated_full_pack, pack_file_p)
387 {:has_shortcode, _} ->
389 |> put_status(:conflict)
390 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
394 |> put_status(:bad_request)
395 |> json(%{error: "shortcode or filename cannot be empty"})
400 def update_file(conn, %{
401 "pack_name" => pack_name,
402 "action" => "remove",
403 "shortcode" => shortcode
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 if Map.has_key?(full_pack["files"], shortcode) do
411 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
413 emoji_file_path = Path.join(pack_dir, emoji_file_path)
415 # Delete the emoji file
416 File.rm!(emoji_file_path)
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)
422 if Enum.empty?(File.ls!(dir)) do
427 update_file_and_send(conn, updated_full_pack, pack_file_p)
430 |> put_status(:bad_request)
431 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
438 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
440 pack_dir = Path.join(@emoji_dir_path, pack_name)
441 pack_file_p = Path.join(pack_dir, "pack.json")
443 full_pack = Jason.decode!(File.read!(pack_file_p))
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)
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))
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)
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)
467 if Enum.empty?(File.ls!(dir)) do
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)
476 {:has_shortcode, _} ->
478 |> put_status(:bad_request)
479 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
483 |> put_status(:bad_request)
484 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
488 |> put_status(:bad_request)
489 |> json(%{error: "new_shortcode or new_file were not specified"})
493 def update_file(conn, %{"action" => action}) do
495 |> put_status(:bad_request)
496 |> json(%{error: "Unknown action: #{action}"})
500 Imports emoji from the filesystem.
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.
509 def import_from_fs(conn, _params) do
510 with {:ok, results} <- File.ls(@emoji_dir_path) do
511 imported_pack_names =
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"))
518 |> Enum.map(&write_pack_json_contents/1)
520 json(conn, imported_pack_names)
524 |> put_status(:internal_server_error)
525 |> json(%{error: "Error accessing emoji pack directory"})
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")
533 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
534 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
536 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
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
546 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
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}
560 |> Enum.filter(fn x -> not is_nil(x) end)
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)