fix: harden privacy auth and realtime workflows

This commit is contained in:
SimpleTest 2026-07-20 13:30:12 +03:00
parent 031b9191a4
commit 05d06e059a
50 changed files with 1893 additions and 587 deletions

View File

@ -26,6 +26,35 @@ import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
import {Hooks} from "./hooks" 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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,

View File

@ -76,9 +76,13 @@ export const Hooks = {
const points = JSON.parse(this.el.dataset.markers || "[]") const points = JSON.parse(this.el.dataset.markers || "[]")
points.forEach(point => { points.forEach(point => {
const popup = new maplibregl.Popup({offset: 18}).setHTML( const popupContent = document.createElement("div")
`<strong>${escapeHtml(point.title)}</strong><br>${escapeHtml(point.location)}` 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"}) const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
.setLngLat([point.longitude, point.latitude]) .setLngLat([point.longitude, point.latitude])
.setPopup(popup) .setPopup(popup)
@ -99,10 +103,21 @@ export const Hooks = {
LocationPicker: { LocationPicker: {
mounted() { mounted() {
this.el.addEventListener("click", () => { 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( navigator.geolocation.getCurrentPosition(
position => { position => {
document.querySelector("#request-latitude").value = position.coords.latitude latitudeInput.value = position.coords.latitude
document.querySelector("#request-longitude").value = position.coords.longitude 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.textContent = "Location added"
this.el.classList.add("btn-success") 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() { function supportsMapCanvas() {
const canvas = document.createElement("canvas") const canvas = document.createElement("canvas")
const attributes = { const attributes = {

View File

@ -123,7 +123,8 @@ defmodule Mix.Tasks.Wnh.LoadFixtures do
"urgency" => "now", "urgency" => "now",
"location_visibility" => "exact_for_active_match", "location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 86_400, :second), "expires_at" => DateTime.add(now, 86_400, :second),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
}) })
|> unwrap!("create request") |> unwrap!("create request")

View File

@ -108,7 +108,8 @@ defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
"urgency" => "now", "urgency" => "now",
"location_visibility" => "exact_for_active_match", "location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 3 * 60 * 60, :second), "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) |> Ecto.Changeset.put_change(:status, :matched)
|> Repo.insert!() |> Repo.insert!()

View File

@ -79,7 +79,9 @@ defmodule WhoNeedHelp.Accounts do
def get_user_by_email_and_password(email, password) def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email) 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 end
@doc """ @doc """
@ -124,7 +126,10 @@ defmodule WhoNeedHelp.Accounts do
when role in [:moderator, :admin], when role in [:moderator, :admin],
do: do:
Repo.exists?( 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 def moderator_authorized?(_user), do: false
@ -133,7 +138,11 @@ defmodule WhoNeedHelp.Accounts do
def admin?(_user), do: false def admin?(_user), do: false
def admin_authorized?(%User{id: id, role: :admin}), 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 def admin_authorized?(_user), do: false
@ -158,47 +167,87 @@ defmodule WhoNeedHelp.Accounts do
end end
def moderate_user(%User{} = moderator, user_id, attrs) do 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 -> 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 if user do
{:error, :cannot_restrict_self} requested_status = attrs["moderation_status"] || attrs[:moderation_status]
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
{: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 end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
def change_user_role(%User{} = admin, user_id, attrs) do 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 -> Repo.transact(fn ->
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!() active_admins = lock_active_admins()
requested_role = attrs["role"] || attrs[:role] user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
admin_count = if user do
Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count) requested_role = attrs["role"] || attrs[:role]
if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do if user.role == :admin and user.moderation_status == :active and
{:error, :last_admin} 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 else
user |> User.role_changeset(attrs) |> Repo.update() {:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
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 ## User registration
@doc """ @doc """
@ -282,9 +331,12 @@ defmodule WhoNeedHelp.Accounts do
end end
def delete_social_identity(%User{id: user_id}, identity_id) do def delete_social_identity(%User{id: user_id}, identity_id) do
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do with {:ok, identity_id} <- Ecto.UUID.cast(identity_id),
nil -> {:error, :not_found} %SocialIdentity{} = identity <-
identity -> Repo.delete(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
end end
@ -401,7 +453,8 @@ defmodule WhoNeedHelp.Accounts do
""" """
def get_user_by_magic_link_token(token) do def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token), 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 user
else else
_ -> nil _ -> nil
@ -430,6 +483,10 @@ defmodule WhoNeedHelp.Accounts do
{:ok, query} = UserToken.verify_magic_link_token_query(token) {:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do 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 # 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) -> {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """ raise """
@ -502,6 +559,13 @@ defmodule WhoNeedHelp.Accounts do
) )
end 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 defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do with {:ok, user} <- Repo.update(changeset) do

View File

@ -108,7 +108,8 @@ defmodule WhoNeedHelp.Accounts.User do
defp validate_url(changeset, field) do defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value -> validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do 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 != "" ->
[] []
_ -> _ ->

View File

@ -62,6 +62,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do
from token in by_token_and_context_query(token, "session"), from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user), join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"), 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} select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query} {:ok, query}

View File

@ -71,6 +71,25 @@ defmodule WhoNeedHelp.Activities do
paginate_my_activities(%Scope{user: user}).entries paginate_my_activities(%Scope{user: user}).entries
end 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 def paginate_my_activities(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
@ -124,35 +143,39 @@ defmodule WhoNeedHelp.Activities do
end end
def get_activity(%Scope{user: user}, id) do def get_activity(%Scope{user: user}, id) do
case get_loaded_activity(id) do with {:ok, id} <- Ecto.UUID.cast(id),
nil -> %Activity{} = activity <- get_loaded_activity(id) do
{:error, :not_found} cond do
activity.creator_id == user.id ->
{:ok, activity}
activity -> not is_nil(activity.hidden_at) ->
cond do {:error, :not_found}
activity.creator_id == user.id ->
{:ok, activity}
approved_participant?(activity, user.id) -> Trust.blocked_between?(activity.creator_id, user.id) ->
{:ok, activity} {:error, :not_found}
not is_nil(activity.hidden_at) -> approved_participant?(activity, user.id) ->
{:error, :not_found} {:ok, activity}
Trust.blocked_between?(activity.creator_id, user.id) -> activity.status == :open ->
{:error, :not_found} {:ok, activity_for_viewer(activity, user.id)}
activity.status == :open -> true ->
{:ok, activity_for_viewer(activity, user.id)} {:error, :not_found}
end
true -> else
{:error, :not_found} _invalid_or_missing -> {:error, :not_found}
end
end end
end end
def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do 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) limit = Pagination.limit(options, 50)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query() public_user = Accounts.public_user_query()
@ -221,6 +244,8 @@ defmodule WhoNeedHelp.Activities do
activity = locked_activity(activity_id) activity = locked_activity(activity_id)
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do cond do
activity.creator_id == user.id -> activity.creator_id == user.id ->
{:error, :organizer_already_joined} {:error, :organizer_already_joined}
@ -248,52 +273,57 @@ defmodule WhoNeedHelp.Activities do
def approve_participant(%Scope{user: organizer}, participant_id) do def approve_participant(%Scope{user: organizer}, participant_id) do
result = result =
Repo.transact(fn -> with {:ok, participant_id} <- cast_id(participant_id) do
participant = locked_participant(participant_id) Repo.transact(fn ->
activity = locked_activity(participant.activity_id) participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
cond do cond do
activity.creator_id != organizer.id -> activity.creator_id != organizer.id ->
{:error, :forbidden} {:error, :forbidden}
participant.status != :requested -> participant.status != :requested ->
{:error, :invalid_transition} {:error, :invalid_transition}
activity.status != :open -> activity.status != :open ->
{:error, :not_open} {:error, :not_open}
Trust.blocked_between?(activity.creator_id, participant.user_id) -> Trust.blocked_between?(activity.creator_id, participant.user_id) ->
{:error, :blocked} {:error, :blocked}
approved_count(activity.id) >= activity.capacity -> approved_count(activity.id) >= activity.capacity ->
{:error, :capacity_reached} {:error, :capacity_reached}
true -> true ->
with {:ok, participant} <- with {:ok, participant} <-
participant participant
|> Participant.changeset(%{ |> Participant.changeset(%{
status: :approved, status: :approved,
reviewed_at: DateTime.utc_now(:second) reviewed_at: DateTime.utc_now(:second)
}) })
|> Repo.update(), |> Repo.update(),
{:ok, _audit} <- {:ok, _audit} <-
Trust.audit( Trust.audit(
organizer.id, organizer.id,
"activity.participant_approved", "activity.participant_approved",
"activity_participant", "activity_participant",
participant.id, participant.id,
%{"activity_id" => activity.id, "user_id" => participant.user_id} %{"activity_id" => activity.id, "user_id" => participant.user_id}
) do ) do
{:ok, participant} {:ok, participant}
end end
end end
end) end)
end
after_participant_change(result, participant_activity_id(result), :participant_approved) after_participant_change(result, participant_activity_id(result), :participant_approved)
end end
def decline_participant(%Scope{user: organizer}, participant_id) do 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 end
def leave_activity(%Scope{user: user}, activity_id) do def leave_activity(%Scope{user: user}, activity_id) do
@ -345,40 +375,54 @@ defmodule WhoNeedHelp.Activities do
end end
def send_message(%Scope{user: user} = scope, activity_id, attrs) do def send_message(%Scope{user: user} = scope, activity_id, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message), result =
{:ok, activity} <- get_activity(scope, activity_id), with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
true <- activity.status == :open, Repo.transact(fn ->
true <- approved_participant?(activity, user.id) do activity = locked_activity(activity_id)
%Message{} :ok = Trust.lock_user_pair(activity.creator_id, user.id)
|> 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())
Phoenix.PubSub.broadcast( cond do
WhoNeedHelp.PubSub, activity.status != :open or not is_nil(activity.hidden_at) ->
"activity:#{activity.id}", {:error, :not_open}
{:activity_message, message}
)
{:ok, message} Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :blocked}
other -> not approved_participant_id?(activity.id, user.id) ->
other {:error, :forbidden}
true ->
%Message{}
|> Message.changeset(%{
body: attrs["body"] || attrs[:body],
activity_id: activity.id,
sender_id: user.id
})
|> Repo.insert()
end
end)
end end
else
false -> {:error, :forbidden} with {:ok, message} <- result do
other -> other 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
end end
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do 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) Activity.exact_coordinates(activity)
else else
Activity.public_coordinates(activity) Activity.public_coordinates(activity)
@ -396,6 +440,15 @@ defmodule WhoNeedHelp.Activities do
) )
end 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 defp validate_category_and_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id) category_id = Ecto.Changeset.get_field(changeset, :category_id)
@ -644,6 +697,13 @@ defmodule WhoNeedHelp.Activities do
defp maybe_filter_category(query, value), defp maybe_filter_category(query, value),
do: where(query, [activity], activity.category_id == ^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(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
defp broadcast_activity(id, message), defp broadcast_activity(id, message),

View File

@ -25,6 +25,7 @@ defmodule WhoNeedHelp.Activities.Activity do
field :hidden_at, :utc_datetime field :hidden_at, :utc_datetime
field :hidden_reason, :string field :hidden_reason, :string
field :approved_participant_count, :integer, virtual: true, default: 0 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 :creator, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category belongs_to :category, WhoNeedHelp.Catalog.Category
@ -45,7 +46,8 @@ defmodule WhoNeedHelp.Activities.Activity do
:starts_at, :starts_at,
:join_deadline, :join_deadline,
:capacity, :capacity,
:category_id :category_id,
:safety_confirmed
]) ])
|> put_location(attrs) |> put_location(attrs)
|> validate_required([ |> validate_required([
@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Activities.Activity do
|> validate_length(:title, min: 5, max: 120) |> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000) |> validate_length(:description, min: 10, max: 2_000)
|> validate_number(:capacity, greater_than_or_equal_to: 2) |> validate_number(:capacity, greater_than_or_equal_to: 2)
|> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing"
)
|> validate_schedule() |> validate_schedule()
end end

View File

@ -74,25 +74,37 @@ defmodule WhoNeedHelp.Catalog do
end end
def vote(%Scope{user: user} = scope, proposal_id) do def vote(%Scope{user: user} = scope, proposal_id) do
with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote), with {:ok, proposal_id} <- cast_id(proposal_id),
%CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do {:ok, _limit} <- Trust.authorize_action(scope, :category_vote) do
%CategoryVote{} Repo.transact(fn ->
|> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id}) case CategoryProposal
|> Repo.insert() |> where([proposal], proposal.id == ^proposal_id)
else |> lock("FOR UPDATE")
nil -> {:error, :not_found} |> Repo.one() do
%CategoryProposal{} -> {:error, :proposal_closed} nil ->
other -> other {: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
end end
def unvote(%Scope{user: user}, proposal_id) do def unvote(%Scope{user: user}, proposal_id) do
{count, _} = with {:ok, proposal_id} <- cast_id(proposal_id) do
CategoryVote {count, _} =
|> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id) CategoryVote
|> Repo.delete_all() |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
|> Repo.delete_all()
{:ok, count} {:ok, count}
end
end end
def seed_defaults do def seed_defaults do
@ -175,46 +187,53 @@ defmodule WhoNeedHelp.Catalog do
end end
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do 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 -> Repo.transact(fn ->
proposal = locked_proposal(proposal_id) proposal = locked_proposal(proposal_id)
if proposal.status == :open do cond do
category_attrs = is_nil(proposal) ->
category_attrs {:error, :not_found}
|> 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} <- proposal.status == :open ->
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(), category_attrs =
{:ok, proposal} <- category_attrs
proposal |> Map.new(fn {key, value} -> {to_string(key), value} end)
|> CategoryProposal.moderation_changeset(%{ |> normalize_descriptions()
status: :approved, |> Map.put_new("parent_id", proposal.parent_id)
merged_into_id: category.id, |> Map.put_new("mode", to_string(proposal.mode))
reviewed_by_id: moderator.id,
reviewed_at: DateTime.utc_now(:second), with {:ok, category} <-
moderation_note: category_attrs["moderation_note"] %Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
}) {:ok, proposal} <-
|> Repo.update(), proposal
{:ok, _audit} <- |> CategoryProposal.moderation_changeset(%{
Trust.audit( status: :approved,
moderator.id, merged_into_id: category.id,
"category_proposal.approved", reviewed_by_id: moderator.id,
"category_proposal", reviewed_at: DateTime.utc_now(:second),
proposal.id, moderation_note: category_attrs["moderation_note"]
%{"category_id" => category.id} })
) do |> Repo.update(),
{:ok, %{proposal: proposal, category: category}} {:ok, _audit} <-
end Trust.audit(
else moderator.id,
{:error, :proposal_closed} "category_proposal.approved",
"category_proposal",
proposal.id,
%{"category_id" => category.id}
) do
{:ok, %{proposal: proposal, category: category}}
end
true ->
{:error, :proposal_closed}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
@ -223,45 +242,54 @@ defmodule WhoNeedHelp.Catalog do
end end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do 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) moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else else
{:error, :not_found} = error -> error
nil -> {:error, :not_found} nil -> {:error, :not_found}
end end
end end
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do 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 -> Repo.transact(fn ->
proposal = locked_proposal(proposal_id) proposal = locked_proposal(proposal_id)
if proposal.status == :open do cond do
with {:ok, proposal} <- is_nil(proposal) ->
proposal {:error, :not_found}
|> CategoryProposal.moderation_changeset(%{
status: status, proposal.status == :open ->
merged_into_id: merged_into_id, with {:ok, proposal} <-
reviewed_by_id: moderator.id, proposal
reviewed_at: DateTime.utc_now(:second), |> CategoryProposal.moderation_changeset(%{
moderation_note: note status: status,
}) merged_into_id: merged_into_id,
|> Repo.update(), reviewed_by_id: moderator.id,
{:ok, _audit} <- reviewed_at: DateTime.utc_now(:second),
Trust.audit( moderation_note: note
moderator.id, })
"category_proposal.#{status}", |> Repo.update(),
"category_proposal", {:ok, _audit} <-
proposal.id, Trust.audit(
%{"merged_into_id" => merged_into_id} moderator.id,
) do "category_proposal.#{status}",
{:ok, proposal} "category_proposal",
end proposal.id,
else %{"merged_into_id" => merged_into_id}
{:error, :proposal_closed} ) do
{:ok, proposal}
end
true ->
{:error, :proposal_closed}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
@ -269,7 +297,7 @@ defmodule WhoNeedHelp.Catalog do
CategoryProposal CategoryProposal
|> where([proposal], proposal.id == ^proposal_id) |> where([proposal], proposal.id == ^proposal_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
end end
defp valid_structured_value?(%{"type" => "select", "options" => options}, value) defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
@ -347,9 +375,11 @@ defmodule WhoNeedHelp.Catalog do
case attrs["parent_id"] do case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] -> parent_id when parent_id not in [nil, ""] ->
case Repo.get(Category, parent_id) do with {:ok, parent_id} <- cast_id(parent_id),
nil -> {:error, :invalid_parent} %Category{} = parent <- Repo.get(Category, parent_id) do
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))} {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
else
_invalid_or_missing -> {:error, :invalid_parent}
end end
_ -> _ ->
@ -863,6 +893,13 @@ defmodule WhoNeedHelp.Catalog do
) )
end 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 names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
defp option(value, label), do: %{"value" => value, "label" => label} defp option(value, label), do: %{"value" => value, "label" => label}

View File

@ -64,6 +64,13 @@ defmodule WhoNeedHelp.Help do
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required") 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 def list_my_requests(%Scope{user: user}) do
paginate_my_requests(%Scope{user: user}).entries paginate_my_requests(%Scope{user: user}).entries
end end
@ -112,43 +119,37 @@ defmodule WhoNeedHelp.Help do
end end
def get_request(%Scope{user: user} = scope, id) do def get_request(%Scope{user: user} = scope, id) do
request = with {:ok, id} <- Ecto.UUID.cast(id),
HelpRequest %HelpRequest{} = request <- Repo.get(HelpRequest, id) do
|> Repo.get(id) request =
|> case do [request]
nil -> |> preload_request_relations(social_identities: true)
nil |> hd()
request -> cond do
[request] request.requester_id == user.id ->
|> preload_request_relations(social_identities: true) {:ok, request}
|> hd()
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 end
else
case request do _invalid_or_missing -> {:error, :not_found}
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
end end
end end
@ -211,6 +212,8 @@ defmodule WhoNeedHelp.Help do
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one!()
:ok = Trust.lock_user_pair(request.requester_id, helper.id)
cond do cond do
request.requester_id == helper.id -> request.requester_id == helper.id ->
{:error, :own_request} {:error, :own_request}
@ -284,6 +287,7 @@ defmodule WhoNeedHelp.Help do
Repo.transact(fn -> Repo.transact(fn ->
assignment = locked_assignment(assignment_id) assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id) request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
cond do cond do
user.id != assignment.helper_id -> 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 helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
participant = owner = request.requester_id == scope.user.id
request.requester_id == scope.user.id or
(not is_nil(request.assignment) and participant?(scope, request.assignment))
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 %Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true} %{latitude: lat, longitude: lng, exact: true}
else else
@ -486,6 +494,7 @@ defmodule WhoNeedHelp.Help do
Repo.transact(fn -> Repo.transact(fn ->
assignment = locked_assignment(assignment_id) assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id) request = Repo.get!(HelpRequest, assignment.request_id)
:ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id)
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do 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]) |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
end 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) when value in [nil, ""], do: query
defp maybe_filter(query, field, value), defp maybe_filter(query, field, value),
@ -638,7 +655,7 @@ defmodule WhoNeedHelp.Help do
category_id = Ecto.Changeset.get_field(changeset, :category_id) category_id = Ecto.Changeset.get_field(changeset, :category_id)
case category_id && Repo.get(Category, category_id) do 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) data = Ecto.Changeset.get_field(changeset, :structured_data)
case Catalog.validate_structured_data(category, data) do case Catalog.validate_structured_data(category, data) do
@ -654,7 +671,7 @@ defmodule WhoNeedHelp.Help do
end end
_ -> _ ->
changeset Ecto.Changeset.add_error(changeset, :category_id, "select an active help category")
end end
end end
end end

View File

@ -28,6 +28,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
field :completed_at, :utc_datetime field :completed_at, :utc_datetime
field :hidden_at, :utc_datetime field :hidden_at, :utc_datetime
field :hidden_reason, :string field :hidden_reason, :string
field :safety_confirmed, :boolean, virtual: true, default: false
belongs_to :requester, WhoNeedHelp.Accounts.User belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category belongs_to :category, WhoNeedHelp.Catalog.Category
has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id
@ -46,7 +47,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
:urgency, :urgency,
:location_visibility, :location_visibility,
:expires_at, :expires_at,
:category_id :category_id,
:safety_confirmed
]) ])
|> put_location(attrs) |> put_location(attrs)
|> validate_required([ |> validate_required([
@ -62,6 +64,9 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|> validate_length(:title, min: 5, max: 120) |> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000) |> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000) |> validate_length(:pickup_instructions, max: 1_000)
|> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing"
)
|> validate_expiry() |> validate_expiry()
end end

View File

@ -46,28 +46,38 @@ defmodule WhoNeedHelp.Messaging do
end end
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do 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) do
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)
result = result =
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, message} <- current =
%Message{assignment_id: assignment.id, sender_id: user.id} 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) |> Message.changeset(attrs)
|> Repo.insert(), |> Repo.insert(),
{:ok, _push_job} <- {:ok, _push_job} <-
Push.enqueue_message_created( Push.enqueue_message_created(
message.id, message.id,
assignment.id, current.id,
request.id, request.id,
recipient_id recipient_id
) do ) do
{:ok, message} {:ok, message}
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end end
end) end)
@ -76,16 +86,12 @@ defmodule WhoNeedHelp.Messaging do
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub, WhoNeedHelp.PubSub,
"messages:#{assignment.id}", "messages:#{message.assignment_id}",
{:new_message, message} {:new_message, message}
) )
{:ok, message} {:ok, message}
end end
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end end
end end

View File

@ -32,47 +32,49 @@ defmodule WhoNeedHelp.Tracking do
%Assignment{} = assignment, %Assignment{} = assignment,
visibility \\ :active_match visibility \\ :active_match
) do ) do
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking), with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking) do
true <- Help.participant?(scope, assignment), Repo.transact(fn ->
true <- assignment.status in [:accepted, :in_progress] do with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
%TrackingSession{} %TrackingSession{}
|> TrackingSession.changeset(%{ |> TrackingSession.changeset(%{
assignment_id: assignment.id, assignment_id: current.id,
user_id: user.id, user_id: user.id,
visibility: visibility, visibility: visibility,
started_at: now started_at: now
}) })
|> Repo.insert( |> Repo.insert(
on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]], on_conflict: [
conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"} set: [active: true, ended_at: nil, started_at: now, visibility: visibility]
) ],
else conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
false -> {:error, :forbidden} )
other -> other end
end)
end end
end end
def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position), with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position) do
true <- Help.participant?(scope, assignment) do
result = result =
Repo.transact(fn -> Repo.transact(fn ->
session = with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
TrackingSession session =
|> where( TrackingSession
[session], |> where(
session.assignment_id == ^assignment.id and session.user_id == ^user.id and [session],
session.active session.assignment_id == ^current.id and session.user_id == ^user.id and
) session.active
|> lock("FOR UPDATE") )
|> Repo.one() |> lock("FOR UPDATE")
|> Repo.one()
if session do if session do
persist_position(session, assignment, user, attrs) persist_position(session, current, user, attrs)
else else
{:error, :tracking_not_active} {:error, :tracking_not_active}
end
end end
end) end)
@ -85,9 +87,6 @@ defmodule WhoNeedHelp.Tracking do
{:ok, position} {:ok, position}
end end
else
false -> {:error, :forbidden}
other -> other
end end
end end
@ -119,15 +118,16 @@ defmodule WhoNeedHelp.Tracking do
end end
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do 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 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)) |> join(:inner, [s], p in assoc(s, :position))
|> select([s, p], {s.user_id, p}) |> select([s, p], {s.user_id, p})
|> Repo.all() |> Repo.all()
|> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end) |> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end)
else else
%{} _ -> %{}
end end
end end
@ -160,13 +160,93 @@ defmodule WhoNeedHelp.Tracking do
end) end)
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 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, Repo.get_by(TrackingSession,
assignment_id: assignment.id, assignment_id: current.id,
user_id: user_id, user_id: user_id,
active: true 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
end end

View File

@ -47,55 +47,66 @@ defmodule WhoNeedHelp.Trust do
def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
attrs = stringify_keys(attrs) attrs = stringify_keys(attrs)
request = with {:ok, _limit} <- authorize_action(scope, :review) do
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
Repo.transact(fn -> Repo.transact(fn ->
with {:ok, review} <- current =
%Review{} Assignment
|> Review.changeset( |> where([current], current.id == ^assignment.id)
Map.merge(attrs, %{ |> lock("FOR UPDATE")
"assignment_id" => assignment.id, |> Repo.one!()
"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)
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id), request = Repo.get!(HelpRequest, current.request_id)
set: [revealed_at: now]
)
end
audit(user.id, "review.submitted", "assignment", assignment.id, %{ cond do
"revealed" => length(reviews) == 2 current.status != :completed ->
}) {:error, :forbidden}
{:ok, review} user.id not in [current.helper_id, request.requester_id] ->
else {:error, :forbidden}
false -> {:error, :invalid_review_count}
other -> other 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
end) end)
else
false -> {:error, :forbidden}
other -> other
end end
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 def visible_reviews(user_id) do
paginate_visible_reviews(user_id).entries paginate_visible_reviews(user_id).entries
end end
@ -259,11 +270,11 @@ defmodule WhoNeedHelp.Trust do
with {:ok, report} <- with {:ok, report} <-
%Report{reporter_id: user.id} %Report{reporter_id: user.id}
|> Report.changeset(attrs) |> Report.changeset(attrs)
|> Repo.insert() do |> Repo.insert(),
audit(user.id, "report.created", "report", report.id, %{ {:ok, _audit} <-
"reason" => to_string(report.reason) audit(user.id, "report.created", "report", report.id, %{
}) "reason" => to_string(report.reason)
}) do
{:ok, report} {:ok, report}
end end
end) end)
@ -301,7 +312,8 @@ defmodule WhoNeedHelp.Trust do
end end
def moderate_report(%Scope{user: moderator}, report_id, attrs) do 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 =
attrs attrs
|> stringify_keys() |> stringify_keys()
@ -312,23 +324,29 @@ defmodule WhoNeedHelp.Trust do
Repo.transact(fn -> Repo.transact(fn ->
report = 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 if report do
audit(moderator.id, "report.moderated", "report", report.id, %{ with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update(),
"status" => to_string(report.status) {:ok, _audit} <-
}) audit(moderator.id, "report.moderated", "report", report.id, %{
"status" => to_string(report.status)
{:ok, report} }) do
{:ok, report}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
def report_evidence(%Scope{user: moderator}, report_id) do 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 =
Report Report
|> Repo.get(report_id) |> Repo.get(report_id)
@ -394,41 +412,80 @@ defmodule WhoNeedHelp.Trust do
{:error, :not_found} {:error, :not_found}
end end
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
def block(%Scope{user: user} = scope, blocked_id) do 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 %User{} <- Repo.get(User, blocked_id) do
Repo.transact(fn -> result =
with {:ok, block} <- Repo.transact(fn ->
%Block{} with :ok <- lock_user_pair(user.id, blocked_id),
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id}) {:ok, block} <-
|> Repo.insert() do %Block{}
audit(user.id, "user.blocked", "user", blocked_id) |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
clear_pair_tracking(user.id, blocked_id) |> Repo.insert(),
{:ok, block} {:ok, _audit} <- audit(user.id, "user.blocked", "user", blocked_id) do
end tracking = clear_pair_tracking(user.id, blocked_id)
end) 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 else
nil -> {:error, :not_found} nil -> {:error, :not_found}
other -> other other -> other
end end
end end
def unblock(%Scope{user: user}, blocked_id) do defp pair_activity_ids(first_user_id, second_user_id) do
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do Activity
nil -> |> join(:inner, [activity], participant in Participant,
{:ok, :already_unblocked} 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 -> def unblock(%Scope{user: user}, blocked_id) do
Repo.transact(fn -> with {:ok, blocked_id} <- cast_id(blocked_id) do
with {:ok, _block} <- Repo.delete(block) do Repo.transact(fn ->
audit(user.id, "user.unblocked", "user", blocked_id) :ok = lock_user_pair(user.id, blocked_id)
{:ok, :unblocked}
end case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
end) 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
end end
@ -468,6 +525,17 @@ defmodule WhoNeedHelp.Trust do
) )
end 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 def record_completion_signals(%Assignment{} = assignment) do
request = request =
case assignment.request do case assignment.request do
@ -550,7 +618,8 @@ defmodule WhoNeedHelp.Trust do
end end
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do 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 =
attrs attrs
|> stringify_keys() |> stringify_keys()
@ -564,44 +633,58 @@ defmodule WhoNeedHelp.Trust do
AbuseSignal AbuseSignal
|> where([signal], signal.id == ^signal_id) |> where([signal], signal.id == ^signal_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do if signal do
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{ with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update(),
"status" => to_string(signal.status) {:ok, _audit} <-
}) audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
"status" => to_string(signal.status)
{:ok, signal} }) do
{:ok, signal}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
end end
def hide_request(%Scope{user: moderator}, request_id, reason) do def hide_request(%Scope{user: moderator}, request_id, reason) do
result = result =
if Accounts.moderator_authorized?(moderator) do with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
|> where([request], request.id == ^request_id) |> where([request], request.id == ^request_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
with {:ok, request} <- if request do
request with {:ok, request} <-
|> HelpRequest.moderation_changeset(%{ request
hidden_at: DateTime.utc_now(:second), |> HelpRequest.moderation_changeset(%{
hidden_reason: reason hidden_at: DateTime.utc_now(:second),
}) hidden_reason: reason
|> Repo.update() do })
audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason}) |> Repo.update(),
{:ok, request} {:ok, _audit} <-
audit(moderator.id, "request.hidden", "request", request.id, %{
"reason" => reason
}) do
{:ok, request}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
with {:ok, request} <- result do with {:ok, request} <- result do
@ -612,24 +695,31 @@ defmodule WhoNeedHelp.Trust do
def restore_request(%Scope{user: moderator}, request_id) do def restore_request(%Scope{user: moderator}, request_id) do
result = result =
if Accounts.moderator_authorized?(moderator) do with {:ok, request_id} <- cast_id(request_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn -> Repo.transact(fn ->
request = request =
HelpRequest HelpRequest
|> where([request], request.id == ^request_id) |> where([request], request.id == ^request_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
with {:ok, request} <- if request do
request with {:ok, request} <-
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) request
|> Repo.update() do |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
audit(moderator.id, "request.restored", "request", request.id) |> Repo.update(),
{:ok, request} {:ok, _audit} <-
audit(moderator.id, "request.restored", "request", request.id) do
{:ok, request}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
with {:ok, request} <- result do with {:ok, request} <- result do
@ -640,30 +730,36 @@ defmodule WhoNeedHelp.Trust do
def hide_activity(%Scope{user: moderator}, activity_id, reason) do def hide_activity(%Scope{user: moderator}, activity_id, reason) do
result = result =
if Accounts.moderator_authorized?(moderator) do with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn -> Repo.transact(fn ->
activity = activity =
Activity Activity
|> where([activity], activity.id == ^activity_id) |> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
with {:ok, activity} <- if activity do
activity with {:ok, activity} <-
|> Activity.moderation_changeset(%{ activity
hidden_at: DateTime.utc_now(:second), |> Activity.moderation_changeset(%{
hidden_reason: reason hidden_at: DateTime.utc_now(:second),
}) hidden_reason: reason
|> Repo.update() do })
audit(moderator.id, "activity.hidden", "activity", activity.id, %{ |> Repo.update(),
"reason" => reason {:ok, _audit} <-
}) audit(moderator.id, "activity.hidden", "activity", activity.id, %{
"reason" => reason
{:ok, activity} }) do
{:ok, activity}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
with {:ok, activity} <- result do with {:ok, activity} <- result do
@ -674,24 +770,31 @@ defmodule WhoNeedHelp.Trust do
def restore_activity(%Scope{user: moderator}, activity_id) do def restore_activity(%Scope{user: moderator}, activity_id) do
result = result =
if Accounts.moderator_authorized?(moderator) do with {:ok, activity_id} <- cast_id(activity_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn -> Repo.transact(fn ->
activity = activity =
Activity Activity
|> where([activity], activity.id == ^activity_id) |> where([activity], activity.id == ^activity_id)
|> lock("FOR UPDATE") |> lock("FOR UPDATE")
|> Repo.one!() |> Repo.one()
with {:ok, activity} <- if activity do
activity with {:ok, activity} <-
|> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) activity
|> Repo.update() do |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
audit(moderator.id, "activity.restored", "activity", activity.id) |> Repo.update(),
{:ok, activity} {:ok, _audit} <-
audit(moderator.id, "activity.restored", "activity", activity.id) do
{:ok, activity}
end
else
{:error, :not_found}
end end
end) end)
else else
{:error, :forbidden} false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end end
with {:ok, activity} <- result do with {:ok, activity} <- result do
@ -701,15 +804,43 @@ defmodule WhoNeedHelp.Trust do
end end
def moderate_user(%Scope{user: moderator}, user_id, attrs) do def moderate_user(%Scope{user: moderator}, user_id, attrs) do
Repo.transact(fn -> result =
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs), Repo.transact(fn ->
{:ok, _audit} <- with {:ok, %{user: user} = moderation} <-
audit(moderator.id, "user.moderated", "user", user.id, %{ Accounts.moderate_user(moderator, user_id, attrs),
"moderation_status" => to_string(user.moderation_status) {:ok, _audit} <-
}) do audit(moderator.id, "user.moderated", "user", user.id, %{
{:ok, user} "moderation_status" => to_string(user.moderation_status)
end }) do
end) 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 end
def moderate_role(%Scope{user: admin}, user_id, attrs) do 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 maybe_status(query, status), do: where(query, [report], report.status == ^status)
defp clear_pair_tracking(first_user_id, second_user_id) do defp clear_pair_tracking(first_user_id, second_user_id) do
assignment_ids = assignments =
Assignment Assignment
|> join(:inner, [assignment], request in HelpRequest, |> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id 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 == ^first_user_id and request.requester_id == ^second_user_id) or
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id) (assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
) )
|> where([assignment], assignment.status in [:accepted, :in_progress]) |> select([assignment, request], {assignment.id, request.id, assignment.status})
|> select([assignment], assignment.id) |> 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 TrackingSession
|> where([session], session.active and session.assignment_id in subquery(assignment_ids)) |> join(:inner, [session], assignment in Assignment,
|> select([session], session.id) 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( session_ids = Enum.map(active_sessions, &elem(&1, 0))
from position in Position, where: position.tracking_session_id in subquery(session_ids)
)
Repo.update_all( if session_ids != [] do
from(session in TrackingSession, where: session.id in subquery(session_ids)), now = DateTime.utc_now(:second)
set: [active: false, ended_at: 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 end
defp stringify_keys(attrs) do defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end) Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end end
defp cast_id(value) do
case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id}
:error -> {:error, :not_found}
end
end
end end

View File

@ -33,6 +33,7 @@ defmodule WhoNeedHelp.Trust.AbuseSignal do
|> unique_constraint([:kind, :subject_id, :assignment_id], |> unique_constraint([:kind, :subject_id, :assignment_id],
name: :abuse_signals_one_kind_per_assignment name: :abuse_signals_one_kind_per_assignment
) )
|> unique_constraint(:subject_id, name: :abuse_signals_one_open_velocity_per_subject)
end end
def moderation_changeset(signal, attrs) do def moderation_changeset(signal, attrs) do

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
use Oban.Worker, queue: :maintenance, unique: [period: 55] use Oban.Worker, queue: :maintenance, unique: [period: 55]
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.HelpRequest alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking alias WhoNeedHelp.Tracking
@ -57,9 +58,15 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
end) end)
case result do case result do
{:ok, :empty} -> {:ok, expired} {:ok, :empty} ->
{:ok, %HelpRequest{}} -> expire_available(now, expired + 1) {:ok, expired}
{:error, reason} -> {:error, reason}
{:ok, %HelpRequest{} = request} ->
:ok = Help.notify_request_updated(request.id)
expire_available(now, expired + 1)
{:error, reason} ->
{:error, reason}
end end
end end
end end

View File

@ -18,34 +18,6 @@
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <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 defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script> </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> </head>
<body> <body>
<a <a

View File

@ -1,11 +1,11 @@
defmodule WhoNeedHelpWeb.HealthController do defmodule WhoNeedHelpWeb.HealthController do
use WhoNeedHelpWeb, :controller 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 def ready(conn, _params) do
case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) 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"}) {:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"})
end end
end end

View File

@ -14,6 +14,8 @@ defmodule WhoNeedHelpWeb.MobileTrackingController do
%Scope{user: nil} -> send_resp(conn, :unauthorized, "") %Scope{user: nil} -> send_resp(conn, :unauthorized, "")
{:error, :not_found} -> send_resp(conn, :not_found, "") {:error, :not_found} -> send_resp(conn, :not_found, "")
{:error, :forbidden} -> send_resp(conn, :forbidden, "") {: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, :tracking_not_active} -> send_resp(conn, :conflict, "")
{:error, :rate_limited} -> send_resp(conn, :too_many_requests, "") {:error, :rate_limited} -> send_resp(conn, :too_many_requests, "")
{:error, %Ecto.Changeset{}} -> send_resp(conn, :unprocessable_entity, "") {:error, %Ecto.Changeset{}} -> send_resp(conn, :unprocessable_entity, "")

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
require Logger require Logger
alias WhoNeedHelp.{Accounts, SocialOAuth, Trust} alias WhoNeedHelp.{Accounts, Repo, SocialOAuth, Trust}
def request(conn, %{"provider" => provider_param}) do def request(conn, %{"provider" => provider_param}) do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
@ -42,10 +42,21 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
redirect_uri <- callback_url(provider), redirect_uri <- callback_url(provider),
{:ok, identity_attrs} <- {:ok, identity_attrs} <-
SocialOAuth.callback(provider, redirect_uri, params, session_params), SocialOAuth.callback(provider, redirect_uri, params, session_params),
{:ok, identity} <- {:ok, _identity} <-
Accounts.upsert_verified_social_identity(user, identity_attrs) do Repo.transact(fn ->
audit_social_verification(user.id, identity) 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 conn
|> delete_session(session_key(provider)) |> delete_session(session_key(provider))
|> put_flash(:info, gettext("GitHub profile verified.")) |> 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(error) when is_atom(error), do: Atom.to_string(error)
defp error_name(%{__struct__: module}), do: inspect(module) defp error_name(%{__struct__: module}), do: inspect(module)
defp error_name(_error), do: "unknown_error" 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 end

View File

@ -35,17 +35,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do
# email + password login # email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do email_scope = email |> String.trim() |> String.downcase()
conn
|> put_flash(:info, gettext("Welcome back!"))
|> UserAuth.log_in_user(user, user_params)
else
form = Phoenix.Component.to_form(user_params, as: "user")
# In order to prevent user enumeration attacks, don't disclose whether the email is registered. case RateLimiter.check(:password_login_email, email_scope) do
conn {:ok, _limit} ->
|> put_flash(:error, gettext("Invalid email or password")) if user = Accounts.get_user_by_email_and_password(email, password) do
|> render(:new, form: form) 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
end end
@ -55,11 +61,15 @@ defmodule WhoNeedHelpWeb.UserSessionController do
case RateLimiter.check(:magic_link_email, email_scope) do case RateLimiter.check(:magic_link_email, email_scope) do
{:ok, _limit} -> {:ok, _limit} ->
if user = Accounts.get_user_by_email(email) do case Accounts.get_user_by_email(email) do
Accounts.deliver_login_instructions( %Accounts.User{moderation_status: status} = user when status != :suspended ->
user, Accounts.deliver_login_instructions(
&url(~p"/users/log-in/#{&1}") user,
) &url(~p"/users/log-in/#{&1}")
)
_missing_or_suspended ->
:ok
end end
info = info =
@ -102,4 +112,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do
|> put_flash(:info, gettext("Logged out successfully.")) |> put_flash(:info, gettext("Logged out successfully."))
|> UserAuth.log_out_user() |> UserAuth.log_out_user()
end 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 end

View File

@ -42,7 +42,10 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do 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 conn
|> put_flash(:info, gettext("Password updated successfully.")) |> put_flash(:info, gettext("Password updated successfully."))
|> put_session(:user_return_to, ~p"/users/settings") |> put_session(:user_return_to, ~p"/users/settings")

View File

@ -15,9 +15,35 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
end end
@impl true @impl true
def handle_info({event, _activity}, socket) def handle_info({event, activity}, socket)
when event in [:activity_created, :activity_updated] do 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 end
@impl true @impl true
@ -80,6 +106,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id)) existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end 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 defp marker(activity) do
case Activity.public_coordinates(activity) do case Activity.public_coordinates(activity) do
nil -> nil ->
@ -131,6 +163,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<.form <.form
for={@filter_form} for={@filter_form}
id="activity-filters"
phx-change="filter" phx-change="filter"
class="mt-5 rounded-2xl bg-base-200 p-4" class="mt-5 rounded-2xl bg-base-200 p-4"
> >

View File

@ -99,8 +99,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
defp error_message(:rate_limited), defp error_message(:rate_limited),
do: gettext("Too many actions in the configured time window.") do: gettext("Too many actions in the configured time window.")
defp error_message(reason), defp error_message(_reason), do: gettext("Could not publish the activity. Please try again.")
do: gettext("Could not publish the activity: %{reason}", reason: inspect(reason))
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -262,6 +261,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
type="button" type="button"
phx-hook="LocationPicker" phx-hook="LocationPicker"
id="activity-location-picker" id="activity-location-picker"
data-latitude-target="#activity-latitude"
data-longitude-target="#activity-longitude"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
> >
{gettext("Use my current foreground location")} {gettext("Use my current foreground location")}
@ -286,14 +287,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
/> />
</div> </div>
</div> </div>
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm"> <div class="rounded-2xl border border-base-300 p-4 text-sm">
<input type="checkbox" required class="checkbox checkbox-info mt-0.5" /> <.input
<span> field={@form[:safety_confirmed]}
{gettext( type="checkbox"
"I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point." label={
)} gettext(
</span> "I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point."
</label> )
}
required
class="checkbox checkbox-info mt-0.5"
/>
</div>
<.button <.button
class="btn btn-info btn-lg w-full" class="btn btn-info btn-lg w-full"
phx-disable-with={gettext("Publishing…")} phx-disable-with={gettext("Publishing…")}

View File

@ -307,14 +307,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
do: gettext("This activity state has already changed.") 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_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), defp error_message(:rate_limited),
do: gettext("Too many actions in the configured time window.") 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(%Ecto.Changeset{}), do: gettext("Please check the submitted message.")
defp error_message(reason), defp error_message(_reason), do: gettext("Could not complete the action. Please try again.")
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp status_label(:open), do: gettext("Open") defp status_label(:open), do: gettext("Open")
defp status_label(:completed), do: gettext("Completed") defp status_label(:completed), do: gettext("Completed")

View File

@ -54,10 +54,10 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
do: gettext("Too many actions in the configured time window.") 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(: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(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
defp action_error(reason), defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp load(socket) do defp load(socket) do
proposals = Catalog.paginate_proposals() proposals = Catalog.paginate_proposals()

View File

@ -232,10 +232,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp error_message(:last_admin), defp error_message(:last_admin),
do: gettext("The last administrator cannot demote themselves.") 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(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(reason), defp error_message(_reason), do: gettext("Could not complete moderation. Please try again.")
do: gettext("Could not complete moderation: %{reason}", reason: inspect(reason))
defp scoped_form(data, as, scope) do defp scoped_form(data, as, scope) do
to_form(data, as: as, id: "#{as}-#{scope}") to_form(data, as: as, id: "#{as}-#{scope}")

View File

@ -79,13 +79,9 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|> assign(:blocks_cursor, blocks.next_cursor) |> assign(:blocks_cursor, blocks.next_cursor)
|> put_flash(:info, gettext("User unblocked."))} |> put_flash(:info, gettext("User unblocked."))}
{:error, reason} -> {:error, _reason} ->
{:noreply, {:noreply,
put_flash( put_flash(socket, :error, gettext("Could not unblock this user. Please try again."))}
socket,
:error,
gettext("Could not unblock: %{reason}", reason: inspect(reason))
)}
end end
end end

View File

@ -14,8 +14,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
end end
@impl true @impl true
def handle_info({event, _request}, socket) when event in [:request_created, :request_updated] do def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
{:noreply, load(socket)} 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 end
@impl true @impl true
@ -77,6 +106,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id)) existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end 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 defp marker(request) do
case Help.HelpRequest.public_coordinates(request) do case Help.HelpRequest.public_coordinates(request) do
nil -> nil ->
@ -141,6 +195,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<.form <.form
for={@filter_form} for={@filter_form}
id="request-filters"
phx-change="filter" phx-change="filter"
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2" class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2"
> >

View File

@ -138,8 +138,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
defp error_message(:rate_limited), defp error_message(:rate_limited),
do: gettext("Too many requests in the configured time window.") do: gettext("Too many requests in the configured time window.")
defp error_message(reason), defp error_message(_reason), do: gettext("Could not publish the request. Please try again.")
do: gettext("Could not publish the request: %{reason}", reason: inspect(reason))
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -320,6 +319,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
type="button" type="button"
phx-hook="LocationPicker" phx-hook="LocationPicker"
id="location-picker" id="location-picker"
data-latitude-target="#request-latitude"
data-longitude-target="#request-longitude"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
> >
{gettext("Use my current foreground location")} {gettext("Use my current foreground location")}
@ -344,10 +345,15 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
/> />
</div> </div>
</div> </div>
<label class="flex items-start gap-3 rounded-2xl border border-base-300 p-4 text-sm"> <div class="rounded-2xl border border-base-300 p-4 text-sm">
<input type="checkbox" required class="checkbox checkbox-success mt-0.5" /> <.input
<span>{safety_confirmation(@selected_category)}</span> field={@form[:safety_confirmed]}
</label> type="checkbox"
label={safety_confirmation(@selected_category)}
required
class="checkbox checkbox-success mt-0.5"
/>
</div>
<.button <.button
class="btn btn-primary btn-lg w-full" class="btn btn-primary btn-lg w-full"
phx-disable-with={gettext("Publishing…")} phx-disable-with={gettext("Publishing…")}

View File

@ -65,7 +65,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
case Help.get_request(socket.assigns.current_scope, request.id) do case Help.get_request(socket.assigns.current_scope, request.id) do
{:ok, request} -> {:ok, request} ->
socket = maybe_subscribe_assignment(socket, 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} -> {:error, :not_found} ->
{:noreply, {:noreply,
@ -464,6 +464,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end end
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 defp assign_positions(socket, positions) do
markers = markers =
request_markers(socket.assigns.current_scope, socket.assigns.request) ++ 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.") do: gettext("Confirm your account and ensure it is active first.")
defp message(:blocked), do: gettext("This interaction is blocked.") 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(: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(%Ecto.Changeset{}), do: gettext("Please check the entered data.")
defp message(reason), defp message(_reason), do: gettext("Could not complete the action. Please try again.")
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp urgency_label(:now), do: gettext("Now") defp urgency_label(:now), do: gettext("Now")
defp urgency_label("now"), do: gettext("Now") defp urgency_label("now"), do: gettext("Now")

View File

@ -3,18 +3,16 @@ defmodule WhoNeedHelpWeb.Router do
import WhoNeedHelpWeb.UserAuth import WhoNeedHelpWeb.UserAuth
@secure_browser_headers %{
"content-security-policy" => "base-uri 'self'; frame-ancestors 'self';"
}
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
plug :fetch_live_flash plug :fetch_live_flash
plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root} plug :put_root_layout, html: {WhoNeedHelpWeb.Layouts, :root}
plug :protect_from_forgery 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 :fetch_current_scope_for_user
plug :put_authenticated_cache_policy
plug WhoNeedHelpWeb.Locale plug WhoNeedHelpWeb.Locale
end end
@ -26,10 +24,15 @@ defmodule WhoNeedHelpWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
plug :fetch_session plug :fetch_session
plug :protect_from_forgery 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 :fetch_current_scope_for_user
plug :put_authenticated_cache_policy
end end
defp put_content_security_policy(conn, opts),
do: WhoNeedHelpWeb.SecurityHeaders.put_content_security_policy(conn, opts)
scope "/healthz", WhoNeedHelpWeb do scope "/healthz", WhoNeedHelpWeb do
pipe_through :api pipe_through :api

View 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

View File

@ -48,6 +48,15 @@ defmodule WhoNeedHelpWeb.UserAuth do
""" """
def log_out_user(conn) do def log_out_user(conn) do
user_token = get_session(conn, :user_token) 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) user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do 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. Will reissue the session token if it is older than the configured age.
""" """
def fetch_current_scope_for_user(conn, _opts) do def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn), case ensure_user_token(conn) do
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do {token, conn} ->
conn case Accounts.get_user_by_session_token(token) do
|> assign(:current_scope, Scope.for_user(user)) {user, token_inserted_at} ->
|> maybe_reissue_user_session_token(user, token_inserted_at) conn
else |> assign(:current_scope, Scope.for_user(user))
nil -> assign(conn, :current_scope, Scope.for_user(nil)) |> 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
end end
@ -164,7 +193,25 @@ defmodule WhoNeedHelpWeb.UserAuth do
end end
defp put_token_in_session(conn, token) do 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 end
@doc """ @doc """

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,5 @@
const CACHE_PREFIX = "who-need-help-static-" const CACHE_PREFIX = "who-need-help-static-"
const CACHE = `${CACHE_PREFIX}v3` const CACHE = `${CACHE_PREFIX}v4`
const OFFLINE_URL = "/offline.html" const OFFLINE_URL = "/offline.html"
const SHELL = [ const SHELL = [
OFFLINE_URL, OFFLINE_URL,

View File

@ -33,6 +33,17 @@ defmodule WhoNeedHelp.AccountsTest do
assert %User{id: ^id} = assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password()) Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end 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 end
describe "get_user!/1" do 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) assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end 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 test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture() user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
@ -393,6 +419,21 @@ defmodule WhoNeedHelp.AccountsTest do
end end
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 describe "deliver_login_instructions/2" do
setup do setup do
%{user: unconfirmed_user_fixture()} %{user: unconfirmed_user_fixture()}

View File

@ -30,7 +30,8 @@ defmodule WhoNeedHelp.ActivitiesTest do
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour), "starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour), "join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 2, "capacity" => 2,
"category_id" => coffee.id "category_id" => coffee.id,
"safety_confirmed" => true
} }
{:ok, {:ok,
@ -87,6 +88,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
Activities.request_to_join(context.outsider_scope, activity.id) Activities.request_to_join(context.outsider_scope, activity.id)
end 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 test "unapproved viewers receive no private chat or pending participant data", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs) {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id) {: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) Activities.request_to_join(context.participant_scope, activity.id)
end 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 test "activity discovery uses stable cursor pages", context do
activities = activities =
for offset <- 2..5 do for offset <- 2..5 do

View File

@ -24,7 +24,8 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
"location_visibility" => "approximate_public", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
} }
{:ok, {:ok,
@ -73,6 +74,11 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
} }
end 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 test "chat is durable and only visible to match participants", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {: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"}) Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
end 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 test "request details do not preload the unbounded chat history", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {: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) assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
end 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 test "category moderation export excludes proposer identity", context do
{:ok, proposal} = {:ok, proposal} =
Catalog.propose(context.requester_scope, %{ Catalog.propose(context.requester_scope, %{

View File

@ -43,7 +43,8 @@ defmodule WhoNeedHelp.PushProductTest do
"location_visibility" => "approximate_public", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
} }
{:ok, {:ok,

View File

@ -24,7 +24,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
"location_visibility" => "approximate_public", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "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) assert [] = Messaging.list_messages(context.requester_scope, assignment)
end 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 test "tracking derives movement and proximity from browser accuracy envelopes", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {: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 assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
end 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 test "configured rate limits are atomic database counters", context do
old = Application.get_env(:who_need_help, :rate_limit_policies) 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) Help.request_coordinates(context.requester_scope, request)
end 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 test "cancelling a matched request ends tracking and deletes raw coordinates", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -291,6 +391,76 @@ defmodule WhoNeedHelp.TrustSafetyTest do
) )
end 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 test "the first administrator bootstrap is one-time and audited", context do
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
assert admin.id == context.helper.id assert admin.id == context.helper.id

View File

@ -27,7 +27,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
"location_visibility" => "approximate_public", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
}) })
request.id request.id
@ -40,8 +41,17 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
set: [expires_at: past] set: [expires_at: past]
) )
:ok = Help.subscribe()
assert {:ok, %{expired: 12}} = ExpireRequests.perform(%Oban.Job{}) 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( assert Repo.aggregate(
from( from(
request in HelpRequest, request in HelpRequest,

View File

@ -57,6 +57,23 @@ defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
refute Repo.get_by(TrackingSession, id: session.id, active: true) refute Repo.get_by(TrackingSession, id: session.id, active: true)
end 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 test "an authenticated non-participant cannot update tracking", context do
outsider = user_fixture(display_name: "Outsider") outsider = user_fixture(display_name: "Outsider")
{conn, csrf_token} = csrf_connection(log_in_user(build_conn(), outsider), ~p"/requests") {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", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
} }
end end
end end

View File

@ -5,8 +5,14 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
html = html_response(conn, 200) html = html_response(conn, 200)
document = LazyHTML.from_document(html) 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 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 =~ assert html =~
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png") ~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) =~ "Помощь может быть ближе, чем кажется." assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
end 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 test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
conn = get(conn, ~p"/safety?locale=uk") conn = get(conn, ~p"/safety?locale=uk")
assert html_response(conn, 200) =~ "Правила безпеки" assert html_response(conn, 200) =~ "Правила безпеки"
@ -76,7 +95,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
body = response(conn, 200) body = response(conn, 200)
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-") 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(const OFFLINE_URL = "/offline.html")
assert body =~ ~s(event.request.mode === "navigate") assert body =~ ~s(event.request.mode === "navigate")
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE| assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|

View File

@ -1,5 +1,5 @@
defmodule WhoNeedHelpWeb.UserSessionControllerTest do defmodule WhoNeedHelpWeb.UserSessionControllerTest do
use WhoNeedHelpWeb.ConnCase, async: true use WhoNeedHelpWeb.ConnCase, async: false
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
@ -82,7 +82,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
"user" => %{"email" => user.email, "password" => valid_user_password()} "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"/" assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu # 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 =~ "Log in"
assert response =~ "Invalid email or password" assert response =~ "Invalid email or password"
end 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 end
describe "POST /users/log-in - magic link" do 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" assert WhoNeedHelp.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
end 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 test "logs the user in", %{conn: conn, user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user) {token, _hashed_token} = generate_user_magic_link_token(user)

View File

@ -31,6 +31,10 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
describe "PUT /users/settings (change password form)" do describe "PUT /users/settings (change password form)" do
test "updates the user password and resets tokens", %{conn: conn, user: user} 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 = new_password_conn =
put(conn, ~p"/users/settings", %{ put(conn, ~p"/users/settings", %{
"action" => "update_password", "action" => "update_password",
@ -47,6 +51,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully" "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") assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end end

View File

@ -23,6 +23,72 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Створити термінову заявку" assert html =~ "Створити термінову заявку"
end 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 test "new request form is driven by category structured fields", %{conn: conn} do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
{:ok, view, html} = live(conn, ~p"/requests/new") {: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), "starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour), "join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 3, "capacity" => 3,
"category_id" => coffee.id "category_id" => coffee.id,
"safety_confirmed" => true
} }
{:ok, activity} = Activities.create_activity(organizer_scope, attrs) {:ok, activity} = Activities.create_activity(organizer_scope, attrs)
@ -250,6 +317,39 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
stop_live_view(helper_view) stop_live_view(helper_view)
end 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 test "regular users cannot enter moderation", %{conn: conn} do
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation") assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
end end
@ -326,7 +426,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"location_visibility" => "approximate_public", "location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"}, "structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id "category_id" => category.id,
"safety_confirmed" => true
} }
end end

View File

@ -3,8 +3,10 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Repo
alias WhoNeedHelpWeb.UserAuth alias WhoNeedHelpWeb.UserAuth
import Ecto.Query, only: [from: 2]
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
@remember_me_cookie "_who_need_help_web_user_remember_me" @remember_me_cookie "_who_need_help_web_user_remember_me"
@ -158,6 +160,37 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
refute conn.assigns.current_scope refute conn.assigns.current_scope
end 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 test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
logged_in_conn = logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})