Credo fix (remove parens on function definition)
[akkoma] / lib / pleroma / web / pleroma_api / controllers / emoji_api_controller.ex
1 defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
3
4 require Logger
5
6 def emoji_dir_path do
7 Path.join(
8 Pleroma.Config.get!([:instance, :static_dir]),
9 "emoji"
10 )
11 end
12
13 @doc """
14 Lists the packs available on the instance as JSON.
15
16 The information is public and does not require authentification. The format is
17 a map of "pack directory name" to pack.json contents.
18 """
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
24 pack_infos =
25 results
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
31 |> Enum.into(%{})
32
33 json(conn, pack_infos)
34 else
35 {:create_dir, {:error, e}} ->
36 conn
37 |> put_status(:internal_server_error)
38 |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
39
40 {:ls, {:error, e}} ->
41 conn
42 |> put_status(:internal_server_error)
43 |> json(%{
44 error:
45 "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
46 })
47 end
48 end
49
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"))
54 end
55
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")
59
60 {pack_name, Jason.decode!(File.read!(pack_file))}
61 end
62
63 defp validate_pack({name, pack}) do
64 pack_path = Path.join(emoji_dir_path(), name)
65
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()
69
70 pack =
71 pack
72 |> put_in(["pack", "can-download"], true)
73 |> put_in(["pack", "download-sha256"], archive_sha)
74
75 {name, pack}
76 else
77 {name, put_in(pack, ["pack", "can-download"], false)}
78 end
79 end
80
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))
88 end)
89 end
90
91 defp create_archive_and_cache(name, pack, pack_dir, md5) do
92 files =
93 ['pack.json'] ++
94 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
95
96 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
97
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))
100
101 Cachex.put!(
102 :emoji_packs_cache,
103 name,
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
107 ttl: cache_ms
108 )
109
110 Logger.debug("Created an archive for the '#{name}' emoji pack, \
111 keeping it in cache for #{div(cache_ms, 1000)}s")
112
113 zip_result
114 end
115
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")))
119
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")
123 zip_result
124
125 _ ->
126 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
127 end
128 end
129
130 @doc """
131 An endpoint for other instances (via admin UI) or users (via browser)
132 to download packs that the instance shares.
133 """
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")
137
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")
143 else
144 {:can_download?, _} ->
145 conn
146 |> put_status(:forbidden)
147 |> json(%{
148 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
149 was disabled for this pack or some files are missing"
150 })
151
152 {:exists?, _} ->
153 conn
154 |> put_status(:not_found)
155 |> json(%{error: "Pack #{name} does not exist"})
156 end
157 end
158
159 @doc """
160 An admin endpoint to request downloading a pack named `pack_name` from the instance
161 `instance_address`.
162
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.
165 """
166 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
167 shareable_packs_available =
168 "#{address}/.well-known/nodeinfo"
169 |> Tesla.get!()
170 |> Map.get(:body)
171 |> Jason.decode!()
172 |> List.last()
173 |> Map.get("href")
174 # Get the actual nodeinfo address and fetch it
175 |> Tesla.get!()
176 |> Map.get(:body)
177 |> Jason.decode!()
178 |> get_in(["metadata", "features"])
179 |> Enum.member?("shareable_emoji_packs")
180
181 if shareable_packs_available do
182 full_pack =
183 "#{address}/api/pleroma/emoji/packs/list"
184 |> Tesla.get!()
185 |> Map.get(:body)
186 |> Jason.decode!()
187 |> Map.get(name)
188
189 pack_info_res =
190 case full_pack["pack"] do
191 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
192 {:ok,
193 %{
194 sha: sha,
195 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
196 }}
197
198 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
199 {:ok,
200 %{
201 sha: sha,
202 uri: src,
203 fallback: true
204 }}
205
206 _ ->
207 {:error,
208 "The pack was not set as shared and there is no fallback src to download from"}
209 end
210
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)
217
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
221
222 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
223
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")
228
229 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
230 end
231
232 json(conn, "ok")
233 else
234 {:error, e} ->
235 conn |> put_status(:internal_server_error) |> json(%{error: e})
236
237 {:checksum, _} ->
238 conn
239 |> put_status(:internal_server_error)
240 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
241 end
242 else
243 conn
244 |> put_status(:internal_server_error)
245 |> json(%{error: "The requested instance does not support sharing emoji packs"})
246 end
247 end
248
249 @doc """
250 Creates an empty pack named `name` which then can be updated via the admin UI.
251 """
252 def create(conn, %{"name" => name}) do
253 pack_dir = Path.join(emoji_dir_path(), name)
254
255 if not File.exists?(pack_dir) do
256 File.mkdir_p!(pack_dir)
257
258 pack_file_p = Path.join(pack_dir, "pack.json")
259
260 File.write!(
261 pack_file_p,
262 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
263 )
264
265 conn |> json("ok")
266 else
267 conn
268 |> put_status(:conflict)
269 |> json(%{error: "A pack named \"#{name}\" already exists"})
270 end
271 end
272
273 @doc """
274 Deletes the pack `name` and all it's files.
275 """
276 def delete(conn, %{"name" => name}) do
277 pack_dir = Path.join(emoji_dir_path(), name)
278
279 case File.rm_rf(pack_dir) do
280 {:ok, _} ->
281 conn |> json("ok")
282
283 {:error, _} ->
284 conn
285 |> put_status(:internal_server_error)
286 |> json(%{error: "Couldn't delete the pack #{name}"})
287 end
288 end
289
290 @doc """
291 An endpoint to update `pack_names`'s metadata.
292
293 `new_data` is the new metadata for the pack, that will replace the old metadata.
294 """
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"])
297
298 full_pack = Jason.decode!(File.read!(pack_file_p))
299
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"]
304
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()
310
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)
313 else
314 {:should_update?, _} ->
315 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
316
317 {:has_all_files?, _} ->
318 conn
319 |> put_status(:bad_request)
320 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
321 end
322 end
323
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
329 end)
330 end)
331 end
332
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))
336
337 # Send new data back with fallback sha filled
338 json(conn, new_data)
339 end
340
341 defp get_filename(%{"filename" => filename}), do: filename
342
343 defp get_filename(%{"file" => file}) do
344 case file do
345 %Plug.Upload{filename: filename} -> filename
346 url when is_binary(url) -> Path.basename(url)
347 end
348 end
349
350 defp empty?(str), do: String.trim(str) == ""
351
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))
355
356 # Return the modified file list
357 json(conn, updated_full_pack["files"])
358 end
359
360 @doc """
361 Updates a file in a pack.
362
363 Updating can mean three things:
364
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
373 """
374
375 # Add
376 def update_file(
377 conn,
378 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
379 ) do
380 pack_dir = Path.join(emoji_dir_path(), pack_name)
381 pack_file_p = Path.join(pack_dir, "pack.json")
382
383 full_pack = Jason.decode!(File.read!(pack_file_p))
384
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)
390
391 # If the name contains directories, create them
392 if String.contains?(file_path, "/") do
393 File.mkdir_p!(Path.dirname(file_path))
394 end
395
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)
400
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)
405 end
406
407 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
408 update_file_and_send(conn, updated_full_pack, pack_file_p)
409 else
410 {:has_shortcode, _} ->
411 conn
412 |> put_status(:conflict)
413 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
414
415 true ->
416 conn
417 |> put_status(:bad_request)
418 |> json(%{error: "shortcode or filename cannot be empty"})
419 end
420 end
421
422 # Remove
423 def update_file(conn, %{
424 "pack_name" => pack_name,
425 "action" => "remove",
426 "shortcode" => shortcode
427 }) do
428 pack_dir = Path.join(emoji_dir_path(), pack_name)
429 pack_file_p = Path.join(pack_dir, "pack.json")
430
431 full_pack = Jason.decode!(File.read!(pack_file_p))
432
433 if Map.has_key?(full_pack["files"], shortcode) do
434 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
435
436 emoji_file_path = Path.join(pack_dir, emoji_file_path)
437
438 # Delete the emoji file
439 File.rm!(emoji_file_path)
440
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)
444
445 if Enum.empty?(File.ls!(dir)) do
446 File.rmdir!(dir)
447 end
448 end
449
450 update_file_and_send(conn, updated_full_pack, pack_file_p)
451 else
452 conn
453 |> put_status(:bad_request)
454 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
455 end
456 end
457
458 # Update
459 def update_file(
460 conn,
461 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
462 ) do
463 pack_dir = Path.join(emoji_dir_path(), pack_name)
464 pack_file_p = Path.join(pack_dir, "pack.json")
465
466 full_pack = Jason.decode!(File.read!(pack_file_p))
467
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)
476
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))
480 end
481
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)
485
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)
489
490 if Enum.empty?(File.ls!(dir)) do
491 File.rmdir!(dir)
492 end
493 end
494
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)
498 else
499 {:has_shortcode, _} ->
500 conn
501 |> put_status(:bad_request)
502 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
503
504 true ->
505 conn
506 |> put_status(:bad_request)
507 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
508
509 _ ->
510 conn
511 |> put_status(:bad_request)
512 |> json(%{error: "new_shortcode or new_file were not specified"})
513 end
514 end
515
516 def update_file(conn, %{"action" => action}) do
517 conn
518 |> put_status(:bad_request)
519 |> json(%{error: "Unknown action: #{action}"})
520 end
521
522 @doc """
523 Imports emoji from the filesystem.
524
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.
531 """
532 def import_from_fs(conn, _params) do
533 with {:ok, results} <- File.ls(emoji_dir_path()) do
534 imported_pack_names =
535 results
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"))
540 end)
541 |> Enum.map(&write_pack_json_contents/1)
542
543 json(conn, imported_pack_names)
544 else
545 {:error, _} ->
546 conn
547 |> put_status(:internal_server_error)
548 |> json(%{error: "Error accessing emoji pack directory"})
549 end
550 end
551
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")
555
556 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
557 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
558
559 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
560
561 dir
562 end
563
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
568
569 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
570
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}
580 _ -> nil
581 end
582 end)
583 |> Enum.filter(fn x -> not is_nil(x) end)
584 |> Enum.into(%{})
585 else
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)
590 end
591 end
592 end