fix: harden privacy auth and realtime workflows
This commit is contained in:
parent
031b9191a4
commit
05d06e059a
|
|
@ -26,6 +26,35 @@ import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help"
|
|||
import topbar from "../vendor/topbar"
|
||||
import {Hooks} from "./hooks"
|
||||
|
||||
const systemTheme = () =>
|
||||
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
|
||||
const setTheme = theme => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme")
|
||||
document.documentElement.setAttribute("data-theme", systemTheme())
|
||||
document.documentElement.setAttribute("data-theme-source", "system")
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme)
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
document.documentElement.setAttribute("data-theme-source", "user")
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system")
|
||||
}
|
||||
|
||||
window.addEventListener("storage", event => {
|
||||
if (event.key === "phx:theme") setTheme(event.newValue || "system")
|
||||
})
|
||||
window.addEventListener("phx:set-theme", event => setTheme(event.target.dataset.phxTheme))
|
||||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
if (document.documentElement.getAttribute("data-theme-source") === "system") {
|
||||
document.documentElement.setAttribute("data-theme", systemTheme())
|
||||
}
|
||||
})
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
|
|
|
|||
|
|
@ -76,9 +76,13 @@ export const Hooks = {
|
|||
|
||||
const points = JSON.parse(this.el.dataset.markers || "[]")
|
||||
points.forEach(point => {
|
||||
const popup = new maplibregl.Popup({offset: 18}).setHTML(
|
||||
`<strong>${escapeHtml(point.title)}</strong><br>${escapeHtml(point.location)}`
|
||||
)
|
||||
const popupContent = document.createElement("div")
|
||||
const title = document.createElement("strong")
|
||||
title.textContent = point.title || ""
|
||||
popupContent.append(title, document.createElement("br"))
|
||||
popupContent.append(document.createTextNode(point.location || ""))
|
||||
|
||||
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
|
||||
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
|
||||
.setLngLat([point.longitude, point.latitude])
|
||||
.setPopup(popup)
|
||||
|
|
@ -99,10 +103,21 @@ export const Hooks = {
|
|||
LocationPicker: {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", () => {
|
||||
const latitudeInput = document.querySelector(this.el.dataset.latitudeTarget)
|
||||
const longitudeInput = document.querySelector(this.el.dataset.longitudeTarget)
|
||||
|
||||
if (!latitudeInput || !longitudeInput || !navigator.geolocation) {
|
||||
this.el.textContent = "Location is unavailable"
|
||||
this.el.classList.add("btn-error")
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
position => {
|
||||
document.querySelector("#request-latitude").value = position.coords.latitude
|
||||
document.querySelector("#request-longitude").value = position.coords.longitude
|
||||
latitudeInput.value = position.coords.latitude
|
||||
longitudeInput.value = position.coords.longitude
|
||||
latitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
|
||||
longitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
|
||||
this.el.textContent = "Location added"
|
||||
this.el.classList.add("btn-success")
|
||||
},
|
||||
|
|
@ -151,12 +166,6 @@ export const Hooks = {
|
|||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement("div")
|
||||
node.textContent = value || ""
|
||||
return node.innerHTML
|
||||
}
|
||||
|
||||
function supportsMapCanvas() {
|
||||
const canvas = document.createElement("canvas")
|
||||
const attributes = {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ defmodule Mix.Tasks.Wnh.LoadFixtures do
|
|||
"urgency" => "now",
|
||||
"location_visibility" => "exact_for_active_match",
|
||||
"expires_at" => DateTime.add(now, 86_400, :second),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
|> unwrap!("create request")
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,8 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
|
|||
"urgency" => "now",
|
||||
"location_visibility" => "exact_for_active_match",
|
||||
"expires_at" => DateTime.add(now, 3 * 60 * 60, :second),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
|> Ecto.Changeset.put_change(:status, :matched)
|
||||
|> Repo.insert!()
|
||||
|
|
|
|||
|
|
@ -79,7 +79,9 @@ defmodule WhoNeedHelp.Accounts do
|
|||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
|
||||
if User.valid_password?(user, password) and user.moderation_status != :suspended,
|
||||
do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -124,7 +126,10 @@ defmodule WhoNeedHelp.Accounts do
|
|||
when role in [:moderator, :admin],
|
||||
do:
|
||||
Repo.exists?(
|
||||
from user in User, where: user.id == ^id and user.role in [:moderator, :admin]
|
||||
from user in User,
|
||||
where:
|
||||
user.id == ^id and user.role in [:moderator, :admin] and
|
||||
user.moderation_status == :active
|
||||
)
|
||||
|
||||
def moderator_authorized?(_user), do: false
|
||||
|
|
@ -133,7 +138,11 @@ defmodule WhoNeedHelp.Accounts do
|
|||
def admin?(_user), do: false
|
||||
|
||||
def admin_authorized?(%User{id: id, role: :admin}),
|
||||
do: Repo.exists?(from user in User, where: user.id == ^id and user.role == :admin)
|
||||
do:
|
||||
Repo.exists?(
|
||||
from user in User,
|
||||
where: user.id == ^id and user.role == :admin and user.moderation_status == :active
|
||||
)
|
||||
|
||||
def admin_authorized?(_user), do: false
|
||||
|
||||
|
|
@ -158,47 +167,87 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
|
||||
def moderate_user(%User{} = moderator, user_id, attrs) do
|
||||
if moderator_authorized?(moderator) do
|
||||
with {:ok, user_id} <- cast_id(user_id),
|
||||
true <- moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
|
||||
active_admins = lock_active_admins()
|
||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
||||
|
||||
if user.id == moderator.id and attrs["moderation_status"] in ["restricted", "suspended"] do
|
||||
{:error, :cannot_restrict_self}
|
||||
else
|
||||
with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
|
||||
if user.moderation_status == :suspended do
|
||||
Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
|
||||
end
|
||||
if user do
|
||||
requested_status = attrs["moderation_status"] || attrs[:moderation_status]
|
||||
|
||||
{:ok, user}
|
||||
cond do
|
||||
user.id == moderator.id and
|
||||
requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
|
||||
{:error, :cannot_restrict_self}
|
||||
|
||||
user.role == :admin and user.moderation_status == :active and
|
||||
requested_status not in [:active, "active"] and length(active_admins) == 1 ->
|
||||
{:error, :last_admin}
|
||||
|
||||
true ->
|
||||
with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
|
||||
session_tokens =
|
||||
if user.moderation_status == :suspended do
|
||||
tokens =
|
||||
UserToken
|
||||
|> where([token], token.user_id == ^user.id and token.context == "session")
|
||||
|> select([token], token.token)
|
||||
|> Repo.all()
|
||||
|
||||
Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
|
||||
tokens
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:ok, %{user: user, expired_session_tokens: session_tokens}}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def change_user_role(%User{} = admin, user_id, attrs) do
|
||||
if admin_authorized?(admin) do
|
||||
with {:ok, user_id} <- cast_id(user_id),
|
||||
true <- admin_authorized?(admin) do
|
||||
Repo.transact(fn ->
|
||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
|
||||
requested_role = attrs["role"] || attrs[:role]
|
||||
active_admins = lock_active_admins()
|
||||
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
||||
|
||||
admin_count =
|
||||
Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count)
|
||||
if user do
|
||||
requested_role = attrs["role"] || attrs[:role]
|
||||
|
||||
if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do
|
||||
{:error, :last_admin}
|
||||
if user.role == :admin and user.moderation_status == :active and
|
||||
requested_role not in [:admin, "admin"] and length(active_admins) == 1 do
|
||||
{:error, :last_admin}
|
||||
else
|
||||
user |> User.role_changeset(attrs) |> Repo.update()
|
||||
end
|
||||
else
|
||||
user |> User.role_changeset(attrs) |> Repo.update()
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp lock_active_admins do
|
||||
User
|
||||
|> where([user], user.role == :admin and user.moderation_status == :active)
|
||||
|> order_by([user], asc: user.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
|
|
@ -282,9 +331,12 @@ defmodule WhoNeedHelp.Accounts do
|
|||
end
|
||||
|
||||
def delete_social_identity(%User{id: user_id}, identity_id) do
|
||||
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
|
||||
nil -> {:error, :not_found}
|
||||
identity -> Repo.delete(identity)
|
||||
with {:ok, identity_id} <- Ecto.UUID.cast(identity_id),
|
||||
%SocialIdentity{} = identity <-
|
||||
Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
|
||||
Repo.delete(identity)
|
||||
else
|
||||
_invalid_or_missing -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -401,7 +453,8 @@ defmodule WhoNeedHelp.Accounts do
|
|||
"""
|
||||
def get_user_by_magic_link_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
|
||||
{user, _token} <- Repo.one(query) do
|
||||
{%User{moderation_status: status} = user, _token} <- Repo.one(query),
|
||||
true <- status != :suspended do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
|
|
@ -430,6 +483,10 @@ defmodule WhoNeedHelp.Accounts do
|
|||
{:ok, query} = UserToken.verify_magic_link_token_query(token)
|
||||
|
||||
case Repo.one(query) do
|
||||
{%User{moderation_status: :suspended}, token} ->
|
||||
Repo.delete!(token)
|
||||
{:error, :not_found}
|
||||
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
|
|
@ -502,6 +559,13 @@ defmodule WhoNeedHelp.Accounts do
|
|||
)
|
||||
end
|
||||
|
||||
defp cast_id(value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, id} -> {:ok, id}
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_user_and_delete_all_tokens(changeset) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
|
|
|
|||
|
|
@ -108,7 +108,8 @@ defmodule WhoNeedHelp.Accounts.User do
|
|||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, value ->
|
||||
case URI.parse(value) do
|
||||
%URI{scheme: scheme, host: host} when scheme in ["https", "http"] and is_binary(host) ->
|
||||
%URI{scheme: scheme, host: host}
|
||||
when scheme in ["https", "http"] and is_binary(host) and host != "" ->
|
||||
[]
|
||||
|
||||
_ ->
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do
|
|||
from token in by_token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
where: user.moderation_status != :suspended,
|
||||
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
|
||||
|
||||
{:ok, query}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,25 @@ defmodule WhoNeedHelp.Activities do
|
|||
paginate_my_activities(%Scope{user: user}).entries
|
||||
end
|
||||
|
||||
def visible_open_activity?(%Scope{user: user}, %Activity{} = activity, filters \\ %{}) do
|
||||
now = DateTime.utc_now(:second)
|
||||
category_id = filters["category_id"] || filters[:category_id]
|
||||
|
||||
activity.status == :open and is_nil(activity.hidden_at) and
|
||||
DateTime.after?(activity.starts_at, now) and
|
||||
DateTime.after?(activity.join_deadline, now) and
|
||||
(category_id in [nil, ""] or
|
||||
to_string(activity.category_id) == to_string(category_id)) and
|
||||
not Trust.blocked_between?(user.id, activity.creator_id)
|
||||
end
|
||||
|
||||
def member_activity?(%Activity{} = activity, user_id) do
|
||||
Enum.any?(
|
||||
activity.participants,
|
||||
&(&1.user_id == user_id and &1.status in [:requested, :approved])
|
||||
)
|
||||
end
|
||||
|
||||
def paginate_my_activities(%Scope{user: user}, options \\ []) do
|
||||
limit = Pagination.limit(options)
|
||||
cursor = Pagination.cursor(options)
|
||||
|
|
@ -124,35 +143,39 @@ defmodule WhoNeedHelp.Activities do
|
|||
end
|
||||
|
||||
def get_activity(%Scope{user: user}, id) do
|
||||
case get_loaded_activity(id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
%Activity{} = activity <- get_loaded_activity(id) do
|
||||
cond do
|
||||
activity.creator_id == user.id ->
|
||||
{:ok, activity}
|
||||
|
||||
activity ->
|
||||
cond do
|
||||
activity.creator_id == user.id ->
|
||||
{:ok, activity}
|
||||
not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_found}
|
||||
|
||||
approved_participant?(activity, user.id) ->
|
||||
{:ok, activity}
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :not_found}
|
||||
|
||||
not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_found}
|
||||
approved_participant?(activity, user.id) ->
|
||||
{:ok, activity}
|
||||
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :not_found}
|
||||
activity.status == :open ->
|
||||
{:ok, activity_for_viewer(activity, user.id)}
|
||||
|
||||
activity.status == :open ->
|
||||
{:ok, activity_for_viewer(activity, user.id)}
|
||||
|
||||
true ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
true ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
else
|
||||
_invalid_or_missing -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do
|
||||
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
|
||||
authorized =
|
||||
activity.creator_id == user.id or
|
||||
(approved_participant?(activity, user.id) and
|
||||
not Trust.blocked_between?(activity.creator_id, user.id))
|
||||
|
||||
if authorized do
|
||||
limit = Pagination.limit(options, 50)
|
||||
cursor = Pagination.cursor(options)
|
||||
public_user = Accounts.public_user_query()
|
||||
|
|
@ -221,6 +244,8 @@ defmodule WhoNeedHelp.Activities do
|
|||
activity = locked_activity(activity_id)
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
|
||||
cond do
|
||||
activity.creator_id == user.id ->
|
||||
{:error, :organizer_already_joined}
|
||||
|
|
@ -248,52 +273,57 @@ defmodule WhoNeedHelp.Activities do
|
|||
|
||||
def approve_participant(%Scope{user: organizer}, participant_id) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
participant = locked_participant(participant_id)
|
||||
activity = locked_activity(participant.activity_id)
|
||||
with {:ok, participant_id} <- cast_id(participant_id) do
|
||||
Repo.transact(fn ->
|
||||
participant = locked_participant(participant_id)
|
||||
activity = locked_activity(participant.activity_id)
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
|
||||
|
||||
cond do
|
||||
activity.creator_id != organizer.id ->
|
||||
{:error, :forbidden}
|
||||
cond do
|
||||
activity.creator_id != organizer.id ->
|
||||
{:error, :forbidden}
|
||||
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
participant.status != :requested ->
|
||||
{:error, :invalid_transition}
|
||||
|
||||
activity.status != :open ->
|
||||
{:error, :not_open}
|
||||
activity.status != :open ->
|
||||
{:error, :not_open}
|
||||
|
||||
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
|
||||
{:error, :blocked}
|
||||
Trust.blocked_between?(activity.creator_id, participant.user_id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
approved_count(activity.id) >= activity.capacity ->
|
||||
{:error, :capacity_reached}
|
||||
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :approved,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer.id,
|
||||
"activity.participant_approved",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
end)
|
||||
true ->
|
||||
with {:ok, participant} <-
|
||||
participant
|
||||
|> Participant.changeset(%{
|
||||
status: :approved,
|
||||
reviewed_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
organizer.id,
|
||||
"activity.participant_approved",
|
||||
"activity_participant",
|
||||
participant.id,
|
||||
%{"activity_id" => activity.id, "user_id" => participant.user_id}
|
||||
) do
|
||||
{:ok, participant}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
after_participant_change(result, participant_activity_id(result), :participant_approved)
|
||||
end
|
||||
|
||||
def decline_participant(%Scope{user: organizer}, participant_id) do
|
||||
transition_participant(organizer.id, participant_id, :declined)
|
||||
with {:ok, participant_id} <- cast_id(participant_id) do
|
||||
transition_participant(organizer.id, participant_id, :declined)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_activity(%Scope{user: user}, activity_id) do
|
||||
|
|
@ -345,40 +375,54 @@ defmodule WhoNeedHelp.Activities do
|
|||
end
|
||||
|
||||
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message),
|
||||
{:ok, activity} <- get_activity(scope, activity_id),
|
||||
true <- activity.status == :open,
|
||||
true <- approved_participant?(activity, user.id) do
|
||||
%Message{}
|
||||
|> Message.changeset(%{
|
||||
body: attrs["body"] || attrs[:body],
|
||||
activity_id: activity.id,
|
||||
sender_id: user.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
|> case do
|
||||
{:ok, message} ->
|
||||
message = Repo.preload(message, sender: Accounts.public_user_query())
|
||||
result =
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
|
||||
Repo.transact(fn ->
|
||||
activity = locked_activity(activity_id)
|
||||
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"activity:#{activity.id}",
|
||||
{:activity_message, message}
|
||||
)
|
||||
cond do
|
||||
activity.status != :open or not is_nil(activity.hidden_at) ->
|
||||
{:error, :not_open}
|
||||
|
||||
{:ok, message}
|
||||
Trust.blocked_between?(activity.creator_id, user.id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
other ->
|
||||
other
|
||||
not approved_participant_id?(activity.id, user.id) ->
|
||||
{:error, :forbidden}
|
||||
|
||||
true ->
|
||||
%Message{}
|
||||
|> Message.changeset(%{
|
||||
body: attrs["body"] || attrs[:body],
|
||||
activity_id: activity.id,
|
||||
sender_id: user.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
end)
|
||||
end
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
|
||||
with {:ok, message} <- result do
|
||||
message = Repo.preload(message, sender: Accounts.public_user_query())
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"activity:#{message.activity_id}",
|
||||
{:activity_message, message}
|
||||
)
|
||||
|
||||
{:ok, message}
|
||||
end
|
||||
end
|
||||
|
||||
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do
|
||||
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
|
||||
authorized =
|
||||
activity.creator_id == user.id or
|
||||
(approved_participant?(activity, user.id) and
|
||||
not Trust.blocked_between?(activity.creator_id, user.id))
|
||||
|
||||
if authorized do
|
||||
Activity.exact_coordinates(activity)
|
||||
else
|
||||
Activity.public_coordinates(activity)
|
||||
|
|
@ -396,6 +440,15 @@ defmodule WhoNeedHelp.Activities do
|
|||
)
|
||||
end
|
||||
|
||||
defp approved_participant_id?(activity_id, user_id) do
|
||||
Repo.exists?(
|
||||
from participant in Participant,
|
||||
where:
|
||||
participant.activity_id == ^activity_id and participant.user_id == ^user_id and
|
||||
participant.status == :approved
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_category_and_data(changeset) do
|
||||
category_id = Ecto.Changeset.get_field(changeset, :category_id)
|
||||
|
||||
|
|
@ -644,6 +697,13 @@ defmodule WhoNeedHelp.Activities do
|
|||
defp maybe_filter_category(query, value),
|
||||
do: where(query, [activity], activity.category_id == ^value)
|
||||
|
||||
defp cast_id(value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, id} -> {:ok, id}
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
|
||||
|
||||
defp broadcast_activity(id, message),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
field :hidden_at, :utc_datetime
|
||||
field :hidden_reason, :string
|
||||
field :approved_participant_count, :integer, virtual: true, default: 0
|
||||
field :safety_confirmed, :boolean, virtual: true, default: false
|
||||
|
||||
belongs_to :creator, WhoNeedHelp.Accounts.User
|
||||
belongs_to :category, WhoNeedHelp.Catalog.Category
|
||||
|
|
@ -45,7 +46,8 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
:starts_at,
|
||||
:join_deadline,
|
||||
:capacity,
|
||||
:category_id
|
||||
:category_id,
|
||||
:safety_confirmed
|
||||
])
|
||||
|> put_location(attrs)
|
||||
|> validate_required([
|
||||
|
|
@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Activities.Activity do
|
|||
|> validate_length(:title, min: 5, max: 120)
|
||||
|> validate_length(:description, min: 10, max: 2_000)
|
||||
|> validate_number(:capacity, greater_than_or_equal_to: 2)
|
||||
|> validate_acceptance(:safety_confirmed,
|
||||
message: "confirm the safety guidance before publishing"
|
||||
)
|
||||
|> validate_schedule()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -74,25 +74,37 @@ defmodule WhoNeedHelp.Catalog do
|
|||
end
|
||||
|
||||
def vote(%Scope{user: user} = scope, proposal_id) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote),
|
||||
%CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do
|
||||
%CategoryVote{}
|
||||
|> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
|
||||
|> Repo.insert()
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
%CategoryProposal{} -> {:error, :proposal_closed}
|
||||
other -> other
|
||||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||||
{:ok, _limit} <- Trust.authorize_action(scope, :category_vote) do
|
||||
Repo.transact(fn ->
|
||||
case CategoryProposal
|
||||
|> where([proposal], proposal.id == ^proposal_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one() do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%CategoryProposal{status: :open} ->
|
||||
%CategoryVote{}
|
||||
|> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
|
||||
|> Repo.insert()
|
||||
|
||||
%CategoryProposal{} ->
|
||||
{:error, :proposal_closed}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def unvote(%Scope{user: user}, proposal_id) do
|
||||
{count, _} =
|
||||
CategoryVote
|
||||
|> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
|
||||
|> Repo.delete_all()
|
||||
with {:ok, proposal_id} <- cast_id(proposal_id) do
|
||||
{count, _} =
|
||||
CategoryVote
|
||||
|> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
|
||||
|> Repo.delete_all()
|
||||
|
||||
{:ok, count}
|
||||
{:ok, count}
|
||||
end
|
||||
end
|
||||
|
||||
def seed_defaults do
|
||||
|
|
@ -175,46 +187,53 @@ defmodule WhoNeedHelp.Catalog do
|
|||
end
|
||||
|
||||
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
proposal = locked_proposal(proposal_id)
|
||||
|
||||
if proposal.status == :open do
|
||||
category_attrs =
|
||||
category_attrs
|
||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||
|> normalize_descriptions()
|
||||
|> Map.put_new("parent_id", proposal.parent_id)
|
||||
|> Map.put_new("mode", to_string(proposal.mode))
|
||||
cond do
|
||||
is_nil(proposal) ->
|
||||
{:error, :not_found}
|
||||
|
||||
with {:ok, category} <-
|
||||
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
||||
{:ok, proposal} <-
|
||||
proposal
|
||||
|> CategoryProposal.moderation_changeset(%{
|
||||
status: :approved,
|
||||
merged_into_id: category.id,
|
||||
reviewed_by_id: moderator.id,
|
||||
reviewed_at: DateTime.utc_now(:second),
|
||||
moderation_note: category_attrs["moderation_note"]
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
moderator.id,
|
||||
"category_proposal.approved",
|
||||
"category_proposal",
|
||||
proposal.id,
|
||||
%{"category_id" => category.id}
|
||||
) do
|
||||
{:ok, %{proposal: proposal, category: category}}
|
||||
end
|
||||
else
|
||||
{:error, :proposal_closed}
|
||||
proposal.status == :open ->
|
||||
category_attrs =
|
||||
category_attrs
|
||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||
|> normalize_descriptions()
|
||||
|> Map.put_new("parent_id", proposal.parent_id)
|
||||
|> Map.put_new("mode", to_string(proposal.mode))
|
||||
|
||||
with {:ok, category} <-
|
||||
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
||||
{:ok, proposal} <-
|
||||
proposal
|
||||
|> CategoryProposal.moderation_changeset(%{
|
||||
status: :approved,
|
||||
merged_into_id: category.id,
|
||||
reviewed_by_id: moderator.id,
|
||||
reviewed_at: DateTime.utc_now(:second),
|
||||
moderation_note: category_attrs["moderation_note"]
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
moderator.id,
|
||||
"category_proposal.approved",
|
||||
"category_proposal",
|
||||
proposal.id,
|
||||
%{"category_id" => category.id}
|
||||
) do
|
||||
{:ok, %{proposal: proposal, category: category}}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:error, :proposal_closed}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -223,45 +242,54 @@ defmodule WhoNeedHelp.Catalog do
|
|||
end
|
||||
|
||||
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
|
||||
with %Category{} <- Repo.get(Category, category_id) do
|
||||
with {:ok, category_id} <- cast_id(category_id),
|
||||
%Category{} <- Repo.get(Category, category_id) do
|
||||
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
|
||||
else
|
||||
{:error, :not_found} = error -> error
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, proposal_id} <- cast_id(proposal_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
proposal = locked_proposal(proposal_id)
|
||||
|
||||
if proposal.status == :open do
|
||||
with {:ok, proposal} <-
|
||||
proposal
|
||||
|> CategoryProposal.moderation_changeset(%{
|
||||
status: status,
|
||||
merged_into_id: merged_into_id,
|
||||
reviewed_by_id: moderator.id,
|
||||
reviewed_at: DateTime.utc_now(:second),
|
||||
moderation_note: note
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
moderator.id,
|
||||
"category_proposal.#{status}",
|
||||
"category_proposal",
|
||||
proposal.id,
|
||||
%{"merged_into_id" => merged_into_id}
|
||||
) do
|
||||
{:ok, proposal}
|
||||
end
|
||||
else
|
||||
{:error, :proposal_closed}
|
||||
cond do
|
||||
is_nil(proposal) ->
|
||||
{:error, :not_found}
|
||||
|
||||
proposal.status == :open ->
|
||||
with {:ok, proposal} <-
|
||||
proposal
|
||||
|> CategoryProposal.moderation_changeset(%{
|
||||
status: status,
|
||||
merged_into_id: merged_into_id,
|
||||
reviewed_by_id: moderator.id,
|
||||
reviewed_at: DateTime.utc_now(:second),
|
||||
moderation_note: note
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
moderator.id,
|
||||
"category_proposal.#{status}",
|
||||
"category_proposal",
|
||||
proposal.id,
|
||||
%{"merged_into_id" => merged_into_id}
|
||||
) do
|
||||
{:ok, proposal}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:error, :proposal_closed}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -269,7 +297,7 @@ defmodule WhoNeedHelp.Catalog do
|
|||
CategoryProposal
|
||||
|> where([proposal], proposal.id == ^proposal_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
|
||||
|
|
@ -347,9 +375,11 @@ defmodule WhoNeedHelp.Catalog do
|
|||
|
||||
case attrs["parent_id"] do
|
||||
parent_id when parent_id not in [nil, ""] ->
|
||||
case Repo.get(Category, parent_id) do
|
||||
nil -> {:error, :invalid_parent}
|
||||
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
|
||||
with {:ok, parent_id} <- cast_id(parent_id),
|
||||
%Category{} = parent <- Repo.get(Category, parent_id) do
|
||||
{:ok, Map.put(attrs, "mode", to_string(parent.mode))}
|
||||
else
|
||||
_invalid_or_missing -> {:error, :invalid_parent}
|
||||
end
|
||||
|
||||
_ ->
|
||||
|
|
@ -863,6 +893,13 @@ defmodule WhoNeedHelp.Catalog do
|
|||
)
|
||||
end
|
||||
|
||||
defp cast_id(value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, id} -> {:ok, id}
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
|
||||
|
||||
defp option(value, label), do: %{"value" => value, "label" => label}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ defmodule WhoNeedHelp.Help do
|
|||
|
||||
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
||||
|
||||
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
|
||||
request.status == :open and is_nil(request.hidden_at) and
|
||||
DateTime.after?(request.expires_at, DateTime.utc_now(:second)) and
|
||||
request_filter_matches?(request, filters) and
|
||||
not Trust.blocked_between?(user.id, request.requester_id)
|
||||
end
|
||||
|
||||
def list_my_requests(%Scope{user: user}) do
|
||||
paginate_my_requests(%Scope{user: user}).entries
|
||||
end
|
||||
|
|
@ -112,43 +119,37 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
def get_request(%Scope{user: user} = scope, id) do
|
||||
request =
|
||||
HelpRequest
|
||||
|> Repo.get(id)
|
||||
|> case do
|
||||
nil ->
|
||||
nil
|
||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||
%HelpRequest{} = request <- Repo.get(HelpRequest, id) do
|
||||
request =
|
||||
[request]
|
||||
|> preload_request_relations(social_identities: true)
|
||||
|> hd()
|
||||
|
||||
request ->
|
||||
[request]
|
||||
|> preload_request_relations(social_identities: true)
|
||||
|> hd()
|
||||
cond do
|
||||
request.requester_id == user.id ->
|
||||
{:ok, request}
|
||||
|
||||
Accounts.moderator_authorized?(user) ->
|
||||
{:ok, request}
|
||||
|
||||
not is_nil(request.hidden_at) ->
|
||||
{:error, :not_found}
|
||||
|
||||
Trust.blocked_between?(user.id, request.requester_id) ->
|
||||
{:error, :not_found}
|
||||
|
||||
request.assignment && participant?(scope, request.assignment) ->
|
||||
{:ok, request}
|
||||
|
||||
request.status == :open and DateTime.after?(request.expires_at, DateTime.utc_now(:second)) ->
|
||||
{:ok, request}
|
||||
|
||||
true ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
case request do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
request ->
|
||||
cond do
|
||||
request.requester_id == user.id ->
|
||||
{:ok, request}
|
||||
|
||||
request.assignment && participant?(scope, request.assignment) ->
|
||||
{:ok, request}
|
||||
|
||||
Accounts.moderator_authorized?(user) ->
|
||||
{:ok, request}
|
||||
|
||||
not is_nil(request.hidden_at) ->
|
||||
{:error, :not_found}
|
||||
|
||||
Trust.blocked_between?(user.id, request.requester_id) ->
|
||||
{:error, :not_found}
|
||||
|
||||
true ->
|
||||
{:ok, request}
|
||||
end
|
||||
else
|
||||
_invalid_or_missing -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -211,6 +212,8 @@ defmodule WhoNeedHelp.Help do
|
|||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|
||||
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
|
||||
|
||||
cond do
|
||||
request.requester_id == helper.id ->
|
||||
{:error, :own_request}
|
||||
|
|
@ -284,6 +287,7 @@ defmodule WhoNeedHelp.Help do
|
|||
Repo.transact(fn ->
|
||||
assignment = locked_assignment(assignment_id)
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
|
||||
cond do
|
||||
user.id != assignment.helper_id ->
|
||||
|
|
@ -458,11 +462,15 @@ defmodule WhoNeedHelp.Help do
|
|||
def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
|
||||
|
||||
def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
|
||||
participant =
|
||||
request.requester_id == scope.user.id or
|
||||
(not is_nil(request.assignment) and participant?(scope, request.assignment))
|
||||
owner = request.requester_id == scope.user.id
|
||||
|
||||
if participant and request.location_visibility in [:hidden, :exact_for_active_match] do
|
||||
matched_participant =
|
||||
not owner and not is_nil(request.assignment) and participant?(scope, request.assignment) and
|
||||
request.assignment.status in [:accepted, :in_progress] and
|
||||
not Trust.blocked_between?(scope.user.id, request.requester_id)
|
||||
|
||||
if (owner or matched_participant) and
|
||||
request.location_visibility in [:hidden, :exact_for_active_match] do
|
||||
%Geo.Point{coordinates: {lng, lat}} = request.location
|
||||
%{latitude: lat, longitude: lng, exact: true}
|
||||
else
|
||||
|
|
@ -486,6 +494,7 @@ defmodule WhoNeedHelp.Help do
|
|||
Repo.transact(fn ->
|
||||
assignment = locked_assignment(assignment_id)
|
||||
request = Repo.get!(HelpRequest, assignment.request_id)
|
||||
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
|
||||
|
|
@ -629,6 +638,14 @@ defmodule WhoNeedHelp.Help do
|
|||
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
|
||||
end
|
||||
|
||||
defp request_filter_matches?(request, filters) do
|
||||
category_id = filters["category_id"] || filters[:category_id]
|
||||
urgency = filters["urgency"] || filters[:urgency]
|
||||
|
||||
(category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
|
||||
(urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency))
|
||||
end
|
||||
|
||||
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
|
||||
|
||||
defp maybe_filter(query, field, value),
|
||||
|
|
@ -638,7 +655,7 @@ defmodule WhoNeedHelp.Help do
|
|||
category_id = Ecto.Changeset.get_field(changeset, :category_id)
|
||||
|
||||
case category_id && Repo.get(Category, category_id) do
|
||||
%Category{} = category ->
|
||||
%Category{mode: :help, active: true} = category ->
|
||||
data = Ecto.Changeset.get_field(changeset, :structured_data)
|
||||
|
||||
case Catalog.validate_structured_data(category, data) do
|
||||
|
|
@ -654,7 +671,7 @@ defmodule WhoNeedHelp.Help do
|
|||
end
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
Ecto.Changeset.add_error(changeset, :category_id, "select an active help category")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
field :completed_at, :utc_datetime
|
||||
field :hidden_at, :utc_datetime
|
||||
field :hidden_reason, :string
|
||||
field :safety_confirmed, :boolean, virtual: true, default: false
|
||||
belongs_to :requester, WhoNeedHelp.Accounts.User
|
||||
belongs_to :category, WhoNeedHelp.Catalog.Category
|
||||
has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id
|
||||
|
|
@ -46,7 +47,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
:urgency,
|
||||
:location_visibility,
|
||||
:expires_at,
|
||||
:category_id
|
||||
:category_id,
|
||||
:safety_confirmed
|
||||
])
|
||||
|> put_location(attrs)
|
||||
|> validate_required([
|
||||
|
|
@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|
|||
|> validate_length(:title, min: 5, max: 120)
|
||||
|> validate_length(:description, min: 10, max: 2_000)
|
||||
|> validate_length(:pickup_instructions, max: 1_000)
|
||||
|> validate_acceptance(:safety_confirmed,
|
||||
message: "confirm the safety guidance before publishing"
|
||||
)
|
||||
|> validate_expiry()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -46,28 +46,38 @@ defmodule WhoNeedHelp.Messaging do
|
|||
end
|
||||
|
||||
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
|
||||
assignment = Repo.preload(assignment, :request)
|
||||
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message),
|
||||
true <- Help.participant?(scope, assignment),
|
||||
false <- blocked_assignment?(scope, assignment) do
|
||||
request = Map.fetch!(assignment, :request)
|
||||
recipient_id = counterpart_id(user.id, assignment, request)
|
||||
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
with {:ok, message} <-
|
||||
%Message{assignment_id: assignment.id, sender_id: user.id}
|
||||
current =
|
||||
Assignment
|
||||
|> where([current], current.id == ^assignment.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(:request)
|
||||
|
||||
request = current.request
|
||||
recipient_id = counterpart_id(user.id, current, request)
|
||||
|
||||
with true <- Help.participant?(scope, current),
|
||||
:ok <- Trust.lock_user_pair(user.id, recipient_id),
|
||||
false <- Trust.blocked_between?(user.id, recipient_id),
|
||||
{:ok, message} <-
|
||||
%Message{assignment_id: current.id, sender_id: user.id}
|
||||
|> Message.changeset(attrs)
|
||||
|> Repo.insert(),
|
||||
{:ok, _push_job} <-
|
||||
Push.enqueue_message_created(
|
||||
message.id,
|
||||
assignment.id,
|
||||
current.id,
|
||||
request.id,
|
||||
recipient_id
|
||||
) do
|
||||
{:ok, message}
|
||||
else
|
||||
true -> {:error, :blocked}
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -76,16 +86,12 @@ defmodule WhoNeedHelp.Messaging do
|
|||
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"messages:#{assignment.id}",
|
||||
"messages:#{message.assignment_id}",
|
||||
{:new_message, message}
|
||||
)
|
||||
|
||||
{:ok, message}
|
||||
end
|
||||
else
|
||||
true -> {:error, :blocked}
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -32,47 +32,49 @@ defmodule WhoNeedHelp.Tracking do
|
|||
%Assignment{} = assignment,
|
||||
visibility \\ :active_match
|
||||
) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking),
|
||||
true <- Help.participant?(scope, assignment),
|
||||
true <- assignment.status in [:accepted, :in_progress] do
|
||||
now = DateTime.utc_now(:second)
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
%TrackingSession{}
|
||||
|> TrackingSession.changeset(%{
|
||||
assignment_id: assignment.id,
|
||||
user_id: user.id,
|
||||
visibility: visibility,
|
||||
started_at: now
|
||||
})
|
||||
|> Repo.insert(
|
||||
on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]],
|
||||
conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
|
||||
)
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
%TrackingSession{}
|
||||
|> TrackingSession.changeset(%{
|
||||
assignment_id: current.id,
|
||||
user_id: user.id,
|
||||
visibility: visibility,
|
||||
started_at: now
|
||||
})
|
||||
|> Repo.insert(
|
||||
on_conflict: [
|
||||
set: [active: true, ended_at: nil, started_at: now, visibility: visibility]
|
||||
],
|
||||
conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position),
|
||||
true <- Help.participant?(scope, assignment) do
|
||||
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
session =
|
||||
TrackingSession
|
||||
|> where(
|
||||
[session],
|
||||
session.assignment_id == ^assignment.id and session.user_id == ^user.id and
|
||||
session.active
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
|
||||
session =
|
||||
TrackingSession
|
||||
|> where(
|
||||
[session],
|
||||
session.assignment_id == ^current.id and session.user_id == ^user.id and
|
||||
session.active
|
||||
)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one()
|
||||
|
||||
if session do
|
||||
persist_position(session, assignment, user, attrs)
|
||||
else
|
||||
{:error, :tracking_not_active}
|
||||
if session do
|
||||
persist_position(session, current, user, attrs)
|
||||
else
|
||||
{:error, :tracking_not_active}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
@ -85,9 +87,6 @@ defmodule WhoNeedHelp.Tracking do
|
|||
|
||||
{:ok, position}
|
||||
end
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -119,15 +118,16 @@ defmodule WhoNeedHelp.Tracking do
|
|||
end
|
||||
|
||||
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do
|
||||
if Trust.eligible?(scope) and Help.participant?(scope, assignment) do
|
||||
with true <- Trust.eligible?(scope),
|
||||
{:ok, current} <- active_assignment(scope, assignment.id) do
|
||||
TrackingSession
|
||||
|> where([s], s.assignment_id == ^assignment.id and s.active)
|
||||
|> where([s], s.assignment_id == ^current.id and s.active)
|
||||
|> join(:inner, [s], p in assoc(s, :position))
|
||||
|> select([s, p], {s.user_id, p})
|
||||
|> Repo.all()
|
||||
|> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end)
|
||||
else
|
||||
%{}
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -160,13 +160,93 @@ defmodule WhoNeedHelp.Tracking do
|
|||
end)
|
||||
end
|
||||
|
||||
def stop_all_sessions(user_id) do
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
active_sessions =
|
||||
TrackingSession
|
||||
|> where([session], session.active and session.user_id == ^user_id)
|
||||
|> select([session], {session.id, session.assignment_id})
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.all()
|
||||
|
||||
session_ids = Enum.map(active_sessions, &elem(&1, 0))
|
||||
|
||||
if session_ids != [] do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
Position
|
||||
|> where([position], position.tracking_session_id in ^session_ids)
|
||||
|> Repo.delete_all()
|
||||
|
||||
TrackingSession
|
||||
|> where([session], session.id in ^session_ids)
|
||||
|> Repo.update_all(set: [active: false, ended_at: now, updated_at: now])
|
||||
end
|
||||
|
||||
{:ok, Enum.map(active_sessions, &elem(&1, 1))}
|
||||
end)
|
||||
|
||||
with {:ok, assignment_ids} <- result do
|
||||
Enum.each(assignment_ids, fn assignment_id ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"tracking:#{assignment_id}",
|
||||
{:tracking_stopped, user_id}
|
||||
)
|
||||
end)
|
||||
|
||||
{:ok, %{sessions_ended: length(assignment_ids)}}
|
||||
end
|
||||
end
|
||||
|
||||
defp active_session(scope, assignment, user_id) do
|
||||
if Help.participant?(scope, assignment) do
|
||||
with {:ok, current} <- active_assignment(scope, assignment.id) do
|
||||
Repo.get_by(TrackingSession,
|
||||
assignment_id: assignment.id,
|
||||
assignment_id: current.id,
|
||||
user_id: user_id,
|
||||
active: true
|
||||
)
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp active_assignment(%Scope{user: user} = scope, assignment_id, options \\ []) do
|
||||
query =
|
||||
Assignment
|
||||
|> where([assignment], assignment.id == ^assignment_id)
|
||||
|> preload(:request)
|
||||
|
||||
query = if Keyword.get(options, :lock, false), do: lock(query, "FOR UPDATE"), else: query
|
||||
|
||||
case Repo.one(query) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%Assignment{} = assignment ->
|
||||
request = assignment.request
|
||||
|
||||
counterpart_id =
|
||||
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
|
||||
|
||||
if Keyword.get(options, :lock, false) do
|
||||
:ok = Trust.lock_user_pair(user.id, counterpart_id)
|
||||
end
|
||||
|
||||
cond do
|
||||
not Help.participant?(scope, assignment) ->
|
||||
{:error, :forbidden}
|
||||
|
||||
assignment.status not in [:accepted, :in_progress] ->
|
||||
{:error, :assignment_inactive}
|
||||
|
||||
Trust.blocked_between?(user.id, counterpart_id) ->
|
||||
{:error, :blocked}
|
||||
|
||||
true ->
|
||||
{:ok, assignment}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -47,55 +47,66 @@ defmodule WhoNeedHelp.Trust do
|
|||
def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
|
||||
attrs = stringify_keys(attrs)
|
||||
|
||||
request =
|
||||
case assignment.request do
|
||||
%HelpRequest{} = request -> request
|
||||
_ -> Repo.get!(HelpRequest, assignment.request_id)
|
||||
end
|
||||
|
||||
with {:ok, _limit} <- authorize_action(scope, :review),
|
||||
true <- assignment.status == :completed,
|
||||
true <- user.id in [assignment.helper_id, request.requester_id] do
|
||||
reviewee_id =
|
||||
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
|
||||
|
||||
with {:ok, _limit} <- authorize_action(scope, :review) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, review} <-
|
||||
%Review{}
|
||||
|> Review.changeset(
|
||||
Map.merge(attrs, %{
|
||||
"assignment_id" => assignment.id,
|
||||
"reviewer_id" => user.id,
|
||||
"reviewee_id" => reviewee_id
|
||||
})
|
||||
)
|
||||
|> Repo.insert(),
|
||||
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id),
|
||||
true <- length(reviews) <= 2 do
|
||||
if length(reviews) == 2 do
|
||||
now = DateTime.utc_now(:second)
|
||||
current =
|
||||
Assignment
|
||||
|> where([current], current.id == ^assignment.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|
||||
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id),
|
||||
set: [revealed_at: now]
|
||||
)
|
||||
end
|
||||
request = Repo.get!(HelpRequest, current.request_id)
|
||||
|
||||
audit(user.id, "review.submitted", "assignment", assignment.id, %{
|
||||
"revealed" => length(reviews) == 2
|
||||
})
|
||||
cond do
|
||||
current.status != :completed ->
|
||||
{:error, :forbidden}
|
||||
|
||||
{:ok, review}
|
||||
else
|
||||
false -> {:error, :invalid_review_count}
|
||||
other -> other
|
||||
user.id not in [current.helper_id, request.requester_id] ->
|
||||
{:error, :forbidden}
|
||||
|
||||
true ->
|
||||
reviewee_id =
|
||||
if user.id == current.helper_id,
|
||||
do: request.requester_id,
|
||||
else: current.helper_id
|
||||
|
||||
with {:ok, review} <-
|
||||
%Review{}
|
||||
|> Review.changeset(
|
||||
Map.merge(attrs, %{
|
||||
"assignment_id" => current.id,
|
||||
"reviewer_id" => user.id,
|
||||
"reviewee_id" => reviewee_id
|
||||
})
|
||||
)
|
||||
|> Repo.insert(),
|
||||
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
|
||||
true <- length(reviews) <= 2,
|
||||
revealed? <- length(reviews) == 2,
|
||||
{_count, _rows} <-
|
||||
maybe_reveal_reviews(current.id, revealed?),
|
||||
{:ok, _audit} <-
|
||||
audit(user.id, "review.submitted", "assignment", current.id, %{
|
||||
"revealed" => revealed?
|
||||
}) do
|
||||
{:ok, review}
|
||||
else
|
||||
false -> {:error, :invalid_review_count}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end)
|
||||
else
|
||||
false -> {:error, :forbidden}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_reveal_reviews(assignment_id, true) do
|
||||
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
|
||||
set: [revealed_at: DateTime.utc_now(:second)]
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_reveal_reviews(_assignment_id, false), do: {0, nil}
|
||||
|
||||
def visible_reviews(user_id) do
|
||||
paginate_visible_reviews(user_id).entries
|
||||
end
|
||||
|
|
@ -259,11 +270,11 @@ defmodule WhoNeedHelp.Trust do
|
|||
with {:ok, report} <-
|
||||
%Report{reporter_id: user.id}
|
||||
|> Report.changeset(attrs)
|
||||
|> Repo.insert() do
|
||||
audit(user.id, "report.created", "report", report.id, %{
|
||||
"reason" => to_string(report.reason)
|
||||
})
|
||||
|
||||
|> Repo.insert(),
|
||||
{:ok, _audit} <-
|
||||
audit(user.id, "report.created", "report", report.id, %{
|
||||
"reason" => to_string(report.reason)
|
||||
}) do
|
||||
{:ok, report}
|
||||
end
|
||||
end)
|
||||
|
|
@ -301,7 +312,8 @@ defmodule WhoNeedHelp.Trust do
|
|||
end
|
||||
|
||||
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, report_id} <- cast_id(report_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
attrs =
|
||||
attrs
|
||||
|> stringify_keys()
|
||||
|
|
@ -312,23 +324,29 @@ defmodule WhoNeedHelp.Trust do
|
|||
|
||||
Repo.transact(fn ->
|
||||
report =
|
||||
Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!()
|
||||
Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one()
|
||||
|
||||
with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do
|
||||
audit(moderator.id, "report.moderated", "report", report.id, %{
|
||||
"status" => to_string(report.status)
|
||||
})
|
||||
|
||||
{:ok, report}
|
||||
if report do
|
||||
with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "report.moderated", "report", report.id, %{
|
||||
"status" => to_string(report.status)
|
||||
}) do
|
||||
{:ok, report}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def report_evidence(%Scope{user: moderator}, report_id) do
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, report_id} <- cast_id(report_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
report =
|
||||
Report
|
||||
|> Repo.get(report_id)
|
||||
|
|
@ -394,41 +412,80 @@ defmodule WhoNeedHelp.Trust do
|
|||
{:error, :not_found}
|
||||
end
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def block(%Scope{user: user} = scope, blocked_id) do
|
||||
with {:ok, _limit} <- authorize_action(scope, :block),
|
||||
with {:ok, blocked_id} <- cast_id(blocked_id),
|
||||
{:ok, _limit} <- authorize_action(scope, :block),
|
||||
%User{} <- Repo.get(User, blocked_id) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, block} <-
|
||||
%Block{}
|
||||
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
|
||||
|> Repo.insert() do
|
||||
audit(user.id, "user.blocked", "user", blocked_id)
|
||||
clear_pair_tracking(user.id, blocked_id)
|
||||
{:ok, block}
|
||||
end
|
||||
end)
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
with :ok <- lock_user_pair(user.id, blocked_id),
|
||||
{:ok, block} <-
|
||||
%Block{}
|
||||
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
|
||||
|> Repo.insert(),
|
||||
{:ok, _audit} <- audit(user.id, "user.blocked", "user", blocked_id) do
|
||||
tracking = clear_pair_tracking(user.id, blocked_id)
|
||||
activity_ids = pair_activity_ids(user.id, blocked_id)
|
||||
{:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}}
|
||||
end
|
||||
end)
|
||||
|
||||
with {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}} <- result do
|
||||
Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"tracking:#{assignment_id}",
|
||||
{:tracking_stopped, stopped_user_id}
|
||||
)
|
||||
end)
|
||||
|
||||
Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
|
||||
Enum.each(activity_ids, &Activities.notify_activity_updated/1)
|
||||
{:ok, block}
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def unblock(%Scope{user: user}, blocked_id) do
|
||||
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
|
||||
nil ->
|
||||
{:ok, :already_unblocked}
|
||||
defp pair_activity_ids(first_user_id, second_user_id) do
|
||||
Activity
|
||||
|> join(:inner, [activity], participant in Participant,
|
||||
on: participant.activity_id == activity.id
|
||||
)
|
||||
|> where(
|
||||
[activity, participant],
|
||||
participant.status == :approved and
|
||||
((activity.creator_id == ^first_user_id and participant.user_id == ^second_user_id) or
|
||||
(activity.creator_id == ^second_user_id and participant.user_id == ^first_user_id))
|
||||
)
|
||||
|> select([activity], activity.id)
|
||||
|> distinct(true)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
block ->
|
||||
Repo.transact(fn ->
|
||||
with {:ok, _block} <- Repo.delete(block) do
|
||||
audit(user.id, "user.unblocked", "user", blocked_id)
|
||||
{:ok, :unblocked}
|
||||
end
|
||||
end)
|
||||
def unblock(%Scope{user: user}, blocked_id) do
|
||||
with {:ok, blocked_id} <- cast_id(blocked_id) do
|
||||
Repo.transact(fn ->
|
||||
:ok = lock_user_pair(user.id, blocked_id)
|
||||
|
||||
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
|
||||
nil ->
|
||||
{:ok, :already_unblocked}
|
||||
|
||||
block ->
|
||||
with {:ok, _block} <- Repo.delete(block),
|
||||
{:ok, _audit} <- audit(user.id, "user.unblocked", "user", blocked_id) do
|
||||
{:ok, :unblocked}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -468,6 +525,17 @@ defmodule WhoNeedHelp.Trust do
|
|||
)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def lock_user_pair(first_user_id, second_user_id) do
|
||||
User
|
||||
|> where([user], user.id in ^Enum.sort([first_user_id, second_user_id]))
|
||||
|> order_by([user], asc: user.id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.all()
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def record_completion_signals(%Assignment{} = assignment) do
|
||||
request =
|
||||
case assignment.request do
|
||||
|
|
@ -550,7 +618,8 @@ defmodule WhoNeedHelp.Trust do
|
|||
end
|
||||
|
||||
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, signal_id} <- cast_id(signal_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
attrs =
|
||||
attrs
|
||||
|> stringify_keys()
|
||||
|
|
@ -564,44 +633,58 @@ defmodule WhoNeedHelp.Trust do
|
|||
AbuseSignal
|
||||
|> where([signal], signal.id == ^signal_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do
|
||||
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
|
||||
"status" => to_string(signal.status)
|
||||
})
|
||||
|
||||
{:ok, signal}
|
||||
if signal do
|
||||
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
|
||||
"status" => to_string(signal.status)
|
||||
}) do
|
||||
{:ok, signal}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def hide_request(%Scope{user: moderator}, request_id, reason) do
|
||||
result =
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, request_id} <- cast_id(request_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
HelpRequest
|
||||
|> where([request], request.id == ^request_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> HelpRequest.moderation_changeset(%{
|
||||
hidden_at: DateTime.utc_now(:second),
|
||||
hidden_reason: reason
|
||||
})
|
||||
|> Repo.update() do
|
||||
audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason})
|
||||
{:ok, request}
|
||||
if request do
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> HelpRequest.moderation_changeset(%{
|
||||
hidden_at: DateTime.utc_now(:second),
|
||||
hidden_reason: reason
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "request.hidden", "request", request.id, %{
|
||||
"reason" => reason
|
||||
}) do
|
||||
{:ok, request}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
|
||||
with {:ok, request} <- result do
|
||||
|
|
@ -612,24 +695,31 @@ defmodule WhoNeedHelp.Trust do
|
|||
|
||||
def restore_request(%Scope{user: moderator}, request_id) do
|
||||
result =
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, request_id} <- cast_id(request_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
request =
|
||||
HelpRequest
|
||||
|> where([request], request.id == ^request_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|
||||
|> Repo.update() do
|
||||
audit(moderator.id, "request.restored", "request", request.id)
|
||||
{:ok, request}
|
||||
if request do
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "request.restored", "request", request.id) do
|
||||
{:ok, request}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
|
||||
with {:ok, request} <- result do
|
||||
|
|
@ -640,30 +730,36 @@ defmodule WhoNeedHelp.Trust do
|
|||
|
||||
def hide_activity(%Scope{user: moderator}, activity_id, reason) do
|
||||
result =
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, activity_id} <- cast_id(activity_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
activity =
|
||||
Activity
|
||||
|> where([activity], activity.id == ^activity_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, activity} <-
|
||||
activity
|
||||
|> Activity.moderation_changeset(%{
|
||||
hidden_at: DateTime.utc_now(:second),
|
||||
hidden_reason: reason
|
||||
})
|
||||
|> Repo.update() do
|
||||
audit(moderator.id, "activity.hidden", "activity", activity.id, %{
|
||||
"reason" => reason
|
||||
})
|
||||
|
||||
{:ok, activity}
|
||||
if activity do
|
||||
with {:ok, activity} <-
|
||||
activity
|
||||
|> Activity.moderation_changeset(%{
|
||||
hidden_at: DateTime.utc_now(:second),
|
||||
hidden_reason: reason
|
||||
})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "activity.hidden", "activity", activity.id, %{
|
||||
"reason" => reason
|
||||
}) do
|
||||
{:ok, activity}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
|
||||
with {:ok, activity} <- result do
|
||||
|
|
@ -674,24 +770,31 @@ defmodule WhoNeedHelp.Trust do
|
|||
|
||||
def restore_activity(%Scope{user: moderator}, activity_id) do
|
||||
result =
|
||||
if Accounts.moderator_authorized?(moderator) do
|
||||
with {:ok, activity_id} <- cast_id(activity_id),
|
||||
true <- Accounts.moderator_authorized?(moderator) do
|
||||
Repo.transact(fn ->
|
||||
activity =
|
||||
Activity
|
||||
|> where([activity], activity.id == ^activity_id)
|
||||
|> lock("FOR UPDATE")
|
||||
|> Repo.one!()
|
||||
|> Repo.one()
|
||||
|
||||
with {:ok, activity} <-
|
||||
activity
|
||||
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|
||||
|> Repo.update() do
|
||||
audit(moderator.id, "activity.restored", "activity", activity.id)
|
||||
{:ok, activity}
|
||||
if activity do
|
||||
with {:ok, activity} <-
|
||||
activity
|
||||
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "activity.restored", "activity", activity.id) do
|
||||
{:ok, activity}
|
||||
end
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end)
|
||||
else
|
||||
{:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
{:error, :not_found} = error -> error
|
||||
end
|
||||
|
||||
with {:ok, activity} <- result do
|
||||
|
|
@ -701,15 +804,43 @@ defmodule WhoNeedHelp.Trust do
|
|||
end
|
||||
|
||||
def moderate_user(%Scope{user: moderator}, user_id, attrs) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "user.moderated", "user", user.id, %{
|
||||
"moderation_status" => to_string(user.moderation_status)
|
||||
}) do
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
result =
|
||||
Repo.transact(fn ->
|
||||
with {:ok, %{user: user} = moderation} <-
|
||||
Accounts.moderate_user(moderator, user_id, attrs),
|
||||
{:ok, _audit} <-
|
||||
audit(moderator.id, "user.moderated", "user", user.id, %{
|
||||
"moderation_status" => to_string(user.moderation_status)
|
||||
}) do
|
||||
tracking =
|
||||
if user.moderation_status == :suspended,
|
||||
do: clear_user_tracking(user.id),
|
||||
else: %{stopped: [], request_ids: []}
|
||||
|
||||
{:ok, Map.put(moderation, :tracking, tracking)}
|
||||
end
|
||||
end)
|
||||
|
||||
with {:ok, %{user: user, expired_session_tokens: tokens, tracking: tracking}} <-
|
||||
result do
|
||||
Enum.each(tokens, fn token ->
|
||||
token
|
||||
|> WhoNeedHelpWeb.UserAuth.live_socket_id()
|
||||
|> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{})
|
||||
end)
|
||||
|
||||
Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
WhoNeedHelp.PubSub,
|
||||
"tracking:#{assignment_id}",
|
||||
{:tracking_stopped, stopped_user_id}
|
||||
)
|
||||
end)
|
||||
|
||||
Enum.each(tracking.request_ids, &Help.notify_request_updated/1)
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
def moderate_role(%Scope{user: admin}, user_id, attrs) do
|
||||
|
|
@ -1057,7 +1188,7 @@ defmodule WhoNeedHelp.Trust do
|
|||
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
|
||||
|
||||
defp clear_pair_tracking(first_user_id, second_user_id) do
|
||||
assignment_ids =
|
||||
assignments =
|
||||
Assignment
|
||||
|> join(:inner, [assignment], request in HelpRequest,
|
||||
on: request.id == assignment.request_id
|
||||
|
|
@ -1067,27 +1198,95 @@ defmodule WhoNeedHelp.Trust do
|
|||
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or
|
||||
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
|
||||
)
|
||||
|> where([assignment], assignment.status in [:accepted, :in_progress])
|
||||
|> select([assignment], assignment.id)
|
||||
|> select([assignment, request], {assignment.id, request.id, assignment.status})
|
||||
|> Repo.all()
|
||||
|
||||
session_ids =
|
||||
active_assignment_ids =
|
||||
assignments
|
||||
|> Enum.filter(fn {_assignment_id, _request_id, status} ->
|
||||
status in [:accepted, :in_progress]
|
||||
end)
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|
||||
active_sessions =
|
||||
if active_assignment_ids == [] do
|
||||
[]
|
||||
else
|
||||
TrackingSession
|
||||
|> where([session], session.active and session.assignment_id in ^active_assignment_ids)
|
||||
|> select([session], {session.id, session.assignment_id, session.user_id})
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
session_ids = Enum.map(active_sessions, &elem(&1, 0))
|
||||
|
||||
if session_ids != [] do
|
||||
Repo.delete_all(
|
||||
from position in Position, where: position.tracking_session_id in ^session_ids
|
||||
)
|
||||
|
||||
Repo.update_all(
|
||||
from(session in TrackingSession, where: session.id in ^session_ids),
|
||||
set: [
|
||||
active: false,
|
||||
ended_at: DateTime.utc_now(:second),
|
||||
updated_at: DateTime.utc_now(:second)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
%{
|
||||
request_ids: assignments |> Enum.map(&elem(&1, 1)) |> Enum.uniq(),
|
||||
stopped:
|
||||
Enum.map(active_sessions, fn {_id, assignment_id, user_id} -> {assignment_id, user_id} end)
|
||||
}
|
||||
end
|
||||
|
||||
defp clear_user_tracking(user_id) do
|
||||
active_sessions =
|
||||
TrackingSession
|
||||
|> where([session], session.active and session.assignment_id in subquery(assignment_ids))
|
||||
|> select([session], session.id)
|
||||
|> join(:inner, [session], assignment in Assignment,
|
||||
on: assignment.id == session.assignment_id
|
||||
)
|
||||
|> join(:inner, [_session, assignment], request in HelpRequest,
|
||||
on: request.id == assignment.request_id
|
||||
)
|
||||
|> where([session], session.active and session.user_id == ^user_id)
|
||||
|> select([session, assignment, request], {session.id, assignment.id, request.id})
|
||||
|> Repo.all()
|
||||
|
||||
Repo.delete_all(
|
||||
from position in Position, where: position.tracking_session_id in subquery(session_ids)
|
||||
)
|
||||
session_ids = Enum.map(active_sessions, &elem(&1, 0))
|
||||
|
||||
Repo.update_all(
|
||||
from(session in TrackingSession, where: session.id in subquery(session_ids)),
|
||||
set: [active: false, ended_at: DateTime.utc_now(:second)]
|
||||
)
|
||||
if session_ids != [] do
|
||||
now = DateTime.utc_now(:second)
|
||||
|
||||
:ok
|
||||
Repo.delete_all(
|
||||
from position in Position, where: position.tracking_session_id in ^session_ids
|
||||
)
|
||||
|
||||
Repo.update_all(
|
||||
from(session in TrackingSession, where: session.id in ^session_ids),
|
||||
set: [active: false, ended_at: now, updated_at: now]
|
||||
)
|
||||
end
|
||||
|
||||
%{
|
||||
stopped:
|
||||
Enum.map(active_sessions, fn {_session_id, assignment_id, _request_id} ->
|
||||
{assignment_id, user_id}
|
||||
end),
|
||||
request_ids: active_sessions |> Enum.map(&elem(&1, 2)) |> Enum.uniq()
|
||||
}
|
||||
end
|
||||
|
||||
defp stringify_keys(attrs) do
|
||||
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
||||
end
|
||||
|
||||
defp cast_id(value) do
|
||||
case Ecto.UUID.cast(value) do
|
||||
{:ok, id} -> {:ok, id}
|
||||
:error -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ defmodule WhoNeedHelp.Trust.AbuseSignal do
|
|||
|> unique_constraint([:kind, :subject_id, :assignment_id],
|
||||
name: :abuse_signals_one_kind_per_assignment
|
||||
)
|
||||
|> unique_constraint(:subject_id, name: :abuse_signals_one_open_velocity_per_subject)
|
||||
end
|
||||
|
||||
def moderation_changeset(signal, attrs) do
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
use Oban.Worker, queue: :maintenance, unique: [period: 55]
|
||||
|
||||
import Ecto.Query
|
||||
alias WhoNeedHelp.Help
|
||||
alias WhoNeedHelp.Help.HelpRequest
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Tracking
|
||||
|
|
@ -57,9 +58,15 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
end)
|
||||
|
||||
case result do
|
||||
{:ok, :empty} -> {:ok, expired}
|
||||
{:ok, %HelpRequest{}} -> expire_available(now, expired + 1)
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, :empty} ->
|
||||
{:ok, expired}
|
||||
|
||||
{:ok, %HelpRequest{} = request} ->
|
||||
:ok = Help.notify_request_updated(request.id)
|
||||
expire_available(now, expired + 1)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,34 +18,6 @@
|
|||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.setAttribute("data-theme", systemTheme());
|
||||
document.documentElement.setAttribute("data-theme-source", "system");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.setAttribute("data-theme-source", "user");
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
|
||||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (document.documentElement.getAttribute("data-theme-source") === "system") {
|
||||
document.documentElement.setAttribute("data-theme", systemTheme());
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
defmodule WhoNeedHelpWeb.HealthController do
|
||||
use WhoNeedHelpWeb, :controller
|
||||
|
||||
def live(conn, _params), do: json(conn, %{status: "ok", node: to_string(Node.self())})
|
||||
def live(conn, _params), do: json(conn, %{status: "ok"})
|
||||
|
||||
def ready(conn, _params) do
|
||||
case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) do
|
||||
{:ok, _} -> json(conn, %{status: "ready", node: to_string(Node.self())})
|
||||
{:ok, _} -> json(conn, %{status: "ready"})
|
||||
{:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ defmodule WhoNeedHelpWeb.MobileTrackingController do
|
|||
%Scope{user: nil} -> send_resp(conn, :unauthorized, "")
|
||||
{:error, :not_found} -> send_resp(conn, :not_found, "")
|
||||
{:error, :forbidden} -> send_resp(conn, :forbidden, "")
|
||||
{:error, :blocked} -> send_resp(conn, :forbidden, "")
|
||||
{:error, :assignment_inactive} -> send_resp(conn, :conflict, "")
|
||||
{:error, :tracking_not_active} -> send_resp(conn, :conflict, "")
|
||||
{:error, :rate_limited} -> send_resp(conn, :too_many_requests, "")
|
||||
{:error, %Ecto.Changeset{}} -> send_resp(conn, :unprocessable_entity, "")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
|||
|
||||
require Logger
|
||||
|
||||
alias WhoNeedHelp.{Accounts, SocialOAuth, Trust}
|
||||
alias WhoNeedHelp.{Accounts, Repo, SocialOAuth, Trust}
|
||||
|
||||
def request(conn, %{"provider" => provider_param}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
|
@ -42,10 +42,21 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
|||
redirect_uri <- callback_url(provider),
|
||||
{:ok, identity_attrs} <-
|
||||
SocialOAuth.callback(provider, redirect_uri, params, session_params),
|
||||
{:ok, identity} <-
|
||||
Accounts.upsert_verified_social_identity(user, identity_attrs) do
|
||||
audit_social_verification(user.id, identity)
|
||||
|
||||
{:ok, _identity} <-
|
||||
Repo.transact(fn ->
|
||||
with {:ok, identity} <-
|
||||
Accounts.upsert_verified_social_identity(user, identity_attrs),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(
|
||||
user.id,
|
||||
"social_identity.verified",
|
||||
"social_identity",
|
||||
identity.id,
|
||||
%{"provider" => to_string(identity.provider)}
|
||||
) do
|
||||
{:ok, identity}
|
||||
end
|
||||
end) do
|
||||
conn
|
||||
|> delete_session(session_key(provider))
|
||||
|> put_flash(:info, gettext("GitHub profile verified."))
|
||||
|
|
@ -100,22 +111,4 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
|
|||
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
|
||||
defp error_name(%{__struct__: module}), do: inspect(module)
|
||||
defp error_name(_error), do: "unknown_error"
|
||||
|
||||
defp audit_social_verification(user_id, identity) do
|
||||
case Trust.audit(
|
||||
user_id,
|
||||
"social_identity.verified",
|
||||
"social_identity",
|
||||
identity.id,
|
||||
%{"provider" => to_string(identity.provider)}
|
||||
) do
|
||||
{:ok, _event} ->
|
||||
:ok
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Verified social identity #{identity.id}, but audit insertion failed: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,17 +35,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|
||||
# email + password login
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, gettext("Welcome back!"))
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
form = Phoenix.Component.to_form(user_params, as: "user")
|
||||
email_scope = email |> String.trim() |> String.downcase()
|
||||
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, gettext("Invalid email or password"))
|
||||
|> render(:new, form: form)
|
||||
case RateLimiter.check(:password_login_email, email_scope) do
|
||||
{:ok, _limit} ->
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, gettext("Welcome back!"))
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
invalid_password_response(conn, user_params)
|
||||
end
|
||||
|
||||
{:error, :rate_limited} ->
|
||||
conn
|
||||
|> put_status(:too_many_requests)
|
||||
|> put_flash(:error, gettext("Too many sign-in attempts in the configured time window."))
|
||||
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -55,11 +61,15 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|
||||
case RateLimiter.check(:magic_link_email, email_scope) do
|
||||
{:ok, _limit} ->
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
case Accounts.get_user_by_email(email) do
|
||||
%Accounts.User{moderation_status: status} = user when status != :suspended ->
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
_missing_or_suspended ->
|
||||
:ok
|
||||
end
|
||||
|
||||
info =
|
||||
|
|
@ -102,4 +112,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|
|||
|> put_flash(:info, gettext("Logged out successfully."))
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
|
||||
defp invalid_password_response(conn, user_params) do
|
||||
# Keep the response identical for unknown accounts and incorrect passwords.
|
||||
conn
|
||||
|> put_flash(:error, gettext("Invalid email or password"))
|
||||
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
|
|||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.update_user_password(user, user_params) do
|
||||
{:ok, {user, _}} ->
|
||||
{:ok, {user, expired_tokens}} ->
|
||||
:ok = UserAuth.disconnect_sessions(expired_tokens)
|
||||
{:ok, _cleanup} = WhoNeedHelp.Tracking.stop_all_sessions(user.id)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, gettext("Password updated successfully."))
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|
|
|
|||
|
|
@ -15,9 +15,35 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({event, _activity}, socket)
|
||||
def handle_info({event, activity}, socket)
|
||||
when event in [:activity_created, :activity_updated] do
|
||||
{:noreply, load(socket)}
|
||||
user_id = socket.assigns.current_scope.user.id
|
||||
|
||||
activities =
|
||||
update_entry(
|
||||
socket.assigns.activities,
|
||||
activity,
|
||||
Activities.visible_open_activity?(
|
||||
socket.assigns.current_scope,
|
||||
activity,
|
||||
socket.assigns.filters
|
||||
),
|
||||
:asc
|
||||
)
|
||||
|
||||
my_activities =
|
||||
update_entry(
|
||||
socket.assigns.my_activities,
|
||||
activity,
|
||||
Activities.member_activity?(activity, user_id),
|
||||
:desc
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:activities, activities)
|
||||
|> assign(:my_activities, my_activities)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -80,6 +106,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||
end
|
||||
|
||||
defp update_entry(entries, activity, visible?, direction) do
|
||||
entries = Enum.reject(entries, &(&1.id == activity.id))
|
||||
entries = if visible?, do: [activity | entries], else: entries
|
||||
Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
|
||||
end
|
||||
|
||||
defp marker(activity) do
|
||||
case Activity.public_coordinates(activity) do
|
||||
nil ->
|
||||
|
|
@ -131,6 +163,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
|||
|
||||
<.form
|
||||
for={@filter_form}
|
||||
id="activity-filters"
|
||||
phx-change="filter"
|
||||
class="mt-5 rounded-2xl bg-base-200 p-4"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -99,8 +99,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not publish the activity: %{reason}", reason: inspect(reason))
|
||||
defp error_message(_reason), do: gettext("Could not publish the activity. Please try again.")
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -262,6 +261,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
type="button"
|
||||
phx-hook="LocationPicker"
|
||||
id="activity-location-picker"
|
||||
data-latitude-target="#activity-latitude"
|
||||
data-longitude-target="#activity-longitude"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
{gettext("Use my current foreground location")}
|
||||
|
|
@ -286,14 +287,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<input type="checkbox" required class="checkbox checkbox-info mt-0.5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point."
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div class="rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<.input
|
||||
field={@form[:safety_confirmed]}
|
||||
type="checkbox"
|
||||
label={
|
||||
gettext(
|
||||
"I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point."
|
||||
)
|
||||
}
|
||||
required
|
||||
class="checkbox checkbox-info mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
class="btn btn-info btn-lg w-full"
|
||||
phx-disable-with={gettext("Publishing…")}
|
||||
|
|
|
|||
|
|
@ -307,14 +307,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
|||
do: gettext("This activity state has already changed.")
|
||||
|
||||
defp error_message(:not_open), do: gettext("This activity is no longer open.")
|
||||
defp error_message(:not_found), do: gettext("This activity is no longer available.")
|
||||
|
||||
defp error_message(:organizer_already_joined),
|
||||
do: gettext("The organizer is already attending.")
|
||||
|
||||
defp error_message(:organizer_cannot_leave),
|
||||
do: gettext("The organizer cannot leave the activity.")
|
||||
|
||||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted message.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
defp error_message(_reason), do: gettext("Could not complete the action. Please try again.")
|
||||
|
||||
defp status_label(:open), do: gettext("Open")
|
||||
defp status_label(:completed), do: gettext("Completed")
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
|||
do: gettext("Too many actions in the configured time window.")
|
||||
|
||||
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
|
||||
defp action_error(:not_found), do: gettext("This proposal is no longer available.")
|
||||
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
|
||||
|
||||
defp action_error(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
|
||||
|
||||
defp load(socket) do
|
||||
proposals = Catalog.paginate_proposals()
|
||||
|
|
|
|||
|
|
@ -232,10 +232,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
|||
defp error_message(:last_admin),
|
||||
do: gettext("The last administrator cannot demote themselves.")
|
||||
|
||||
defp error_message(:not_found), do: gettext("The selected record is no longer available.")
|
||||
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not complete moderation: %{reason}", reason: inspect(reason))
|
||||
defp error_message(_reason), do: gettext("Could not complete moderation. Please try again.")
|
||||
|
||||
defp scoped_form(data, as, scope) do
|
||||
to_form(data, as: as, id: "#{as}-#{scope}")
|
||||
|
|
|
|||
|
|
@ -79,13 +79,9 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
|||
|> assign(:blocks_cursor, blocks.next_cursor)
|
||||
|> put_flash(:info, gettext("User unblocked."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, _reason} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not unblock: %{reason}", reason: inspect(reason))
|
||||
)}
|
||||
put_flash(socket, :error, gettext("Could not unblock this user. Please try again."))}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({event, _request}, socket) when event in [:request_created, :request_updated] do
|
||||
{:noreply, load(socket)}
|
||||
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
requests =
|
||||
update_entry(
|
||||
socket.assigns.requests,
|
||||
request,
|
||||
Help.visible_open_request?(
|
||||
socket.assigns.current_scope,
|
||||
request,
|
||||
socket.assigns.filters
|
||||
),
|
||||
:open
|
||||
)
|
||||
|
||||
my_requests =
|
||||
update_entry(
|
||||
socket.assigns.my_requests,
|
||||
request,
|
||||
request.requester_id == user.id,
|
||||
:mine
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:requests, requests)
|
||||
|> assign(:my_requests, my_requests)
|
||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
||||
|> maybe_refresh_reputation(request, user.id)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -77,6 +106,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||
end
|
||||
|
||||
defp update_entry(entries, request, visible?, order) do
|
||||
entries = Enum.reject(entries, &(&1.id == request.id))
|
||||
|
||||
entries =
|
||||
if visible?,
|
||||
do: [request | entries],
|
||||
else: entries
|
||||
|
||||
case order do
|
||||
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
||||
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_refresh_reputation(socket, request, user_id) do
|
||||
assignment = request.assignment
|
||||
|
||||
if assignment && assignment.status == :completed &&
|
||||
user_id in [request.requester_id, assignment.helper_id] do
|
||||
assign(socket, :reputation, Trust.reputation(user_id))
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp marker(request) do
|
||||
case Help.HelpRequest.public_coordinates(request) do
|
||||
nil ->
|
||||
|
|
@ -141,6 +195,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
|||
|
||||
<.form
|
||||
for={@filter_form}
|
||||
id="request-filters"
|
||||
phx-change="filter"
|
||||
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -138,8 +138,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
defp error_message(:rate_limited),
|
||||
do: gettext("Too many requests in the configured time window.")
|
||||
|
||||
defp error_message(reason),
|
||||
do: gettext("Could not publish the request: %{reason}", reason: inspect(reason))
|
||||
defp error_message(_reason), do: gettext("Could not publish the request. Please try again.")
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -320,6 +319,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
type="button"
|
||||
phx-hook="LocationPicker"
|
||||
id="location-picker"
|
||||
data-latitude-target="#request-latitude"
|
||||
data-longitude-target="#request-longitude"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
{gettext("Use my current foreground location")}
|
||||
|
|
@ -344,10 +345,15 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" />
|
||||
<span>{safety_confirmation(@selected_category)}</span>
|
||||
</label>
|
||||
<div class="rounded-2xl border border-base-300 p-4 text-sm">
|
||||
<.input
|
||||
field={@form[:safety_confirmed]}
|
||||
type="checkbox"
|
||||
label={safety_confirmation(@selected_category)}
|
||||
required
|
||||
class="checkbox checkbox-success mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
phx-disable-with={gettext("Publishing…")}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
case Help.get_request(socket.assigns.current_scope, request.id) do
|
||||
{:ok, request} ->
|
||||
socket = maybe_subscribe_assignment(socket, request)
|
||||
{:noreply, load(socket, request, socket.assigns.tracking_active)}
|
||||
{:noreply, sync_tracking_after_request_update(socket, request)}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply,
|
||||
|
|
@ -464,6 +464,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp sync_tracking_after_request_update(socket, request) do
|
||||
tracking_was_active = socket.assigns.tracking_active
|
||||
|
||||
tracking_active =
|
||||
(tracking_was_active and request.assignment) &&
|
||||
request.assignment.status in [:accepted, :in_progress]
|
||||
|
||||
socket = load(socket, request, tracking_active)
|
||||
|
||||
if tracking_was_active and not tracking_active do
|
||||
socket
|
||||
|> assign(:tracking_session_id, nil)
|
||||
|> maybe_untrack_browser_presence()
|
||||
|> maybe_stop_native_tracking()
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_positions(socket, positions) do
|
||||
markers =
|
||||
request_markers(socket.assigns.current_scope, socket.assigns.request) ++
|
||||
|
|
@ -569,11 +588,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
do: gettext("Confirm your account and ensure it is active first.")
|
||||
|
||||
defp message(:blocked), do: gettext("This interaction is blocked.")
|
||||
defp message(:expired), do: gettext("This request has expired.")
|
||||
defp message(:not_found), do: gettext("This request is no longer available.")
|
||||
defp message(:assignment_inactive), do: gettext("This match is no longer active.")
|
||||
defp message(:tracking_not_active), do: gettext("Live location sharing is not active.")
|
||||
defp message(:rate_limited), do: gettext("Too many actions in the configured time window.")
|
||||
defp message(%Ecto.Changeset{}), do: gettext("Please check the entered data.")
|
||||
|
||||
defp message(reason),
|
||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||
defp message(_reason), do: gettext("Could not complete the action. Please try again.")
|
||||
|
||||
defp urgency_label(:now), do: gettext("Now")
|
||||
defp urgency_label("now"), do: gettext("Now")
|
||||
|
|
|
|||
|
|
@ -3,18 +3,16 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
|
||||
import WhoNeedHelpWeb.UserAuth
|
||||
|
||||
@secure_browser_headers %{
|
||||
"content-security-policy" => "base-uri 'self'; frame-ancestors 'self';"
|
||||
}
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers, @secure_browser_headers
|
||||
plug :put_secure_browser_headers
|
||||
plug :put_content_security_policy
|
||||
plug :fetch_current_scope_for_user
|
||||
plug :put_authenticated_cache_policy
|
||||
plug WhoNeedHelpWeb.Locale
|
||||
end
|
||||
|
||||
|
|
@ -26,10 +24,15 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
plug :accepts, ["json"]
|
||||
plug :fetch_session
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers, @secure_browser_headers
|
||||
plug :put_secure_browser_headers
|
||||
plug :put_content_security_policy
|
||||
plug :fetch_current_scope_for_user
|
||||
plug :put_authenticated_cache_policy
|
||||
end
|
||||
|
||||
defp put_content_security_policy(conn, opts),
|
||||
do: WhoNeedHelpWeb.SecurityHeaders.put_content_security_policy(conn, opts)
|
||||
|
||||
scope "/healthz", WhoNeedHelpWeb do
|
||||
pipe_through :api
|
||||
|
||||
|
|
|
|||
56
lib/who_need_help_web/security_headers.ex
Normal file
56
lib/who_need_help_web/security_headers.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule WhoNeedHelpWeb.SecurityHeaders do
|
||||
@moduledoc false
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
def put_content_security_policy(conn, _opts) do
|
||||
tile_origin =
|
||||
:who_need_help
|
||||
|> Application.fetch_env!(:map_tile_url)
|
||||
|> origin()
|
||||
|
||||
websocket_origins =
|
||||
[
|
||||
origin(%URI{scheme: "ws", host: conn.host, port: conn.port}),
|
||||
origin(%URI{scheme: "wss", host: conn.host, port: default_https_port(conn)})
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|
||||
connect_sources = sources(["'self'", tile_origin | websocket_origins])
|
||||
image_sources = sources(["'self'", "data:", "blob:", tile_origin])
|
||||
|
||||
policy =
|
||||
[
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src #{image_sources}",
|
||||
"connect-src #{connect_sources}",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"form-action 'self'"
|
||||
]
|
||||
|> Enum.join("; ")
|
||||
|
||||
put_resp_header(conn, "content-security-policy", policy)
|
||||
end
|
||||
|
||||
defp default_https_port(%Plug.Conn{port: 443}), do: 443
|
||||
defp default_https_port(_conn), do: nil
|
||||
|
||||
defp sources(values),
|
||||
do: values |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.join(" ")
|
||||
|
||||
defp origin(url) when is_binary(url), do: url |> URI.parse() |> origin()
|
||||
|
||||
defp origin(%URI{scheme: scheme, host: host} = uri)
|
||||
when scheme in ["http", "https", "ws", "wss"] and is_binary(host) and host != "" do
|
||||
URI.to_string(%URI{scheme: scheme, host: host, port: uri.port})
|
||||
end
|
||||
|
||||
defp origin(_uri), do: nil
|
||||
end
|
||||
|
|
@ -48,6 +48,15 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
|
||||
case get_in(conn.assigns, [:current_scope, Access.key(:user)]) do
|
||||
%Accounts.User{id: user_id} ->
|
||||
{:ok, _cleanup} = WhoNeedHelp.Tracking.stop_all_sessions(user_id)
|
||||
|
||||
_anonymous ->
|
||||
:ok
|
||||
end
|
||||
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
|
|
@ -66,13 +75,33 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
Will reissue the session token if it is older than the configured age.
|
||||
"""
|
||||
def fetch_current_scope_for_user(conn, _opts) do
|
||||
with {token, conn} <- ensure_user_token(conn),
|
||||
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
||||
else
|
||||
nil -> assign(conn, :current_scope, Scope.for_user(nil))
|
||||
case ensure_user_token(conn) do
|
||||
{token, conn} ->
|
||||
case Accounts.get_user_by_session_token(token) do
|
||||
{user, token_inserted_at} ->
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
||||
|
||||
nil ->
|
||||
conn
|
||||
|> delete_session(:user_token)
|
||||
|> delete_session(:live_socket_id)
|
||||
|> delete_session(:user_remember_me)
|
||||
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|
||||
|> assign(:current_scope, Scope.for_user(nil))
|
||||
end
|
||||
|
||||
nil ->
|
||||
assign(conn, :current_scope, Scope.for_user(nil))
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Prevents authenticated HTML and mobile responses from being stored by browsers or proxies."
|
||||
def put_authenticated_cache_policy(conn, _opts) do
|
||||
case conn.assigns[:current_scope] do
|
||||
%Scope{user: %Accounts.User{}} -> put_resp_header(conn, "cache-control", "no-store")
|
||||
_ -> conn
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -164,7 +193,25 @@ defmodule WhoNeedHelpWeb.UserAuth do
|
|||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
put_session(conn, :user_token, token)
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, live_socket_id(token))
|
||||
end
|
||||
|
||||
def live_socket_id(token) when is_binary(token),
|
||||
do: "users_sessions:#{Base.url_encode64(token, padding: false)}"
|
||||
|
||||
@doc "Disconnects LiveView sockets backed by the supplied expired session tokens."
|
||||
def disconnect_sessions(tokens) when is_list(tokens) do
|
||||
tokens
|
||||
|> Enum.filter(&(&1.context == "session"))
|
||||
|> Enum.each(fn token ->
|
||||
token.token
|
||||
|> live_socket_id()
|
||||
|> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddOpenVelocitySignalUniqueIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create unique_index(:abuse_signals, [:subject_id],
|
||||
where: "kind = 'velocity' AND status = 'open'",
|
||||
name: :abuse_signals_one_open_velocity_per_subject
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
defmodule WhoNeedHelp.Repo.Migrations.AddActiveTrackingSessionsUserIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create index(:tracking_sessions, [:user_id],
|
||||
where: "active",
|
||||
name: :tracking_sessions_active_user_index
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
const CACHE_PREFIX = "who-need-help-static-"
|
||||
const CACHE = `${CACHE_PREFIX}v3`
|
||||
const CACHE = `${CACHE_PREFIX}v4`
|
||||
const OFFLINE_URL = "/offline.html"
|
||||
const SHELL = [
|
||||
OFFLINE_URL,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
assert %User{id: ^id} =
|
||||
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
|
||||
end
|
||||
|
||||
test "does not authenticate a suspended user with a valid password" do
|
||||
user = user_fixture() |> set_password()
|
||||
|
||||
{1, nil} =
|
||||
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
|
||||
set: [moderation_status: :suspended]
|
||||
)
|
||||
|
||||
refute Accounts.get_user_by_email_and_password(user.email, valid_user_password())
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user!/1" do
|
||||
|
|
@ -373,6 +384,21 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
end
|
||||
|
||||
test "rejects a suspended user and consumes the magic link" do
|
||||
user = user_fixture()
|
||||
|
||||
{1, nil} =
|
||||
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
|
||||
set: [moderation_status: :suspended]
|
||||
)
|
||||
|
||||
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
refute Accounts.get_user_by_magic_link_token(encoded_token)
|
||||
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
end
|
||||
|
||||
test "raises when unconfirmed user has password set" do
|
||||
user = unconfirmed_user_fixture()
|
||||
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
|
||||
|
|
@ -393,6 +419,21 @@ defmodule WhoNeedHelp.AccountsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_session_token/1 moderation boundary" do
|
||||
test "never authenticates a suspended user even if a token still exists" do
|
||||
user = user_fixture()
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
|
||||
{1, nil} =
|
||||
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
|
||||
set: [moderation_status: :suspended]
|
||||
)
|
||||
|
||||
refute Accounts.get_user_by_session_token(token)
|
||||
assert Repo.exists?(from(candidate in UserToken, where: candidate.token == ^token))
|
||||
end
|
||||
end
|
||||
|
||||
describe "deliver_login_instructions/2" do
|
||||
setup do
|
||||
%{user: unconfirmed_user_fixture()}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
||||
"capacity" => 2,
|
||||
"category_id" => coffee.id
|
||||
"category_id" => coffee.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
|
||||
{:ok,
|
||||
|
|
@ -87,6 +88,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
Activities.request_to_join(context.outsider_scope, activity.id)
|
||||
end
|
||||
|
||||
test "invalid participant identifiers are rejected", context do
|
||||
assert {:error, :not_found} =
|
||||
Activities.approve_participant(context.organizer_scope, "not-a-uuid")
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.decline_participant(context.organizer_scope, "not-a-uuid")
|
||||
end
|
||||
|
||||
test "unapproved viewers receive no private chat or pending participant data", context do
|
||||
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
||||
|
|
@ -158,6 +167,36 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
|||
Activities.request_to_join(context.participant_scope, activity.id)
|
||||
end
|
||||
|
||||
test "blocking an approved participant revokes exact location and group chat", context do
|
||||
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
|
||||
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
|
||||
{:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
|
||||
|
||||
assert Activities.coordinates_for(context.participant_scope, loaded).exact
|
||||
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Activities.get_activity(context.participant_scope, activity.id)
|
||||
|
||||
assert Activities.coordinates_for(context.participant_scope, loaded).exact == false
|
||||
assert Activities.paginate_messages(context.participant_scope, loaded).entries == []
|
||||
|
||||
assert {:error, :blocked} =
|
||||
Activities.send_message(context.participant_scope, activity.id, %{
|
||||
"body" => "This must not be delivered"
|
||||
})
|
||||
end
|
||||
|
||||
test "activity creation requires server-side safety consent", context do
|
||||
attrs = Map.delete(context.attrs, "safety_confirmed")
|
||||
|
||||
assert {:error, changeset} =
|
||||
Activities.create_activity(context.organizer_scope, attrs)
|
||||
|
||||
assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
|
||||
end
|
||||
|
||||
test "activity discovery uses stable cursor pages", context do
|
||||
activities =
|
||||
for offset <- 2..5 do
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
|
||||
{:ok,
|
||||
|
|
@ -73,6 +74,11 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
}
|
||||
end
|
||||
|
||||
test "invalid proposal identifiers are rejected", context do
|
||||
assert {:error, :not_found} = Catalog.vote(context.requester_scope, "not-a-uuid")
|
||||
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
|
||||
end
|
||||
|
||||
test "chat is durable and only visible to match participants", context do
|
||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
|
|
@ -98,6 +104,15 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
|
||||
end
|
||||
|
||||
test "non-participants cannot reopen matched request details by UUID", context do
|
||||
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
|
||||
assert {:ok, _public_request} = Help.get_request(outsider_scope, request.id)
|
||||
assert {:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
assert {:error, :not_found} = Help.get_request(outsider_scope, request.id)
|
||||
end
|
||||
|
||||
test "request details do not preload the unbounded chat history", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
|
@ -279,6 +294,34 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
|||
assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
|
||||
end
|
||||
|
||||
test "concurrent blind reviews are both revealed", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
{:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
|
||||
{:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)
|
||||
|
||||
{:ok, assignment} =
|
||||
Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))
|
||||
|
||||
submissions = [
|
||||
{context.requester_scope, %{"rating" => 5, "comment" => "Kind"}},
|
||||
{context.helper_scope, %{"rating" => 4, "comment" => "Clear"}}
|
||||
]
|
||||
|
||||
results =
|
||||
submissions
|
||||
|> Task.async_stream(
|
||||
fn {scope, attrs} -> Trust.submit_review(scope, assignment, attrs) end,
|
||||
max_concurrency: 2,
|
||||
ordered: false
|
||||
)
|
||||
|> Enum.map(fn {:ok, result} -> result end)
|
||||
|
||||
assert Enum.all?(results, &match?({:ok, _review}, &1))
|
||||
assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
|
||||
assert [%{rating: 4}] = Trust.visible_reviews(context.requester.id)
|
||||
end
|
||||
|
||||
test "category moderation export excludes proposer identity", context do
|
||||
{:ok, proposal} =
|
||||
Catalog.propose(context.requester_scope, %{
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ defmodule WhoNeedHelp.PushProductTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
|
||||
%{
|
||||
|
|
@ -56,6 +57,52 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
assert [] = Messaging.list_messages(context.requester_scope, assignment)
|
||||
end
|
||||
|
||||
test "invalid block identifiers are rejected without a query cast failure", context do
|
||||
assert {:error, :not_found} = Trust.block(context.requester_scope, "not-a-uuid")
|
||||
assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid")
|
||||
end
|
||||
|
||||
test "forged moderation identifiers return not found without crashing", context do
|
||||
moderator =
|
||||
user_fixture(display_name: "Moderator")
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> Repo.update!()
|
||||
|
||||
admin =
|
||||
user_fixture(display_name: "Administrator")
|
||||
|> Ecto.Changeset.change(role: :admin)
|
||||
|> Repo.update!()
|
||||
|
||||
moderator_scope = user_scope_fixture(moderator)
|
||||
admin_scope = user_scope_fixture(admin)
|
||||
missing_id = Ecto.UUID.generate()
|
||||
|
||||
for id <- ["not-a-uuid", missing_id] do
|
||||
assert {:error, :not_found} = Trust.moderate_report(moderator_scope, id, %{})
|
||||
assert {:error, :not_found} = Trust.report_evidence(moderator_scope, id)
|
||||
assert {:error, :not_found} = Trust.moderate_signal(moderator_scope, id, %{})
|
||||
assert {:error, :not_found} = Trust.hide_request(moderator_scope, id, "review")
|
||||
assert {:error, :not_found} = Trust.restore_request(moderator_scope, id)
|
||||
assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review")
|
||||
assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id)
|
||||
assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{})
|
||||
assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{})
|
||||
assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{})
|
||||
assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review")
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Catalog.merge_proposal(moderator_scope, id, context.category.id, "review")
|
||||
end
|
||||
|
||||
assert {:error, :not_found} =
|
||||
Catalog.merge_proposal(
|
||||
moderator_scope,
|
||||
Ecto.UUID.generate(),
|
||||
"not-a-uuid",
|
||||
"review"
|
||||
)
|
||||
end
|
||||
|
||||
test "tracking derives movement and proximity from browser accuracy envelopes", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
|
@ -195,6 +242,26 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
|
||||
end
|
||||
|
||||
test "help creation rejects missing safety consent and non-help categories", context do
|
||||
without_consent = Map.delete(context.attrs, "safety_confirmed")
|
||||
|
||||
assert {:error, changeset} =
|
||||
Help.create_request(context.requester_scope, without_consent)
|
||||
|
||||
assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
|
||||
|
||||
activity_category =
|
||||
Catalog.list_categories(:activity)
|
||||
|> List.first()
|
||||
|
||||
wrong_category = Map.put(context.attrs, "category_id", activity_category.id)
|
||||
|
||||
assert {:error, changeset} =
|
||||
Help.create_request(context.requester_scope, wrong_category)
|
||||
|
||||
assert "select an active help category" in errors_on(changeset).category_id
|
||||
end
|
||||
|
||||
test "configured rate limits are atomic database counters", context do
|
||||
old = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
|
|
@ -239,6 +306,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
Help.request_coordinates(context.requester_scope, request)
|
||||
end
|
||||
|
||||
test "blocking an accepted helper revokes request, exact location, and tracking", context do
|
||||
attrs = Map.put(context.attrs, "location_visibility", "exact_for_active_match")
|
||||
{:ok, request} = Help.create_request(context.requester_scope, attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
request = Help.get_request!(request.id)
|
||||
|
||||
{:ok, _session} = Tracking.start_session(context.helper_scope, assignment)
|
||||
|
||||
{:ok, position} =
|
||||
Tracking.update_position(context.helper_scope, assignment, %{
|
||||
"latitude" => 50.45,
|
||||
"longitude" => 30.52,
|
||||
"accuracy_meters" => 10.0
|
||||
})
|
||||
|
||||
assert %{exact: true} = Help.request_coordinates(context.helper_scope, request)
|
||||
assert {:ok, _block} = Trust.block(context.requester_scope, context.helper.id)
|
||||
|
||||
assert {:error, :not_found} = Help.get_request(context.helper_scope, request.id)
|
||||
assert %{exact: false} = Help.request_coordinates(context.helper_scope, request)
|
||||
assert Tracking.list_current_positions(context.helper_scope, assignment) == %{}
|
||||
assert {:error, :blocked} = Tracking.start_session(context.helper_scope, assignment)
|
||||
|
||||
assert {:error, :blocked} =
|
||||
Tracking.update_position(context.helper_scope, assignment, %{
|
||||
"latitude" => 50.46,
|
||||
"longitude" => 30.53,
|
||||
"accuracy_meters" => 10.0
|
||||
})
|
||||
|
||||
refute Repo.get(Position, position.id)
|
||||
end
|
||||
|
||||
test "cancelling a matched request ends tracking and deletes raw coordinates", context do
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
|
|
@ -291,6 +391,76 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
)
|
||||
end
|
||||
|
||||
test "restricted moderators lose authorization and the last active admin cannot be suspended",
|
||||
context do
|
||||
admin =
|
||||
user_fixture(display_name: "Administrator")
|
||||
|> Ecto.Changeset.change(role: :admin)
|
||||
|> Repo.update!()
|
||||
|
||||
moderator =
|
||||
user_fixture(display_name: "Moderator")
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> Repo.update!()
|
||||
|
||||
active_moderator =
|
||||
user_fixture(display_name: "Active moderator")
|
||||
|> Ecto.Changeset.change(role: :moderator)
|
||||
|> Repo.update!()
|
||||
|
||||
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
|
||||
|
||||
restricted =
|
||||
moderator
|
||||
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{
|
||||
"moderation_status" => "restricted"
|
||||
})
|
||||
|> Repo.update!()
|
||||
|
||||
refute WhoNeedHelp.Accounts.moderator_authorized?(restricted)
|
||||
|
||||
session_token = WhoNeedHelp.Accounts.generate_user_session_token(context.helper)
|
||||
socket_id = WhoNeedHelpWeb.UserAuth.live_socket_id(session_token)
|
||||
:ok = WhoNeedHelpWeb.Endpoint.subscribe(socket_id)
|
||||
|
||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||
{:ok, tracking_session} = Tracking.start_session(context.helper_scope, assignment)
|
||||
|
||||
{:ok, _position} =
|
||||
Tracking.update_position(context.helper_scope, assignment, %{
|
||||
"latitude" => 50.4501,
|
||||
"longitude" => 30.5234,
|
||||
"accuracy_meters" => 5.0
|
||||
})
|
||||
|
||||
:ok = Tracking.subscribe(assignment.id)
|
||||
|
||||
assert {:ok, suspended} =
|
||||
Trust.moderate_user(user_scope_fixture(active_moderator), context.helper.id, %{
|
||||
"moderation_status" => "suspended"
|
||||
})
|
||||
|
||||
assert suspended.moderation_status == :suspended
|
||||
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^socket_id}
|
||||
assert_receive {:tracking_stopped, helper_id}
|
||||
assert helper_id == context.helper.id
|
||||
assert WhoNeedHelp.Accounts.get_user_by_session_token(session_token) == nil
|
||||
refute Repo.get_by(Position, tracking_session_id: tracking_session.id)
|
||||
|
||||
refute Repo.get_by(WhoNeedHelp.Tracking.TrackingSession,
|
||||
id: tracking_session.id,
|
||||
active: true
|
||||
)
|
||||
|
||||
assert {:error, :last_admin} =
|
||||
Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{
|
||||
"moderation_status" => "suspended"
|
||||
})
|
||||
|
||||
assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active
|
||||
end
|
||||
|
||||
test "the first administrator bootstrap is one-time and audited", context do
|
||||
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
|
||||
assert admin.id == context.helper.id
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
|
||||
request.id
|
||||
|
|
@ -40,8 +41,17 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
|||
set: [expires_at: past]
|
||||
)
|
||||
|
||||
:ok = Help.subscribe()
|
||||
assert {:ok, %{expired: 12}} = ExpireRequests.perform(%Oban.Job{})
|
||||
|
||||
expired_ids =
|
||||
for _ <- 1..12 do
|
||||
assert_receive {:request_updated, %HelpRequest{status: :expired} = request}
|
||||
request.id
|
||||
end
|
||||
|
||||
assert MapSet.new(expired_ids) == MapSet.new(request_ids)
|
||||
|
||||
assert Repo.aggregate(
|
||||
from(
|
||||
request in HelpRequest,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,23 @@ defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
|
|||
refute Repo.get_by(TrackingSession, id: session.id, active: true)
|
||||
end
|
||||
|
||||
test "logging out removes the active native position from the server", context do
|
||||
{:ok, session} = Tracking.start_session(context.helper_scope, context.assignment)
|
||||
|
||||
{:ok, _position} =
|
||||
Tracking.update_position(context.helper_scope, context.assignment, %{
|
||||
"latitude" => 50.4501,
|
||||
"longitude" => 30.5234,
|
||||
"accuracy_meters" => 7.5
|
||||
})
|
||||
|
||||
conn = delete(context.logged_in_conn, ~p"/users/log-out")
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute Repo.get_by(Position, tracking_session_id: session.id)
|
||||
refute Repo.get_by(TrackingSession, id: session.id, active: true)
|
||||
end
|
||||
|
||||
test "an authenticated non-participant cannot update tracking", context do
|
||||
outsider = user_fixture(display_name: "Outsider")
|
||||
{conn, csrf_token} = csrf_connection(log_in_user(build_conn(), outsider), ~p"/requests")
|
||||
|
|
@ -116,7 +133,8 @@ defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
conn = get(conn, ~p"/")
|
||||
html = html_response(conn, 200)
|
||||
document = LazyHTML.from_document(html)
|
||||
[content_security_policy] = get_resp_header(conn, "content-security-policy")
|
||||
|
||||
assert html =~ "Help can be closer than you think."
|
||||
assert content_security_policy =~ "default-src 'self'"
|
||||
assert content_security_policy =~ "script-src 'self'"
|
||||
refute content_security_policy =~ "script-src 'self' 'unsafe-inline'"
|
||||
assert content_security_policy =~ "frame-ancestors 'none'"
|
||||
assert content_security_policy =~ "https://tile.openstreetmap.org"
|
||||
|
||||
assert html =~
|
||||
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
|
||||
|
|
@ -33,6 +39,19 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
|
||||
end
|
||||
|
||||
test "health endpoints expose status without internal node names", %{conn: conn} do
|
||||
assert %{"status" => "ok"} =
|
||||
conn
|
||||
|> get(~p"/healthz/live")
|
||||
|> json_response(200)
|
||||
|
||||
assert %{"status" => "ready"} =
|
||||
conn
|
||||
|> recycle()
|
||||
|> get(~p"/healthz/ready")
|
||||
|> json_response(200)
|
||||
end
|
||||
|
||||
test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
|
||||
conn = get(conn, ~p"/safety?locale=uk")
|
||||
assert html_response(conn, 200) =~ "Правила безпеки"
|
||||
|
|
@ -76,7 +95,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
body = response(conn, 200)
|
||||
|
||||
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
|
||||
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v3`)
|
||||
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v4`)
|
||||
assert body =~ ~s(const OFFLINE_URL = "/offline.html")
|
||||
assert body =~ ~s(event.request.mode === "navigate")
|
||||
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
||||
use WhoNeedHelpWeb.ConnCase, async: true
|
||||
use WhoNeedHelpWeb.ConnCase, async: false
|
||||
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
alias WhoNeedHelp.Accounts
|
||||
|
|
@ -82,7 +82,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
"user" => %{"email" => user.email, "password" => valid_user_password()}
|
||||
})
|
||||
|
||||
assert get_session(conn, :user_token)
|
||||
token = get_session(conn, :user_token)
|
||||
assert token
|
||||
|
||||
assert get_session(conn, :live_socket_id) ==
|
||||
WhoNeedHelpWeb.UserAuth.live_socket_id(token)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
|
|
@ -136,6 +141,51 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
assert response =~ "Log in"
|
||||
assert response =~ "Invalid email or password"
|
||||
end
|
||||
|
||||
test "does not log in a suspended user with a valid password", %{conn: conn, user: user} do
|
||||
user = set_password(user)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ecto.Changeset.change(moderation_status: :suspended)
|
||||
|> WhoNeedHelp.Repo.update()
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in?mode=password", %{
|
||||
"user" => %{"email" => user.email, "password" => valid_user_password()}
|
||||
})
|
||||
|
||||
refute get_session(conn, :user_token)
|
||||
assert html_response(conn, 200) =~ "Invalid email or password"
|
||||
end
|
||||
|
||||
test "applies the configured password-login throttle", %{conn: conn, user: user} do
|
||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||
|
||||
Application.put_env(:who_need_help, :rate_limit_policies, %{
|
||||
"password_login_email" => %{"limit" => 1, "window_seconds" => 60}
|
||||
})
|
||||
|
||||
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
|
||||
|
||||
params = %{"user" => %{"email" => user.email, "password" => "invalid_password"}}
|
||||
|
||||
assert conn |> post(~p"/users/log-in", params) |> html_response(200) =~
|
||||
"Invalid email or password"
|
||||
|
||||
throttled = post(build_conn(), ~p"/users/log-in", params)
|
||||
assert throttled.status == 429
|
||||
assert html_response(throttled, 429) =~ "Too many sign-in attempts"
|
||||
end
|
||||
|
||||
test "authenticated responses are not stored", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> get(~p"/users/settings")
|
||||
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - magic link" do
|
||||
|
|
@ -149,6 +199,21 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
|
|||
assert WhoNeedHelp.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
|
||||
end
|
||||
|
||||
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ecto.Changeset.change(moderation_status: :suspended)
|
||||
|> WhoNeedHelp.Repo.update()
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"email" => user.email}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
refute WhoNeedHelp.Repo.get_by(Accounts.UserToken, user_id: user.id)
|
||||
end
|
||||
|
||||
test "logs the user in", %{conn: conn, user: user} do
|
||||
{token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
|
||||
describe "PUT /users/settings (change password form)" do
|
||||
test "updates the user password and resets tokens", %{conn: conn, user: user} do
|
||||
second_token = Accounts.generate_user_session_token(user)
|
||||
second_socket_id = WhoNeedHelpWeb.UserAuth.live_socket_id(second_token)
|
||||
:ok = WhoNeedHelpWeb.Endpoint.subscribe(second_socket_id)
|
||||
|
||||
new_password_conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_password",
|
||||
|
|
@ -47,6 +51,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
|||
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
|
||||
"Password updated successfully"
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^second_socket_id}
|
||||
refute Accounts.get_user_by_session_token(second_token)
|
||||
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,72 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
assert html =~ "Створити термінову заявку"
|
||||
end
|
||||
|
||||
test "invalid request and activity identifiers redirect without crashing", %{conn: conn} do
|
||||
assert {:error, {:live_redirect, %{to: "/requests"}}} =
|
||||
live(conn, "/requests/not-a-uuid")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/activities"}}} =
|
||||
live(conn, "/activities/not-a-uuid")
|
||||
end
|
||||
|
||||
test "request index applies PubSub updates without a full page reload" do
|
||||
category = Catalog.seed_defaults()
|
||||
requester = user_fixture(display_name: "Realtime requester")
|
||||
helper = user_fixture(display_name: "Realtime helper")
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(helper) |> live(~p"/requests")
|
||||
|
||||
{:ok, request} =
|
||||
Help.create_request(Accounts.Scope.for_user(requester), %{
|
||||
"title" => "Realtime medicine pickup",
|
||||
"description" => "The legal medicine is reserved and ready for collection.",
|
||||
"pickup_instructions" => "Use the order number from private coordination.",
|
||||
"location_label" => "Central district",
|
||||
"latitude" => "50.4501",
|
||||
"longitude" => "30.5234",
|
||||
"urgency" => "now",
|
||||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
|
||||
assert render(view) =~ "Realtime medicine pickup"
|
||||
|
||||
{:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
|
||||
refute render(view) =~ "Realtime medicine pickup"
|
||||
end
|
||||
|
||||
test "activity index applies PubSub updates without a full page reload" do
|
||||
Catalog.seed_defaults()
|
||||
organizer = user_fixture(display_name: "Realtime organizer")
|
||||
viewer = user_fixture(display_name: "Realtime activity viewer")
|
||||
|
||||
category =
|
||||
Catalog.list_categories(:activity)
|
||||
|> Enum.find(&(&1.slug == "coffee-meetup"))
|
||||
|
||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
|
||||
|
||||
{:ok, _activity} =
|
||||
Activities.create_activity(Accounts.Scope.for_user(organizer), %{
|
||||
"title" => "Realtime coffee meetup",
|
||||
"description" => "Meet at a public café for a short conversation.",
|
||||
"structured_data" => %{"setting" => "cafe"},
|
||||
"location_label" => "Central square",
|
||||
"latitude" => "50.4501",
|
||||
"longitude" => "30.5234",
|
||||
"location_visibility" => "approximate_public",
|
||||
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
||||
"capacity" => 3,
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
})
|
||||
|
||||
assert render(view) =~ "Realtime coffee meetup"
|
||||
end
|
||||
|
||||
test "new request form is driven by category structured fields", %{conn: conn} do
|
||||
category = Catalog.seed_defaults()
|
||||
{:ok, view, html} = live(conn, ~p"/requests/new")
|
||||
|
|
@ -152,7 +218,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
|
||||
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
|
||||
"capacity" => 3,
|
||||
"category_id" => coffee.id
|
||||
"category_id" => coffee.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
|
||||
{:ok, activity} = Activities.create_activity(organizer_scope, attrs)
|
||||
|
|
@ -250,6 +317,39 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
stop_live_view(helper_view)
|
||||
end
|
||||
|
||||
test "terminal request updates stop Android foreground tracking", _context do
|
||||
category = Catalog.seed_defaults()
|
||||
requester = user_fixture(display_name: "Tracking requester")
|
||||
helper = user_fixture(display_name: "Tracking helper")
|
||||
|
||||
{:ok, request} =
|
||||
Help.create_request(user_scope_fixture(requester), request_attrs(category))
|
||||
|
||||
{:ok, assignment} = Help.accept_request(user_scope_fixture(helper), request.id)
|
||||
|
||||
helper_conn =
|
||||
build_conn()
|
||||
|> log_in_user(helper)
|
||||
|> put_connect_params(%{"client_type" => "android"})
|
||||
|
||||
{:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}")
|
||||
|
||||
helper_view
|
||||
|> element("button[phx-click='start-tracking']")
|
||||
|> render_click()
|
||||
|
||||
assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id})
|
||||
assert assignment_id == assignment.id
|
||||
|
||||
assert {:ok, _cancelled} =
|
||||
Help.cancel_request(user_scope_fixture(requester), request.id)
|
||||
|
||||
assert_push_event(helper_view, "native-tracking-stop", %{})
|
||||
refute has_element?(helper_view, "button[phx-click='stop-tracking']")
|
||||
|
||||
stop_live_view(helper_view)
|
||||
end
|
||||
|
||||
test "regular users cannot enter moderation", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
|
||||
end
|
||||
|
|
@ -326,7 +426,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
|||
"location_visibility" => "approximate_public",
|
||||
"structured_data" => %{"pickup_status" => "reserved"},
|
||||
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
|
||||
"category_id" => category.id
|
||||
"category_id" => category.id,
|
||||
"safety_confirmed" => true
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
|
|||
|
||||
alias WhoNeedHelp.Accounts
|
||||
alias WhoNeedHelp.Accounts.Scope
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelpWeb.UserAuth
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
import WhoNeedHelp.AccountsFixtures
|
||||
|
||||
@remember_me_cookie "_who_need_help_web_user_remember_me"
|
||||
|
|
@ -158,6 +160,37 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
|
|||
refute conn.assigns.current_scope
|
||||
end
|
||||
|
||||
test "clears a suspended user's surviving session and remember cookie", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
||||
token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
|
||||
{1, nil} =
|
||||
Repo.update_all(
|
||||
from(candidate in WhoNeedHelp.Accounts.User, where: candidate.id == ^user.id),
|
||||
set: [moderation_status: :suspended]
|
||||
)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, UserAuth.live_socket_id(token))
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
refute conn.assigns.current_scope
|
||||
refute get_session(conn, :user_token)
|
||||
refute get_session(conn, :live_socket_id)
|
||||
refute get_session(conn, :user_remember_me)
|
||||
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
|
||||
end
|
||||
|
||||
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user