diff --git a/assets/js/app.js b/assets/js/app.js index cb89426..76761e9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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, diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 0a4bd70..b6b0b04 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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( - `${escapeHtml(point.title)}
${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 = { diff --git a/lib/mix/tasks/wnh.load_fixtures.ex b/lib/mix/tasks/wnh.load_fixtures.ex index 0342790..2de5ace 100644 --- a/lib/mix/tasks/wnh.load_fixtures.ex +++ b/lib/mix/tasks/wnh.load_fixtures.ex @@ -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") diff --git a/lib/mix/tasks/wnh.staging_android_e2e.ex b/lib/mix/tasks/wnh.staging_android_e2e.ex index 9552eb6..cf5ed8f 100644 --- a/lib/mix/tasks/wnh.staging_android_e2e.ex +++ b/lib/mix/tasks/wnh.staging_android_e2e.ex @@ -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!() diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index c5e32a8..f374e87 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -79,7 +79,9 @@ defmodule WhoNeedHelp.Accounts do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do user = Repo.get_by(User, email: email) - if User.valid_password?(user, password), do: user + + if User.valid_password?(user, password) and user.moderation_status != :suspended, + do: user end @doc """ @@ -124,7 +126,10 @@ defmodule WhoNeedHelp.Accounts do when role in [:moderator, :admin], do: Repo.exists?( - from user in User, where: user.id == ^id and user.role in [:moderator, :admin] + from user in User, + where: + user.id == ^id and user.role in [:moderator, :admin] and + user.moderation_status == :active ) def moderator_authorized?(_user), do: false @@ -133,7 +138,11 @@ defmodule WhoNeedHelp.Accounts do def admin?(_user), do: false def admin_authorized?(%User{id: id, role: :admin}), - do: Repo.exists?(from user in User, where: user.id == ^id and user.role == :admin) + do: + Repo.exists?( + from user in User, + where: user.id == ^id and user.role == :admin and user.moderation_status == :active + ) def admin_authorized?(_user), do: false @@ -158,47 +167,87 @@ defmodule WhoNeedHelp.Accounts do end def moderate_user(%User{} = moderator, user_id, attrs) do - if moderator_authorized?(moderator) do + with {:ok, user_id} <- cast_id(user_id), + true <- moderator_authorized?(moderator) do Repo.transact(fn -> - user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!() + active_admins = lock_active_admins() + user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() - if user.id == moderator.id and attrs["moderation_status"] in ["restricted", "suspended"] do - {:error, :cannot_restrict_self} - else - with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do - if user.moderation_status == :suspended do - Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id) - end + if user do + requested_status = attrs["moderation_status"] || attrs[:moderation_status] - {:ok, user} + cond do + user.id == moderator.id and + requested_status in [:restricted, :suspended, "restricted", "suspended"] -> + {:error, :cannot_restrict_self} + + user.role == :admin and user.moderation_status == :active and + requested_status not in [:active, "active"] and length(active_admins) == 1 -> + {:error, :last_admin} + + true -> + with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do + session_tokens = + if user.moderation_status == :suspended do + tokens = + UserToken + |> where([token], token.user_id == ^user.id and token.context == "session") + |> select([token], token.token) + |> Repo.all() + + Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id) + tokens + else + [] + end + + {:ok, %{user: user, expired_session_tokens: session_tokens}} + end end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end def change_user_role(%User{} = admin, user_id, attrs) do - if admin_authorized?(admin) do + with {:ok, user_id} <- cast_id(user_id), + true <- admin_authorized?(admin) do Repo.transact(fn -> - user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!() - requested_role = attrs["role"] || attrs[:role] + active_admins = lock_active_admins() + user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one() - admin_count = - Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count) + if user do + requested_role = attrs["role"] || attrs[:role] - if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do - {:error, :last_admin} + if user.role == :admin and user.moderation_status == :active and + requested_role not in [:admin, "admin"] and length(active_admins) == 1 do + {:error, :last_admin} + else + user |> User.role_changeset(attrs) |> Repo.update() + end else - user |> User.role_changeset(attrs) |> Repo.update() + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end + defp lock_active_admins do + User + |> where([user], user.role == :admin and user.moderation_status == :active) + |> order_by([user], asc: user.id) + |> lock("FOR UPDATE") + |> Repo.all() + end + ## User registration @doc """ @@ -282,9 +331,12 @@ defmodule WhoNeedHelp.Accounts do end def delete_social_identity(%User{id: user_id}, identity_id) do - case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do - nil -> {:error, :not_found} - identity -> Repo.delete(identity) + with {:ok, identity_id} <- Ecto.UUID.cast(identity_id), + %SocialIdentity{} = identity <- + Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do + Repo.delete(identity) + else + _invalid_or_missing -> {:error, :not_found} end end @@ -401,7 +453,8 @@ defmodule WhoNeedHelp.Accounts do """ def get_user_by_magic_link_token(token) do with {:ok, query} <- UserToken.verify_magic_link_token_query(token), - {user, _token} <- Repo.one(query) do + {%User{moderation_status: status} = user, _token} <- Repo.one(query), + true <- status != :suspended do user else _ -> nil @@ -430,6 +483,10 @@ defmodule WhoNeedHelp.Accounts do {:ok, query} = UserToken.verify_magic_link_token_query(token) case Repo.one(query) do + {%User{moderation_status: :suspended}, token} -> + Repo.delete!(token) + {:error, :not_found} + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> raise """ @@ -502,6 +559,13 @@ defmodule WhoNeedHelp.Accounts do ) end + defp cast_id(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + defp update_user_and_delete_all_tokens(changeset) do Repo.transact(fn -> with {:ok, user} <- Repo.update(changeset) do diff --git a/lib/who_need_help/accounts/user.ex b/lib/who_need_help/accounts/user.ex index b7d3076..04da1ef 100644 --- a/lib/who_need_help/accounts/user.ex +++ b/lib/who_need_help/accounts/user.ex @@ -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 != "" -> [] _ -> diff --git a/lib/who_need_help/accounts/user_token.ex b/lib/who_need_help/accounts/user_token.ex index 0bcbe25..9e13a3b 100644 --- a/lib/who_need_help/accounts/user_token.ex +++ b/lib/who_need_help/accounts/user_token.ex @@ -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} diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index e3eb830..1e5abc6 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -71,6 +71,25 @@ defmodule WhoNeedHelp.Activities do paginate_my_activities(%Scope{user: user}).entries end + def visible_open_activity?(%Scope{user: user}, %Activity{} = activity, filters \\ %{}) do + now = DateTime.utc_now(:second) + category_id = filters["category_id"] || filters[:category_id] + + activity.status == :open and is_nil(activity.hidden_at) and + DateTime.after?(activity.starts_at, now) and + DateTime.after?(activity.join_deadline, now) and + (category_id in [nil, ""] or + to_string(activity.category_id) == to_string(category_id)) and + not Trust.blocked_between?(user.id, activity.creator_id) + end + + def member_activity?(%Activity{} = activity, user_id) do + Enum.any?( + activity.participants, + &(&1.user_id == user_id and &1.status in [:requested, :approved]) + ) + end + def paginate_my_activities(%Scope{user: user}, options \\ []) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) @@ -124,35 +143,39 @@ defmodule WhoNeedHelp.Activities do end def get_activity(%Scope{user: user}, id) do - case get_loaded_activity(id) do - nil -> - {:error, :not_found} + with {:ok, id} <- Ecto.UUID.cast(id), + %Activity{} = activity <- get_loaded_activity(id) do + cond do + activity.creator_id == user.id -> + {:ok, activity} - activity -> - cond do - activity.creator_id == user.id -> - {:ok, activity} + not is_nil(activity.hidden_at) -> + {:error, :not_found} - approved_participant?(activity, user.id) -> - {:ok, activity} + Trust.blocked_between?(activity.creator_id, user.id) -> + {:error, :not_found} - not is_nil(activity.hidden_at) -> - {:error, :not_found} + approved_participant?(activity, user.id) -> + {:ok, activity} - Trust.blocked_between?(activity.creator_id, user.id) -> - {:error, :not_found} + activity.status == :open -> + {:ok, activity_for_viewer(activity, user.id)} - activity.status == :open -> - {:ok, activity_for_viewer(activity, user.id)} - - true -> - {:error, :not_found} - end + true -> + {:error, :not_found} + end + else + _invalid_or_missing -> {:error, :not_found} end end def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do - if activity.creator_id == user.id or approved_participant?(activity, user.id) do + authorized = + activity.creator_id == user.id or + (approved_participant?(activity, user.id) and + not Trust.blocked_between?(activity.creator_id, user.id)) + + if authorized do limit = Pagination.limit(options, 50) cursor = Pagination.cursor(options) public_user = Accounts.public_user_query() @@ -221,6 +244,8 @@ defmodule WhoNeedHelp.Activities do activity = locked_activity(activity_id) now = DateTime.utc_now(:second) + :ok = Trust.lock_user_pair(activity.creator_id, user.id) + cond do activity.creator_id == user.id -> {:error, :organizer_already_joined} @@ -248,52 +273,57 @@ defmodule WhoNeedHelp.Activities do def approve_participant(%Scope{user: organizer}, participant_id) do result = - Repo.transact(fn -> - participant = locked_participant(participant_id) - activity = locked_activity(participant.activity_id) + with {:ok, participant_id} <- cast_id(participant_id) do + Repo.transact(fn -> + participant = locked_participant(participant_id) + activity = locked_activity(participant.activity_id) + :ok = Trust.lock_user_pair(activity.creator_id, participant.user_id) - cond do - activity.creator_id != organizer.id -> - {:error, :forbidden} + cond do + activity.creator_id != organizer.id -> + {:error, :forbidden} - participant.status != :requested -> - {:error, :invalid_transition} + participant.status != :requested -> + {:error, :invalid_transition} - activity.status != :open -> - {:error, :not_open} + activity.status != :open -> + {:error, :not_open} - Trust.blocked_between?(activity.creator_id, participant.user_id) -> - {:error, :blocked} + Trust.blocked_between?(activity.creator_id, participant.user_id) -> + {:error, :blocked} - approved_count(activity.id) >= activity.capacity -> - {:error, :capacity_reached} + approved_count(activity.id) >= activity.capacity -> + {:error, :capacity_reached} - true -> - with {:ok, participant} <- - participant - |> Participant.changeset(%{ - status: :approved, - reviewed_at: DateTime.utc_now(:second) - }) - |> Repo.update(), - {:ok, _audit} <- - Trust.audit( - organizer.id, - "activity.participant_approved", - "activity_participant", - participant.id, - %{"activity_id" => activity.id, "user_id" => participant.user_id} - ) do - {:ok, participant} - end - end - end) + true -> + with {:ok, participant} <- + participant + |> Participant.changeset(%{ + status: :approved, + reviewed_at: DateTime.utc_now(:second) + }) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + organizer.id, + "activity.participant_approved", + "activity_participant", + participant.id, + %{"activity_id" => activity.id, "user_id" => participant.user_id} + ) do + {:ok, participant} + end + end + end) + end after_participant_change(result, participant_activity_id(result), :participant_approved) end def decline_participant(%Scope{user: organizer}, participant_id) do - transition_participant(organizer.id, participant_id, :declined) + with {:ok, participant_id} <- cast_id(participant_id) do + transition_participant(organizer.id, participant_id, :declined) + end end def leave_activity(%Scope{user: user}, activity_id) do @@ -345,40 +375,54 @@ defmodule WhoNeedHelp.Activities do end def send_message(%Scope{user: user} = scope, activity_id, attrs) do - with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message), - {:ok, activity} <- get_activity(scope, activity_id), - true <- activity.status == :open, - true <- approved_participant?(activity, user.id) do - %Message{} - |> Message.changeset(%{ - body: attrs["body"] || attrs[:body], - activity_id: activity.id, - sender_id: user.id - }) - |> Repo.insert() - |> case do - {:ok, message} -> - message = Repo.preload(message, sender: Accounts.public_user_query()) + result = + with {:ok, _limit} <- Trust.authorize_action(scope, :send_activity_message) do + Repo.transact(fn -> + activity = locked_activity(activity_id) + :ok = Trust.lock_user_pair(activity.creator_id, user.id) - Phoenix.PubSub.broadcast( - WhoNeedHelp.PubSub, - "activity:#{activity.id}", - {:activity_message, message} - ) + cond do + activity.status != :open or not is_nil(activity.hidden_at) -> + {:error, :not_open} - {:ok, message} + Trust.blocked_between?(activity.creator_id, user.id) -> + {:error, :blocked} - other -> - other + not approved_participant_id?(activity.id, user.id) -> + {:error, :forbidden} + + true -> + %Message{} + |> Message.changeset(%{ + body: attrs["body"] || attrs[:body], + activity_id: activity.id, + sender_id: user.id + }) + |> Repo.insert() + end + end) end - else - false -> {:error, :forbidden} - other -> other + + with {:ok, message} <- result do + message = Repo.preload(message, sender: Accounts.public_user_query()) + + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "activity:#{message.activity_id}", + {:activity_message, message} + ) + + {:ok, message} end end def coordinates_for(%Scope{user: user}, %Activity{} = activity) do - if activity.creator_id == user.id or approved_participant?(activity, user.id) do + authorized = + activity.creator_id == user.id or + (approved_participant?(activity, user.id) and + not Trust.blocked_between?(activity.creator_id, user.id)) + + if authorized do Activity.exact_coordinates(activity) else Activity.public_coordinates(activity) @@ -396,6 +440,15 @@ defmodule WhoNeedHelp.Activities do ) end + defp approved_participant_id?(activity_id, user_id) do + Repo.exists?( + from participant in Participant, + where: + participant.activity_id == ^activity_id and participant.user_id == ^user_id and + participant.status == :approved + ) + end + defp validate_category_and_data(changeset) do category_id = Ecto.Changeset.get_field(changeset, :category_id) @@ -644,6 +697,13 @@ defmodule WhoNeedHelp.Activities do defp maybe_filter_category(query, value), do: where(query, [activity], activity.category_id == ^value) + defp cast_id(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + defp broadcast(message), do: Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, message) defp broadcast_activity(id, message), diff --git a/lib/who_need_help/activities/activity.ex b/lib/who_need_help/activities/activity.ex index a64e3db..a74b550 100644 --- a/lib/who_need_help/activities/activity.ex +++ b/lib/who_need_help/activities/activity.ex @@ -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 diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index 4e0c84e..ff317ab 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -74,25 +74,37 @@ defmodule WhoNeedHelp.Catalog do end def vote(%Scope{user: user} = scope, proposal_id) do - with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote), - %CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do - %CategoryVote{} - |> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id}) - |> Repo.insert() - else - nil -> {:error, :not_found} - %CategoryProposal{} -> {:error, :proposal_closed} - other -> other + with {:ok, proposal_id} <- cast_id(proposal_id), + {:ok, _limit} <- Trust.authorize_action(scope, :category_vote) do + Repo.transact(fn -> + case CategoryProposal + |> where([proposal], proposal.id == ^proposal_id) + |> lock("FOR UPDATE") + |> Repo.one() do + nil -> + {:error, :not_found} + + %CategoryProposal{status: :open} -> + %CategoryVote{} + |> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id}) + |> Repo.insert() + + %CategoryProposal{} -> + {:error, :proposal_closed} + end + end) end end def unvote(%Scope{user: user}, proposal_id) do - {count, _} = - CategoryVote - |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id) - |> Repo.delete_all() + with {:ok, proposal_id} <- cast_id(proposal_id) do + {count, _} = + CategoryVote + |> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id) + |> Repo.delete_all() - {:ok, count} + {:ok, count} + end end def seed_defaults do @@ -175,46 +187,53 @@ defmodule WhoNeedHelp.Catalog do end def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do - if Accounts.moderator_authorized?(moderator) do + with {:ok, proposal_id} <- cast_id(proposal_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> proposal = locked_proposal(proposal_id) - if proposal.status == :open do - category_attrs = - category_attrs - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> normalize_descriptions() - |> Map.put_new("parent_id", proposal.parent_id) - |> Map.put_new("mode", to_string(proposal.mode)) + cond do + is_nil(proposal) -> + {:error, :not_found} - with {:ok, category} <- - %Category{} |> Category.changeset(category_attrs) |> Repo.insert(), - {:ok, proposal} <- - proposal - |> CategoryProposal.moderation_changeset(%{ - status: :approved, - merged_into_id: category.id, - reviewed_by_id: moderator.id, - reviewed_at: DateTime.utc_now(:second), - moderation_note: category_attrs["moderation_note"] - }) - |> Repo.update(), - {:ok, _audit} <- - Trust.audit( - moderator.id, - "category_proposal.approved", - "category_proposal", - proposal.id, - %{"category_id" => category.id} - ) do - {:ok, %{proposal: proposal, category: category}} - end - else - {:error, :proposal_closed} + proposal.status == :open -> + category_attrs = + category_attrs + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> normalize_descriptions() + |> Map.put_new("parent_id", proposal.parent_id) + |> Map.put_new("mode", to_string(proposal.mode)) + + with {:ok, category} <- + %Category{} |> Category.changeset(category_attrs) |> Repo.insert(), + {:ok, proposal} <- + proposal + |> CategoryProposal.moderation_changeset(%{ + status: :approved, + merged_into_id: category.id, + reviewed_by_id: moderator.id, + reviewed_at: DateTime.utc_now(:second), + moderation_note: category_attrs["moderation_note"] + }) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "category_proposal.approved", + "category_proposal", + proposal.id, + %{"category_id" => category.id} + ) do + {:ok, %{proposal: proposal, category: category}} + end + + true -> + {:error, :proposal_closed} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end @@ -223,45 +242,54 @@ defmodule WhoNeedHelp.Catalog do end def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do - with %Category{} <- Repo.get(Category, category_id) do + with {:ok, category_id} <- cast_id(category_id), + %Category{} <- Repo.get(Category, category_id) do moderate_proposal(moderator, proposal_id, :merged, category_id, note) else + {:error, :not_found} = error -> error nil -> {:error, :not_found} end end defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do - if Accounts.moderator_authorized?(moderator) do + with {:ok, proposal_id} <- cast_id(proposal_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> proposal = locked_proposal(proposal_id) - if proposal.status == :open do - with {:ok, proposal} <- - proposal - |> CategoryProposal.moderation_changeset(%{ - status: status, - merged_into_id: merged_into_id, - reviewed_by_id: moderator.id, - reviewed_at: DateTime.utc_now(:second), - moderation_note: note - }) - |> Repo.update(), - {:ok, _audit} <- - Trust.audit( - moderator.id, - "category_proposal.#{status}", - "category_proposal", - proposal.id, - %{"merged_into_id" => merged_into_id} - ) do - {:ok, proposal} - end - else - {:error, :proposal_closed} + cond do + is_nil(proposal) -> + {:error, :not_found} + + proposal.status == :open -> + with {:ok, proposal} <- + proposal + |> CategoryProposal.moderation_changeset(%{ + status: status, + merged_into_id: merged_into_id, + reviewed_by_id: moderator.id, + reviewed_at: DateTime.utc_now(:second), + moderation_note: note + }) + |> Repo.update(), + {:ok, _audit} <- + Trust.audit( + moderator.id, + "category_proposal.#{status}", + "category_proposal", + proposal.id, + %{"merged_into_id" => merged_into_id} + ) do + {:ok, proposal} + end + + true -> + {:error, :proposal_closed} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end @@ -269,7 +297,7 @@ defmodule WhoNeedHelp.Catalog do CategoryProposal |> where([proposal], proposal.id == ^proposal_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() end defp valid_structured_value?(%{"type" => "select", "options" => options}, value) @@ -347,9 +375,11 @@ defmodule WhoNeedHelp.Catalog do case attrs["parent_id"] do parent_id when parent_id not in [nil, ""] -> - case Repo.get(Category, parent_id) do - nil -> {:error, :invalid_parent} - parent -> {:ok, Map.put(attrs, "mode", to_string(parent.mode))} + with {:ok, parent_id} <- cast_id(parent_id), + %Category{} = parent <- Repo.get(Category, parent_id) do + {:ok, Map.put(attrs, "mode", to_string(parent.mode))} + else + _invalid_or_missing -> {:error, :invalid_parent} end _ -> @@ -863,6 +893,13 @@ defmodule WhoNeedHelp.Catalog do ) end + defp cast_id(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru} defp option(value, label), do: %{"value" => value, "label" => label} diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 97da558..e1a799e 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -64,6 +64,13 @@ defmodule WhoNeedHelp.Help do def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required") + def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do + request.status == :open and is_nil(request.hidden_at) and + DateTime.after?(request.expires_at, DateTime.utc_now(:second)) and + request_filter_matches?(request, filters) and + not Trust.blocked_between?(user.id, request.requester_id) + end + def list_my_requests(%Scope{user: user}) do paginate_my_requests(%Scope{user: user}).entries end @@ -112,43 +119,37 @@ defmodule WhoNeedHelp.Help do end def get_request(%Scope{user: user} = scope, id) do - request = - HelpRequest - |> Repo.get(id) - |> case do - nil -> - nil + with {:ok, id} <- Ecto.UUID.cast(id), + %HelpRequest{} = request <- Repo.get(HelpRequest, id) do + request = + [request] + |> preload_request_relations(social_identities: true) + |> hd() - request -> - [request] - |> preload_request_relations(social_identities: true) - |> hd() + cond do + request.requester_id == user.id -> + {:ok, request} + + Accounts.moderator_authorized?(user) -> + {:ok, request} + + not is_nil(request.hidden_at) -> + {:error, :not_found} + + Trust.blocked_between?(user.id, request.requester_id) -> + {:error, :not_found} + + request.assignment && participant?(scope, request.assignment) -> + {:ok, request} + + request.status == :open and DateTime.after?(request.expires_at, DateTime.utc_now(:second)) -> + {:ok, request} + + true -> + {:error, :not_found} end - - case request do - nil -> - {:error, :not_found} - - request -> - cond do - request.requester_id == user.id -> - {:ok, request} - - request.assignment && participant?(scope, request.assignment) -> - {:ok, request} - - Accounts.moderator_authorized?(user) -> - {:ok, request} - - not is_nil(request.hidden_at) -> - {:error, :not_found} - - Trust.blocked_between?(user.id, request.requester_id) -> - {:error, :not_found} - - true -> - {:ok, request} - end + else + _invalid_or_missing -> {:error, :not_found} end end @@ -211,6 +212,8 @@ defmodule WhoNeedHelp.Help do |> lock("FOR UPDATE") |> Repo.one!() + :ok = Trust.lock_user_pair(request.requester_id, helper.id) + cond do request.requester_id == helper.id -> {:error, :own_request} @@ -284,6 +287,7 @@ defmodule WhoNeedHelp.Help do Repo.transact(fn -> assignment = locked_assignment(assignment_id) request = Repo.get!(HelpRequest, assignment.request_id) + :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id) cond do user.id != assignment.helper_id -> @@ -458,11 +462,15 @@ defmodule WhoNeedHelp.Help do def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do - participant = - request.requester_id == scope.user.id or - (not is_nil(request.assignment) and participant?(scope, request.assignment)) + owner = request.requester_id == scope.user.id - if participant and request.location_visibility in [:hidden, :exact_for_active_match] do + matched_participant = + not owner and not is_nil(request.assignment) and participant?(scope, request.assignment) and + request.assignment.status in [:accepted, :in_progress] and + not Trust.blocked_between?(scope.user.id, request.requester_id) + + if (owner or matched_participant) and + request.location_visibility in [:hidden, :exact_for_active_match] do %Geo.Point{coordinates: {lng, lat}} = request.location %{latitude: lat, longitude: lng, exact: true} else @@ -486,6 +494,7 @@ defmodule WhoNeedHelp.Help do Repo.transact(fn -> assignment = locked_assignment(assignment_id) request = Repo.get!(HelpRequest, assignment.request_id) + :ok = Trust.lock_user_pair(assignment.helper_id, request.requester_id) now = DateTime.utc_now(:second) if Trust.blocked_between?(assignment.helper_id, request.requester_id) do @@ -629,6 +638,14 @@ defmodule WhoNeedHelp.Help do |> maybe_filter(:urgency, filters["urgency"] || filters[:urgency]) end + defp request_filter_matches?(request, filters) do + category_id = filters["category_id"] || filters[:category_id] + urgency = filters["urgency"] || filters[:urgency] + + (category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and + (urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency)) + end + defp maybe_filter(query, _field, value) when value in [nil, ""], do: query defp maybe_filter(query, field, value), @@ -638,7 +655,7 @@ defmodule WhoNeedHelp.Help do category_id = Ecto.Changeset.get_field(changeset, :category_id) case category_id && Repo.get(Category, category_id) do - %Category{} = category -> + %Category{mode: :help, active: true} = category -> data = Ecto.Changeset.get_field(changeset, :structured_data) case Catalog.validate_structured_data(category, data) do @@ -654,7 +671,7 @@ defmodule WhoNeedHelp.Help do end _ -> - changeset + Ecto.Changeset.add_error(changeset, :category_id, "select an active help category") end end end diff --git a/lib/who_need_help/help/help_request.ex b/lib/who_need_help/help/help_request.ex index 26088f1..1dc3c36 100644 --- a/lib/who_need_help/help/help_request.ex +++ b/lib/who_need_help/help/help_request.ex @@ -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 diff --git a/lib/who_need_help/messaging.ex b/lib/who_need_help/messaging.ex index c878a7e..815736e 100644 --- a/lib/who_need_help/messaging.ex +++ b/lib/who_need_help/messaging.ex @@ -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 diff --git a/lib/who_need_help/tracking.ex b/lib/who_need_help/tracking.ex index 5bc5427..d1944c7 100644 --- a/lib/who_need_help/tracking.ex +++ b/lib/who_need_help/tracking.ex @@ -32,47 +32,49 @@ defmodule WhoNeedHelp.Tracking do %Assignment{} = assignment, visibility \\ :active_match ) do - with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking), - true <- Help.participant?(scope, assignment), - true <- assignment.status in [:accepted, :in_progress] do - now = DateTime.utc_now(:second) + with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking) do + Repo.transact(fn -> + with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do + now = DateTime.utc_now(:second) - %TrackingSession{} - |> TrackingSession.changeset(%{ - assignment_id: assignment.id, - user_id: user.id, - visibility: visibility, - started_at: now - }) - |> Repo.insert( - on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]], - conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"} - ) - else - false -> {:error, :forbidden} - other -> other + %TrackingSession{} + |> TrackingSession.changeset(%{ + assignment_id: current.id, + user_id: user.id, + visibility: visibility, + started_at: now + }) + |> Repo.insert( + on_conflict: [ + set: [active: true, ended_at: nil, started_at: now, visibility: visibility] + ], + conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"} + ) + end + end) end end def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do - with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position), - true <- Help.participant?(scope, assignment) do + with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position) do result = Repo.transact(fn -> - session = - TrackingSession - |> where( - [session], - session.assignment_id == ^assignment.id and session.user_id == ^user.id and - session.active - ) - |> lock("FOR UPDATE") - |> Repo.one() + with {:ok, current} <- active_assignment(scope, assignment.id, lock: true) do + session = + TrackingSession + |> where( + [session], + session.assignment_id == ^current.id and session.user_id == ^user.id and + session.active + ) + |> lock("FOR UPDATE") + |> Repo.one() - if session do - persist_position(session, assignment, user, attrs) - else - {:error, :tracking_not_active} + if session do + persist_position(session, current, user, attrs) + else + {:error, :tracking_not_active} + end end end) @@ -85,9 +87,6 @@ defmodule WhoNeedHelp.Tracking do {:ok, position} end - else - false -> {:error, :forbidden} - other -> other end end @@ -119,15 +118,16 @@ defmodule WhoNeedHelp.Tracking do end def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do - if Trust.eligible?(scope) and Help.participant?(scope, assignment) do + with true <- Trust.eligible?(scope), + {:ok, current} <- active_assignment(scope, assignment.id) do TrackingSession - |> where([s], s.assignment_id == ^assignment.id and s.active) + |> where([s], s.assignment_id == ^current.id and s.active) |> join(:inner, [s], p in assoc(s, :position)) |> select([s, p], {s.user_id, p}) |> Repo.all() |> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end) else - %{} + _ -> %{} end end @@ -160,13 +160,93 @@ defmodule WhoNeedHelp.Tracking do end) end + def stop_all_sessions(user_id) do + result = + Repo.transact(fn -> + active_sessions = + TrackingSession + |> where([session], session.active and session.user_id == ^user_id) + |> select([session], {session.id, session.assignment_id}) + |> lock("FOR UPDATE") + |> Repo.all() + + session_ids = Enum.map(active_sessions, &elem(&1, 0)) + + if session_ids != [] do + now = DateTime.utc_now(:second) + + Position + |> where([position], position.tracking_session_id in ^session_ids) + |> Repo.delete_all() + + TrackingSession + |> where([session], session.id in ^session_ids) + |> Repo.update_all(set: [active: false, ended_at: now, updated_at: now]) + end + + {:ok, Enum.map(active_sessions, &elem(&1, 1))} + end) + + with {:ok, assignment_ids} <- result do + Enum.each(assignment_ids, fn assignment_id -> + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment_id}", + {:tracking_stopped, user_id} + ) + end) + + {:ok, %{sessions_ended: length(assignment_ids)}} + end + end + defp active_session(scope, assignment, user_id) do - if Help.participant?(scope, assignment) do + with {:ok, current} <- active_assignment(scope, assignment.id) do Repo.get_by(TrackingSession, - assignment_id: assignment.id, + assignment_id: current.id, user_id: user_id, active: true ) + else + _ -> nil + end + end + + defp active_assignment(%Scope{user: user} = scope, assignment_id, options \\ []) do + query = + Assignment + |> where([assignment], assignment.id == ^assignment_id) + |> preload(:request) + + query = if Keyword.get(options, :lock, false), do: lock(query, "FOR UPDATE"), else: query + + case Repo.one(query) do + nil -> + {:error, :not_found} + + %Assignment{} = assignment -> + request = assignment.request + + counterpart_id = + if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id + + if Keyword.get(options, :lock, false) do + :ok = Trust.lock_user_pair(user.id, counterpart_id) + end + + cond do + not Help.participant?(scope, assignment) -> + {:error, :forbidden} + + assignment.status not in [:accepted, :in_progress] -> + {:error, :assignment_inactive} + + Trust.blocked_between?(user.id, counterpart_id) -> + {:error, :blocked} + + true -> + {:ok, assignment} + end end end diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex index 44ab858..8ad59ee 100644 --- a/lib/who_need_help/trust.ex +++ b/lib/who_need_help/trust.ex @@ -47,55 +47,66 @@ defmodule WhoNeedHelp.Trust do def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do attrs = stringify_keys(attrs) - request = - case assignment.request do - %HelpRequest{} = request -> request - _ -> Repo.get!(HelpRequest, assignment.request_id) - end - - with {:ok, _limit} <- authorize_action(scope, :review), - true <- assignment.status == :completed, - true <- user.id in [assignment.helper_id, request.requester_id] do - reviewee_id = - if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id - + with {:ok, _limit} <- authorize_action(scope, :review) do Repo.transact(fn -> - with {:ok, review} <- - %Review{} - |> Review.changeset( - Map.merge(attrs, %{ - "assignment_id" => assignment.id, - "reviewer_id" => user.id, - "reviewee_id" => reviewee_id - }) - ) - |> Repo.insert(), - reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id), - true <- length(reviews) <= 2 do - if length(reviews) == 2 do - now = DateTime.utc_now(:second) + current = + Assignment + |> where([current], current.id == ^assignment.id) + |> lock("FOR UPDATE") + |> Repo.one!() - Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id), - set: [revealed_at: now] - ) - end + request = Repo.get!(HelpRequest, current.request_id) - audit(user.id, "review.submitted", "assignment", assignment.id, %{ - "revealed" => length(reviews) == 2 - }) + cond do + current.status != :completed -> + {:error, :forbidden} - {:ok, review} - else - false -> {:error, :invalid_review_count} - other -> other + user.id not in [current.helper_id, request.requester_id] -> + {:error, :forbidden} + + true -> + reviewee_id = + if user.id == current.helper_id, + do: request.requester_id, + else: current.helper_id + + with {:ok, review} <- + %Review{} + |> Review.changeset( + Map.merge(attrs, %{ + "assignment_id" => current.id, + "reviewer_id" => user.id, + "reviewee_id" => reviewee_id + }) + ) + |> Repo.insert(), + reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id), + true <- length(reviews) <= 2, + revealed? <- length(reviews) == 2, + {_count, _rows} <- + maybe_reveal_reviews(current.id, revealed?), + {:ok, _audit} <- + audit(user.id, "review.submitted", "assignment", current.id, %{ + "revealed" => revealed? + }) do + {:ok, review} + else + false -> {:error, :invalid_review_count} + other -> other + end end end) - else - false -> {:error, :forbidden} - other -> other end end + defp maybe_reveal_reviews(assignment_id, true) do + Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id), + set: [revealed_at: DateTime.utc_now(:second)] + ) + end + + defp maybe_reveal_reviews(_assignment_id, false), do: {0, nil} + def visible_reviews(user_id) do paginate_visible_reviews(user_id).entries end @@ -259,11 +270,11 @@ defmodule WhoNeedHelp.Trust do with {:ok, report} <- %Report{reporter_id: user.id} |> Report.changeset(attrs) - |> Repo.insert() do - audit(user.id, "report.created", "report", report.id, %{ - "reason" => to_string(report.reason) - }) - + |> Repo.insert(), + {:ok, _audit} <- + audit(user.id, "report.created", "report", report.id, %{ + "reason" => to_string(report.reason) + }) do {:ok, report} end end) @@ -301,7 +312,8 @@ defmodule WhoNeedHelp.Trust do end def moderate_report(%Scope{user: moderator}, report_id, attrs) do - if Accounts.moderator_authorized?(moderator) do + with {:ok, report_id} <- cast_id(report_id), + true <- Accounts.moderator_authorized?(moderator) do attrs = attrs |> stringify_keys() @@ -312,23 +324,29 @@ defmodule WhoNeedHelp.Trust do Repo.transact(fn -> report = - Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!() + Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one() - with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do - audit(moderator.id, "report.moderated", "report", report.id, %{ - "status" => to_string(report.status) - }) - - {:ok, report} + if report do + with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "report.moderated", "report", report.id, %{ + "status" => to_string(report.status) + }) do + {:ok, report} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end def report_evidence(%Scope{user: moderator}, report_id) do - if Accounts.moderator_authorized?(moderator) do + with {:ok, report_id} <- cast_id(report_id), + true <- Accounts.moderator_authorized?(moderator) do report = Report |> Repo.get(report_id) @@ -394,41 +412,80 @@ defmodule WhoNeedHelp.Trust do {:error, :not_found} end else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end def block(%Scope{user: user} = scope, blocked_id) do - with {:ok, _limit} <- authorize_action(scope, :block), + with {:ok, blocked_id} <- cast_id(blocked_id), + {:ok, _limit} <- authorize_action(scope, :block), %User{} <- Repo.get(User, blocked_id) do - Repo.transact(fn -> - with {:ok, block} <- - %Block{} - |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id}) - |> Repo.insert() do - audit(user.id, "user.blocked", "user", blocked_id) - clear_pair_tracking(user.id, blocked_id) - {:ok, block} - end - end) + result = + Repo.transact(fn -> + with :ok <- lock_user_pair(user.id, blocked_id), + {:ok, block} <- + %Block{} + |> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id}) + |> Repo.insert(), + {:ok, _audit} <- audit(user.id, "user.blocked", "user", blocked_id) do + tracking = clear_pair_tracking(user.id, blocked_id) + activity_ids = pair_activity_ids(user.id, blocked_id) + {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}} + end + end) + + with {:ok, %{block: block, tracking: tracking, activity_ids: activity_ids}} <- result do + Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} -> + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment_id}", + {:tracking_stopped, stopped_user_id} + ) + end) + + Enum.each(tracking.request_ids, &Help.notify_request_updated/1) + Enum.each(activity_ids, &Activities.notify_activity_updated/1) + {:ok, block} + end else nil -> {:error, :not_found} other -> other end end - def unblock(%Scope{user: user}, blocked_id) do - case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do - nil -> - {:ok, :already_unblocked} + defp pair_activity_ids(first_user_id, second_user_id) do + Activity + |> join(:inner, [activity], participant in Participant, + on: participant.activity_id == activity.id + ) + |> where( + [activity, participant], + participant.status == :approved and + ((activity.creator_id == ^first_user_id and participant.user_id == ^second_user_id) or + (activity.creator_id == ^second_user_id and participant.user_id == ^first_user_id)) + ) + |> select([activity], activity.id) + |> distinct(true) + |> Repo.all() + end - block -> - Repo.transact(fn -> - with {:ok, _block} <- Repo.delete(block) do - audit(user.id, "user.unblocked", "user", blocked_id) - {:ok, :unblocked} - end - end) + def unblock(%Scope{user: user}, blocked_id) do + with {:ok, blocked_id} <- cast_id(blocked_id) do + Repo.transact(fn -> + :ok = lock_user_pair(user.id, blocked_id) + + case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do + nil -> + {:ok, :already_unblocked} + + block -> + with {:ok, _block} <- Repo.delete(block), + {:ok, _audit} <- audit(user.id, "user.unblocked", "user", blocked_id) do + {:ok, :unblocked} + end + end + end) end end @@ -468,6 +525,17 @@ defmodule WhoNeedHelp.Trust do ) end + @doc false + def lock_user_pair(first_user_id, second_user_id) do + User + |> where([user], user.id in ^Enum.sort([first_user_id, second_user_id])) + |> order_by([user], asc: user.id) + |> lock("FOR UPDATE") + |> Repo.all() + + :ok + end + def record_completion_signals(%Assignment{} = assignment) do request = case assignment.request do @@ -550,7 +618,8 @@ defmodule WhoNeedHelp.Trust do end def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do - if Accounts.moderator_authorized?(moderator) do + with {:ok, signal_id} <- cast_id(signal_id), + true <- Accounts.moderator_authorized?(moderator) do attrs = attrs |> stringify_keys() @@ -564,44 +633,58 @@ defmodule WhoNeedHelp.Trust do AbuseSignal |> where([signal], signal.id == ^signal_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() - with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do - audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{ - "status" => to_string(signal.status) - }) - - {:ok, signal} + if signal do + with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{ + "status" => to_string(signal.status) + }) do + {:ok, signal} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end end def hide_request(%Scope{user: moderator}, request_id, reason) do result = - if Accounts.moderator_authorized?(moderator) do + with {:ok, request_id} <- cast_id(request_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> request = HelpRequest |> where([request], request.id == ^request_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() - with {:ok, request} <- - request - |> HelpRequest.moderation_changeset(%{ - hidden_at: DateTime.utc_now(:second), - hidden_reason: reason - }) - |> Repo.update() do - audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason}) - {:ok, request} + if request do + with {:ok, request} <- + request + |> HelpRequest.moderation_changeset(%{ + hidden_at: DateTime.utc_now(:second), + hidden_reason: reason + }) + |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "request.hidden", "request", request.id, %{ + "reason" => reason + }) do + {:ok, request} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end with {:ok, request} <- result do @@ -612,24 +695,31 @@ defmodule WhoNeedHelp.Trust do def restore_request(%Scope{user: moderator}, request_id) do result = - if Accounts.moderator_authorized?(moderator) do + with {:ok, request_id} <- cast_id(request_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> request = HelpRequest |> where([request], request.id == ^request_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() - with {:ok, request} <- - request - |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) - |> Repo.update() do - audit(moderator.id, "request.restored", "request", request.id) - {:ok, request} + if request do + with {:ok, request} <- + request + |> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) + |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "request.restored", "request", request.id) do + {:ok, request} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end with {:ok, request} <- result do @@ -640,30 +730,36 @@ defmodule WhoNeedHelp.Trust do def hide_activity(%Scope{user: moderator}, activity_id, reason) do result = - if Accounts.moderator_authorized?(moderator) do + with {:ok, activity_id} <- cast_id(activity_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> activity = Activity |> where([activity], activity.id == ^activity_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() - with {:ok, activity} <- - activity - |> Activity.moderation_changeset(%{ - hidden_at: DateTime.utc_now(:second), - hidden_reason: reason - }) - |> Repo.update() do - audit(moderator.id, "activity.hidden", "activity", activity.id, %{ - "reason" => reason - }) - - {:ok, activity} + if activity do + with {:ok, activity} <- + activity + |> Activity.moderation_changeset(%{ + hidden_at: DateTime.utc_now(:second), + hidden_reason: reason + }) + |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "activity.hidden", "activity", activity.id, %{ + "reason" => reason + }) do + {:ok, activity} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end with {:ok, activity} <- result do @@ -674,24 +770,31 @@ defmodule WhoNeedHelp.Trust do def restore_activity(%Scope{user: moderator}, activity_id) do result = - if Accounts.moderator_authorized?(moderator) do + with {:ok, activity_id} <- cast_id(activity_id), + true <- Accounts.moderator_authorized?(moderator) do Repo.transact(fn -> activity = Activity |> where([activity], activity.id == ^activity_id) |> lock("FOR UPDATE") - |> Repo.one!() + |> Repo.one() - with {:ok, activity} <- - activity - |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) - |> Repo.update() do - audit(moderator.id, "activity.restored", "activity", activity.id) - {:ok, activity} + if activity do + with {:ok, activity} <- + activity + |> Activity.moderation_changeset(%{hidden_at: nil, hidden_reason: nil}) + |> Repo.update(), + {:ok, _audit} <- + audit(moderator.id, "activity.restored", "activity", activity.id) do + {:ok, activity} + end + else + {:error, :not_found} end end) else - {:error, :forbidden} + false -> {:error, :forbidden} + {:error, :not_found} = error -> error end with {:ok, activity} <- result do @@ -701,15 +804,43 @@ defmodule WhoNeedHelp.Trust do end def moderate_user(%Scope{user: moderator}, user_id, attrs) do - Repo.transact(fn -> - with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs), - {:ok, _audit} <- - audit(moderator.id, "user.moderated", "user", user.id, %{ - "moderation_status" => to_string(user.moderation_status) - }) do - {:ok, user} - end - end) + result = + Repo.transact(fn -> + with {:ok, %{user: user} = moderation} <- + Accounts.moderate_user(moderator, user_id, attrs), + {:ok, _audit} <- + audit(moderator.id, "user.moderated", "user", user.id, %{ + "moderation_status" => to_string(user.moderation_status) + }) do + tracking = + if user.moderation_status == :suspended, + do: clear_user_tracking(user.id), + else: %{stopped: [], request_ids: []} + + {:ok, Map.put(moderation, :tracking, tracking)} + end + end) + + with {:ok, %{user: user, expired_session_tokens: tokens, tracking: tracking}} <- + result do + Enum.each(tokens, fn token -> + token + |> WhoNeedHelpWeb.UserAuth.live_socket_id() + |> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{}) + end) + + Enum.each(tracking.stopped, fn {assignment_id, stopped_user_id} -> + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment_id}", + {:tracking_stopped, stopped_user_id} + ) + end) + + Enum.each(tracking.request_ids, &Help.notify_request_updated/1) + + {:ok, user} + end end def moderate_role(%Scope{user: admin}, user_id, attrs) do @@ -1057,7 +1188,7 @@ defmodule WhoNeedHelp.Trust do defp maybe_status(query, status), do: where(query, [report], report.status == ^status) defp clear_pair_tracking(first_user_id, second_user_id) do - assignment_ids = + assignments = Assignment |> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id @@ -1067,27 +1198,95 @@ defmodule WhoNeedHelp.Trust do (assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or (assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id) ) - |> where([assignment], assignment.status in [:accepted, :in_progress]) - |> select([assignment], assignment.id) + |> select([assignment, request], {assignment.id, request.id, assignment.status}) + |> Repo.all() - session_ids = + active_assignment_ids = + assignments + |> Enum.filter(fn {_assignment_id, _request_id, status} -> + status in [:accepted, :in_progress] + end) + |> Enum.map(&elem(&1, 0)) + + active_sessions = + if active_assignment_ids == [] do + [] + else + TrackingSession + |> where([session], session.active and session.assignment_id in ^active_assignment_ids) + |> select([session], {session.id, session.assignment_id, session.user_id}) + |> Repo.all() + end + + session_ids = Enum.map(active_sessions, &elem(&1, 0)) + + if session_ids != [] do + Repo.delete_all( + from position in Position, where: position.tracking_session_id in ^session_ids + ) + + Repo.update_all( + from(session in TrackingSession, where: session.id in ^session_ids), + set: [ + active: false, + ended_at: DateTime.utc_now(:second), + updated_at: DateTime.utc_now(:second) + ] + ) + end + + %{ + request_ids: assignments |> Enum.map(&elem(&1, 1)) |> Enum.uniq(), + stopped: + Enum.map(active_sessions, fn {_id, assignment_id, user_id} -> {assignment_id, user_id} end) + } + end + + defp clear_user_tracking(user_id) do + active_sessions = TrackingSession - |> where([session], session.active and session.assignment_id in subquery(assignment_ids)) - |> select([session], session.id) + |> join(:inner, [session], assignment in Assignment, + on: assignment.id == session.assignment_id + ) + |> join(:inner, [_session, assignment], request in HelpRequest, + on: request.id == assignment.request_id + ) + |> where([session], session.active and session.user_id == ^user_id) + |> select([session, assignment, request], {session.id, assignment.id, request.id}) + |> Repo.all() - Repo.delete_all( - from position in Position, where: position.tracking_session_id in subquery(session_ids) - ) + session_ids = Enum.map(active_sessions, &elem(&1, 0)) - Repo.update_all( - from(session in TrackingSession, where: session.id in subquery(session_ids)), - set: [active: false, ended_at: DateTime.utc_now(:second)] - ) + if session_ids != [] do + now = DateTime.utc_now(:second) - :ok + Repo.delete_all( + from position in Position, where: position.tracking_session_id in ^session_ids + ) + + Repo.update_all( + from(session in TrackingSession, where: session.id in ^session_ids), + set: [active: false, ended_at: now, updated_at: now] + ) + end + + %{ + stopped: + Enum.map(active_sessions, fn {_session_id, assignment_id, _request_id} -> + {assignment_id, user_id} + end), + request_ids: active_sessions |> Enum.map(&elem(&1, 2)) |> Enum.uniq() + } end defp stringify_keys(attrs) do Map.new(attrs, fn {key, value} -> {to_string(key), value} end) end + + defp cast_id(value) do + case Ecto.UUID.cast(value) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end end diff --git a/lib/who_need_help/trust/abuse_signal.ex b/lib/who_need_help/trust/abuse_signal.ex index ad45c4a..eff3872 100644 --- a/lib/who_need_help/trust/abuse_signal.ex +++ b/lib/who_need_help/trust/abuse_signal.ex @@ -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 diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex index e1c9e71..81b0a0d 100644 --- a/lib/who_need_help/workers/expire_requests.ex +++ b/lib/who_need_help/workers/expire_requests.ex @@ -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 diff --git a/lib/who_need_help_web/components/layouts/root.html.heex b/lib/who_need_help_web/components/layouts/root.html.heex index 84843ce..1cf33f4 100644 --- a/lib/who_need_help_web/components/layouts/root.html.heex +++ b/lib/who_need_help_web/components/layouts/root.html.heex @@ -18,34 +18,6 @@ - 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 diff --git a/lib/who_need_help_web/controllers/mobile_tracking_controller.ex b/lib/who_need_help_web/controllers/mobile_tracking_controller.ex index 3aee00e..92cd9e0 100644 --- a/lib/who_need_help_web/controllers/mobile_tracking_controller.ex +++ b/lib/who_need_help_web/controllers/mobile_tracking_controller.ex @@ -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, "") diff --git a/lib/who_need_help_web/controllers/social_oauth_controller.ex b/lib/who_need_help_web/controllers/social_oauth_controller.ex index 5a36be3..6dc0a67 100644 --- a/lib/who_need_help_web/controllers/social_oauth_controller.ex +++ b/lib/who_need_help_web/controllers/social_oauth_controller.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/user_session_controller.ex b/lib/who_need_help_web/controllers/user_session_controller.ex index 0a0b53b..8708a32 100644 --- a/lib/who_need_help_web/controllers/user_session_controller.ex +++ b/lib/who_need_help_web/controllers/user_session_controller.ex @@ -35,17 +35,23 @@ defmodule WhoNeedHelpWeb.UserSessionController do # email + password login def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do - if user = Accounts.get_user_by_email_and_password(email, password) do - conn - |> put_flash(:info, gettext("Welcome back!")) - |> UserAuth.log_in_user(user, user_params) - else - form = Phoenix.Component.to_form(user_params, as: "user") + email_scope = email |> String.trim() |> String.downcase() - # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - conn - |> put_flash(:error, gettext("Invalid email or password")) - |> render(:new, form: form) + case RateLimiter.check(:password_login_email, email_scope) do + {:ok, _limit} -> + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, gettext("Welcome back!")) + |> UserAuth.log_in_user(user, user_params) + else + invalid_password_response(conn, user_params) + end + + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> put_flash(:error, gettext("Too many sign-in attempts in the configured time window.")) + |> render(:new, form: Phoenix.Component.to_form(user_params, as: "user")) end end @@ -55,11 +61,15 @@ defmodule WhoNeedHelpWeb.UserSessionController do case RateLimiter.check(:magic_link_email, email_scope) do {:ok, _limit} -> - if user = Accounts.get_user_by_email(email) do - Accounts.deliver_login_instructions( - user, - &url(~p"/users/log-in/#{&1}") - ) + case Accounts.get_user_by_email(email) do + %Accounts.User{moderation_status: status} = user when status != :suspended -> + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + _missing_or_suspended -> + :ok end info = @@ -102,4 +112,11 @@ defmodule WhoNeedHelpWeb.UserSessionController do |> put_flash(:info, gettext("Logged out successfully.")) |> UserAuth.log_out_user() end + + defp invalid_password_response(conn, user_params) do + # Keep the response identical for unknown accounts and incorrect passwords. + conn + |> put_flash(:error, gettext("Invalid email or password")) + |> render(:new, form: Phoenix.Component.to_form(user_params, as: "user")) + end end diff --git a/lib/who_need_help_web/controllers/user_settings_controller.ex b/lib/who_need_help_web/controllers/user_settings_controller.ex index d112cd2..cff4cf3 100644 --- a/lib/who_need_help_web/controllers/user_settings_controller.ex +++ b/lib/who_need_help_web/controllers/user_settings_controller.ex @@ -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") diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index 95db499..26a1586 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -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" > diff --git a/lib/who_need_help_web/live/activity_live/new.ex b/lib/who_need_help_web/live/activity_live/new.ex index 912d7e0..abd25f4 100644 --- a/lib/who_need_help_web/live/activity_live/new.ex +++ b/lib/who_need_help_web/live/activity_live/new.ex @@ -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 /> - +
+ <.input + field={@form[:safety_confirmed]} + type="checkbox" + label={ + gettext( + "I will approve participants individually, keep sensitive details in the approved group, and use an appropriate public first meeting point." + ) + } + required + class="checkbox checkbox-info mt-0.5" + /> +
<.button class="btn btn-info btn-lg w-full" phx-disable-with={gettext("Publishing…")} diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex index 49360db..77cdd4b 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -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") diff --git a/lib/who_need_help_web/live/category_proposal_live.ex b/lib/who_need_help_web/live/category_proposal_live.ex index bfa66d4..b69530d 100644 --- a/lib/who_need_help_web/live/category_proposal_live.ex +++ b/lib/who_need_help_web/live/category_proposal_live.ex @@ -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() diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex index 10362cc..fc1b9f8 100644 --- a/lib/who_need_help_web/live/moderation_live.ex +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -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}") diff --git a/lib/who_need_help_web/live/profile_live.ex b/lib/who_need_help_web/live/profile_live.ex index 50bbe8f..3ccad85 100644 --- a/lib/who_need_help_web/live/profile_live.ex +++ b/lib/who_need_help_web/live/profile_live.ex @@ -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 diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex index 8b1764b..4dc29f7 100644 --- a/lib/who_need_help_web/live/request_live/index.ex +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -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" > diff --git a/lib/who_need_help_web/live/request_live/new.ex b/lib/who_need_help_web/live/request_live/new.ex index 2ee9c5d..829b14b 100644 --- a/lib/who_need_help_web/live/request_live/new.ex +++ b/lib/who_need_help_web/live/request_live/new.ex @@ -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 /> - +
+ <.input + field={@form[:safety_confirmed]} + type="checkbox" + label={safety_confirmation(@selected_category)} + required + class="checkbox checkbox-success mt-0.5" + /> +
<.button class="btn btn-primary btn-lg w-full" phx-disable-with={gettext("Publishing…")} diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex index c01c740..899948f 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -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") diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 56819ea..f2a4975 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -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 diff --git a/lib/who_need_help_web/security_headers.ex b/lib/who_need_help_web/security_headers.ex new file mode 100644 index 0000000..963ee70 --- /dev/null +++ b/lib/who_need_help_web/security_headers.ex @@ -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 diff --git a/lib/who_need_help_web/user_auth.ex b/lib/who_need_help_web/user_auth.ex index 5d2e5c9..9eb5565 100644 --- a/lib/who_need_help_web/user_auth.ex +++ b/lib/who_need_help_web/user_auth.ex @@ -48,6 +48,15 @@ defmodule WhoNeedHelpWeb.UserAuth do """ def log_out_user(conn) do user_token = get_session(conn, :user_token) + + case get_in(conn.assigns, [:current_scope, Access.key(:user)]) do + %Accounts.User{id: user_id} -> + {:ok, _cleanup} = WhoNeedHelp.Tracking.stop_all_sessions(user_id) + + _anonymous -> + :ok + end + user_token && Accounts.delete_user_session_token(user_token) if live_socket_id = get_session(conn, :live_socket_id) do @@ -66,13 +75,33 @@ defmodule WhoNeedHelpWeb.UserAuth do Will reissue the session token if it is older than the configured age. """ def fetch_current_scope_for_user(conn, _opts) do - with {token, conn} <- ensure_user_token(conn), - {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do - conn - |> assign(:current_scope, Scope.for_user(user)) - |> maybe_reissue_user_session_token(user, token_inserted_at) - else - nil -> assign(conn, :current_scope, Scope.for_user(nil)) + case ensure_user_token(conn) do + {token, conn} -> + case Accounts.get_user_by_session_token(token) do + {user, token_inserted_at} -> + conn + |> assign(:current_scope, Scope.for_user(user)) + |> maybe_reissue_user_session_token(user, token_inserted_at) + + nil -> + conn + |> delete_session(:user_token) + |> delete_session(:live_socket_id) + |> delete_session(:user_remember_me) + |> delete_resp_cookie(@remember_me_cookie, @remember_me_options) + |> assign(:current_scope, Scope.for_user(nil)) + end + + nil -> + assign(conn, :current_scope, Scope.for_user(nil)) + end + end + + @doc "Prevents authenticated HTML and mobile responses from being stored by browsers or proxies." + def put_authenticated_cache_policy(conn, _opts) do + case conn.assigns[:current_scope] do + %Scope{user: %Accounts.User{}} -> put_resp_header(conn, "cache-control", "no-store") + _ -> conn end end @@ -164,7 +193,25 @@ defmodule WhoNeedHelpWeb.UserAuth do end defp put_token_in_session(conn, token) do - put_session(conn, :user_token, token) + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, live_socket_id(token)) + end + + def live_socket_id(token) when is_binary(token), + do: "users_sessions:#{Base.url_encode64(token, padding: false)}" + + @doc "Disconnects LiveView sockets backed by the supplied expired session tokens." + def disconnect_sessions(tokens) when is_list(tokens) do + tokens + |> Enum.filter(&(&1.context == "session")) + |> Enum.each(fn token -> + token.token + |> live_socket_id() + |> WhoNeedHelpWeb.Endpoint.broadcast("disconnect", %{}) + end) + + :ok end @doc """ diff --git a/priv/repo/migrations/20260720093823_add_open_velocity_signal_unique_index.exs b/priv/repo/migrations/20260720093823_add_open_velocity_signal_unique_index.exs new file mode 100644 index 0000000..60aa314 --- /dev/null +++ b/priv/repo/migrations/20260720093823_add_open_velocity_signal_unique_index.exs @@ -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 diff --git a/priv/repo/migrations/20260720101905_add_active_tracking_sessions_user_index.exs b/priv/repo/migrations/20260720101905_add_active_tracking_sessions_user_index.exs new file mode 100644 index 0000000..ac7c5af --- /dev/null +++ b/priv/repo/migrations/20260720101905_add_active_tracking_sessions_user_index.exs @@ -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 diff --git a/priv/static/sw.js b/priv/static/sw.js index 78949a1..3ab327c 100644 --- a/priv/static/sw.js +++ b/priv/static/sw.js @@ -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, diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs index f74e988..29a372c 100644 --- a/test/who_need_help/accounts_test.exs +++ b/test/who_need_help/accounts_test.exs @@ -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()} diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs index e0007e3..56cf9d3 100644 --- a/test/who_need_help/activities_test.exs +++ b/test/who_need_help/activities_test.exs @@ -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 diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index 10f76b2..48a97fc 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -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, %{ diff --git a/test/who_need_help/push_product_test.exs b/test/who_need_help/push_product_test.exs index eb184ce..58141b8 100644 --- a/test/who_need_help/push_product_test.exs +++ b/test/who_need_help/push_product_test.exs @@ -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, diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs index 262697a..e2fa8ac 100644 --- a/test/who_need_help/trust_safety_test.exs +++ b/test/who_need_help/trust_safety_test.exs @@ -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 diff --git a/test/who_need_help/workers/expire_requests_test.exs b/test/who_need_help/workers/expire_requests_test.exs index 91c238a..f26864a 100644 --- a/test/who_need_help/workers/expire_requests_test.exs +++ b/test/who_need_help/workers/expire_requests_test.exs @@ -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, diff --git a/test/who_need_help_web/controllers/mobile_tracking_controller_test.exs b/test/who_need_help_web/controllers/mobile_tracking_controller_test.exs index d08edac..a6da531 100644 --- a/test/who_need_help_web/controllers/mobile_tracking_controller_test.exs +++ b/test/who_need_help_web/controllers/mobile_tracking_controller_test.exs @@ -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 diff --git a/test/who_need_help_web/controllers/page_controller_test.exs b/test/who_need_help_web/controllers/page_controller_test.exs index e88d1d7..34d478b 100644 --- a/test/who_need_help_web/controllers/page_controller_test.exs +++ b/test/who_need_help_web/controllers/page_controller_test.exs @@ -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| diff --git a/test/who_need_help_web/controllers/user_session_controller_test.exs b/test/who_need_help_web/controllers/user_session_controller_test.exs index 60e87bc..3ec53df 100644 --- a/test/who_need_help_web/controllers/user_session_controller_test.exs +++ b/test/who_need_help_web/controllers/user_session_controller_test.exs @@ -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) diff --git a/test/who_need_help_web/controllers/user_settings_controller_test.exs b/test/who_need_help_web/controllers/user_settings_controller_test.exs index c65d398..0ab55a8 100644 --- a/test/who_need_help_web/controllers/user_settings_controller_test.exs +++ b/test/who_need_help_web/controllers/user_settings_controller_test.exs @@ -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 diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index 9a2d211..b6c77fa 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -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 diff --git a/test/who_need_help_web/user_auth_test.exs b/test/who_need_help_web/user_auth_test.exs index 6cf5d66..b190945 100644 --- a/test/who_need_help_web/user_auth_test.exs +++ b/test/who_need_help_web/user_auth_test.exs @@ -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"})