Merge branch 'develop' into feature/moderation-log-filters
[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 packs from the remote instance.
15
16 Since JS cannot ask remote instances for their packs due to CPS, it has to
17 be done by the server
18 """
19 def list_from(conn, %{"instance_address" => address}) do
20 address = String.trim(address)
21
22 if shareable_packs_available(address) do
23 list_resp =
24 "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
25
26 json(conn, list_resp)
27 else
28 conn
29 |> put_status(:internal_server_error)
30 |> json(%{error: "The requested instance does not support sharing emoji packs"})
31 end
32 end
33
34 @doc """
35 Lists the packs available on the instance as JSON.
36
37 The information is public and does not require authentification. The format is
38 a map of "pack directory name" to pack.json contents.
39 """
40 def list_packs(conn, _params) do
41 # Create the directory first if it does not exist. This is probably the first request made
42 # with the API so it should be sufficient
43 with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())},
44 {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do
45 pack_infos =
46 results
47 |> Enum.filter(&has_pack_json?/1)
48 |> Enum.map(&load_pack/1)
49 # Check if all the files are in place and can be sent
50 |> Enum.map(&validate_pack/1)
51 # Transform into a map of pack-name => pack-data
52 |> Enum.into(%{})
53
54 json(conn, pack_infos)
55 else
56 {:create_dir, {:error, e}} ->
57 conn
58 |> put_status(:internal_server_error)
59 |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
60
61 {:ls, {:error, e}} ->
62 conn
63 |> put_status(:internal_server_error)
64 |> json(%{
65 error:
66 "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
67 })
68 end
69 end
70
71 defp has_pack_json?(file) do
72 dir_path = Path.join(emoji_dir_path(), file)
73 # Filter to only use the pack.json packs
74 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
75 end
76
77 defp load_pack(pack_name) do
78 pack_path = Path.join(emoji_dir_path(), pack_name)
79 pack_file = Path.join(pack_path, "pack.json")
80
81 {pack_name, Jason.decode!(File.read!(pack_file))}
82 end
83
84 defp validate_pack({name, pack}) do
85 pack_path = Path.join(emoji_dir_path(), name)
86
87 if can_download?(pack, pack_path) do
88 archive_for_sha = make_archive(name, pack, pack_path)
89 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
90
91 pack =
92 pack
93 |> put_in(["pack", "can-download"], true)
94 |> put_in(["pack", "download-sha256"], archive_sha)
95
96 {name, pack}
97 else
98 {name, put_in(pack, ["pack", "can-download"], false)}
99 end
100 end
101
102 defp can_download?(pack, pack_path) do
103 # If the pack is set as shared, check if it can be downloaded
104 # That means that when asked, the pack can be packed and sent to the remote
105 # Otherwise, they'd have to download it from external-src
106 pack["pack"]["share-files"] &&
107 Enum.all?(pack["files"], fn {_, path} ->
108 File.exists?(Path.join(pack_path, path))
109 end)
110 end
111
112 defp create_archive_and_cache(name, pack, pack_dir, md5) do
113 files =
114 ['pack.json'] ++
115 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
116
117 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
118
119 cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
120 cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files))
121
122 Cachex.put!(
123 :emoji_packs_cache,
124 name,
125 # if pack.json MD5 changes, the cache is not valid anymore
126 %{pack_json_md5: md5, pack_data: zip_result},
127 # Add a minute to cache time for every file in the pack
128 ttl: cache_ms
129 )
130
131 Logger.debug("Created an archive for the '#{name}' emoji pack, \
132 keeping it in cache for #{div(cache_ms, 1000)}s")
133
134 zip_result
135 end
136
137 defp make_archive(name, pack, pack_dir) do
138 # Having a different pack.json md5 invalidates cache
139 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
140
141 case Cachex.get!(:emoji_packs_cache, name) do
142 %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} ->
143 Logger.debug("Using cache for the '#{name}' shared emoji pack")
144 zip_result
145
146 _ ->
147 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
148 end
149 end
150
151 @doc """
152 An endpoint for other instances (via admin UI) or users (via browser)
153 to download packs that the instance shares.
154 """
155 def download_shared(conn, %{"name" => name}) do
156 pack_dir = Path.join(emoji_dir_path(), name)
157 pack_file = Path.join(pack_dir, "pack.json")
158
159 with {_, true} <- {:exists?, File.exists?(pack_file)},
160 pack = Jason.decode!(File.read!(pack_file)),
161 {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do
162 zip_result = make_archive(name, pack, pack_dir)
163 send_download(conn, {:binary, zip_result}, filename: "#{name}.zip")
164 else
165 {:can_download?, _} ->
166 conn
167 |> put_status(:forbidden)
168 |> json(%{
169 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
170 was disabled for this pack or some files are missing"
171 })
172
173 {:exists?, _} ->
174 conn
175 |> put_status(:not_found)
176 |> json(%{error: "Pack #{name} does not exist"})
177 end
178 end
179
180 defp shareable_packs_available(address) do
181 "#{address}/.well-known/nodeinfo"
182 |> Tesla.get!()
183 |> Map.get(:body)
184 |> Jason.decode!()
185 |> Map.get("links")
186 |> List.last()
187 |> Map.get("href")
188 # Get the actual nodeinfo address and fetch it
189 |> Tesla.get!()
190 |> Map.get(:body)
191 |> Jason.decode!()
192 |> get_in(["metadata", "features"])
193 |> Enum.member?("shareable_emoji_packs")
194 end
195
196 @doc """
197 An admin endpoint to request downloading a pack named `pack_name` from the instance
198 `instance_address`.
199
200 If the requested instance's admin chose to share the pack, it will be downloaded
201 from that instance, otherwise it will be downloaded from the fallback source, if there is one.
202 """
203 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
204 address = String.trim(address)
205
206 if shareable_packs_available(address) do
207 full_pack =
208 "#{address}/api/pleroma/emoji/packs/list"
209 |> Tesla.get!()
210 |> Map.get(:body)
211 |> Jason.decode!()
212 |> Map.get(name)
213
214 pack_info_res =
215 case full_pack["pack"] do
216 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
217 {:ok,
218 %{
219 sha: sha,
220 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
221 }}
222
223 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
224 {:ok,
225 %{
226 sha: sha,
227 uri: src,
228 fallback: true
229 }}
230
231 _ ->
232 {:error,
233 "The pack was not set as shared and there is no fallback src to download from"}
234 end
235
236 with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res,
237 %{body: emoji_archive} <- Tesla.get!(uri),
238 {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do
239 local_name = data["as"] || name
240 pack_dir = Path.join(emoji_dir_path(), local_name)
241 File.mkdir_p!(pack_dir)
242
243 files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end)
244 # Fallback cannot contain a pack.json file
245 files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files
246
247 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
248
249 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
250 # in it to depend on itself
251 if pinfo[:fallback] do
252 pack_file_path = Path.join(pack_dir, "pack.json")
253
254 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
255 end
256
257 json(conn, "ok")
258 else
259 {:error, e} ->
260 conn |> put_status(:internal_server_error) |> json(%{error: e})
261
262 {:checksum, _} ->
263 conn
264 |> put_status(:internal_server_error)
265 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
266 end
267 else
268 conn
269 |> put_status(:internal_server_error)
270 |> json(%{error: "The requested instance does not support sharing emoji packs"})
271 end
272 end
273
274 @doc """
275 Creates an empty pack named `name` which then can be updated via the admin UI.
276 """
277 def create(conn, %{"name" => name}) do
278 pack_dir = Path.join(emoji_dir_path(), name)
279
280 if not File.exists?(pack_dir) do
281 File.mkdir_p!(pack_dir)
282
283 pack_file_p = Path.join(pack_dir, "pack.json")
284
285 File.write!(
286 pack_file_p,
287 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
288 )
289
290 conn |> json("ok")
291 else
292 conn
293 |> put_status(:conflict)
294 |> json(%{error: "A pack named \"#{name}\" already exists"})
295 end
296 end
297
298 @doc """
299 Deletes the pack `name` and all it's files.
300 """
301 def delete(conn, %{"name" => name}) do
302 pack_dir = Path.join(emoji_dir_path(), name)
303
304 case File.rm_rf(pack_dir) do
305 {:ok, _} ->
306 conn |> json("ok")
307
308 {:error, _} ->
309 conn
310 |> put_status(:internal_server_error)
311 |> json(%{error: "Couldn't delete the pack #{name}"})
312 end
313 end
314
315 @doc """
316 An endpoint to update `pack_names`'s metadata.
317
318 `new_data` is the new metadata for the pack, that will replace the old metadata.
319 """
320 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
321 pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"])
322
323 full_pack = Jason.decode!(File.read!(pack_file_p))
324
325 # The new fallback-src is in the new data and it's not the same as it was in the old data
326 should_update_fb_sha =
327 not is_nil(new_data["fallback-src"]) and
328 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
329
330 with {_, true} <- {:should_update?, should_update_fb_sha},
331 %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]),
332 {:ok, flist} <- :zip.unzip(pack_arch, [:memory]),
333 {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do
334 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
335
336 new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha)
337 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
338 else
339 {:should_update?, _} ->
340 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
341
342 {:has_all_files?, _} ->
343 conn
344 |> put_status(:bad_request)
345 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
346 end
347 end
348
349 # Check if all files from the pack.json are in the archive
350 defp has_all_files?(%{"files" => files}, flist) do
351 Enum.all?(files, fn {_, from_manifest} ->
352 Enum.find(flist, fn {from_archive, _} ->
353 to_string(from_archive) == from_manifest
354 end)
355 end)
356 end
357
358 defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do
359 full_pack = Map.put(full_pack, "pack", new_data)
360 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
361
362 # Send new data back with fallback sha filled
363 json(conn, new_data)
364 end
365
366 defp get_filename(%{"filename" => filename}), do: filename
367
368 defp get_filename(%{"file" => file}) do
369 case file do
370 %Plug.Upload{filename: filename} -> filename
371 url when is_binary(url) -> Path.basename(url)
372 end
373 end
374
375 defp empty?(str), do: String.trim(str) == ""
376
377 defp update_file_and_send(conn, updated_full_pack, pack_file_p) do
378 # Write the emoji pack file
379 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
380
381 # Return the modified file list
382 json(conn, updated_full_pack["files"])
383 end
384
385 @doc """
386 Updates a file in a pack.
387
388 Updating can mean three things:
389
390 - `add` adds an emoji named `shortcode` to the pack `pack_name`,
391 that means that the emoji file needs to be uploaded with the request
392 (thus requiring it to be a multipart request) and be named `file`.
393 There can also be an optional `filename` that will be the new emoji file name
394 (if it's not there, the name will be taken from the uploaded file).
395 - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file
396 (from the current filename to `new_filename`)
397 - `remove` removes the emoji named `shortcode` and it's associated file
398 """
399
400 # Add
401 def update_file(
402 conn,
403 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
404 ) do
405 pack_dir = Path.join(emoji_dir_path(), pack_name)
406 pack_file_p = Path.join(pack_dir, "pack.json")
407
408 full_pack = Jason.decode!(File.read!(pack_file_p))
409
410 with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
411 filename <- get_filename(params),
412 false <- empty?(shortcode),
413 false <- empty?(filename) do
414 file_path = Path.join(pack_dir, filename)
415
416 # If the name contains directories, create them
417 if String.contains?(file_path, "/") do
418 File.mkdir_p!(Path.dirname(file_path))
419 end
420
421 case params["file"] do
422 %Plug.Upload{path: upload_path} ->
423 # Copy the uploaded file from the temporary directory
424 File.copy!(upload_path, file_path)
425
426 url when is_binary(url) ->
427 # Download and write the file
428 file_contents = Tesla.get!(url).body
429 File.write!(file_path, file_contents)
430 end
431
432 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
433 update_file_and_send(conn, updated_full_pack, pack_file_p)
434 else
435 {:has_shortcode, _} ->
436 conn
437 |> put_status(:conflict)
438 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
439
440 true ->
441 conn
442 |> put_status(:bad_request)
443 |> json(%{error: "shortcode or filename cannot be empty"})
444 end
445 end
446
447 # Remove
448 def update_file(conn, %{
449 "pack_name" => pack_name,
450 "action" => "remove",
451 "shortcode" => shortcode
452 }) do
453 pack_dir = Path.join(emoji_dir_path(), pack_name)
454 pack_file_p = Path.join(pack_dir, "pack.json")
455
456 full_pack = Jason.decode!(File.read!(pack_file_p))
457
458 if Map.has_key?(full_pack["files"], shortcode) do
459 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
460
461 emoji_file_path = Path.join(pack_dir, emoji_file_path)
462
463 # Delete the emoji file
464 File.rm!(emoji_file_path)
465
466 # If the old directory has no more files, remove it
467 if String.contains?(emoji_file_path, "/") do
468 dir = Path.dirname(emoji_file_path)
469
470 if Enum.empty?(File.ls!(dir)) do
471 File.rmdir!(dir)
472 end
473 end
474
475 update_file_and_send(conn, updated_full_pack, pack_file_p)
476 else
477 conn
478 |> put_status(:bad_request)
479 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
480 end
481 end
482
483 # Update
484 def update_file(
485 conn,
486 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
487 ) do
488 pack_dir = Path.join(emoji_dir_path(), pack_name)
489 pack_file_p = Path.join(pack_dir, "pack.json")
490
491 full_pack = Jason.decode!(File.read!(pack_file_p))
492
493 with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)},
494 %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params,
495 false <- empty?(new_shortcode),
496 false <- empty?(new_filename) do
497 # First, remove the old shortcode, saving the old path
498 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
499 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
500 new_emoji_file_path = Path.join(pack_dir, new_filename)
501
502 # If the name contains directories, create them
503 if String.contains?(new_emoji_file_path, "/") do
504 File.mkdir_p!(Path.dirname(new_emoji_file_path))
505 end
506
507 # Move/Rename the old filename to a new filename
508 # These are probably on the same filesystem, so just rename should work
509 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
510
511 # If the old directory has no more files, remove it
512 if String.contains?(old_emoji_file_path, "/") do
513 dir = Path.dirname(old_emoji_file_path)
514
515 if Enum.empty?(File.ls!(dir)) do
516 File.rmdir!(dir)
517 end
518 end
519
520 # Then, put in the new shortcode with the new path
521 updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename)
522 update_file_and_send(conn, updated_full_pack, pack_file_p)
523 else
524 {:has_shortcode, _} ->
525 conn
526 |> put_status(:bad_request)
527 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
528
529 true ->
530 conn
531 |> put_status(:bad_request)
532 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
533
534 _ ->
535 conn
536 |> put_status(:bad_request)
537 |> json(%{error: "new_shortcode or new_file were not specified"})
538 end
539 end
540
541 def update_file(conn, %{"action" => action}) do
542 conn
543 |> put_status(:bad_request)
544 |> json(%{error: "Unknown action: #{action}"})
545 end
546
547 @doc """
548 Imports emoji from the filesystem.
549
550 Importing means checking all the directories in the
551 `$instance_static/emoji/` for directories which do not have
552 `pack.json`. If one has an emoji.txt file, that file will be used
553 to create a `pack.json` file with it's contents. If the directory has
554 neither, all the files with specific configured extenstions will be
555 assumed to be emojis and stored in the new `pack.json` file.
556 """
557 def import_from_fs(conn, _params) do
558 with {:ok, results} <- File.ls(emoji_dir_path()) do
559 imported_pack_names =
560 results
561 |> Enum.filter(fn file ->
562 dir_path = Path.join(emoji_dir_path(), file)
563 # Find the directories that do NOT have pack.json
564 File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json"))
565 end)
566 |> Enum.map(&write_pack_json_contents/1)
567
568 json(conn, imported_pack_names)
569 else
570 {:error, _} ->
571 conn
572 |> put_status(:internal_server_error)
573 |> json(%{error: "Error accessing emoji pack directory"})
574 end
575 end
576
577 defp write_pack_json_contents(dir) do
578 dir_path = Path.join(emoji_dir_path(), dir)
579 emoji_txt_path = Path.join(dir_path, "emoji.txt")
580
581 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
582 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
583
584 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
585
586 dir
587 end
588
589 defp files_for_pack(emoji_txt_path, dir_path) do
590 if File.exists?(emoji_txt_path) do
591 # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
592 # Make a pack.json file from the contents of that emoji.txt fileh
593
594 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
595
596 # Create a map of shortcodes to filenames from emoji.txt
597 File.read!(emoji_txt_path)
598 |> String.split("\n")
599 |> Enum.map(&String.trim/1)
600 |> Enum.map(fn line ->
601 case String.split(line, ~r/,\s*/) do
602 # This matches both strings with and without tags
603 # and we don't care about tags here
604 [name, file | _] -> {name, file}
605 _ -> nil
606 end
607 end)
608 |> Enum.filter(fn x -> not is_nil(x) end)
609 |> Enum.into(%{})
610 else
611 # If there's no emoji.txt, assume all files
612 # that are of certain extensions from the config are emojis and import them all
613 pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
614 Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions)
615 end
616 end
617 end