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