Limit audit storage and speed notification navigation
This commit is contained in:
parent
e499ed7553
commit
d3114e211b
|
|
@ -1039,6 +1039,39 @@ export const mountStaticAidMaps = root => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Hooks = {
|
export const Hooks = {
|
||||||
|
NotificationLink: {
|
||||||
|
mounted() {
|
||||||
|
this.markOpened = event => {
|
||||||
|
if (event.defaultPrevented || event.button > 1) return
|
||||||
|
|
||||||
|
const openUrl = this.el.dataset.openUrl
|
||||||
|
const csrfToken = document.querySelector("meta[name='csrf-token']")?.content
|
||||||
|
if (!openUrl || !csrfToken) return
|
||||||
|
|
||||||
|
// Do not await this request. The link owns navigation, while keepalive
|
||||||
|
// lets the small read-marker request finish during LiveView or full-page
|
||||||
|
// navigation without making the destination wait for it.
|
||||||
|
fetch(openUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
keepalive: true,
|
||||||
|
headers: {
|
||||||
|
// This endpoint is protected by the regular browser pipeline,
|
||||||
|
// which accepts HTML requests. The 204 response has no body.
|
||||||
|
"accept": "text/html",
|
||||||
|
"x-csrf-token": csrfToken,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.addEventListener("click", this.markOpened, {capture: true})
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("click", this.markOpened, {capture: true})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
SearchableCategoryPicker: {
|
SearchableCategoryPicker: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh = () => {
|
this.refresh = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,34 @@
|
||||||
defmodule WhoNeedHelp.Trust do
|
defmodule WhoNeedHelp.Trust do
|
||||||
@moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation."
|
@moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation."
|
||||||
|
|
||||||
|
# The audit log is intentionally reserved for security-sensitive identity
|
||||||
|
# changes and staff/safety/legal decisions. Routine product lifecycle events
|
||||||
|
# already have durable domain records and must not duplicate high-volume data
|
||||||
|
# here. The allowlist also makes a newly introduced audit action opt-in.
|
||||||
|
@retained_audit_actions MapSet.new(~w(
|
||||||
|
abuse_signal.moderated
|
||||||
|
activity.hidden
|
||||||
|
activity.restored
|
||||||
|
auth_identity.connected
|
||||||
|
auth_identity.disconnected
|
||||||
|
category_proposal.approved
|
||||||
|
category_proposal.merged
|
||||||
|
category_proposal.rejected
|
||||||
|
content_removal_notice.moderated
|
||||||
|
report.evidence_viewed
|
||||||
|
report.moderated
|
||||||
|
request.hidden
|
||||||
|
request.restored
|
||||||
|
social_identity.verified
|
||||||
|
support_request.contact_verified
|
||||||
|
support_request.moderated
|
||||||
|
user.admin_bootstrapped
|
||||||
|
user.blocked
|
||||||
|
user.moderated
|
||||||
|
user.staff_roles_changed
|
||||||
|
user.unblocked
|
||||||
|
))
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias WhoNeedHelp.Accounts
|
alias WhoNeedHelp.Accounts
|
||||||
|
|
@ -954,17 +982,24 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
||||||
%AuditEvent{}
|
if retained_audit_action?(action) do
|
||||||
|> AuditEvent.changeset(%{
|
%AuditEvent{}
|
||||||
actor_id: actor_id,
|
|> AuditEvent.changeset(%{
|
||||||
action: action,
|
actor_id: actor_id,
|
||||||
target_type: target_type,
|
action: action,
|
||||||
target_id: target_id,
|
target_type: target_type,
|
||||||
metadata: metadata
|
target_id: target_id,
|
||||||
})
|
metadata: metadata
|
||||||
|> Repo.insert()
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
else
|
||||||
|
{:ok, :not_audited}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def retained_audit_actions, do: @retained_audit_actions |> Enum.sort()
|
||||||
|
def retained_audit_action?(action), do: MapSet.member?(@retained_audit_actions, action)
|
||||||
|
|
||||||
defp maybe_audit_action(query, value) when value in [nil, ""], do: query
|
defp maybe_audit_action(query, value) when value in [nil, ""], do: query
|
||||||
defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value)
|
defp maybe_audit_action(query, value), do: where(query, [event], event.action == ^value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
||||||
alias WhoNeedHelp.Help.HelpRequest
|
alias WhoNeedHelp.Help.HelpRequest
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Tracking
|
alias WhoNeedHelp.Tracking
|
||||||
alias WhoNeedHelp.Trust
|
|
||||||
alias WhoNeedHelp.Trust.RateLimiter
|
alias WhoNeedHelp.Trust.RateLimiter
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
|
|
@ -48,14 +47,9 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
||||||
{:ok, :empty}
|
{:ok, :empty}
|
||||||
|
|
||||||
request ->
|
request ->
|
||||||
with {:ok, request} <-
|
request
|
||||||
request
|
|> Ecto.Changeset.change(status: :expired)
|
||||||
|> Ecto.Changeset.change(status: :expired)
|
|> Repo.update()
|
||||||
|> Repo.update(),
|
|
||||||
{:ok, _audit} <-
|
|
||||||
Trust.audit(nil, "request.expired", "request", request.id) do
|
|
||||||
{:ok, request}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule WhoNeedHelpWeb.NotificationOpenController do
|
||||||
|
use WhoNeedHelpWeb, :controller
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Notifications
|
||||||
|
|
||||||
|
def create(conn, %{"id" => id}) do
|
||||||
|
case Notifications.notification_opened(conn.assigns.current_scope, id) do
|
||||||
|
{:ok, _notification} -> send_resp(conn, :no_content, "")
|
||||||
|
{:error, :not_found} -> send_resp(conn, :not_found, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -47,16 +47,6 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
||||||
{:noreply, load_notifications(socket)}
|
{:noreply, load_notifications(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("open-notification", %{"id" => id}, socket) do
|
|
||||||
case Notifications.notification_opened(socket.assigns.current_scope, id) do
|
|
||||||
{:ok, notification} ->
|
|
||||||
{:noreply, push_navigate(socket, to: notification.path)}
|
|
||||||
|
|
||||||
{:error, :not_found} ->
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("Notification not found."))}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("load-more-notifications", _params, socket) do
|
def handle_event("load-more-notifications", _params, socket) do
|
||||||
page =
|
page =
|
||||||
Notifications.paginate_notifications(socket.assigns.current_scope,
|
Notifications.paginate_notifications(socket.assigns.current_scope,
|
||||||
|
|
@ -290,11 +280,13 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@notifications != []} class="mt-4 divide-y divide-base-300">
|
<div :if={@notifications != []} class="mt-4 divide-y divide-base-300">
|
||||||
<button
|
<.link
|
||||||
:for={notification <- @notifications}
|
:for={notification <- @notifications}
|
||||||
type="button"
|
navigate={notification.path}
|
||||||
phx-click="open-notification"
|
id={"notification-#{notification.id}"}
|
||||||
phx-value-id={notification.id}
|
phx-hook="NotificationLink"
|
||||||
|
data-open-url={~p"/notifications/#{notification.id}/opened"}
|
||||||
|
data-notification-id={notification.id}
|
||||||
class={[
|
class={[
|
||||||
"flex w-full items-start gap-3 px-2 py-4 text-left transition hover:bg-base-200",
|
"flex w-full items-start gap-3 px-2 py-4 text-left transition hover:bg-base-200",
|
||||||
is_nil(notification.read_at) && "font-semibold"
|
is_nil(notification.read_at) && "font-semibold"
|
||||||
|
|
@ -320,7 +312,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
|
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
|
||||||
</button>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/users/settings", UserSettingsController, :edit
|
get "/users/settings", UserSettingsController, :edit
|
||||||
put "/users/settings", UserSettingsController, :update
|
put "/users/settings", UserSettingsController, :update
|
||||||
get "/users/data-export", UserDataExportController, :show
|
get "/users/data-export", UserDataExportController, :show
|
||||||
|
post "/notifications/:id/opened", NotificationOpenController, :create
|
||||||
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
|
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
|
||||||
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
|
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
|
||||||
post "/auth/google/link", GoogleAuthController, :start_link
|
post "/auth/google/link", GoogleAuthController, :start_link
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
assert Repo.exists?(
|
assert Repo.exists?(
|
||||||
from event in AuditEvent,
|
from event in AuditEvent,
|
||||||
where:
|
where:
|
||||||
event.action == "support_request.created" and
|
event.action == "support_request.contact_verified" and
|
||||||
event.target_id == ^request.id
|
event.target_id == ^request.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,33 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "audit storage keeps security decisions and skips routine lifecycle events", context do
|
||||||
|
assert {:ok, :not_audited} =
|
||||||
|
Trust.audit(
|
||||||
|
context.requester.id,
|
||||||
|
"request.created",
|
||||||
|
"request",
|
||||||
|
Ecto.UUID.generate()
|
||||||
|
)
|
||||||
|
|
||||||
|
refute Repo.exists?(from event in AuditEvent, where: event.action == "request.created")
|
||||||
|
|
||||||
|
target_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:ok, %AuditEvent{} = event} =
|
||||||
|
Trust.audit(
|
||||||
|
context.requester.id,
|
||||||
|
"user.blocked",
|
||||||
|
"user",
|
||||||
|
target_id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.action == "user.blocked"
|
||||||
|
assert event.target_id == target_id
|
||||||
|
assert Trust.retained_audit_action?("user.blocked")
|
||||||
|
refute Trust.retained_audit_action?("request.created")
|
||||||
|
end
|
||||||
|
|
||||||
test "restricted moderators lose authorization and moderators cannot suspend administrators",
|
test "restricted moderators lose authorization and moderators cannot suspend administrators",
|
||||||
context do
|
context do
|
||||||
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
||||||
event.target_id in ^request_ids
|
event.target_id in ^request_ids
|
||||||
),
|
),
|
||||||
:count
|
:count
|
||||||
) == 12
|
) == 0
|
||||||
|
|
||||||
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
defmodule WhoNeedHelpWeb.NotificationOpenControllerTest do
|
||||||
|
use WhoNeedHelpWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Notifications
|
||||||
|
alias WhoNeedHelp.Notifications.Notification
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
|
test "marks an owned notification as read without redirecting", %{conn: conn} do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
{:ok, notification} =
|
||||||
|
Notifications.notify_user(user.id, %{
|
||||||
|
kind: :support_update,
|
||||||
|
title: "Support request updated",
|
||||||
|
body: "Open the latest support response.",
|
||||||
|
path: "/support",
|
||||||
|
idempotency_key: "notification-open-controller:#{Ecto.UUID.generate()}"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = conn |> log_in_user(user) |> post(~p"/notifications/#{notification.id}/opened")
|
||||||
|
|
||||||
|
assert response(conn, 204) == ""
|
||||||
|
assert Repo.get!(Notification, notification.id).read_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not reveal or update another user's notification", %{conn: conn} do
|
||||||
|
user = user_fixture()
|
||||||
|
other_user = user_fixture()
|
||||||
|
|
||||||
|
{:ok, notification} =
|
||||||
|
Notifications.notify_user(other_user.id, %{
|
||||||
|
kind: :support_update,
|
||||||
|
title: "Private support update",
|
||||||
|
body: "This belongs to another account.",
|
||||||
|
path: "/support",
|
||||||
|
idempotency_key: "notification-open-controller:#{Ecto.UUID.generate()}"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn = conn |> log_in_user(user) |> post(~p"/notifications/#{notification.id}/opened")
|
||||||
|
|
||||||
|
assert response(conn, 404) == ""
|
||||||
|
refute Repo.get!(Notification, notification.id).read_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -80,10 +80,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert html =~ "Support request updated"
|
assert html =~ "Support request updated"
|
||||||
assert html =~ "1 unread"
|
assert html =~ "1 unread"
|
||||||
|
|
||||||
view |> element("button[phx-click='mark-all-read']") |> render_click()
|
assert has_element?(
|
||||||
assert render(view) =~ "0 unread"
|
view,
|
||||||
assert Notifications.unread_count(scope) == 0
|
"a[href='/support'][phx-hook='NotificationLink'][data-open-url='/notifications/#{notification.id}/opened'][data-notification-id='#{notification.id}']"
|
||||||
assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at
|
)
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("a[href='/notifications?section=settings']")
|
|> element("a[href='/notifications?section=settings']")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user