Add a way to create emoji packs via an endpoint
[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 def list_packs(conn, _params) do
20 pack_infos =
21 case File.ls(@emoji_dir_path) do
22 {:error, _} ->
23 %{}
24
25 {:ok, results} ->
26 results
27 |> Enum.filter(fn file ->
28 dir_path = Path.join(@emoji_dir_path, file)
29 # Filter to only use the pack.json packs
30 File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json"))
31 end)
32 |> Enum.map(fn pack_name ->
33 pack_path = Path.join(@emoji_dir_path, pack_name)
34 pack_file = Path.join(pack_path, "pack.json")
35
36 {pack_name, Jason.decode!(File.read!(pack_file))}
37 end)
38 # Transform into a map of pack-name => pack-data
39 # Check if all the files are in place and can be sent
40 |> Enum.map(fn {name, pack} ->
41 pack_path = Path.join(@emoji_dir_path, name)
42
43 if can_download?(pack, pack_path) do
44 archive_for_sha = make_archive(name, pack, pack_path)
45 archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16()
46
47 {name,
48 pack
49 |> put_in(["pack", "can-download"], true)
50 |> put_in(["pack", "download-sha256"], archive_sha)}
51 else
52 {name,
53 pack
54 |> put_in(["pack", "can-download"], false)}
55 end
56 end)
57 |> Enum.into(%{})
58 end
59
60 conn |> json(pack_infos)
61 end
62
63 defp can_download?(pack, pack_path) do
64 # If the pack is set as shared, check if it can be downloaded
65 # That means that when asked, the pack can be packed and sent to the remote
66 # Otherwise, they'd have to download it from external-src
67 pack["pack"]["share-files"] and
68 Enum.all?(pack["files"], fn {_, path} ->
69 File.exists?(Path.join(pack_path, path))
70 end)
71 end
72
73 defp create_archive_and_cache(name, pack, pack_dir, md5) do
74 files =
75 ['pack.json'] ++
76 (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end))
77
78 {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)])
79
80 cache_ms = :timer.seconds(@cache_seconds_per_file * Enum.count(files))
81
82 Cachex.put!(
83 :emoji_packs_cache,
84 name,
85 # if pack.json MD5 changes, the cache is not valid anymore
86 %{pack_json_md5: md5, pack_data: zip_result},
87 # Add a minute to cache time for every file in the pack
88 ttl: cache_ms
89 )
90
91 Logger.debug("Create an archive for the '#{name}' emoji pack, \
92 keeping it in cache for #{div(cache_ms, 1000)}s")
93
94 zip_result
95 end
96
97 defp make_archive(name, pack, pack_dir) do
98 # Having a different pack.json md5 invalidates cache
99 pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json")))
100
101 maybe_cached_pack = Cachex.get!(:emoji_packs_cache, name)
102
103 zip_result =
104 if is_nil(maybe_cached_pack) do
105 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
106 else
107 if maybe_cached_pack[:pack_file_md5] == pack_file_md5 do
108 Logger.debug("Using cache for the '#{name}' shared emoji pack")
109
110 maybe_cached_pack[:pack_data]
111 else
112 create_archive_and_cache(name, pack, pack_dir, pack_file_md5)
113 end
114 end
115
116 zip_result
117 end
118
119 def download_shared(conn, %{"name" => name}) do
120 pack_dir = Path.join(@emoji_dir_path, name)
121 pack_file = Path.join(pack_dir, "pack.json")
122
123 if File.exists?(pack_file) do
124 pack = Jason.decode!(File.read!(pack_file))
125
126 if can_download?(pack, pack_dir) do
127 zip_result = make_archive(name, pack, pack_dir)
128
129 conn
130 |> send_download({:binary, zip_result}, filename: "#{name}.zip")
131 else
132 {:error,
133 conn
134 |> put_status(:forbidden)
135 |> text("Pack #{name} cannot be downloaded from this instance, either pack sharing\
136 was disabled for this pack or some files are missing")}
137 end
138 else
139 {:error,
140 conn
141 |> put_status(:not_found)
142 |> text("Pack #{name} does not exist")}
143 end
144 end
145
146 def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do
147 list_uri = "#{address}/api/pleroma/emoji/packs/list"
148
149 list = Tesla.get!(list_uri).body |> Jason.decode!()
150 full_pack = list[name]
151 pfiles = full_pack["files"]
152 pack = full_pack["pack"]
153
154 pack_info_res =
155 cond do
156 pack["share-files"] && pack["can-download"] ->
157 {:ok,
158 %{
159 sha: pack["download-sha256"],
160 uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}"
161 }}
162
163 pack["fallback-src"] ->
164 {:ok,
165 %{
166 sha: pack["fallback-src-sha256"],
167 uri: pack["fallback-src"],
168 fallback: true
169 }}
170
171 true ->
172 {:error, "The pack was not set as shared and there is no fallback src to download from"}
173 end
174
175 case pack_info_res do
176 {:ok, %{sha: sha, uri: uri} = pinfo} ->
177 sha = Base.decode16!(sha)
178 emoji_archive = Tesla.get!(uri).body
179
180 got_sha = :crypto.hash(:sha256, emoji_archive)
181
182 if got_sha == sha do
183 local_name = data["as"] || name
184 pack_dir = Path.join(@emoji_dir_path, local_name)
185 File.mkdir_p!(pack_dir)
186
187 # Fallback cannot contain a pack.json file
188 files =
189 unless(pinfo[:fallback], do: ['pack.json'], else: []) ++
190 (pfiles |> Enum.map(fn {_, path} -> to_charlist(path) end))
191
192 {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files)
193
194 # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
195 # in it to depend on itself
196 if pinfo[:fallback] do
197 pack_file_path = Path.join(pack_dir, "pack.json")
198
199 File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true))
200 end
201
202 conn |> text("ok")
203 else
204 conn
205 |> put_status(:internal_server_error)
206 |> text("SHA256 for the pack doesn't match the one sent by the server")
207 end
208
209 {:error, e} ->
210 conn |> put_status(:internal_server_error) |> text(e)
211 end
212 end
213
214 def create(conn, %{"name" => name}) do
215 pack_dir = Path.join(@emoji_dir_path, name)
216
217 unless File.exists?(pack_dir) do
218 File.mkdir_p!(pack_dir)
219
220 pack_file_p = Path.join(pack_dir, "pack.json")
221
222 File.write!(
223 pack_file_p,
224 Jason.encode!(%{pack: %{}, files: %{}})
225 )
226
227 conn |> text("ok")
228 else
229 conn
230 |> put_status(:conflict)
231 |> text("A pack named \"#{name}\" already exists")
232 end
233 end
234
235 def delete(conn, %{"name" => name}) do
236 pack_dir = Path.join(@emoji_dir_path, name)
237
238 case File.rm_rf(pack_dir) do
239 {:ok, _} ->
240 conn |> text("ok")
241
242 {:error, _} ->
243 conn |> put_status(:internal_server_error) |> text("Couldn't delete the pack #{name}")
244 end
245 end
246
247 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
248 pack_dir = Path.join(@emoji_dir_path, name)
249 pack_file_p = Path.join(pack_dir, "pack.json")
250
251 full_pack = Jason.decode!(File.read!(pack_file_p))
252
253 # The new fallback-src is in the new data and it's not the same as it was in the old data
254 should_update_fb_sha =
255 not is_nil(new_data["fallback-src"]) and
256 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
257
258 new_data =
259 if should_update_fb_sha do
260 pack_arch = Tesla.get!(new_data["fallback-src"]).body
261
262 {:ok, flist} = :zip.unzip(pack_arch, [:memory])
263
264 # Check if all files from the pack.json are in the archive
265 has_all_files =
266 Enum.all?(full_pack["files"], fn {_, from_manifest} ->
267 Enum.find(flist, fn {from_archive, _} ->
268 to_string(from_archive) == from_manifest
269 end)
270 end)
271
272 unless has_all_files do
273 {:error,
274 conn
275 |> put_status(:bad_request)
276 |> text("The fallback archive does not have all files specified in pack.json")}
277 else
278 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
279
280 {:ok, new_data |> Map.put("fallback-src-sha256", fallback_sha)}
281 end
282 else
283 {:ok, new_data}
284 end
285
286 case new_data do
287 {:ok, new_data} ->
288 full_pack = Map.put(full_pack, "pack", new_data)
289 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
290
291 # Send new data back with fallback sha filled
292 conn |> json(new_data)
293
294 {:error, e} ->
295 e
296 end
297 end
298
299 def update_file(
300 conn,
301 %{"pack_name" => pack_name, "action" => action, "shortcode" => shortcode} = params
302 ) do
303 pack_dir = Path.join(@emoji_dir_path, pack_name)
304 pack_file_p = Path.join(pack_dir, "pack.json")
305
306 full_pack = Jason.decode!(File.read!(pack_file_p))
307
308 res =
309 case action do
310 "add" ->
311 unless Map.has_key?(full_pack["files"], shortcode) do
312 filename =
313 if Map.has_key?(params, "filename") do
314 params["filename"]
315 else
316 case params["file"] do
317 %Plug.Upload{filename: filename} -> filename
318 url when is_binary(url) -> Path.basename(url)
319 end
320 end
321
322 unless String.trim(shortcode) |> String.length() == 0 or
323 String.trim(filename) |> String.length() == 0 do
324 file_path = Path.join(pack_dir, filename)
325
326 # If the name contains directories, create them
327 if String.contains?(file_path, "/") do
328 File.mkdir_p!(Path.dirname(file_path))
329 end
330
331 case params["file"] do
332 %Plug.Upload{path: upload_path} ->
333 # Copy the uploaded file from the temporary directory
334 File.copy!(upload_path, file_path)
335
336 url when is_binary(url) ->
337 # Download and write the file
338 file_contents = Tesla.get!(url).body
339 File.write!(file_path, file_contents)
340 end
341
342 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
343
344 {:ok, updated_full_pack}
345 else
346 {:error,
347 conn
348 |> put_status(:bad_request)
349 |> text("shortcode or filename cannot be empty")}
350 end
351 else
352 {:error,
353 conn
354 |> put_status(:conflict)
355 |> text("An emoji with the \"#{shortcode}\" shortcode already exists")}
356 end
357
358 "remove" ->
359 if Map.has_key?(full_pack["files"], shortcode) do
360 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
361
362 emoji_file_path = Path.join(pack_dir, emoji_file_path)
363
364 # Delete the emoji file
365 File.rm!(emoji_file_path)
366
367 # If the old directory has no more files, remove it
368 if String.contains?(emoji_file_path, "/") do
369 dir = Path.dirname(emoji_file_path)
370
371 if Enum.empty?(File.ls!(dir)) do
372 File.rmdir!(dir)
373 end
374 end
375
376 {:ok, updated_full_pack}
377 else
378 {:error,
379 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
380 end
381
382 "update" ->
383 if Map.has_key?(full_pack["files"], shortcode) do
384 with %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params do
385 unless String.trim(new_shortcode) |> String.length() == 0 or
386 String.trim(new_filename) |> String.length() == 0 do
387 # First, remove the old shortcode, saving the old path
388 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
389 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
390 new_emoji_file_path = Path.join(pack_dir, new_filename)
391
392 # If the name contains directories, create them
393 if String.contains?(new_emoji_file_path, "/") do
394 File.mkdir_p!(Path.dirname(new_emoji_file_path))
395 end
396
397 # Move/Rename the old filename to a new filename
398 # These are probably on the same filesystem, so just rename should work
399 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
400
401 # If the old directory has no more files, remove it
402 if String.contains?(old_emoji_file_path, "/") do
403 dir = Path.dirname(old_emoji_file_path)
404
405 if Enum.empty?(File.ls!(dir)) do
406 File.rmdir!(dir)
407 end
408 end
409
410 # Then, put in the new shortcode with the new path
411 updated_full_pack =
412 put_in(updated_full_pack, ["files", new_shortcode], new_filename)
413
414 {:ok, updated_full_pack}
415 else
416 {:error,
417 conn
418 |> put_status(:bad_request)
419 |> text("new_shortcode or new_filename cannot be empty")}
420 end
421 else
422 _ ->
423 {:error,
424 conn
425 |> put_status(:bad_request)
426 |> text("new_shortcode or new_file were not specified")}
427 end
428 else
429 {:error,
430 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
431 end
432
433 _ ->
434 {:error, conn |> put_status(:bad_request) |> text("Unknown action: #{action}")}
435 end
436
437 case res do
438 {:ok, updated_full_pack} ->
439 # Write the emoji pack file
440 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
441
442 # Return the modified file list
443 conn |> json(updated_full_pack["files"])
444
445 {:error, e} ->
446 e
447 end
448 end
449 end