@@ -3,10 +3,16 @@ defmodule EpochtalkServer.Models.AutoModeration do
33 import Ecto.Changeset
44 import Ecto.Query
55 alias EpochtalkServer.Repo
6+ alias EpochtalkServer.Session
67 alias EpochtalkServer.Models.AutoModeration
8+ alias EpochtalkServer.Models.Ban
9+ alias EpochtalkServerWeb.CustomErrors.AutoModeratorReject
710
811 @ postgres_varchar255_max 255
912 @ postgres_varchar1000_max 1000
13+ @ hours_per_day 24
14+ @ minutes_per_hour 60
15+ @ seconds_per_minute 60
1016
1117 @ moduledoc """
1218 `AutoModeration` model, for performing actions relating to `User` `AutoModeration`
@@ -38,8 +44,8 @@ defmodule EpochtalkServer.Models.AutoModeration do
3844 field :name , :string
3945 field :description , :string
4046 field :message , :string
41- field :conditions , : map
42- field :actions , :map
47+ field :conditions , { :array , : map}
48+ field :actions , { :array , :string }
4349 field :options , :map
4450 field :created_at , :naive_datetime
4551 field :updated_at , :naive_datetime
@@ -149,4 +155,230 @@ defmodule EpochtalkServer.Models.AutoModeration do
149155 |> update_changeset ( auto_moderation_attrs )
150156 |> Repo . update ( )
151157 end
158+
159+ ## === Public Helper Functions ===
160+
161+ @ doc """
162+ Executes `AutoModeration` rules
163+
164+ TODO(akinsey): Optimize and store rules in Redis so we dont have to query every request
165+
166+ ### Rule Anatomy
167+ * Only works on posts
168+ * = Name: Name for this rule (for admin readability)
169+ * = Description: What this rule does (for admin readbility)
170+ * = Message: Error reported back to the user on reject action
171+ * = Conditions: condition regex will only work on
172+ * - body
173+ * - thread_id
174+ * - user_id
175+ * - title (although it's not much use)
176+ * == REGEX IS AN OBJECT with a pattern and flag property
177+ * Multiple conditions are allow but they all must pass to enable rule actions
178+ * = Actions: reject, ban, edit, delete (filter not yet implemented)
179+ * = Options:
180+ * - banInterval:
181+ * - Affects ban action.
182+ * - Leave blank for permanent
183+ * - Otherwise, JS date string
184+ * - edit:
185+ * - replace (replace chunks of text):
186+ * - regex: Regex used to match post body
187+ * - regex object has a pattern and flag property
188+ * - text: Text used to replace any matches
189+ * - template: String template used to add text above or below post body
190+ """
191+ @ spec moderate ( user :: map , post_attrs :: map ) :: post_attrs :: map
192+ def moderate ( % { id: user_id } = _user , post_attrs ) do
193+ # query auto moderation rules from the db, check their validity then return them
194+ rule_actions = get_rule_actions ( post_attrs )
195+
196+ # append user_id to post attributes
197+ post_attrs = post_attrs |> Map . put ( "user_id" , user_id )
198+
199+ # execute rule actions if action set isn't empty, then return updated post_attributes
200+ post_attrs =
201+ if MapSet . size ( rule_actions . action_set ) > 0 ,
202+ do: execute_rule_actions ( post_attrs , rule_actions ) ,
203+ else: post_attrs
204+
205+ # return updated post attributes
206+ post_attrs
207+ end
208+
209+ ## === Private Helper Functions ===
210+
211+ defp get_rule_actions ( post_attrs ) do
212+ acc_init = % {
213+ action_set: MapSet . new ( ) ,
214+ messages: [ ] ,
215+ ban_interval: nil ,
216+ edits: [ ]
217+ }
218+
219+ rules = AutoModeration . all ( )
220+
221+ Enum . reduce ( rules , acc_init , fn rule , acc ->
222+ if rule_condition_is_valid? ( post_attrs , rule . conditions ) do
223+ # Aggregate all actions, using MapSet ensures actions are unique
224+ action_set = ( MapSet . to_list ( acc . action_set ) ++ rule . actions ) |> MapSet . new ( )
225+
226+ # Aggregate all reject messages if applicable
227+ messages =
228+ if Enum . member? ( rule . actions , "reject" ) and is_binary ( rule . message ) ,
229+ do: acc . messages ++ [ rule . message ] ,
230+ else: acc . messages
231+
232+ # attempt to set default value for acc.ban_interval if nil
233+ acc =
234+ if is_nil ( acc . ban_interval ) ,
235+ do: Map . put ( acc , :ban_interval , rule . options [ "ban_interval" ] ) ,
236+ else: acc
237+
238+ # Pick the latest ban interval, in the event multiple are provided
239+ ban_interval =
240+ if Enum . member? ( rule . actions , "ban" ) and
241+ Map . has_key? ( rule . options , "ban_interval" ) and
242+ acc . ban_interval < rule . options [ "ban_interval" ] ,
243+ do: rule . options [ "ban_interval" ] ,
244+ else: acc . ban_interval
245+
246+ # Aggregate all edit options
247+ edits =
248+ if Enum . member? ( rule . actions , "edit" ) ,
249+ do: acc . edits ++ [ rule . options [ "edit" ] ] ,
250+ else: acc . edits
251+
252+ # return updated acc
253+ % {
254+ action_set: action_set ,
255+ messages: messages ,
256+ ban_interval: ban_interval ,
257+ edits: edits
258+ }
259+ else
260+ acc
261+ end
262+ end )
263+ end
264+
265+ defp execute_rule_actions (
266+ post_attrs ,
267+ % {
268+ action_set: action_set ,
269+ messages: messages ,
270+ ban_interval: ban_interval ,
271+ edits: edits
272+ } = _rule_actions
273+ ) do
274+ # handle rule actions that edit the post body
275+ post_attrs =
276+ if MapSet . member? ( action_set , "edit" ) ,
277+ do:
278+ Enum . reduce ( edits , post_attrs , fn edit , acc ->
279+ post_body = acc [ "body" ]
280+
281+ # handle actions that replace text in post body
282+ acc =
283+ if is_map ( edit [ "replace" ] ) do
284+ replacement_text = edit [ "replace" ] [ "text" ]
285+ test_pattern = edit [ "replace" ] [ "regex" ] [ "pattern" ]
286+ test_flags = edit [ "replace" ] [ "regex" ] [ "flags" ]
287+
288+ # compensate for elixir not supporting /g/ flag
289+ replace_globally = String . contains? ( test_flags , "g" )
290+
291+ # remove g flag (doesnt work in elixir), compensate later using string replace
292+ test_flags = Regex . replace ( ~r/ g/ , test_flags , "" )
293+ match_regex = Regex . compile! ( test_pattern , test_flags )
294+
295+ # update body of post with replacement text
296+ updated_post_body =
297+ String . replace ( post_body , match_regex , replacement_text ,
298+ global: replace_globally
299+ )
300+
301+ # return acc with updated post body
302+ Map . put ( acc , "body" , updated_post_body )
303+ else
304+ acc
305+ end
306+
307+ # handle actions that replace post body using a template
308+ acc =
309+ if is_binary ( edit [ "template" ] ) do
310+ # get new post body template
311+ template = edit [ "template" ]
312+
313+ # update post body using template
314+ updated_post_body = String . replace ( template , "{body}" , post_body )
315+
316+ # return post_attrs with updated post body
317+ Map . put ( acc , "body" , updated_post_body )
318+ else
319+ acc
320+ end
321+
322+ # return updated acc
323+ acc
324+ end ) ,
325+ else: post_attrs
326+
327+ # handle rule actions that ban the user
328+ if MapSet . member? ( action_set , "ban" ) do
329+ # ban period is utc now plus how ever many days ban_interval is
330+ ban_period =
331+ if ban_interval ,
332+ do:
333+ DateTime . utc_now ( )
334+ |> DateTime . add (
335+ ban_interval * @ hours_per_day * @ minutes_per_hour * @ seconds_per_minute ,
336+ :second
337+ )
338+ |> DateTime . to_naive ( )
339+
340+ # get user_id from post_attrs
341+ user_id = post_attrs [ "user_id" ]
342+
343+ # ban the user, ban_period is either a date or nil (permanent)
344+ Ban . ban_by_user_id ( user_id , ban_period )
345+
346+ # update user session after banning
347+ Session . update ( user_id )
348+
349+ # send websocket notification to reauthenticate user
350+ EpochtalkServerWeb.Endpoint . broadcast ( "user:#{ user_id } " , "reauthenticate" , % { } )
351+ end
352+
353+ # handle rule actions that shadow delete the post (auto lock/delete)
354+ post_attrs =
355+ if MapSet . member? ( action_set , "delete" ) ,
356+ do: post_attrs |> Map . put ( "deleted" , true ) |> Map . put ( "locked" , true ) ,
357+ else: post_attrs
358+
359+ # handle rule actions that reject the post entirely
360+ if MapSet . member? ( action_set , "reject" ) ,
361+ do:
362+ raise ( AutoModeratorReject ,
363+ message: "Post rejected by Auto Moderator: #{ Enum . join ( messages , ", " ) } "
364+ )
365+
366+ post_attrs
367+ end
368+
369+ defp rule_condition_is_valid? ( post_attrs , conditions ) do
370+ matches =
371+ Enum . map ( conditions , fn condition ->
372+ test_param = post_attrs [ condition [ "param" ] ]
373+ test_pattern = condition [ "regex" ] [ "pattern" ]
374+ test_flags = condition [ "regex" ] [ "flags" ]
375+ # remove g flag, one match is good enough to determine if condition is valid
376+ test_flags = Regex . replace ( ~r/ g/ , test_flags , "" )
377+ match_regex = Regex . compile! ( test_pattern , test_flags )
378+ Regex . match? ( match_regex , test_param )
379+ end )
380+
381+ # Only valid if every condition returns a valid regex match
382+ ! Enum . member? ( matches , false )
383+ end
152384end
0 commit comments