Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma into develop
[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 alias Pleroma.Plugs.OAuthScopesPlug
5
6 require Logger
7
8 plug(
9 OAuthScopesPlug,
10 %{scopes: ["write"], admin: true}
11 when action in [
12 :create,
13 :delete,
14 :download_from,
15 :list_from,
16 :import_from_fs,
17 :update_file,
18 :update_metadata
19 ]
20 )
21
22 plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
23
24 def emoji_dir_path do
25 Path.join(
26 Pleroma.Config.get!([:instance, :static_dir]),
27 "emoji"
28 )
29 end
30
31 @doc """
32 Lists packs from the remote instance.
33
34 Since JS cannot ask remote instances for their packs due to CPS, it has to
35 be done by the server
36 """
37 def list_from(conn, %{"instance_address" => address}) do
38 address = String.trim(address)
39
40 if shareable_packs_available(address) do
41 list_resp =
42 "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!()
43
44 json(conn, list_resp)
45 else
46 conn
47 |> put_status(:internal_server_error)
48 |> json(%{error: "The requested instance does not support sharing emoji packs"})
49 end
50 end
51
52 @doc """
53 Lists the packs available on the instance as JSON.
54
55 The information is public and does not require authentification. The format is
56 a map of "pack directory name" to pack.json contents.
57 """
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
63 pack_infos =
64 results
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
70 |> Enum.into(%{})
71
72 json(conn, pack_infos)
73 else
74 {:create_dir, {:error, e}} ->
75 conn
76 |> put_status(:internal_server_error)
77 |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"})
78
79 {:ls, {:error, e}} ->
80 conn
81 |> put_status(:internal_server_error)
82 |> json(%{
83 error:
84 "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}"
85 })
86 end
87 end
88
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"))
93 end
94
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")
98
99 {pack_name, Jason.decode!(File.read!(pack_file))}
100 end
101
102 defp validate_pack({name, pack}) do
103 pack_path = Path.join(emoji_dir_path(), name)
104
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()
108
109 pack =
110 pack
111 |> put_in(["pack", "can-download"], true)
112 |> put_in(["pack", "download-sha256"], archive_sha)
113
114 {name, pack}
115 else
116 {name, put_in(pack, ["pack", "can-download"], false)}
117 end
118 end
119
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))
127 end)
128 end
129
130 defp create_archive_and_cache(name, pack, pack_dir, md5) do
131 files =
132 ['pack.json'] ++
133 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
134
135 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
136
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))
139
140 Cachex.put!(
141 :emoji_packs_cache,
142 name,
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
146 ttl: cache_ms
147 )
148
149 Logger.debug("Created an archive for the '#{name}' emoji pack, \
150 keeping it in cache for #{div(cache_ms, 1000)}s")
151
152 zip_result
153 end
154
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")))
158
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")
162 zip_result
163
164 _ ->
165 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
166 end
167 end
168
169 @doc """
170 An endpoint for other instances (via admin UI) or users (via browser)
171 to download packs that the instance shares.
172 """
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")
176
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")
182 else
183 {:can_download?, _} ->
184 conn
185 |> put_status(:forbidden)
186 |> json(%{
187 error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\
188 was disabled for this pack or some files are missing"
189 })
190
191 {:exists?, _} ->
192 conn
193 |> put_status(:not_found)
194 |> json(%{error: "Pack #{name} does not exist"})
195 end
196 end
197
198 defp shareable_packs_available(address) do
199 "#{address}/.well-known/nodeinfo"
200 |> Tesla.get!()
201 |> Map.get(:body)
202 |> Jason.decode!()
203 |> Map.get("links")
204 |> List.last()
205 |> Map.get("href")
206 # Get the actual nodeinfo address and fetch it
207 |> Tesla.get!()
208 |> Map.get(:body)
209 |> Jason.decode!()
210 |> get_in(["metadata", "features"])
211 |> Enum.member?("shareable_emoji_packs")
212 end
213
214 @doc """
215 An admin endpoint to request downloading a pack named `pack_name` from the instance
216 `instance_address`.
217
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.
220 """
221 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
222 address = String.trim(address)
223
224 if shareable_packs_available(address) do
225 full_pack =
226 "#{address}/api/pleroma/emoji/packs/list"
227 |> Tesla.get!()
228 |> Map.get(:body)
229 |> Jason.decode!()
230 |> Map.get(name)
231
232 pack_info_res =
233 case full_pack["pack"] do
234 %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
235 {:ok,
236 %{
237 sha: sha,
238 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
239 }}
240
241 %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
242 {:ok,
243 %{
244 sha: sha,
245 uri: src,
246 fallback: true
247 }}
248
249 _ ->
250 {:error,
251 "The pack was not set as shared and there is no fallback src to download from"}
252 end
253
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)
260
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
264
265 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
266
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")
271
272 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
273 end
274
275 json(conn, "ok")
276 else
277 {:error, e} ->
278 conn |> put_status(:internal_server_error) |> json(%{error: e})
279
280 {:checksum, _} ->
281 conn
282 |> put_status(:internal_server_error)
283 |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"})
284 end
285 else
286 conn
287 |> put_status(:internal_server_error)
288 |> json(%{error: "The requested instance does not support sharing emoji packs"})
289 end
290 end
291
292 @doc """
293 Creates an empty pack named `name` which then can be updated via the admin UI.
294 """
295 def create(conn, %{"name" => name}) do
296 pack_dir = Path.join(emoji_dir_path(), name)
297
298 if not File.exists?(pack_dir) do
299 File.mkdir_p!(pack_dir)
300
301 pack_file_p = Path.join(pack_dir, "pack.json")
302
303 File.write!(
304 pack_file_p,
305 Jason.encode!(%{pack: %{}, files: %{}}, pretty: true)
306 )
307
308 conn |> json("ok")
309 else
310 conn
311 |> put_status(:conflict)
312 |> json(%{error: "A pack named \"#{name}\" already exists"})
313 end
314 end
315
316 @doc """
317 Deletes the pack `name` and all it's files.
318 """
319 def delete(conn, %{"name" => name}) do
320 pack_dir = Path.join(emoji_dir_path(), name)
321
322 case File.rm_rf(pack_dir) do
323 {:ok, _} ->
324 conn |> json("ok")
325
326 {:error, _} ->
327 conn
328 |> put_status(:internal_server_error)
329 |> json(%{error: "Couldn't delete the pack #{name}"})
330 end
331 end
332
333 @doc """
334 An endpoint to update `pack_names`'s metadata.
335
336 `new_data` is the new metadata for the pack, that will replace the old metadata.
337 """
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"])
340
341 full_pack = Jason.decode!(File.read!(pack_file_p))
342
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"]
347
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()
353
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)
356 else
357 {:should_update?, _} ->
358 update_metadata_and_send(conn, full_pack, new_data, pack_file_p)
359
360 {:has_all_files?, _} ->
361 conn
362 |> put_status(:bad_request)
363 |> json(%{error: "The fallback archive does not have all files specified in pack.json"})
364 end
365 end
366
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
372 end)
373 end)
374 end
375
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))
379
380 # Send new data back with fallback sha filled
381 json(conn, new_data)
382 end
383
384 defp get_filename(%{"filename" => filename}), do: filename
385
386 defp get_filename(%{"file" => file}) do
387 case file do
388 %Plug.Upload{filename: filename} -> filename
389 url when is_binary(url) -> Path.basename(url)
390 end
391 end
392
393 defp empty?(str), do: String.trim(str) == ""
394
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))
398
399 # Return the modified file list
400 json(conn, updated_full_pack["files"])
401 end
402
403 @doc """
404 Updates a file in a pack.
405
406 Updating can mean three things:
407
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
416 """
417
418 # Add
419 def update_file(
420 conn,
421 %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params
422 ) do
423 pack_dir = Path.join(emoji_dir_path(), pack_name)
424 pack_file_p = Path.join(pack_dir, "pack.json")
425
426 full_pack = Jason.decode!(File.read!(pack_file_p))
427
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)
433
434 # If the name contains directories, create them
435 if String.contains?(file_path, "/") do
436 File.mkdir_p!(Path.dirname(file_path))
437 end
438
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)
443
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)
448 end
449
450 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
451 update_file_and_send(conn, updated_full_pack, pack_file_p)
452 else
453 {:has_shortcode, _} ->
454 conn
455 |> put_status(:conflict)
456 |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"})
457
458 true ->
459 conn
460 |> put_status(:bad_request)
461 |> json(%{error: "shortcode or filename cannot be empty"})
462 end
463 end
464
465 # Remove
466 def update_file(conn, %{
467 "pack_name" => pack_name,
468 "action" => "remove",
469 "shortcode" => shortcode
470 }) do
471 pack_dir = Path.join(emoji_dir_path(), pack_name)
472 pack_file_p = Path.join(pack_dir, "pack.json")
473
474 full_pack = Jason.decode!(File.read!(pack_file_p))
475
476 if Map.has_key?(full_pack["files"], shortcode) do
477 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
478
479 emoji_file_path = Path.join(pack_dir, emoji_file_path)
480
481 # Delete the emoji file
482 File.rm!(emoji_file_path)
483
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)
487
488 if Enum.empty?(File.ls!(dir)) do
489 File.rmdir!(dir)
490 end
491 end
492
493 update_file_and_send(conn, updated_full_pack, pack_file_p)
494 else
495 conn
496 |> put_status(:bad_request)
497 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
498 end
499 end
500
501 # Update
502 def update_file(
503 conn,
504 %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params
505 ) do
506 pack_dir = Path.join(emoji_dir_path(), pack_name)
507 pack_file_p = Path.join(pack_dir, "pack.json")
508
509 full_pack = Jason.decode!(File.read!(pack_file_p))
510
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)
519
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))
523 end
524
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)
528
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)
532
533 if Enum.empty?(File.ls!(dir)) do
534 File.rmdir!(dir)
535 end
536 end
537
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)
541 else
542 {:has_shortcode, _} ->
543 conn
544 |> put_status(:bad_request)
545 |> json(%{error: "Emoji \"#{shortcode}\" does not exist"})
546
547 true ->
548 conn
549 |> put_status(:bad_request)
550 |> json(%{error: "new_shortcode or new_filename cannot be empty"})
551
552 _ ->
553 conn
554 |> put_status(:bad_request)
555 |> json(%{error: "new_shortcode or new_file were not specified"})
556 end
557 end
558
559 def update_file(conn, %{"action" => action}) do
560 conn
561 |> put_status(:bad_request)
562 |> json(%{error: "Unknown action: #{action}"})
563 end
564
565 @doc """
566 Imports emoji from the filesystem.
567
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.
574 """
575 def import_from_fs(conn, _params) do
576 with {:ok, results} <- File.ls(emoji_dir_path()) do
577 imported_pack_names =
578 results
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"))
583 end)
584 |> Enum.map(&write_pack_json_contents/1)
585
586 json(conn, imported_pack_names)
587 else
588 {:error, _} ->
589 conn
590 |> put_status(:internal_server_error)
591 |> json(%{error: "Error accessing emoji pack directory"})
592 end
593 end
594
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")
598
599 files_for_pack = files_for_pack(emoji_txt_path, dir_path)
600 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
601
602 File.write!(Path.join(dir_path, "pack.json"), pack_json_contents)
603
604 dir
605 end
606
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
611
612 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
613
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}
623 _ -> nil
624 end
625 end)
626 |> Enum.filter(fn x -> not is_nil(x) end)
627 |> Enum.into(%{})
628 else
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)
633 end
634 end
635 end