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