Fork of Pleroma with site-specific changes and feature branches https://git.pleroma.social/pleroma/pleroma
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

637 lignes
18KB

  1. defmodule Pleroma.Emoji.Pack do
  2. @derive {Jason.Encoder, only: [:files, :pack, :files_count]}
  3. defstruct files: %{},
  4. files_count: 0,
  5. pack_file: nil,
  6. path: nil,
  7. pack: %{},
  8. name: nil
  9. @type t() :: %__MODULE__{
  10. files: %{String.t() => Path.t()},
  11. files_count: non_neg_integer(),
  12. pack_file: Path.t(),
  13. path: Path.t(),
  14. pack: map(),
  15. name: String.t()
  16. }
  17. alias Pleroma.Emoji
  18. alias Pleroma.Emoji.Pack
  19. @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
  20. def create(name) do
  21. with :ok <- validate_not_empty([name]),
  22. dir <- Path.join(emoji_path(), name),
  23. :ok <- File.mkdir(dir) do
  24. %__MODULE__{pack_file: Path.join(dir, "pack.json")}
  25. |> save_pack()
  26. end
  27. end
  28. defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size)
  29. defp paginate(entities, page, page_size) do
  30. entities
  31. |> Enum.chunk_every(page_size)
  32. |> Enum.at(page - 1)
  33. end
  34. @spec show(keyword()) :: {:ok, t()} | {:error, atom()}
  35. def show(opts) do
  36. name = opts[:name]
  37. with :ok <- validate_not_empty([name]),
  38. {:ok, pack} <- load_pack(name) do
  39. shortcodes =
  40. pack.files
  41. |> Map.keys()
  42. |> Enum.sort()
  43. |> paginate(opts[:page], opts[:page_size])
  44. pack = Map.put(pack, :files, Map.take(pack.files, shortcodes))
  45. {:ok, validate_pack(pack)}
  46. end
  47. end
  48. @spec delete(String.t()) ::
  49. {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values}
  50. def delete(name) do
  51. with :ok <- validate_not_empty([name]) do
  52. emoji_path()
  53. |> Path.join(name)
  54. |> File.rm_rf()
  55. end
  56. end
  57. @spec unpack_zip_emojies(list(tuple())) :: list(map())
  58. defp unpack_zip_emojies(zip_files) do
  59. Enum.reduce(zip_files, [], fn
  60. {_, path, s, _, _, _}, acc when elem(s, 2) == :regular ->
  61. with(
  62. filename <- Path.basename(path),
  63. shortcode <- Path.basename(filename, Path.extname(filename)),
  64. false <- Emoji.exist?(shortcode)
  65. ) do
  66. [%{path: path, filename: path, shortcode: shortcode} | acc]
  67. else
  68. _ -> acc
  69. end
  70. _, acc ->
  71. acc
  72. end)
  73. end
  74. @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) ::
  75. {:ok, t()}
  76. | {:error, File.posix() | atom()}
  77. def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do
  78. with {:ok, zip_files} <- :zip.table(to_charlist(file.path)),
  79. [_ | _] = emojies <- unpack_zip_emojies(zip_files),
  80. {:ok, tmp_dir} <- Pleroma.Utils.tmp_dir("emoji") do
  81. try do
  82. {:ok, _emoji_files} =
  83. :zip.unzip(
  84. to_charlist(file.path),
  85. [{:file_list, Enum.map(emojies, & &1[:path])}, {:cwd, tmp_dir}]
  86. )
  87. {_, updated_pack} =
  88. Enum.map_reduce(emojies, pack, fn item, emoji_pack ->
  89. emoji_file = %Plug.Upload{
  90. filename: item[:filename],
  91. path: Path.join(tmp_dir, item[:path])
  92. }
  93. {:ok, updated_pack} =
  94. do_add_file(
  95. emoji_pack,
  96. item[:shortcode],
  97. to_string(item[:filename]),
  98. emoji_file
  99. )
  100. {item, updated_pack}
  101. end)
  102. Emoji.reload()
  103. {:ok, updated_pack}
  104. after
  105. File.rm_rf(tmp_dir)
  106. end
  107. else
  108. {:error, _} = error ->
  109. error
  110. _ ->
  111. {:ok, pack}
  112. end
  113. end
  114. def add_file(%Pack{} = pack, shortcode, filename, %Plug.Upload{} = file) do
  115. with :ok <- validate_not_empty([shortcode, filename]),
  116. :ok <- validate_emoji_not_exists(shortcode),
  117. {:ok, updated_pack} <- do_add_file(pack, shortcode, filename, file) do
  118. Emoji.reload()
  119. {:ok, updated_pack}
  120. end
  121. end
  122. defp do_add_file(pack, shortcode, filename, file) do
  123. with :ok <- save_file(file, pack, filename) do
  124. pack
  125. |> put_emoji(shortcode, filename)
  126. |> save_pack()
  127. end
  128. end
  129. @spec delete_file(t(), String.t()) ::
  130. {:ok, t()} | {:error, File.posix() | atom()}
  131. def delete_file(%Pack{} = pack, shortcode) do
  132. with :ok <- validate_not_empty([shortcode]),
  133. :ok <- remove_file(pack, shortcode),
  134. {:ok, updated_pack} <- pack |> delete_emoji(shortcode) |> save_pack() do
  135. Emoji.reload()
  136. {:ok, updated_pack}
  137. end
  138. end
  139. @spec update_file(t(), String.t(), String.t(), String.t(), boolean()) ::
  140. {:ok, t()} | {:error, File.posix() | atom()}
  141. def update_file(%Pack{} = pack, shortcode, new_shortcode, new_filename, force) do
  142. with :ok <- validate_not_empty([shortcode, new_shortcode, new_filename]),
  143. {:ok, filename} <- get_filename(pack, shortcode),
  144. :ok <- validate_emoji_not_exists(new_shortcode, force),
  145. :ok <- rename_file(pack, filename, new_filename),
  146. {:ok, updated_pack} <-
  147. pack
  148. |> delete_emoji(shortcode)
  149. |> put_emoji(new_shortcode, new_filename)
  150. |> save_pack() do
  151. Emoji.reload()
  152. {:ok, updated_pack}
  153. end
  154. end
  155. @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, File.posix() | atom()}
  156. def import_from_filesystem do
  157. emoji_path = emoji_path()
  158. with {:ok, %{access: :read_write}} <- File.stat(emoji_path),
  159. {:ok, results} <- File.ls(emoji_path) do
  160. names =
  161. results
  162. |> Enum.map(&Path.join(emoji_path, &1))
  163. |> Enum.reject(fn path ->
  164. File.dir?(path) and File.exists?(Path.join(path, "pack.json"))
  165. end)
  166. |> Enum.map(&write_pack_contents/1)
  167. |> Enum.reject(&is_nil/1)
  168. {:ok, names}
  169. else
  170. {:ok, %{access: _}} -> {:error, :no_read_write}
  171. e -> e
  172. end
  173. end
  174. @spec list_remote(String.t()) :: {:ok, map()} | {:error, atom()}
  175. def list_remote(url) do
  176. uri = url |> String.trim() |> URI.parse()
  177. with :ok <- validate_shareable_packs_available(uri) do
  178. uri
  179. |> URI.merge("/api/pleroma/emoji/packs")
  180. |> http_get()
  181. end
  182. end
  183. @spec list_local(keyword()) :: {:ok, map(), non_neg_integer()}
  184. def list_local(opts) do
  185. with {:ok, results} <- list_packs_dir() do
  186. all_packs =
  187. results
  188. |> Enum.map(fn name ->
  189. case load_pack(name) do
  190. {:ok, pack} -> pack
  191. _ -> nil
  192. end
  193. end)
  194. |> Enum.reject(&is_nil/1)
  195. packs =
  196. all_packs
  197. |> paginate(opts[:page], opts[:page_size])
  198. |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end)
  199. {:ok, packs, length(all_packs)}
  200. end
  201. end
  202. @spec get_archive(String.t()) :: {:ok, binary()} | {:error, atom()}
  203. def get_archive(name) do
  204. with {:ok, pack} <- load_pack(name),
  205. :ok <- validate_downloadable(pack) do
  206. {:ok, fetch_archive(pack)}
  207. end
  208. end
  209. @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()}
  210. def download(name, url, as) do
  211. uri = url |> String.trim() |> URI.parse()
  212. with :ok <- validate_shareable_packs_available(uri),
  213. {:ok, remote_pack} <-
  214. uri |> URI.merge("/api/pleroma/emoji/packs/show?name=#{name}") |> http_get(),
  215. {:ok, %{sha: sha, url: url} = pack_info} <- fetch_pack_info(remote_pack, uri, name),
  216. {:ok, archive} <- download_archive(url, sha),
  217. pack <- copy_as(remote_pack, as || name),
  218. {:ok, _} = unzip(archive, pack_info, remote_pack, pack) do
  219. # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
  220. # in it to depend on itself
  221. if pack_info[:fallback] do
  222. save_pack(pack)
  223. else
  224. {:ok, pack}
  225. end
  226. end
  227. end
  228. @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()}
  229. def save_metadata(metadata, %__MODULE__{} = pack) do
  230. pack
  231. |> Map.put(:pack, metadata)
  232. |> save_pack()
  233. end
  234. @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()}
  235. def update_metadata(name, data) do
  236. with {:ok, pack} <- load_pack(name) do
  237. if fallback_sha_changed?(pack, data) do
  238. update_sha_and_save_metadata(pack, data)
  239. else
  240. save_metadata(data, pack)
  241. end
  242. end
  243. end
  244. @spec load_pack(String.t()) :: {:ok, t()} | {:error, :not_found}
  245. def load_pack(name) do
  246. pack_file = Path.join([emoji_path(), name, "pack.json"])
  247. if File.exists?(pack_file) do
  248. pack =
  249. pack_file
  250. |> File.read!()
  251. |> from_json()
  252. |> Map.put(:pack_file, pack_file)
  253. |> Map.put(:path, Path.dirname(pack_file))
  254. |> Map.put(:name, name)
  255. files_count =
  256. pack.files
  257. |> Map.keys()
  258. |> length()
  259. {:ok, Map.put(pack, :files_count, files_count)}
  260. else
  261. {:error, :not_found}
  262. end
  263. end
  264. @spec emoji_path() :: Path.t()
  265. defp emoji_path do
  266. [:instance, :static_dir]
  267. |> Pleroma.Config.get!()
  268. |> Path.join("emoji")
  269. end
  270. defp validate_emoji_not_exists(shortcode, force \\ false)
  271. defp validate_emoji_not_exists(_shortcode, true), do: :ok
  272. defp validate_emoji_not_exists(shortcode, _) do
  273. if Emoji.exist?(shortcode) do
  274. {:error, :already_exists}
  275. else
  276. :ok
  277. end
  278. end
  279. defp write_pack_contents(path) do
  280. pack = %__MODULE__{
  281. files: files_from_path(path),
  282. path: path,
  283. pack_file: Path.join(path, "pack.json")
  284. }
  285. case save_pack(pack) do
  286. {:ok, _pack} -> Path.basename(path)
  287. _ -> nil
  288. end
  289. end
  290. defp files_from_path(path) do
  291. txt_path = Path.join(path, "emoji.txt")
  292. if File.exists?(txt_path) do
  293. # There's an emoji.txt file, it's likely from a pack installed by the pack manager.
  294. # Make a pack.json file from the contents of that emoji.txt file
  295. # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
  296. # Create a map of shortcodes to filenames from emoji.txt
  297. txt_path
  298. |> File.read!()
  299. |> String.split("\n")
  300. |> Enum.map(&String.trim/1)
  301. |> Enum.map(fn line ->
  302. case String.split(line, ~r/,\s*/) do
  303. # This matches both strings with and without tags
  304. # and we don't care about tags here
  305. [name, file | _] ->
  306. file_dir_name = Path.dirname(file)
  307. if String.ends_with?(path, file_dir_name) do
  308. {name, Path.basename(file)}
  309. else
  310. {name, file}
  311. end
  312. _ ->
  313. nil
  314. end
  315. end)
  316. |> Enum.reject(&is_nil/1)
  317. |> Map.new()
  318. else
  319. # If there's no emoji.txt, assume all files
  320. # that are of certain extensions from the config are emojis and import them all
  321. pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions])
  322. Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions)
  323. end
  324. end
  325. defp validate_pack(pack) do
  326. info =
  327. if downloadable?(pack) do
  328. archive = fetch_archive(pack)
  329. archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16()
  330. pack.pack
  331. |> Map.put("can-download", true)
  332. |> Map.put("download-sha256", archive_sha)
  333. else
  334. Map.put(pack.pack, "can-download", false)
  335. end
  336. Map.put(pack, :pack, info)
  337. end
  338. defp downloadable?(pack) do
  339. # If the pack is set as shared, check if it can be downloaded
  340. # That means that when asked, the pack can be packed and sent to the remote
  341. # Otherwise, they'd have to download it from external-src
  342. pack.pack["share-files"] &&
  343. Enum.all?(pack.files, fn {_, file} ->
  344. pack.path
  345. |> Path.join(file)
  346. |> File.exists?()
  347. end)
  348. end
  349. defp create_archive_and_cache(pack, hash) do
  350. files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)]
  351. {:ok, {_, result}} =
  352. :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)])
  353. ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file])
  354. overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files))
  355. Cachex.put!(
  356. :emoji_packs_cache,
  357. pack.name,
  358. # if pack.json MD5 changes, the cache is not valid anymore
  359. %{hash: hash, pack_data: result},
  360. # Add a minute to cache time for every file in the pack
  361. ttl: overall_ttl
  362. )
  363. result
  364. end
  365. defp save_pack(pack) do
  366. with {:ok, json} <- Jason.encode(pack, pretty: true),
  367. :ok <- File.write(pack.pack_file, json) do
  368. {:ok, pack}
  369. end
  370. end
  371. defp from_json(json) do
  372. map = Jason.decode!(json)
  373. struct(__MODULE__, %{files: map["files"], pack: map["pack"]})
  374. end
  375. defp validate_shareable_packs_available(uri) do
  376. with {:ok, %{"links" => links}} <- uri |> URI.merge("/.well-known/nodeinfo") |> http_get(),
  377. # Get the actual nodeinfo address and fetch it
  378. {:ok, %{"metadata" => %{"features" => features}}} <-
  379. links |> List.last() |> Map.get("href") |> http_get() do
  380. if Enum.member?(features, "shareable_emoji_packs") do
  381. :ok
  382. else
  383. {:error, :not_shareable}
  384. end
  385. end
  386. end
  387. defp validate_not_empty(list) do
  388. if Enum.all?(list, fn i -> is_binary(i) and i != "" end) do
  389. :ok
  390. else
  391. {:error, :empty_values}
  392. end
  393. end
  394. defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do
  395. file_path = Path.join(pack.path, filename)
  396. create_subdirs(file_path)
  397. with {:ok, _} <- File.copy(upload_path, file_path) do
  398. :ok
  399. end
  400. end
  401. defp put_emoji(pack, shortcode, filename) do
  402. files = Map.put(pack.files, shortcode, filename)
  403. %{pack | files: files, files_count: length(Map.keys(files))}
  404. end
  405. defp delete_emoji(pack, shortcode) do
  406. files = Map.delete(pack.files, shortcode)
  407. %{pack | files: files}
  408. end
  409. defp rename_file(pack, filename, new_filename) do
  410. old_path = Path.join(pack.path, filename)
  411. new_path = Path.join(pack.path, new_filename)
  412. create_subdirs(new_path)
  413. with :ok <- File.rename(old_path, new_path) do
  414. remove_dir_if_empty(old_path, filename)
  415. end
  416. end
  417. defp create_subdirs(file_path) do
  418. if String.contains?(file_path, "/") do
  419. file_path
  420. |> Path.dirname()
  421. |> File.mkdir_p!()
  422. end
  423. end
  424. defp remove_file(pack, shortcode) do
  425. with {:ok, filename} <- get_filename(pack, shortcode),
  426. emoji <- Path.join(pack.path, filename),
  427. :ok <- File.rm(emoji) do
  428. remove_dir_if_empty(emoji, filename)
  429. end
  430. end
  431. defp remove_dir_if_empty(emoji, filename) do
  432. dir = Path.dirname(emoji)
  433. if String.contains?(filename, "/") and File.ls!(dir) == [] do
  434. File.rmdir!(dir)
  435. else
  436. :ok
  437. end
  438. end
  439. defp get_filename(pack, shortcode) do
  440. with %{^shortcode => filename} when is_binary(filename) <- pack.files,
  441. true <- pack.path |> Path.join(filename) |> File.exists?() do
  442. {:ok, filename}
  443. else
  444. _ -> {:error, :doesnt_exist}
  445. end
  446. end
  447. defp http_get(%URI{} = url), do: url |> to_string() |> http_get()
  448. defp http_get(url) do
  449. with {:ok, %{body: body}} <- url |> Pleroma.HTTP.get() do
  450. Jason.decode(body)
  451. end
  452. end
  453. defp list_packs_dir do
  454. emoji_path = emoji_path()
  455. # Create the directory first if it does not exist. This is probably the first request made
  456. # with the API so it should be sufficient
  457. with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)},
  458. {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do
  459. {:ok, Enum.sort(results)}
  460. else
  461. {:create_dir, {:error, e}} -> {:error, :create_dir, e}
  462. {:ls, {:error, e}} -> {:error, :ls, e}
  463. end
  464. end
  465. defp validate_downloadable(pack) do
  466. if downloadable?(pack), do: :ok, else: {:error, :cant_download}
  467. end
  468. defp copy_as(remote_pack, local_name) do
  469. path = Path.join(emoji_path(), local_name)
  470. %__MODULE__{
  471. name: local_name,
  472. path: path,
  473. files: remote_pack["files"],
  474. pack_file: Path.join(path, "pack.json")
  475. }
  476. end
  477. defp unzip(archive, pack_info, remote_pack, local_pack) do
  478. with :ok <- File.mkdir_p!(local_pack.path) do
  479. files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end)
  480. # Fallback cannot contain a pack.json file
  481. files = if pack_info[:fallback], do: files, else: ['pack.json' | files]
  482. :zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files)
  483. end
  484. end
  485. defp fetch_pack_info(remote_pack, uri, name) do
  486. case remote_pack["pack"] do
  487. %{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
  488. {:ok,
  489. %{
  490. sha: sha,
  491. url: URI.merge(uri, "/api/pleroma/emoji/packs/archive?name=#{name}") |> to_string()
  492. }}
  493. %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
  494. {:ok,
  495. %{
  496. sha: sha,
  497. url: src,
  498. fallback: true
  499. }}
  500. _ ->
  501. {:error, "The pack was not set as shared and there is no fallback src to download from"}
  502. end
  503. end
  504. defp download_archive(url, sha) do
  505. with {:ok, %{body: archive}} <- Tesla.get(url) do
  506. if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do
  507. {:ok, archive}
  508. else
  509. {:error, :invalid_checksum}
  510. end
  511. end
  512. end
  513. defp fetch_archive(pack) do
  514. hash = :crypto.hash(:md5, File.read!(pack.pack_file))
  515. case Cachex.get!(:emoji_packs_cache, pack.name) do
  516. %{hash: ^hash, pack_data: archive} -> archive
  517. _ -> create_archive_and_cache(pack, hash)
  518. end
  519. end
  520. defp fallback_sha_changed?(pack, data) do
  521. is_binary(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack["fallback-src"]
  522. end
  523. defp update_sha_and_save_metadata(pack, data) do
  524. with {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]),
  525. :ok <- validate_has_all_files(pack, zip) do
  526. fallback_sha = :sha256 |> :crypto.hash(zip) |> Base.encode16()
  527. data
  528. |> Map.put("fallback-src-sha256", fallback_sha)
  529. |> save_metadata(pack)
  530. end
  531. end
  532. defp validate_has_all_files(pack, zip) do
  533. with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do
  534. # Check if all files from the pack.json are in the archive
  535. pack.files
  536. |> Enum.all?(fn {_, from_manifest} ->
  537. List.keyfind(f_list, to_charlist(from_manifest), 0)
  538. end)
  539. |> if(do: :ok, else: {:error, :incomplete})
  540. end
  541. end
  542. end