1 defmodule Pleroma.Web.EmojiAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
6 def reload(conn, _params) do
12 @emoji_dir_path Path.join(
13 Pleroma.Config.get!([:instance, :static_dir]),
17 @cache_seconds_per_file Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
20 Lists the packs available on the instance as JSON.
22 The information is public and does not require authentification. The format is
23 a map of "pack directory name" to pack.json contents.
25 def list_packs(conn, _params) do
27 case File.ls(@emoji_dir_path) do
33 |> Enum.filter(fn file ->
34 dir_path = Path.join(@emoji_dir_path, file)
35 # Filter to only use the pack.json packs
36 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
38 |> Enum.map(fn pack_name ->
39 pack_path = Path.join(@emoji_dir_path, pack_name)
40 pack_file = Path.join(pack_path, "pack.json")
42 {pack_name, Jason.decode!(File.read!(pack_file))}
44 # Transform into a map of pack-name => pack-data
45 # Check if all the files are in place and can be sent
46 |> Enum.map(fn {name, pack} ->
47 pack_path = Path.join(@emoji_dir_path, name)
49 if can_download?(pack, pack_path) do
50 archive_for_sha = make_archive(name, pack, pack_path)
51 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
55 |> put_in(["pack", "can-download"], true)
56 |> put_in(["pack", "download-sha256"], archive_sha)}
60 |> put_in(["pack", "can-download"], false)}
66 conn |> json(pack_infos)
69 defp can_download?(pack, pack_path) do
70 # If the pack is set as shared, check if it can be downloaded
71 # That means that when asked, the pack can be packed and sent to the remote
72 # Otherwise, they'd have to download it from external-src
73 pack["pack"]["share-files"] &&
74 Enum.all?(pack["files"], fn {_, path} ->
75 File.exists?(Path.join(pack_path, path))
79 defp create_archive_and_cache(name, pack, pack_dir, md5) do
82 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
84 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
86 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
91 # if pack.json MD5 changes, the cache is not valid anymore
92 %{pack_json_md5: md5, pack_data: zip_result},
93 # Add a minute to cache time for every file in the pack
97 Logger.debug("Create an archive for the '#{name}' emoji pack, \
98 keeping it in cache for #{div(cache_ms, 1000)}s")
103 defp make_archive(name, pack, pack_dir) do
104 # Having a different pack.json md5 invalidates cache
105 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
107 maybe_cached_pack = Cachex.get!(:emoji_packs_cache, name)
110 if is_nil(maybe_cached_pack) do
111 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
113 if maybe_cached_pack[:pack_file_md5] == pack_file_md5 do
114 Logger.debug("Using cache for the '#{name}' shared emoji pack")
116 maybe_cached_pack[:pack_data]
118 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
126 An endpoint for other instances (via admin UI) or users (via browser)
127 to download packs that the instance shares.
129 def download_shared(conn, %{"name" => name}) do
130 pack_dir = Path.join(@emoji_dir_path, name)
131 pack_file = Path.join(pack_dir, "pack.json")
133 if File.exists?(pack_file) do
134 pack = Jason.decode!(File.read!(pack_file))
136 if can_download?(pack, pack_dir) do
137 zip_result = make_archive(name, pack, pack_dir)
140 |> send_download({:binary, zip_result}, filename: "#{name}.zip")
144 |> put_status(:forbidden)
145 |> text("Pack #{name} cannot be downloaded from this instance, either pack sharing\
146 was disabled for this pack or some files are missing")}
151 |> put_status(:not_found)
152 |> text("Pack #{name} does not exist")}
157 An admin endpoint to request downloading a pack named `pack_name` from the instance
160 If the requested instance's admin chose to share the pack, it will be downloaded
161 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
163 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
164 list_uri = "#{address}/api/pleroma/emoji/packs/list"
166 list = Tesla.get!(list_uri).body |> Jason.decode!()
167 full_pack = list[name]
168 pfiles = full_pack["files"]
169 pack = full_pack["pack"]
173 pack["share-files"] && pack["can-download"] ->
176 sha: pack["download-sha256"],
177 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
180 pack["fallback-src"] ->
183 sha: pack["fallback-src-sha256"],
184 uri: pack["fallback-src"],
189 {:error, "The pack was not set as shared and there is no fallback src to download from"}
192 case pack_info_res do
193 {:ok, %{sha: sha, uri: uri} = pinfo} ->
194 sha = Base.decode16!(sha)
195 emoji_archive = Tesla.get!(uri).body
197 got_sha = :crypto.hash(:sha256, emoji_archive)
200 local_name = data["as"] || name
201 pack_dir = Path.join(@emoji_dir_path, local_name)
202 File.mkdir_p!(pack_dir)
204 # Fallback cannot contain a pack.json file
206 unless(pinfo[:fallback], do: ['pack.json'], else: []) ++
207 (pfiles |> Enum.map(fn {_, path} -> to_charlist(path) end))
209 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
211 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
212 # in it to depend on itself
213 if pinfo[:fallback] do
214 pack_file_path = Path.join(pack_dir, "pack.json")
216 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
222 |> put_status(:internal_server_error)
223 |> text("SHA256 for the pack doesn't match the one sent by the server")
227 conn |> put_status(:internal_server_error) |> text(e)
232 Creates an empty pack named `name` which then can be updated via the admin UI.
234 def create(conn, %{"name" => name}) do
235 pack_dir = Path.join(@emoji_dir_path, name)
237 unless File.exists?(pack_dir) do
238 File.mkdir_p!(pack_dir)
240 pack_file_p = Path.join(pack_dir, "pack.json")
244 Jason.encode!(%{pack: %{}, files: %{}})
250 |> put_status(:conflict)
251 |> text("A pack named \"#{name}\" already exists")
256 Deletes the pack `name` and all it's files.
258 def delete(conn, %{"name" => name}) do
259 pack_dir = Path.join(@emoji_dir_path, name)
261 case File.rm_rf(pack_dir) do
266 conn |> put_status(:internal_server_error) |> text("Couldn't delete the pack #{name}")
271 An endpoint to update `pack_names`'s metadata.
273 `new_data` is the new metadata for the pack, that will replace the old metadata.
275 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
276 pack_dir = Path.join(@emoji_dir_path, name)
277 pack_file_p = Path.join(pack_dir, "pack.json")
279 full_pack = Jason.decode!(File.read!(pack_file_p))
281 # The new fallback-src is in the new data and it's not the same as it was in the old data
282 should_update_fb_sha =
283 not is_nil(new_data["fallback-src"]) and
284 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
287 if should_update_fb_sha do
288 pack_arch = Tesla.get!(new_data["fallback-src"]).body
290 {:ok, flist} = :zip.unzip(pack_arch, [:memory])
292 # Check if all files from the pack.json are in the archive
294 Enum.all?(full_pack["files"], fn {_, from_manifest} ->
295 Enum.find(flist, fn {from_archive, _} ->
296 to_string(from_archive) == from_manifest
300 unless has_all_files do
303 |> put_status(:bad_request)
304 |> text("The fallback archive does not have all files specified in pack.json")}
306 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
308 {:ok, new_data |> Map.put("fallback-src-sha256", fallback_sha)}
316 full_pack = Map.put(full_pack, "pack", new_data)
317 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
319 # Send new data back with fallback sha filled
320 conn |> json(new_data)
328 Updates a file in a pack.
330 Updating can mean three things:
332 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
333 that means that the emoji file needs to be uploaded with the request
334 (thus requiring it to be a multipart request) and be named `file`.
335 There can also be an optional `filename` that will be the new emoji file name
336 (if it's not there, the name will be taken from the uploaded file).
337 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
338 (from the current filename to `new_filename`)
339 - `remove` removes the emoji named `shortcode` and it's associated file
343 %{"pack_name" => pack_name, "action" => action, "shortcode" => shortcode} = params
345 pack_dir = Path.join(@emoji_dir_path, pack_name)
346 pack_file_p = Path.join(pack_dir, "pack.json")
348 full_pack = Jason.decode!(File.read!(pack_file_p))
353 unless Map.has_key?(full_pack["files"], shortcode) do
355 if Map.has_key?(params, "filename") do
358 case params["file"] do
359 %Plug.Upload{filename: filename} -> filename
360 url when is_binary(url) -> Path.basename(url)
364 unless String.trim(shortcode) |> String.length() == 0 or
365 String.trim(filename) |> String.length() == 0 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)
386 {:ok, updated_full_pack}
390 |> put_status(:bad_request)
391 |> text("shortcode or filename cannot be empty")}
396 |> put_status(:conflict)
397 |> text("An emoji with the \"#{shortcode}\" shortcode already exists")}
401 if Map.has_key?(full_pack["files"], shortcode) do
402 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
404 emoji_file_path = Path.join(pack_dir, emoji_file_path)
406 # Delete the emoji file
407 File.rm!(emoji_file_path)
409 # If the old directory has no more files, remove it
410 if String.contains?(emoji_file_path, "/") do
411 dir = Path.dirname(emoji_file_path)
413 if Enum.empty?(File.ls!(dir)) do
418 {:ok, updated_full_pack}
421 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
425 if Map.has_key?(full_pack["files"], shortcode) do
426 with %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params do
427 unless String.trim(new_shortcode) |> String.length() == 0 or
428 String.trim(new_filename) |> String.length() == 0 do
429 # First, remove the old shortcode, saving the old path
430 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
431 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
432 new_emoji_file_path = Path.join(pack_dir, new_filename)
434 # If the name contains directories, create them
435 if String.contains?(new_emoji_file_path, "/") do
436 File.mkdir_p!(Path.dirname(new_emoji_file_path))
439 # Move/Rename the old filename to a new filename
440 # These are probably on the same filesystem, so just rename should work
441 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
443 # If the old directory has no more files, remove it
444 if String.contains?(old_emoji_file_path, "/") do
445 dir = Path.dirname(old_emoji_file_path)
447 if Enum.empty?(File.ls!(dir)) do
452 # Then, put in the new shortcode with the new path
454 put_in(updated_full_pack, ["files", new_shortcode], new_filename)
456 {:ok, updated_full_pack}
460 |> put_status(:bad_request)
461 |> text("new_shortcode or new_filename cannot be empty")}
467 |> put_status(:bad_request)
468 |> text("new_shortcode or new_file were not specified")}
472 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
476 {:error, conn |> put_status(:bad_request) |> text("Unknown action: #{action}")}
480 {:ok, updated_full_pack} ->
481 # Write the emoji pack file
482 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
484 # Return the modified file list
485 conn |> json(updated_full_pack["files"])
493 Imports emoji from the filesystem.
495 Importing means checking all the directories in the
496 `$instance_static/emoji/` for directories which do not have
497 `pack.json`. If one has an emoji.txt file, that file will be used
498 to create a `pack.json` file with it's contents. If the directory has
499 neither, all the files with specific configured extenstions will be
500 assumed to be emojis and stored in the new `pack.json` file.
502 def import_from_fs(conn, _params) do
503 case File.ls(@emoji_dir_path) do
506 |> put_status(:internal_server_error)
507 |> text("Error accessing emoji pack directory")
510 imported_pack_names =
512 |> Enum.filter(fn file ->
513 dir_path = Path.join(@emoji_dir_path, file)
514 # Find the directories that do NOT have pack.json
515 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
517 |> Enum.map(fn dir ->
518 dir_path = Path.join(@emoji_dir_path, dir)
519 emoji_txt_path = Path.join(dir_path, "emoji.txt")
522 if File.exists?(emoji_txt_path) do
523 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
524 # Make a pack.json file from the contents of that emoji.txt fileh
526 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
528 # Create a map of shortcodes to filenames from emoji.txt
530 File.read!(emoji_txt_path)
531 |> String.split("\n")
532 |> Enum.map(&String.trim/1)
533 |> Enum.map(fn line ->
534 case String.split(line, ~r/,\s*/) do
535 # This matches both strings with and without tags and we don't care about tags here
543 |> Enum.filter(fn x -> not is_nil(x) end)
546 # If there's no emoji.txt, assume all files that are of certain extensions from the config
547 # are emojis and import them all
548 Pleroma.Emoji.make_shortcode_to_file_map(
550 Pleroma.Config.get!([:emoji, :pack_extensions])
554 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
557 Path.join(dir_path, "pack.json"),
564 conn |> json(imported_pack_names)