Merge branch 'change-containment-default' into 'develop'
[akkoma] / lib / pleroma / emoji.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Emoji do
6 @moduledoc """
7 The emojis are loaded from:
8
9 * emoji packs in INSTANCE-DIR/emoji
10 * the files: `config/emoji.txt` and `config/custom_emoji.txt`
11 * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder
12
13 This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime.
14 """
15 use GenServer
16
17 require Logger
18
19 @type pattern :: Regex.t() | module() | String.t()
20 @type patterns :: pattern() | [pattern()]
21 @type group_patterns :: keyword(patterns())
22
23 @ets __MODULE__.Ets
24 @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}]
25 @groups Pleroma.Config.get([:emoji, :groups])
26
27 @doc false
28 def start_link do
29 GenServer.start_link(__MODULE__, [], name: __MODULE__)
30 end
31
32 @doc "Reloads the emojis from disk."
33 @spec reload() :: :ok
34 def reload do
35 GenServer.call(__MODULE__, :reload)
36 end
37
38 @doc "Returns the path of the emoji `name`."
39 @spec get(String.t()) :: String.t() | nil
40 def get(name) do
41 case :ets.lookup(@ets, name) do
42 [{_, path}] -> path
43 _ -> nil
44 end
45 end
46
47 @doc "Returns all the emojos!!"
48 @spec get_all() :: [{String.t(), String.t()}, ...]
49 def get_all do
50 :ets.tab2list(@ets)
51 end
52
53 @doc false
54 def init(_) do
55 @ets = :ets.new(@ets, @ets_options)
56 GenServer.cast(self(), :reload)
57 {:ok, nil}
58 end
59
60 @doc false
61 def handle_cast(:reload, state) do
62 load()
63 {:noreply, state}
64 end
65
66 @doc false
67 def handle_call(:reload, _from, state) do
68 load()
69 {:reply, :ok, state}
70 end
71
72 @doc false
73 def terminate(_, _) do
74 :ok
75 end
76
77 @doc false
78 def code_change(_old_vsn, state, _extra) do
79 load()
80 {:ok, state}
81 end
82
83 defp load do
84 emoji_dir_path =
85 Path.join(
86 Pleroma.Config.get!([:instance, :static_dir]),
87 "emoji"
88 )
89
90 case File.ls(emoji_dir_path) do
91 {:error, :enoent} ->
92 # The custom emoji directory doesn't exist,
93 # don't do anything
94 nil
95
96 {:error, e} ->
97 # There was some other error
98 Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}")
99
100 {:ok, results} ->
101 grouped =
102 Enum.group_by(results, fn file -> File.dir?(Path.join(emoji_dir_path, file)) end)
103
104 packs = grouped[true] || []
105 files = grouped[false] || []
106
107 # Print the packs we've found
108 Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}")
109
110 if not Enum.empty?(files) do
111 Logger.warn(
112 "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{
113 Enum.join(files, ", ")
114 }"
115 )
116 end
117
118 emojis =
119 Enum.flat_map(
120 packs,
121 fn pack -> load_pack(Path.join(emoji_dir_path, pack)) end
122 )
123
124 true = :ets.insert(@ets, emojis)
125 end
126
127 # Compat thing for old custom emoji handling & default emoji,
128 # it should run even if there are no emoji packs
129 shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], [])
130
131 emojis =
132 (load_from_file("config/emoji.txt") ++
133 load_from_file("config/custom_emoji.txt") ++
134 load_from_globs(shortcode_globs))
135 |> Enum.reject(fn value -> value == nil end)
136
137 true = :ets.insert(@ets, emojis)
138
139 :ok
140 end
141
142 defp load_pack(pack_dir) do
143 pack_name = Path.basename(pack_dir)
144
145 emoji_txt = Path.join(pack_dir, "emoji.txt")
146
147 if File.exists?(emoji_txt) do
148 load_from_file(emoji_txt)
149 else
150 Logger.info(
151 "No emoji.txt found for pack \"#{pack_name}\", assuming all .png files are emoji"
152 )
153
154 make_shortcode_to_file_map(pack_dir, [".png"])
155 |> Enum.map(fn {shortcode, rel_file} ->
156 filename = Path.join("/emoji/#{pack_name}", rel_file)
157
158 {shortcode, filename, [to_string(match_extra(@groups, filename))]}
159 end)
160 end
161 end
162
163 def make_shortcode_to_file_map(pack_dir, exts) do
164 find_all_emoji(pack_dir, exts)
165 |> Enum.map(&Path.relative_to(&1, pack_dir))
166 |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end)
167 |> Enum.into(%{})
168 end
169
170 def find_all_emoji(dir, exts) do
171 Enum.reduce(
172 File.ls!(dir),
173 [],
174 fn f, acc ->
175 filepath = Path.join(dir, f)
176
177 if File.dir?(filepath) do
178 acc ++ find_all_emoji(filepath, exts)
179 else
180 acc ++ [filepath]
181 end
182 end
183 )
184 |> Enum.filter(fn f -> Path.extname(f) in exts end)
185 end
186
187 defp load_from_file(file) do
188 if File.exists?(file) do
189 load_from_file_stream(File.stream!(file))
190 else
191 []
192 end
193 end
194
195 defp load_from_file_stream(stream) do
196 stream
197 |> Stream.map(&String.trim/1)
198 |> Stream.map(fn line ->
199 case String.split(line, ~r/,\s*/) do
200 [name, file] ->
201 {name, file, [to_string(match_extra(@groups, file))]}
202
203 [name, file | tags] ->
204 {name, file, tags}
205
206 _ ->
207 nil
208 end
209 end)
210 |> Enum.to_list()
211 end
212
213 defp load_from_globs(globs) do
214 static_path = Path.join(:code.priv_dir(:pleroma), "static")
215
216 paths =
217 Enum.map(globs, fn glob ->
218 Path.join(static_path, glob)
219 |> Path.wildcard()
220 end)
221 |> Enum.concat()
222
223 Enum.map(paths, fn path ->
224 tag = match_extra(@groups, Path.join("/", Path.relative_to(path, static_path)))
225 shortcode = Path.basename(path, Path.extname(path))
226 external_path = Path.join("/", Path.relative_to(path, static_path))
227 {shortcode, external_path, [to_string(tag)]}
228 end)
229 end
230
231 @doc """
232 Finds a matching group for the given emoji filename
233 """
234 @spec match_extra(group_patterns(), String.t()) :: atom() | nil
235 def match_extra(group_patterns, filename) do
236 match_group_patterns(group_patterns, fn pattern ->
237 case pattern do
238 %Regex{} = regex -> Regex.match?(regex, filename)
239 string when is_binary(string) -> filename == string
240 end
241 end)
242 end
243
244 defp match_group_patterns(group_patterns, matcher) do
245 Enum.find_value(group_patterns, fn {group, patterns} ->
246 patterns =
247 patterns
248 |> List.wrap()
249 |> Enum.map(fn pattern ->
250 if String.contains?(pattern, "*") do
251 ~r(#{String.replace(pattern, "*", ".*")})
252 else
253 pattern
254 end
255 end)
256
257 Enum.any?(patterns, matcher) && group
258 end)
259 end
260 end