Skip to content

Commit f722090

Browse files
Merge pull request #71 from epochtalk/posts-by-thread
Posts by thread
2 parents 0caaa9a + 3af12b7 commit f722090

File tree

33 files changed

+1390
-65
lines changed

33 files changed

+1390
-65
lines changed

lib/epochtalk_server/auth/guardian.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ defmodule EpochtalkServer.Auth.Guardian do
130130
The subject should be a short identifier that can be used to identify
131131
the resource.
132132
"""
133-
def subject_for_token(%{user_id: user_id}, _claims) do
133+
def subject_for_token(%{id: user_id}, _claims) do
134134
# You can use any value for the subject of your token but
135135
# it should be useful in retrieving the resource later, see
136136
# how it being used on `resource_from_claims/1` function.

lib/epochtalk_server/mailer.ex

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,8 @@ defmodule EpochtalkServer.Mailer do
4747
email: email,
4848
id: last_post_id,
4949
position: last_post_position,
50-
thread_author: _thread_author,
5150
thread_slug: thread_slug,
5251
title: thread_title,
53-
user_id: _user_id,
5452
username: username
5553
}) do
5654
config = Application.get_env(:epochtalk_server, :frontend_config)
@@ -81,6 +79,45 @@ defmodule EpochtalkServer.Mailer do
8179
|> handle_delivered_email()
8280
end
8381

82+
@doc """
83+
Sends mention notification email
84+
"""
85+
@spec send_mention_notification(email_data :: map) :: {:ok, term} | {:error, term}
86+
def send_mention_notification(%{
87+
email: email,
88+
post_id: post_id,
89+
post_position: post_position,
90+
post_author: post_author,
91+
thread_slug: thread_slug,
92+
thread_title: thread_title
93+
}) do
94+
config = Application.get_env(:epochtalk_server, :frontend_config)
95+
frontend_url = config["frontend_url"]
96+
website_title = config["website"]["title"]
97+
from_address = config["emailer"]["options"]["from_address"]
98+
99+
thread_url = "#{frontend_url}/threads/#{thread_slug}?start=#{post_position}##{post_id}"
100+
101+
content =
102+
generate_from_base_template(
103+
"""
104+
<h3>#{post_author} mentioned you in the thread "#{thread_title}"</h3>
105+
Please visit the link below to view the post you were mentioned in.<br /><br />
106+
<a href="#{thread_url}">View Mention</a>
107+
<small>Raw thread URL: #{thread_url}</small>
108+
""",
109+
config
110+
)
111+
112+
new()
113+
|> to(email)
114+
|> from({website_title, from_address})
115+
|> subject("[#{website_title}] New replies to thread #{thread_title}")
116+
|> html_body(content)
117+
|> deliver()
118+
|> handle_delivered_email()
119+
end
120+
84121
defp handle_delivered_email(result) do
85122
case result do
86123
{:ok, email_metadata} ->

lib/epochtalk_server/models/auto_moderation.ex

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
152384
end

lib/epochtalk_server/models/board.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,25 @@ defmodule EpochtalkServer.Models.Board do
139139
end
140140
end
141141

142+
@doc """
143+
Given an id and moderated property, returns boolean indicating if `Board` allows self moderation
144+
"""
145+
@spec allows_self_moderation?(id :: non_neg_integer, moderated :: boolean) ::
146+
allowed :: boolean
147+
def allows_self_moderation?(id, true) do
148+
query =
149+
from b in Board,
150+
where: b.id == ^id,
151+
select: b.meta["disable_self_mod"]
152+
153+
query
154+
|> Repo.one() || false
155+
end
156+
157+
# pass through if model being created doesn't have "moderated" property set
158+
def allows_self_moderation?(_id, false), do: true
159+
def allows_self_moderation?(_id, nil), do: true
160+
142161
@doc """
143162
Determines if the provided `user_priority` has write access to the board that contains the thread
144163
the specified `thread_id`

0 commit comments

Comments
 (0)