Add documentation for the emoji api endpoints
[akkoma] / lib / pleroma / web / emoji_api / emoji_api_controller.ex
1 defmodule Pleroma.Web.EmojiAPI.EmojiAPIController do
2 use Pleroma.Web, :controller
3
4 require Logger
5
6 def reload(conn, _params) do
7 Pleroma.Emoji.reload()
8
9 conn |> text("ok")
10 end
11
12 @emoji_dir_path Path.join(
13 Pleroma.Config.get!([:instance, :static_dir]),
14 "emoji"
15 )
16
17 @cache_seconds_per_file Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
18
19 @doc """
20 Lists the packs available on the instance as JSON.
21
22 The information is public and does not require authentification. The format is
23 a map of "pack directory name" to pack.json contents.
24 """
25 def list_packs(conn, _params) do
26 pack_infos =
27 case File.ls(@emoji_dir_path) do
28 {:error, _} ->
29 %{}
30
31 {:ok, results} ->
32 results
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"))
37 end)
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")
41
42 {pack_name, Jason.decode!(File.read!(pack_file))}
43 end)
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)
48
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()
52
53 {name,
54 pack
55 |> put_in(["pack", "can-download"], true)
56 |> put_in(["pack", "download-sha256"], archive_sha)}
57 else
58 {name,
59 pack
60 |> put_in(["pack", "can-download"], false)}
61 end
62 end)
63 |> Enum.into(%{})
64 end
65
66 conn |> json(pack_infos)
67 end
68
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))
76 end)
77 end
78
79 defp create_archive_and_cache(name, pack, pack_dir, md5) do
80 files =
81 ['pack.json'] ++
82 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
83
84 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
85
86 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
87
88 Cachex.put!(
89 :emoji_packs_cache,
90 name,
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
94 ttl: cache_ms
95 )
96
97 Logger.debug("Create an archive for the '#{name}' emoji pack, \
98 keeping it in cache for #{div(cache_ms, 1000)}s")
99
100 zip_result
101 end
102
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")))
106
107 maybe_cached_pack = Cachex.get!(:emoji_packs_cache, name)
108
109 zip_result =
110 if is_nil(maybe_cached_pack) do
111 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
112 else
113 if maybe_cached_pack[:pack_file_md5] == pack_file_md5 do
114 Logger.debug("Using cache for the '#{name}' shared emoji pack")
115
116 maybe_cached_pack[:pack_data]
117 else
118 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
119 end
120 end
121
122 zip_result
123 end
124
125 @doc """
126 An endpoint for other instances (via admin UI) or users (via browser)
127 to download packs that the instance shares.
128 """
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")
132
133 if File.exists?(pack_file) do
134 pack = Jason.decode!(File.read!(pack_file))
135
136 if can_download?(pack, pack_dir) do
137 zip_result = make_archive(name, pack, pack_dir)
138
139 conn
140 |> send_download({:binary, zip_result}, filename: "#{name}.zip")
141 else
142 {:error,
143 conn
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")}
147 end
148 else
149 {:error,
150 conn
151 |> put_status(:not_found)
152 |> text("Pack #{name} does not exist")}
153 end
154 end
155
156 @doc """
157 An admin endpoint to request downloading a pack named `pack_name` from the instance
158 `instance_address`.
159
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.
162 """
163 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
164 list_uri = "#{address}/api/pleroma/emoji/packs/list"
165
166 list = Tesla.get!(list_uri).body |> Jason.decode!()
167 full_pack = list[name]
168 pfiles = full_pack["files"]
169 pack = full_pack["pack"]
170
171 pack_info_res =
172 cond do
173 pack["share-files"] && pack["can-download"] ->
174 {:ok,
175 %{
176 sha: pack["download-sha256"],
177 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
178 }}
179
180 pack["fallback-src"] ->
181 {:ok,
182 %{
183 sha: pack["fallback-src-sha256"],
184 uri: pack["fallback-src"],
185 fallback: true
186 }}
187
188 true ->
189 {:error, "The pack was not set as shared and there is no fallback src to download from"}
190 end
191
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
196
197 got_sha = :crypto.hash(:sha256, emoji_archive)
198
199 if got_sha == sha do
200 local_name = data["as"] || name
201 pack_dir = Path.join(@emoji_dir_path, local_name)
202 File.mkdir_p!(pack_dir)
203
204 # Fallback cannot contain a pack.json file
205 files =
206 unless(pinfo[:fallback], do: ['pack.json'], else: []) ++
207 (pfiles |> Enum.map(fn {_, path} -> to_charlist(path) end))
208
209 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
210
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")
215
216 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
217 end
218
219 conn |> text("ok")
220 else
221 conn
222 |> put_status(:internal_server_error)
223 |> text("SHA256 for the pack doesn't match the one sent by the server")
224 end
225
226 {:error, e} ->
227 conn |> put_status(:internal_server_error) |> text(e)
228 end
229 end
230
231 @doc """
232 Creates an empty pack named `name` which then can be updated via the admin UI.
233 """
234 def create(conn, %{"name" => name}) do
235 pack_dir = Path.join(@emoji_dir_path, name)
236
237 unless File.exists?(pack_dir) do
238 File.mkdir_p!(pack_dir)
239
240 pack_file_p = Path.join(pack_dir, "pack.json")
241
242 File.write!(
243 pack_file_p,
244 Jason.encode!(%{pack: %{}, files: %{}})
245 )
246
247 conn |> text("ok")
248 else
249 conn
250 |> put_status(:conflict)
251 |> text("A pack named \"#{name}\" already exists")
252 end
253 end
254
255 @doc """
256 Deletes the pack `name` and all it's files.
257 """
258 def delete(conn, %{"name" => name}) do
259 pack_dir = Path.join(@emoji_dir_path, name)
260
261 case File.rm_rf(pack_dir) do
262 {:ok, _} ->
263 conn |> text("ok")
264
265 {:error, _} ->
266 conn |> put_status(:internal_server_error) |> text("Couldn't delete the pack #{name}")
267 end
268 end
269
270 @doc """
271 An endpoint to update `pack_names`'s metadata.
272
273 `new_data` is the new metadata for the pack, that will replace the old metadata.
274 """
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")
278
279 full_pack = Jason.decode!(File.read!(pack_file_p))
280
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"]
285
286 new_data =
287 if should_update_fb_sha do
288 pack_arch = Tesla.get!(new_data["fallback-src"]).body
289
290 {:ok, flist} = :zip.unzip(pack_arch, [:memory])
291
292 # Check if all files from the pack.json are in the archive
293 has_all_files =
294 Enum.all?(full_pack["files"], fn {_, from_manifest} ->
295 Enum.find(flist, fn {from_archive, _} ->
296 to_string(from_archive) == from_manifest
297 end)
298 end)
299
300 unless has_all_files do
301 {:error,
302 conn
303 |> put_status(:bad_request)
304 |> text("The fallback archive does not have all files specified in pack.json")}
305 else
306 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
307
308 {:ok, new_data |> Map.put("fallback-src-sha256", fallback_sha)}
309 end
310 else
311 {:ok, new_data}
312 end
313
314 case new_data do
315 {:ok, new_data} ->
316 full_pack = Map.put(full_pack, "pack", new_data)
317 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
318
319 # Send new data back with fallback sha filled
320 conn |> json(new_data)
321
322 {:error, e} ->
323 e
324 end
325 end
326
327 @doc """
328 Updates a file in a pack.
329
330 Updating can mean three things:
331
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
340 """
341 def update_file(
342 conn,
343 %{"pack_name" => pack_name, "action" => action, "shortcode" => shortcode} = params
344 ) do
345 pack_dir = Path.join(@emoji_dir_path, pack_name)
346 pack_file_p = Path.join(pack_dir, "pack.json")
347
348 full_pack = Jason.decode!(File.read!(pack_file_p))
349
350 res =
351 case action do
352 "add" ->
353 unless Map.has_key?(full_pack["files"], shortcode) do
354 filename =
355 if Map.has_key?(params, "filename") do
356 params["filename"]
357 else
358 case params["file"] do
359 %Plug.Upload{filename: filename} -> filename
360 url when is_binary(url) -> Path.basename(url)
361 end
362 end
363
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)
367
368 # If the name contains directories, create them
369 if String.contains?(file_path, "/") do
370 File.mkdir_p!(Path.dirname(file_path))
371 end
372
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)
377
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)
382 end
383
384 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
385
386 {:ok, updated_full_pack}
387 else
388 {:error,
389 conn
390 |> put_status(:bad_request)
391 |> text("shortcode or filename cannot be empty")}
392 end
393 else
394 {:error,
395 conn
396 |> put_status(:conflict)
397 |> text("An emoji with the \"#{shortcode}\" shortcode already exists")}
398 end
399
400 "remove" ->
401 if Map.has_key?(full_pack["files"], shortcode) do
402 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
403
404 emoji_file_path = Path.join(pack_dir, emoji_file_path)
405
406 # Delete the emoji file
407 File.rm!(emoji_file_path)
408
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)
412
413 if Enum.empty?(File.ls!(dir)) do
414 File.rmdir!(dir)
415 end
416 end
417
418 {:ok, updated_full_pack}
419 else
420 {:error,
421 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
422 end
423
424 "update" ->
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)
433
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))
437 end
438
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)
442
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)
446
447 if Enum.empty?(File.ls!(dir)) do
448 File.rmdir!(dir)
449 end
450 end
451
452 # Then, put in the new shortcode with the new path
453 updated_full_pack =
454 put_in(updated_full_pack, ["files", new_shortcode], new_filename)
455
456 {:ok, updated_full_pack}
457 else
458 {:error,
459 conn
460 |> put_status(:bad_request)
461 |> text("new_shortcode or new_filename cannot be empty")}
462 end
463 else
464 _ ->
465 {:error,
466 conn
467 |> put_status(:bad_request)
468 |> text("new_shortcode or new_file were not specified")}
469 end
470 else
471 {:error,
472 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
473 end
474
475 _ ->
476 {:error, conn |> put_status(:bad_request) |> text("Unknown action: #{action}")}
477 end
478
479 case res do
480 {:ok, updated_full_pack} ->
481 # Write the emoji pack file
482 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
483
484 # Return the modified file list
485 conn |> json(updated_full_pack["files"])
486
487 {:error, e} ->
488 e
489 end
490 end
491
492 @doc """
493 Imports emoji from the filesystem.
494
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.
501 """
502 def import_from_fs(conn, _params) do
503 case File.ls(@emoji_dir_path) do
504 {:error, _} ->
505 conn
506 |> put_status(:internal_server_error)
507 |> text("Error accessing emoji pack directory")
508
509 {:ok, results} ->
510 imported_pack_names =
511 results
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"))
516 end)
517 |> Enum.map(fn dir ->
518 dir_path = Path.join(@emoji_dir_path, dir)
519 emoji_txt_path = Path.join(dir_path, "emoji.txt")
520
521 files_for_pack =
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
525
526 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
527
528 # Create a map of shortcodes to filenames from emoji.txt
529
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
536 [name, file | _] ->
537 {name, file}
538
539 _ ->
540 nil
541 end
542 end)
543 |> Enum.filter(fn x -> not is_nil(x) end)
544 |> Enum.into(%{})
545 else
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(
549 dir_path,
550 Pleroma.Config.get!([:emoji, :pack_extensions])
551 )
552 end
553
554 pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack})
555
556 File.write!(
557 Path.join(dir_path, "pack.json"),
558 pack_json_contents
559 )
560
561 dir
562 end)
563
564 conn |> json(imported_pack_names)
565 end
566 end
567 end