Handle empty shortcode/filename/new_shortcode/new_filename
[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 delete(conn, %{"name" => name}) do
215 pack_dir = Path.join(@emoji_dir_path, name)
216
217 case File.rm_rf(pack_dir) do
218 {:ok, _} ->
219 conn |> text("ok")
220
221 {:error, _} ->
222 conn |> put_status(:internal_server_error) |> text("Couldn't delete the pack #{name}")
223 end
224 end
225
226 def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do
227 pack_dir = Path.join(@emoji_dir_path, name)
228 pack_file_p = Path.join(pack_dir, "pack.json")
229
230 full_pack = Jason.decode!(File.read!(pack_file_p))
231
232 # The new fallback-src is in the new data and it's not the same as it was in the old data
233 should_update_fb_sha =
234 not is_nil(new_data["fallback-src"]) and
235 new_data["fallback-src"] != full_pack["pack"]["fallback-src"]
236
237 new_data =
238 if should_update_fb_sha do
239 pack_arch = Tesla.get!(new_data["fallback-src"]).body
240
241 {:ok, flist} = :zip.unzip(pack_arch, [:memory])
242
243 # Check if all files from the pack.json are in the archive
244 has_all_files =
245 Enum.all?(full_pack["files"], fn {_, from_manifest} ->
246 Enum.find(flist, fn {from_archive, _} ->
247 to_string(from_archive) == from_manifest
248 end)
249 end)
250
251 unless has_all_files do
252 {:error,
253 conn
254 |> put_status(:bad_request)
255 |> text("The fallback archive does not have all files specified in pack.json")}
256 else
257 fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16()
258
259 {:ok, new_data |> Map.put("fallback-src-sha256", fallback_sha)}
260 end
261 else
262 {:ok, new_data}
263 end
264
265 case new_data do
266 {:ok, new_data} ->
267 full_pack = Map.put(full_pack, "pack", new_data)
268 File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true))
269
270 # Send new data back with fallback sha filled
271 conn |> json(new_data)
272
273 {:error, e} ->
274 e
275 end
276 end
277
278 def update_file(
279 conn,
280 %{"pack_name" => pack_name, "action" => action, "shortcode" => shortcode} = params
281 ) do
282 pack_dir = Path.join(@emoji_dir_path, pack_name)
283 pack_file_p = Path.join(pack_dir, "pack.json")
284
285 full_pack = Jason.decode!(File.read!(pack_file_p))
286
287 res =
288 case action do
289 "add" ->
290 unless Map.has_key?(full_pack["files"], shortcode) do
291 with %{"file" => %Plug.Upload{filename: filename, path: upload_path}} <- params do
292 # If there was a file name provided with the request, use it, otherwise just use the
293 # uploaded file name
294 filename =
295 if Map.has_key?(params, "filename") do
296 params["filename"]
297 else
298 filename
299 end
300
301 unless String.trim(shortcode) |> String.length() == 0 or
302 String.trim(filename) |> String.length() == 0 do
303 file_path = Path.join(pack_dir, filename)
304
305 # If the name contains directories, create them
306 if String.contains?(file_path, "/") do
307 File.mkdir_p!(Path.dirname(file_path))
308 end
309
310 # Copy the uploaded file from the temporary directory
311 File.copy!(upload_path, file_path)
312
313 updated_full_pack = put_in(full_pack, ["files", shortcode], filename)
314
315 {:ok, updated_full_pack}
316 else
317 {:error,
318 conn
319 |> put_status(:bad_request)
320 |> text("shortcode or filename cannot be empty")}
321 end
322 else
323 _ -> {:error, conn |> put_status(:bad_request) |> text("\"file\" not provided")}
324 end
325 else
326 {:error,
327 conn
328 |> put_status(:conflict)
329 |> text("An emoji with the \"#{shortcode}\" shortcode already exists")}
330 end
331
332 "remove" ->
333 if Map.has_key?(full_pack["files"], shortcode) do
334 {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
335
336 emoji_file_path = Path.join(pack_dir, emoji_file_path)
337
338 # Delete the emoji file
339 File.rm!(emoji_file_path)
340
341 # If the old directory has no more files, remove it
342 if String.contains?(emoji_file_path, "/") do
343 dir = Path.dirname(emoji_file_path)
344
345 if Enum.empty?(File.ls!(dir)) do
346 File.rmdir!(dir)
347 end
348 end
349
350 {:ok, updated_full_pack}
351 else
352 {:error,
353 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
354 end
355
356 "update" ->
357 if Map.has_key?(full_pack["files"], shortcode) do
358 with %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params do
359 unless String.trim(new_shortcode) |> String.length() == 0 or
360 String.trim(new_filename) |> String.length() == 0 do
361 # First, remove the old shortcode, saving the old path
362 {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode])
363 old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path)
364 new_emoji_file_path = Path.join(pack_dir, new_filename)
365
366 # If the name contains directories, create them
367 if String.contains?(new_emoji_file_path, "/") do
368 File.mkdir_p!(Path.dirname(new_emoji_file_path))
369 end
370
371 # Move/Rename the old filename to a new filename
372 # These are probably on the same filesystem, so just rename should work
373 :ok = File.rename(old_emoji_file_path, new_emoji_file_path)
374
375 # If the old directory has no more files, remove it
376 if String.contains?(old_emoji_file_path, "/") do
377 dir = Path.dirname(old_emoji_file_path)
378
379 if Enum.empty?(File.ls!(dir)) do
380 File.rmdir!(dir)
381 end
382 end
383
384 # Then, put in the new shortcode with the new path
385 updated_full_pack =
386 put_in(updated_full_pack, ["files", new_shortcode], new_filename)
387
388 {:ok, updated_full_pack}
389 else
390 {:error,
391 conn
392 |> put_status(:bad_request)
393 |> text("new_shortcode or new_filename cannot be empty")}
394 end
395 else
396 _ ->
397 {:error,
398 conn
399 |> put_status(:bad_request)
400 |> text("new_shortcode or new_file were not specified")}
401 end
402 else
403 {:error,
404 conn |> put_status(:bad_request) |> text("Emoji \"#{shortcode}\" does not exist")}
405 end
406
407 _ ->
408 {:error, conn |> put_status(:bad_request) |> text("Unknown action: #{action}")}
409 end
410
411 case res do
412 {:ok, updated_full_pack} ->
413 # Write the emoji pack file
414 File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true))
415
416 # Return the modified file list
417 conn |> json(updated_full_pack["files"])
418
419 {:error, e} ->
420 e
421 end
422 end
423 end