Limit audit storage and speed notification navigation

This commit is contained in:
SimpleTest 2026-08-01 20:54:35 +03:00
parent e499ed7553
commit d3114e211b
11 changed files with 179 additions and 39 deletions

View File

@ -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 = () => {

View File

@ -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,6 +982,7 @@ 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
if retained_audit_action?(action) do
%AuditEvent{} %AuditEvent{}
|> AuditEvent.changeset(%{ |> AuditEvent.changeset(%{
actor_id: actor_id, actor_id: actor_id,
@ -963,7 +992,13 @@ defmodule WhoNeedHelp.Trust do
metadata: metadata 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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']")