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 {Hooks} from "./hooks"
const systemTheme = () =>
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
const setTheme = theme => {
if (theme === "system") {
localStorage.removeItem("phx:theme")
document.documentElement.setAttribute("data-theme", systemTheme())
document.documentElement.setAttribute("data-theme-source", "system")
} else {
localStorage.setItem("phx:theme", theme)
document.documentElement.setAttribute("data-theme", theme)
document.documentElement.setAttribute("data-theme-source", "user")
}
}
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system")
}
window.addEventListener("storage", event => {
if (event.key === "phx:theme") setTheme(event.newValue || "system")
})
window.addEventListener("phx:set-theme", event => setTheme(event.target.dataset.phxTheme))
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (document.documentElement.getAttribute("data-theme-source") === "system") {
document.documentElement.setAttribute("data-theme", systemTheme())
}
})
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,

View File

@ -76,9 +76,13 @@ export const Hooks = {
const points = JSON.parse(this.el.dataset.markers || "[]")
points.forEach(point => {
const popup = new maplibregl.Popup({offset: 18}).setHTML(
`<strong>${escapeHtml(point.title)}</strong><br>${escapeHtml(point.location)}`
)
const popupContent = document.createElement("div")
const title = document.createElement("strong")
title.textContent = point.title || ""
popupContent.append(title, document.createElement("br"))
popupContent.append(document.createTextNode(point.location || ""))
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
.setLngLat([point.longitude, point.latitude])
.setPopup(popup)
@ -99,10 +103,21 @@ export const Hooks = {
LocationPicker: {
mounted() {
this.el.addEventListener("click", () => {
const latitudeInput = document.querySelector(this.el.dataset.latitudeTarget)
const longitudeInput = document.querySelector(this.el.dataset.longitudeTarget)
if (!latitudeInput || !longitudeInput || !navigator.geolocation) {
this.el.textContent = "Location is unavailable"
this.el.classList.add("btn-error")
return
}
navigator.geolocation.getCurrentPosition(
position => {
document.querySelector("#request-latitude").value = position.coords.latitude
document.querySelector("#request-longitude").value = position.coords.longitude
latitudeInput.value = position.coords.latitude
longitudeInput.value = position.coords.longitude
latitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
longitudeInput.dispatchEvent(new Event("input", {bubbles: true}))
this.el.textContent = "Location added"
this.el.classList.add("btn-success")
},
@ -151,12 +166,6 @@ export const Hooks = {
}
}
function escapeHtml(value) {
const node = document.createElement("div")
node.textContent = value || ""
return node.innerHTML
}
function supportsMapCanvas() {
const canvas = document.createElement("canvas")
const attributes = {

View File

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

View File

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

View File

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

View File

@ -108,7 +108,8 @@ defmodule WhoNeedHelp.Accounts.User do
defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do
%URI{scheme: scheme, host: host} when scheme in ["https", "http"] and is_binary(host) ->
%URI{scheme: scheme, host: host}
when scheme in ["https", "http"] and is_binary(host) and host != "" ->
[]
_ ->

View File

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

View File

@ -71,6 +71,25 @@ defmodule WhoNeedHelp.Activities do
paginate_my_activities(%Scope{user: user}).entries
end
def visible_open_activity?(%Scope{user: user}, %Activity{} = activity, filters \\ %{}) do
now = DateTime.utc_now(:second)
category_id = filters["category_id"] || filters[:category_id]
activity.status == :open and is_nil(activity.hidden_at) and
DateTime.after?(activity.starts_at, now) and
DateTime.after?(activity.join_deadline, now) and
(category_id in [nil, ""] or
to_string(activity.category_id) == to_string(category_id)) and
not Trust.blocked_between?(user.id, activity.creator_id)
end
def member_activity?(%Activity{} = activity, user_id) do
Enum.any?(
activity.participants,
&(&1.user_id == user_id and &1.status in [:requested, :approved])
)
end
def paginate_my_activities(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options)
cursor = Pagination.cursor(options)
@ -124,35 +143,39 @@ defmodule WhoNeedHelp.Activities do
end
def get_activity(%Scope{user: user}, id) do
case get_loaded_activity(id) do
nil ->
{:error, :not_found}
activity ->
with {:ok, id} <- Ecto.UUID.cast(id),
%Activity{} = activity <- get_loaded_activity(id) do
cond do
activity.creator_id == user.id ->
{:ok, activity}
approved_participant?(activity, user.id) ->
{:ok, activity}
not is_nil(activity.hidden_at) ->
{:error, :not_found}
Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :not_found}
approved_participant?(activity, user.id) ->
{:ok, activity}
activity.status == :open ->
{:ok, activity_for_viewer(activity, user.id)}
true ->
{:error, :not_found}
end
else
_invalid_or_missing -> {:error, :not_found}
end
end
def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
authorized =
activity.creator_id == user.id or
(approved_participant?(activity, user.id) and
not Trust.blocked_between?(activity.creator_id, user.id))
if authorized do
limit = Pagination.limit(options, 50)
cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
@ -221,6 +244,8 @@ defmodule WhoNeedHelp.Activities do
activity = locked_activity(activity_id)
now = DateTime.utc_now(:second)
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do
activity.creator_id == user.id ->
{:error, :organizer_already_joined}
@ -248,9 +273,11 @@ defmodule WhoNeedHelp.Activities do
def approve_participant(%Scope{user: organizer}, participant_id) do
result =
with {:ok, participant_id} <- cast_id(participant_id) do
Repo.transact(fn ->
participant = locked_participant(participant_id)
activity = locked_activity(participant.activity_id)
:ok = Trust.lock_user_pair(activity.creator_id, participant.user_id)
cond do
activity.creator_id != organizer.id ->
@ -288,13 +315,16 @@ defmodule WhoNeedHelp.Activities do
end
end
end)
end
after_participant_change(result, participant_activity_id(result), :participant_approved)
end
def decline_participant(%Scope{user: organizer}, participant_id) do
with {:ok, participant_id} <- cast_id(participant_id) do
transition_participant(organizer.id, participant_id, :declined)
end
end
def leave_activity(%Scope{user: user}, activity_id) do
result =
@ -345,10 +375,23 @@ defmodule WhoNeedHelp.Activities do
end
def send_message(%Scope{user: user} = scope, activity_id, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message),
{:ok, activity} <- get_activity(scope, activity_id),
true <- activity.status == :open,
true <- approved_participant?(activity, user.id) do
result =
with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do
Repo.transact(fn ->
activity = locked_activity(activity_id)
:ok = Trust.lock_user_pair(activity.creator_id, user.id)
cond do
activity.status != :open or not is_nil(activity.hidden_at) ->
{:error, :not_open}
Trust.blocked_between?(activity.creator_id, user.id) ->
{:error, :blocked}
not approved_participant_id?(activity.id, user.id) ->
{:error, :forbidden}
true ->
%Message{}
|> Message.changeset(%{
body: attrs["body"] || attrs[:body],
@ -356,29 +399,30 @@ defmodule WhoNeedHelp.Activities do
sender_id: user.id
})
|> Repo.insert()
|> case do
{:ok, message} ->
end
end)
end
with {:ok, message} <- result do
message = Repo.preload(message, sender: Accounts.public_user_query())
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"activity:#{activity.id}",
"activity:#{message.activity_id}",
{:activity_message, message}
)
{:ok, message}
other ->
other
end
else
false -> {:error, :forbidden}
other -> other
end
end
def coordinates_for(%Scope{user: user}, %Activity{} = activity) do
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
authorized =
activity.creator_id == user.id or
(approved_participant?(activity, user.id) and
not Trust.blocked_between?(activity.creator_id, user.id))
if authorized do
Activity.exact_coordinates(activity)
else
Activity.public_coordinates(activity)
@ -396,6 +440,15 @@ defmodule WhoNeedHelp.Activities do
)
end
defp approved_participant_id?(activity_id, user_id) do
Repo.exists?(
from participant in Participant,
where:
participant.activity_id == ^activity_id and participant.user_id == ^user_id and
participant.status == :approved
)
end
defp validate_category_and_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
@ -644,6 +697,13 @@ defmodule WhoNeedHelp.Activities do
defp maybe_filter_category(query, value),
do: where(query, [activity], activity.category_id == ^value)
defp cast_id(value) do
case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id}
:error -> {:error, :not_found}
end
end
defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message)
defp broadcast_activity(id, message),

View File

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

View File

@ -74,19 +74,30 @@ defmodule WhoNeedHelp.Catalog do
end
def vote(%Scope{user: user} = scope, proposal_id) do
with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote),
%CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do
with {:ok, proposal_id} <- cast_id(proposal_id),
{:ok, _limit} <- Trust.authorize_action(scope, :category_vote) do
Repo.transact(fn ->
case CategoryProposal
|> where([proposal], proposal.id == ^proposal_id)
|> lock("FOR UPDATE")
|> Repo.one() do
nil ->
{:error, :not_found}
%CategoryProposal{status: :open} ->
%CategoryVote{}
|> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
|> Repo.insert()
else
nil -> {:error, :not_found}
%CategoryProposal{} -> {:error, :proposal_closed}
other -> other
%CategoryProposal{} ->
{:error, :proposal_closed}
end
end)
end
end
def unvote(%Scope{user: user}, proposal_id) do
with {:ok, proposal_id} <- cast_id(proposal_id) do
{count, _} =
CategoryVote
|> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
@ -94,6 +105,7 @@ defmodule WhoNeedHelp.Catalog do
{:ok, count}
end
end
def seed_defaults do
categories =
@ -175,11 +187,16 @@ defmodule WhoNeedHelp.Catalog do
end
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
if Accounts.moderator_authorized?(moderator) do
with {:ok, proposal_id} <- cast_id(proposal_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
if proposal.status == :open do
cond do
is_nil(proposal) ->
{:error, :not_found}
proposal.status == :open ->
category_attrs =
category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end)
@ -209,12 +226,14 @@ defmodule WhoNeedHelp.Catalog do
) do
{:ok, %{proposal: proposal, category: category}}
end
else
true ->
{:error, :proposal_closed}
end
end)
else
{:error, :forbidden}
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end
end
@ -223,19 +242,26 @@ defmodule WhoNeedHelp.Catalog do
end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
with %Category{} <- Repo.get(Category, category_id) do
with {:ok, category_id} <- cast_id(category_id),
%Category{} <- Repo.get(Category, category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else
{:error, :not_found} = error -> error
nil -> {:error, :not_found}
end
end
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
if Accounts.moderator_authorized?(moderator) do
with {:ok, proposal_id} <- cast_id(proposal_id),
true <- Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
if proposal.status == :open do
cond do
is_nil(proposal) ->
{:error, :not_found}
proposal.status == :open ->
with {:ok, proposal} <-
proposal
|> CategoryProposal.moderation_changeset(%{
@ -256,12 +282,14 @@ defmodule WhoNeedHelp.Catalog do
) do
{:ok, proposal}
end
else
true ->
{:error, :proposal_closed}
end
end)
else
{:error, :forbidden}
false -> {:error, :forbidden}
{:error, :not_found} = error -> error
end
end
@ -269,7 +297,7 @@ defmodule WhoNeedHelp.Catalog do
CategoryProposal
|> where([proposal], proposal.id == ^proposal_id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.one()
end
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
@ -347,9 +375,11 @@ defmodule WhoNeedHelp.Catalog do
case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] ->
case Repo.get(Category, parent_id) do
nil -> {:error, :invalid_parent}
parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
with {:ok, parent_id} <- cast_id(parent_id),
%Category{} = parent <- Repo.get(Category, parent_id) do
{:ok, Map.put(attrs, "mode", to_string(parent.mode))}
else
_invalid_or_missing -> {:error, :invalid_parent}
end
_ ->
@ -863,6 +893,13 @@ defmodule WhoNeedHelp.Catalog do
)
end
defp cast_id(value) do
case Ecto.UUID.cast(value) do
{:ok, id} -> {:ok, id}
:error -> {:error, :not_found}
end
end
defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
defp option(value, label), do: %{"value" => value, "label" => label}

View File

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

View File

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

View File

@ -46,28 +46,38 @@ defmodule WhoNeedHelp.Messaging do
end
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
assignment = Repo.preload(assignment, :request)
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message),
true <- Help.participant?(scope, assignment),
false <- blocked_assignment?(scope, assignment) do
request = Map.fetch!(assignment, :request)
recipient_id = counterpart_id(user.id, assignment, request)
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message) do
result =
Repo.transact(fn ->
with {:ok, message} <-
%Message{assignment_id: assignment.id, sender_id: user.id}
current =
Assignment
|> where([current], current.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one!()
|> Repo.preload(:request)
request = current.request
recipient_id = counterpart_id(user.id, current, request)
with true <- Help.participant?(scope, current),
:ok <- Trust.lock_user_pair(user.id, recipient_id),
false <- Trust.blocked_between?(user.id, recipient_id),
{:ok, message} <-
%Message{assignment_id: current.id, sender_id: user.id}
|> Message.changeset(attrs)
|> Repo.insert(),
{:ok, _push_job} <-
Push.enqueue_message_created(
message.id,
assignment.id,
current.id,
request.id,
recipient_id
) do
{:ok, message}
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end
end)
@ -76,16 +86,12 @@ defmodule WhoNeedHelp.Messaging do
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"messages:#{assignment.id}",
"messages:#{message.assignment_id}",
{:new_message, message}
)
{:ok, message}
end
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end
end

View File

@ -32,48 +32,50 @@ defmodule WhoNeedHelp.Tracking do
%Assignment{} = assignment,
visibility \\ :active_match
) do
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking),
true <- Help.participant?(scope, assignment),
true <- assignment.status in [:accepted, :in_progress] do
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking) do
Repo.transact(fn ->
with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
now = DateTime.utc_now(:second)
%TrackingSession{}
|> TrackingSession.changeset(%{
assignment_id: assignment.id,
assignment_id: current.id,
user_id: user.id,
visibility: visibility,
started_at: now
})
|> Repo.insert(
on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]],
on_conflict: [
set: [active: true, ended_at: nil, started_at: now, visibility: visibility]
],
conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
)
else
false -> {:error, :forbidden}
other -> other
end
end)
end
end
def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position),
true <- Help.participant?(scope, assignment) do
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position) do
result =
Repo.transact(fn ->
with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do
session =
TrackingSession
|> where(
[session],
session.assignment_id == ^assignment.id and session.user_id == ^user.id and
session.assignment_id == ^current.id and session.user_id == ^user.id and
session.active
)
|> lock("FOR UPDATE")
|> Repo.one()
if session do
persist_position(session, assignment, user, attrs)
persist_position(session, current, user, attrs)
else
{:error, :tracking_not_active}
end
end
end)
with {:ok, {position, evidence}} <- result do
@ -85,9 +87,6 @@ defmodule WhoNeedHelp.Tracking do
{:ok, position}
end
else
false -> {:error, :forbidden}
other -> other
end
end
@ -119,15 +118,16 @@ defmodule WhoNeedHelp.Tracking do
end
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do
if Trust.eligible?(scope) and Help.participant?(scope, assignment) do
with true <- Trust.eligible?(scope),
{:ok, current} <- active_assignment(scope, assignment.id) do
TrackingSession
|> where([s], s.assignment_id == ^assignment.id and s.active)
|> where([s], s.assignment_id == ^current.id and s.active)
|> join(:inner, [s], p in assoc(s, :position))
|> select([s, p], {s.user_id, p})
|> Repo.all()
|> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end)
else
%{}
_ -> %{}
end
end
@ -160,13 +160,93 @@ defmodule WhoNeedHelp.Tracking do
end)
end
def stop_all_sessions(user_id) do
result =
Repo.transact(fn ->
active_sessions =
TrackingSession
|> where([session], session.active and session.user_id == ^user_id)
|> select([session], {session.id, session.assignment_id})
|> lock("FOR UPDATE")
|> Repo.all()
session_ids = Enum.map(active_sessions, &elem(&1, 0))
if session_ids != [] do
now = DateTime.utc_now(:second)
Position
|> where([position], position.tracking_session_id in ^session_ids)
|> Repo.delete_all()
TrackingSession
|> where([session], session.id in ^session_ids)
|> Repo.update_all(set: [active: false, ended_at: now, updated_at: now])
end
{:ok, Enum.map(active_sessions, &elem(&1, 1))}
end)
with {:ok, assignment_ids} <- result do
Enum.each(assignment_ids, fn assignment_id ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment_id}",
{:tracking_stopped, user_id}
)
end)
{:ok, %{sessions_ended: length(assignment_ids)}}
end
end
defp active_session(scope, assignment, user_id) do
if Help.participant?(scope, assignment) do
with {:ok, current} <- active_assignment(scope, assignment.id) do
Repo.get_by(TrackingSession,
assignment_id: assignment.id,
assignment_id: current.id,
user_id: user_id,
active: true
)
else
_ -> nil
end
end
defp active_assignment(%Scope{user: user} = scope, assignment_id, options \\ []) do
query =
Assignment
|> where([assignment], assignment.id == ^assignment_id)
|> preload(:request)
query = if Keyword.get(options, :lock, false), do: lock(query, "FOR UPDATE"), else: query
case Repo.one(query) do
nil ->
{:error, :not_found}
%Assignment{} = assignment ->
request = assignment.request
counterpart_id =
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
if Keyword.get(options, :lock, false) do
:ok = Trust.lock_user_pair(user.id, counterpart_id)
end
cond do
not Help.participant?(scope, assignment) ->
{:error, :forbidden}
assignment.status not in [:accepted, :in_progress] ->
{:error, :assignment_inactive}
Trust.blocked_between?(user.id, counterpart_id) ->
{:error, :blocked}
true ->
{:ok, assignment}
end
end
end

View File

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

View File

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

View File

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

View File

@ -18,34 +18,6 @@
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.setAttribute("data-theme", systemTheme());
document.documentElement.setAttribute("data-theme-source", "system");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute("data-theme-source", "user");
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (document.documentElement.getAttribute("data-theme-source") === "system") {
document.documentElement.setAttribute("data-theme", systemTheme());
}
});
})();
</script>
</head>
<body>
<a

View File

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

View File

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

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
require Logger
alias WhoNeedHelp.{Accounts, SocialOAuth, Trust}
alias WhoNeedHelp.{Accounts, Repo, SocialOAuth, Trust}
def request(conn, %{"provider" => provider_param}) do
user = conn.assigns.current_scope.user
@ -42,10 +42,21 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
redirect_uri <- callback_url(provider),
{:ok, identity_attrs} <-
SocialOAuth.callback(provider, redirect_uri, params, session_params),
{:ok, identity} <-
Accounts.upsert_verified_social_identity(user, identity_attrs) do
audit_social_verification(user.id, identity)
{:ok, _identity} <-
Repo.transact(fn ->
with {:ok, identity} <-
Accounts.upsert_verified_social_identity(user, identity_attrs),
{:ok, _audit} <-
Trust.audit(
user.id,
"social_identity.verified",
"social_identity",
identity.id,
%{"provider" => to_string(identity.provider)}
) do
{:ok, identity}
end
end) do
conn
|> delete_session(session_key(provider))
|> put_flash(:info, gettext("GitHub profile verified."))
@ -100,22 +111,4 @@ defmodule WhoNeedHelpWeb.SocialOAuthController do
defp error_name(error) when is_atom(error), do: Atom.to_string(error)
defp error_name(%{__struct__: module}), do: inspect(module)
defp error_name(_error), do: "unknown_error"
defp audit_social_verification(user_id, identity) do
case Trust.audit(
user_id,
"social_identity.verified",
"social_identity",
identity.id,
%{"provider" => to_string(identity.provider)}
) do
{:ok, _event} ->
:ok
{:error, error} ->
Logger.error(
"Verified social identity #{identity.id}, but audit insertion failed: #{inspect(error)}"
)
end
end
end

View File

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

View File

@ -42,7 +42,10 @@ defmodule WhoNeedHelpWeb.UserSettingsController do
user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do
{:ok, {user, _}} ->
{:ok, {user, expired_tokens}} ->
:ok = UserAuth.disconnect_sessions(expired_tokens)
{:ok, _cleanup} = WhoNeedHelp.Tracking.stop_all_sessions(user.id)
conn
|> put_flash(:info, gettext("Password updated successfully."))
|> put_session(:user_return_to, ~p"/users/settings")

View File

@ -15,9 +15,35 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
end
@impl true
def handle_info({event, _activity}, socket)
def handle_info({event, activity}, socket)
when event in [:activity_created, :activity_updated] do
{:noreply, load(socket)}
user_id = socket.assigns.current_scope.user.id
activities =
update_entry(
socket.assigns.activities,
activity,
Activities.visible_open_activity?(
socket.assigns.current_scope,
activity,
socket.assigns.filters
),
:asc
)
my_activities =
update_entry(
socket.assigns.my_activities,
activity,
Activities.member_activity?(activity, user_id),
:desc
)
{:noreply,
socket
|> assign(:activities, activities)
|> assign(:my_activities, my_activities)
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
end
@impl true
@ -80,6 +106,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end
defp update_entry(entries, activity, visible?, direction) do
entries = Enum.reject(entries, &(&1.id == activity.id))
entries = if visible?, do: [activity | entries], else: entries
Enum.sort_by(entries, &{&1.starts_at, &1.id}, direction)
end
defp marker(activity) do
case Activity.public_coordinates(activity) do
nil ->
@ -131,6 +163,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<.form
for={@filter_form}
id="activity-filters"
phx-change="filter"
class="mt-5 rounded-2xl bg-base-200 p-4"
>

View File

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

View File

@ -307,14 +307,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
do: gettext("This activity state has already changed.")
defp error_message(:not_open), do: gettext("This activity is no longer open.")
defp error_message(:not_found), do: gettext("This activity is no longer available.")
defp error_message(:organizer_already_joined),
do: gettext("The organizer is already attending.")
defp error_message(:organizer_cannot_leave),
do: gettext("The organizer cannot leave the activity.")
defp error_message(:rate_limited),
do: gettext("Too many actions in the configured time window.")
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted message.")
defp error_message(reason),
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp error_message(_reason), do: gettext("Could not complete the action. Please try again.")
defp status_label(:open), do: gettext("Open")
defp status_label(:completed), do: gettext("Completed")

View File

@ -54,10 +54,10 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
do: gettext("Too many actions in the configured time window.")
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
defp action_error(:not_found), do: gettext("This proposal is no longer available.")
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
defp action_error(reason),
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
defp load(socket) do
proposals = Catalog.paginate_proposals()

View File

@ -232,10 +232,10 @@ defmodule WhoNeedHelpWeb.ModerationLive do
defp error_message(:last_admin),
do: gettext("The last administrator cannot demote themselves.")
defp error_message(:not_found), do: gettext("The selected record is no longer available.")
defp error_message(%Ecto.Changeset{}), do: gettext("Please check the submitted fields.")
defp error_message(reason),
do: gettext("Could not complete moderation: %{reason}", reason: inspect(reason))
defp error_message(_reason), do: gettext("Could not complete moderation. Please try again.")
defp scoped_form(data, as, scope) do
to_form(data, as: as, id: "#{as}-#{scope}")

View File

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

View File

@ -14,8 +14,37 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
end
@impl true
def handle_info({event, _request}, socket) when event in [:request_created, :request_updated] do
{:noreply, load(socket)}
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
user = socket.assigns.current_scope.user
requests =
update_entry(
socket.assigns.requests,
request,
Help.visible_open_request?(
socket.assigns.current_scope,
request,
socket.assigns.filters
),
:open
)
my_requests =
update_entry(
socket.assigns.my_requests,
request,
request.requester_id == user.id,
:mine
)
socket =
socket
|> assign(:requests, requests)
|> assign(:my_requests, my_requests)
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|> maybe_refresh_reputation(request, user.id)
{:noreply, socket}
end
@impl true
@ -77,6 +106,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
end
defp update_entry(entries, request, visible?, order) do
entries = Enum.reject(entries, &(&1.id == request.id))
entries =
if visible?,
do: [request | entries],
else: entries
case order do
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
end
end
defp maybe_refresh_reputation(socket, request, user_id) do
assignment = request.assignment
if assignment && assignment.status == :completed &&
user_id in [request.requester_id, assignment.helper_id] do
assign(socket, :reputation, Trust.reputation(user_id))
else
socket
end
end
defp marker(request) do
case Help.HelpRequest.public_coordinates(request) do
nil ->
@ -141,6 +195,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<.form
for={@filter_form}
id="request-filters"
phx-change="filter"
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2"
>

View File

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

View File

@ -65,7 +65,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
case Help.get_request(socket.assigns.current_scope, request.id) do
{:ok, request} ->
socket = maybe_subscribe_assignment(socket, request)
{:noreply, load(socket, request, socket.assigns.tracking_active)}
{:noreply, sync_tracking_after_request_update(socket, request)}
{:error, :not_found} ->
{:noreply,
@ -464,6 +464,25 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end
end
defp sync_tracking_after_request_update(socket, request) do
tracking_was_active = socket.assigns.tracking_active
tracking_active =
(tracking_was_active and request.assignment) &&
request.assignment.status in [:accepted, :in_progress]
socket = load(socket, request, tracking_active)
if tracking_was_active and not tracking_active do
socket
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking()
else
socket
end
end
defp assign_positions(socket, positions) do
markers =
request_markers(socket.assigns.current_scope, socket.assigns.request) ++
@ -569,11 +588,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
do: gettext("Confirm your account and ensure it is active first.")
defp message(:blocked), do: gettext("This interaction is blocked.")
defp message(:expired), do: gettext("This request has expired.")
defp message(:not_found), do: gettext("This request is no longer available.")
defp message(:assignment_inactive), do: gettext("This match is no longer active.")
defp message(:tracking_not_active), do: gettext("Live location sharing is not active.")
defp message(:rate_limited), do: gettext("Too many actions in the configured time window.")
defp message(%Ecto.Changeset{}), do: gettext("Please check the entered data.")
defp message(reason),
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
defp message(_reason), do: gettext("Could not complete the action. Please try again.")
defp urgency_label(:now), do: gettext("Now")
defp urgency_label("now"), do: gettext("Now")

View File

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

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
user_token = get_session(conn, :user_token)
case get_in(conn.assigns, [:current_scope, Access.key(:user)]) do
%Accounts.User{id: user_id} ->
{:ok, _cleanup} = WhoNeedHelp.Tracking.stop_all_sessions(user_id)
_anonymous ->
:ok
end
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
@ -66,13 +75,33 @@ defmodule WhoNeedHelpWeb.UserAuth do
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
case ensure_user_token(conn) do
{token, conn} ->
case Accounts.get_user_by_session_token(token) do
{user, token_inserted_at} ->
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
nil ->
conn
|> delete_session(:user_token)
|> delete_session(:live_socket_id)
|> delete_session(:user_remember_me)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> assign(:current_scope, Scope.for_user(nil))
end
nil ->
assign(conn, :current_scope, Scope.for_user(nil))
end
end
@doc "Prevents authenticated HTML and mobile responses from being stored by browsers or proxies."
def put_authenticated_cache_policy(conn, _opts) do
case conn.assigns[:current_scope] do
%Scope{user: %Accounts.User{}} -> put_resp_header(conn, "cache-control", "no-store")
_ -> conn
end
end
@ -164,7 +193,25 @@ defmodule WhoNeedHelpWeb.UserAuth do
end
defp put_token_in_session(conn, token) do
put_session(conn, :user_token, token)
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, live_socket_id(token))
end
def live_socket_id(token) when is_binary(token),
do: "users_sessions:#{Base.url_encode64(token, padding: false)}"
@doc "Disconnects LiveView sockets backed by the supplied expired session tokens."
def disconnect_sessions(tokens) when is_list(tokens) do
tokens
|> Enum.filter(&(&1.context == "session"))
|> Enum.each(fn token ->
token.token
|> live_socket_id()
|> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{})
end)
:ok
end
@doc """

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 = `${CACHE_PREFIX}v3`
const CACHE = `${CACHE_PREFIX}v4`
const OFFLINE_URL = "/offline.html"
const SHELL = [
OFFLINE_URL,

View File

@ -33,6 +33,17 @@ defmodule WhoNeedHelp.AccountsTest do
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
test "does not authenticate a suspended user with a valid password" do
user = user_fixture() |> set_password()
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
refute Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
@ -373,6 +384,21 @@ defmodule WhoNeedHelp.AccountsTest do
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "rejects a suspended user and consumes the magic link" do
user = user_fixture()
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
refute Accounts.get_user_by_magic_link_token(encoded_token)
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
@ -393,6 +419,21 @@ defmodule WhoNeedHelp.AccountsTest do
end
end
describe "get_user_by_session_token/1 moderation boundary" do
test "never authenticates a suspended user even if a token still exists" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
{1, nil} =
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
refute Accounts.get_user_by_session_token(token)
assert Repo.exists?(from(candidate in UserToken, where: candidate.token == ^token))
end
end
describe "deliver_login_instructions/2" do
setup do
%{user: unconfirmed_user_fixture()}

View File

@ -30,7 +30,8 @@ defmodule WhoNeedHelp.ActivitiesTest do
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 2,
"category_id" => coffee.id
"category_id" => coffee.id,
"safety_confirmed" => true
}
{:ok,
@ -87,6 +88,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
Activities.request_to_join(context.outsider_scope, activity.id)
end
test "invalid participant identifiers are rejected", context do
assert {:error, :not_found} =
Activities.approve_participant(context.organizer_scope, "not-a-uuid")
assert {:error, :not_found} =
Activities.decline_participant(context.organizer_scope, "not-a-uuid")
end
test "unapproved viewers receive no private chat or pending participant data", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
@ -158,6 +167,36 @@ defmodule WhoNeedHelp.ActivitiesTest do
Activities.request_to_join(context.participant_scope, activity.id)
end
test "blocking an approved participant revokes exact location and group chat", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
{:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
{:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, loaded).exact
assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
assert {:error, :not_found} =
Activities.get_activity(context.participant_scope, activity.id)
assert Activities.coordinates_for(context.participant_scope, loaded).exact == false
assert Activities.paginate_messages(context.participant_scope, loaded).entries == []
assert {:error, :blocked} =
Activities.send_message(context.participant_scope, activity.id, %{
"body" => "This must not be delivered"
})
end
test "activity creation requires server-side safety consent", context do
attrs = Map.delete(context.attrs, "safety_confirmed")
assert {:error, changeset} =
Activities.create_activity(context.organizer_scope, attrs)
assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
end
test "activity discovery uses stable cursor pages", context do
activities =
for offset <- 2..5 do

View File

@ -24,7 +24,8 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
"category_id" => category.id,
"safety_confirmed" => true
}
{:ok,
@ -73,6 +74,11 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
}
end
test "invalid proposal identifiers are rejected", context do
assert {:error, :not_found} = Catalog.vote(context.requester_scope, "not-a-uuid")
assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
end
test "chat is durable and only visible to match participants", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
@ -98,6 +104,15 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
end
test "non-participants cannot reopen matched request details by UUID", context do
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
assert {:ok, _public_request} = Help.get_request(outsider_scope, request.id)
assert {:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
assert {:error, :not_found} = Help.get_request(outsider_scope, request.id)
end
test "request details do not preload the unbounded chat history", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -279,6 +294,34 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
end
test "concurrent blind reviews are both revealed", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
{:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
{:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)
{:ok, assignment} =
Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))
submissions = [
{context.requester_scope, %{"rating" => 5, "comment" => "Kind"}},
{context.helper_scope, %{"rating" => 4, "comment" => "Clear"}}
]
results =
submissions
|> Task.async_stream(
fn {scope, attrs} -> Trust.submit_review(scope, assignment, attrs) end,
max_concurrency: 2,
ordered: false
)
|> Enum.map(fn {:ok, result} -> result end)
assert Enum.all?(results, &match?({:ok, _review}, &1))
assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
assert [%{rating: 4}] = Trust.visible_reviews(context.requester.id)
end
test "category moderation export excludes proposer identity", context do
{:ok, proposal} =
Catalog.propose(context.requester_scope, %{

View File

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

View File

@ -24,7 +24,8 @@ defmodule WhoNeedHelp.TrustSafetyTest do
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
"category_id" => category.id,
"safety_confirmed" => true
}
%{
@ -56,6 +57,52 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert [] = Messaging.list_messages(context.requester_scope, assignment)
end
test "invalid block identifiers are rejected without a query cast failure", context do
assert {:error, :not_found} = Trust.block(context.requester_scope, "not-a-uuid")
assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid")
end
test "forged moderation identifiers return not found without crashing", context do
moderator =
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
admin =
user_fixture(display_name: "Administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator_scope = user_scope_fixture(moderator)
admin_scope = user_scope_fixture(admin)
missing_id = Ecto.UUID.generate()
for id <- ["not-a-uuid", missing_id] do
assert {:error, :not_found} = Trust.moderate_report(moderator_scope, id, %{})
assert {:error, :not_found} = Trust.report_evidence(moderator_scope, id)
assert {:error, :not_found} = Trust.moderate_signal(moderator_scope, id, %{})
assert {:error, :not_found} = Trust.hide_request(moderator_scope, id, "review")
assert {:error, :not_found} = Trust.restore_request(moderator_scope, id)
assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review")
assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id)
assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{})
assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{})
assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{})
assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review")
assert {:error, :not_found} =
Catalog.merge_proposal(moderator_scope, id, context.category.id, "review")
end
assert {:error, :not_found} =
Catalog.merge_proposal(
moderator_scope,
Ecto.UUID.generate(),
"not-a-uuid",
"review"
)
end
test "tracking derives movement and proximity from browser accuracy envelopes", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -195,6 +242,26 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert "pickup_status has an invalid value" in errors_on(changeset).structured_data
end
test "help creation rejects missing safety consent and non-help categories", context do
without_consent = Map.delete(context.attrs, "safety_confirmed")
assert {:error, changeset} =
Help.create_request(context.requester_scope, without_consent)
assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
activity_category =
Catalog.list_categories(:activity)
|> List.first()
wrong_category = Map.put(context.attrs, "category_id", activity_category.id)
assert {:error, changeset} =
Help.create_request(context.requester_scope, wrong_category)
assert "select an active help category" in errors_on(changeset).category_id
end
test "configured rate limits are atomic database counters", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
@ -239,6 +306,39 @@ defmodule WhoNeedHelp.TrustSafetyTest do
Help.request_coordinates(context.requester_scope, request)
end
test "blocking an accepted helper revokes request, exact location, and tracking", context do
attrs = Map.put(context.attrs, "location_visibility", "exact_for_active_match")
{:ok, request} = Help.create_request(context.requester_scope, attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
request = Help.get_request!(request.id)
{:ok, _session} = Tracking.start_session(context.helper_scope, assignment)
{:ok, position} =
Tracking.update_position(context.helper_scope, assignment, %{
"latitude" => 50.45,
"longitude" => 30.52,
"accuracy_meters" => 10.0
})
assert %{exact: true} = Help.request_coordinates(context.helper_scope, request)
assert {:ok, _block} = Trust.block(context.requester_scope, context.helper.id)
assert {:error, :not_found} = Help.get_request(context.helper_scope, request.id)
assert %{exact: false} = Help.request_coordinates(context.helper_scope, request)
assert Tracking.list_current_positions(context.helper_scope, assignment) == %{}
assert {:error, :blocked} = Tracking.start_session(context.helper_scope, assignment)
assert {:error, :blocked} =
Tracking.update_position(context.helper_scope, assignment, %{
"latitude" => 50.46,
"longitude" => 30.53,
"accuracy_meters" => 10.0
})
refute Repo.get(Position, position.id)
end
test "cancelling a matched request ends tracking and deletes raw coordinates", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -291,6 +391,76 @@ defmodule WhoNeedHelp.TrustSafetyTest do
)
end
test "restricted moderators lose authorization and the last active admin cannot be suspended",
context do
admin =
user_fixture(display_name: "Administrator")
|> Ecto.Changeset.change(role: :admin)
|> Repo.update!()
moderator =
user_fixture(display_name: "Moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
active_moderator =
user_fixture(display_name: "Active moderator")
|> Ecto.Changeset.change(role: :moderator)
|> Repo.update!()
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
restricted =
moderator
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{
"moderation_status" => "restricted"
})
|> Repo.update!()
refute WhoNeedHelp.Accounts.moderator_authorized?(restricted)
session_token = WhoNeedHelp.Accounts.generate_user_session_token(context.helper)
socket_id = WhoNeedHelpWeb.UserAuth.live_socket_id(session_token)
:ok = WhoNeedHelpWeb.Endpoint.subscribe(socket_id)
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
{:ok, tracking_session} = Tracking.start_session(context.helper_scope, assignment)
{:ok, _position} =
Tracking.update_position(context.helper_scope, assignment, %{
"latitude" => 50.4501,
"longitude" => 30.5234,
"accuracy_meters" => 5.0
})
:ok = Tracking.subscribe(assignment.id)
assert {:ok, suspended} =
Trust.moderate_user(user_scope_fixture(active_moderator), context.helper.id, %{
"moderation_status" => "suspended"
})
assert suspended.moderation_status == :suspended
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^socket_id}
assert_receive {:tracking_stopped, helper_id}
assert helper_id == context.helper.id
assert WhoNeedHelp.Accounts.get_user_by_session_token(session_token) == nil
refute Repo.get_by(Position, tracking_session_id: tracking_session.id)
refute Repo.get_by(WhoNeedHelp.Tracking.TrackingSession,
id: tracking_session.id,
active: true
)
assert {:error, :last_admin} =
Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{
"moderation_status" => "suspended"
})
assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active
end
test "the first administrator bootstrap is one-time and audited", context do
assert {:ok, admin} = Release.bootstrap_admin(context.helper.email)
assert admin.id == context.helper.id

View File

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

View File

@ -57,6 +57,23 @@ defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
refute Repo.get_by(TrackingSession, id: session.id, active: true)
end
test "logging out removes the active native position from the server", context do
{:ok, session} = Tracking.start_session(context.helper_scope, context.assignment)
{:ok, _position} =
Tracking.update_position(context.helper_scope, context.assignment, %{
"latitude" => 50.4501,
"longitude" => 30.5234,
"accuracy_meters" => 7.5
})
conn = delete(context.logged_in_conn, ~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
refute Repo.get_by(Position, tracking_session_id: session.id)
refute Repo.get_by(TrackingSession, id: session.id, active: true)
end
test "an authenticated non-participant cannot update tracking", context do
outsider = user_fixture(display_name: "Outsider")
{conn, csrf_token} = csrf_connection(log_in_user(build_conn(), outsider), ~p"/requests")
@ -116,7 +133,8 @@ defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
"category_id" => category.id,
"safety_confirmed" => true
}
end
end

View File

@ -5,8 +5,14 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
conn = get(conn, ~p"/")
html = html_response(conn, 200)
document = LazyHTML.from_document(html)
[content_security_policy] = get_resp_header(conn, "content-security-policy")
assert html =~ "Help can be closer than you think."
assert content_security_policy =~ "default-src 'self'"
assert content_security_policy =~ "script-src 'self'"
refute content_security_policy =~ "script-src 'self' 'unsafe-inline'"
assert content_security_policy =~ "frame-ancestors 'none'"
assert content_security_policy =~ "https://tile.openstreetmap.org"
assert html =~
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
@ -33,6 +39,19 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
assert html_response(conn, 200) =~ "Помощь может быть ближе, чем кажется."
end
test "health endpoints expose status without internal node names", %{conn: conn} do
assert %{"status" => "ok"} =
conn
|> get(~p"/healthz/live")
|> json_response(200)
assert %{"status" => "ready"} =
conn
|> recycle()
|> get(~p"/healthz/ready")
|> json_response(200)
end
test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
conn = get(conn, ~p"/safety?locale=uk")
assert html_response(conn, 200) =~ "Правила безпеки"
@ -76,7 +95,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
body = response(conn, 200)
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v3`)
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v4`)
assert body =~ ~s(const OFFLINE_URL = "/offline.html")
assert body =~ ~s(event.request.mode === "navigate")
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|

View File

@ -1,5 +1,5 @@
defmodule WhoNeedHelpWeb.UserSessionControllerTest do
use WhoNeedHelpWeb.ConnCase, async: true
use WhoNeedHelpWeb.ConnCase, async: false
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.Accounts
@ -82,7 +82,12 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
token = get_session(conn, :user_token)
assert token
assert get_session(conn, :live_socket_id) ==
WhoNeedHelpWeb.UserAuth.live_socket_id(token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
@ -136,6 +141,51 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
assert response =~ "Log in"
assert response =~ "Invalid email or password"
end
test "does not log in a suspended user with a valid password", %{conn: conn, user: user} do
user = set_password(user)
{:ok, user} =
user
|> Ecto.Changeset.change(moderation_status: :suspended)
|> WhoNeedHelp.Repo.update()
conn =
post(conn, ~p"/users/log-in?mode=password", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
refute get_session(conn, :user_token)
assert html_response(conn, 200) =~ "Invalid email or password"
end
test "applies the configured password-login throttle", %{conn: conn, user: user} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"password_login_email" => %{"limit" => 1, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, previous) end)
params = %{"user" => %{"email" => user.email, "password" => "invalid_password"}}
assert conn |> post(~p"/users/log-in", params) |> html_response(200) =~
"Invalid email or password"
throttled = post(build_conn(), ~p"/users/log-in", params)
assert throttled.status == 429
assert html_response(throttled, 429) =~ "Too many sign-in attempts"
end
test "authenticated responses are not stored", %{conn: conn, user: user} do
conn =
conn
|> log_in_user(user)
|> get(~p"/users/settings")
assert get_resp_header(conn, "cache-control") == ["no-store"]
end
end
describe "POST /users/log-in - magic link" do
@ -149,6 +199,21 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do
assert WhoNeedHelp.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
end
test "does not send a magic link for a suspended user", %{conn: conn, user: user} do
{:ok, user} =
user
|> Ecto.Changeset.change(moderation_status: :suspended)
|> WhoNeedHelp.Repo.update()
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == ~p"/users/log-in"
refute WhoNeedHelp.Repo.get_by(Accounts.UserToken, user_id: user.id)
end
test "logs the user in", %{conn: conn, user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user)

View File

@ -31,6 +31,10 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
describe "PUT /users/settings (change password form)" do
test "updates the user password and resets tokens", %{conn: conn, user: user} do
second_token = Accounts.generate_user_session_token(user)
second_socket_id = WhoNeedHelpWeb.UserAuth.live_socket_id(second_token)
:ok = WhoNeedHelpWeb.Endpoint.subscribe(second_socket_id)
new_password_conn =
put(conn, ~p"/users/settings", %{
"action" => "update_password",
@ -47,6 +51,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^second_socket_id}
refute Accounts.get_user_by_session_token(second_token)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end

View File

@ -23,6 +23,72 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Створити термінову заявку"
end
test "invalid request and activity identifiers redirect without crashing", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/requests"}}} =
live(conn, "/requests/not-a-uuid")
assert {:error, {:live_redirect, %{to: "/activities"}}} =
live(conn, "/activities/not-a-uuid")
end
test "request index applies PubSub updates without a full page reload" do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Realtime requester")
helper = user_fixture(display_name: "Realtime helper")
{:ok, view, _html} = build_conn() |> log_in_user(helper) |> live(~p"/requests")
{:ok, request} =
Help.create_request(Accounts.Scope.for_user(requester), %{
"title" => "Realtime medicine pickup",
"description" => "The legal medicine is reserved and ready for collection.",
"pickup_instructions" => "Use the order number from private coordination.",
"location_label" => "Central district",
"latitude" => "50.4501",
"longitude" => "30.5234",
"urgency" => "now",
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"category_id" => category.id,
"safety_confirmed" => true
})
assert render(view) =~ "Realtime medicine pickup"
{:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
refute render(view) =~ "Realtime medicine pickup"
end
test "activity index applies PubSub updates without a full page reload" do
Catalog.seed_defaults()
organizer = user_fixture(display_name: "Realtime organizer")
viewer = user_fixture(display_name: "Realtime activity viewer")
category =
Catalog.list_categories(:activity)
|> Enum.find(&(&1.slug == "coffee-meetup"))
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/activities")
{:ok, _activity} =
Activities.create_activity(Accounts.Scope.for_user(organizer), %{
"title" => "Realtime coffee meetup",
"description" => "Meet at a public café for a short conversation.",
"structured_data" => %{"setting" => "cafe"},
"location_label" => "Central square",
"latitude" => "50.4501",
"longitude" => "30.5234",
"location_visibility" => "approximate_public",
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 3,
"category_id" => category.id,
"safety_confirmed" => true
})
assert render(view) =~ "Realtime coffee meetup"
end
test "new request form is driven by category structured fields", %{conn: conn} do
category = Catalog.seed_defaults()
{:ok, view, html} = live(conn, ~p"/requests/new")
@ -152,7 +218,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
"join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
"capacity" => 3,
"category_id" => coffee.id
"category_id" => coffee.id,
"safety_confirmed" => true
}
{:ok, activity} = Activities.create_activity(organizer_scope, attrs)
@ -250,6 +317,39 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
stop_live_view(helper_view)
end
test "terminal request updates stop Android foreground tracking", _context do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Tracking requester")
helper = user_fixture(display_name: "Tracking helper")
{:ok, request} =
Help.create_request(user_scope_fixture(requester), request_attrs(category))
{:ok, assignment} = Help.accept_request(user_scope_fixture(helper), request.id)
helper_conn =
build_conn()
|> log_in_user(helper)
|> put_connect_params(%{"client_type" => "android"})
{:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}")
helper_view
|> element("button[phx-click='start-tracking']")
|> render_click()
assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id})
assert assignment_id == assignment.id
assert {:ok, _cancelled} =
Help.cancel_request(user_scope_fixture(requester), request.id)
assert_push_event(helper_view, "native-tracking-stop", %{})
refute has_element?(helper_view, "button[phx-click='stop-tracking']")
stop_live_view(helper_view)
end
test "regular users cannot enter moderation", %{conn: conn} do
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
end
@ -326,7 +426,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
"category_id" => category.id,
"safety_confirmed" => true
}
end

View File

@ -3,8 +3,10 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Repo
alias WhoNeedHelpWeb.UserAuth
import Ecto.Query, only: [from: 2]
import WhoNeedHelp.AccountsFixtures
@remember_me_cookie "_who_need_help_web_user_remember_me"
@ -158,6 +160,37 @@ defmodule WhoNeedHelpWeb.UserAuthTest do
refute conn.assigns.current_scope
end
test "clears a suspended user's surviving session and remember cookie", %{
conn: conn,
user: user
} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
{1, nil} =
Repo.update_all(
from(candidate in WhoNeedHelp.Accounts.User, where: candidate.id == ^user.id),
set: [moderation_status: :suspended]
)
conn =
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, UserAuth.live_socket_id(token))
|> put_session(:user_remember_me, true)
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
refute conn.assigns.current_scope
refute get_session(conn, :user_token)
refute get_session(conn, :live_socket_id)
refute get_session(conn, :user_remember_me)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
end
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})