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 = {
|
||||
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: {
|
||||
mounted() {
|
||||
this.refresh = () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,34 @@
|
|||
defmodule WhoNeedHelp.Trust do
|
||||
@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
|
||||
|
||||
alias WhoNeedHelp.Accounts
|
||||
|
|
@ -954,17 +982,24 @@ defmodule WhoNeedHelp.Trust do
|
|||
end
|
||||
|
||||
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
|
||||
%AuditEvent{}
|
||||
|> AuditEvent.changeset(%{
|
||||
actor_id: actor_id,
|
||||
action: action,
|
||||
target_type: target_type,
|
||||
target_id: target_id,
|
||||
metadata: metadata
|
||||
})
|
||||
|> Repo.insert()
|
||||
if retained_audit_action?(action) do
|
||||
%AuditEvent{}
|
||||
|> AuditEvent.changeset(%{
|
||||
actor_id: actor_id,
|
||||
action: action,
|
||||
target_type: target_type,
|
||||
target_id: target_id,
|
||||
metadata: metadata
|
||||
})
|
||||
|> Repo.insert()
|
||||
else
|
||||
{:ok, :not_audited}
|
||||
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), do: where(query, [event], event.action == ^value)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
alias WhoNeedHelp.Help.HelpRequest
|
||||
alias WhoNeedHelp.Repo
|
||||
alias WhoNeedHelp.Tracking
|
||||
alias WhoNeedHelp.Trust
|
||||
alias WhoNeedHelp.Trust.RateLimiter
|
||||
|
||||
@impl Oban.Worker
|
||||
|
|
@ -48,14 +47,9 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
|||
{:ok, :empty}
|
||||
|
||||
request ->
|
||||
with {:ok, request} <-
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :expired)
|
||||
|> Repo.update(),
|
||||
{:ok, _audit} <-
|
||||
Trust.audit(nil, "request.expired", "request", request.id) do
|
||||
{:ok, request}
|
||||
end
|
||||
request
|
||||
|> Ecto.Changeset.change(status: :expired)
|
||||
|> Repo.update()
|
||||
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)}
|
||||
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
|
||||
page =
|
||||
Notifications.paginate_notifications(socket.assigns.current_scope,
|
||||
|
|
@ -290,11 +280,13 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
</div>
|
||||
|
||||
<div :if={@notifications != []} class="mt-4 divide-y divide-base-300">
|
||||
<button
|
||||
<.link
|
||||
:for={notification <- @notifications}
|
||||
type="button"
|
||||
phx-click="open-notification"
|
||||
phx-value-id={notification.id}
|
||||
navigate={notification.path}
|
||||
id={"notification-#{notification.id}"}
|
||||
phx-hook="NotificationLink"
|
||||
data-open-url={~p"/notifications/#{notification.id}/opened"}
|
||||
data-notification-id={notification.id}
|
||||
class={[
|
||||
"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"
|
||||
|
|
@ -320,7 +312,7 @@ defmodule WhoNeedHelpWeb.NotificationLive do
|
|||
/>
|
||||
</span>
|
||||
<.icon name="hero-chevron-right" class="mt-2 size-4 shrink-0 opacity-45" />
|
||||
</button>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ defmodule WhoNeedHelpWeb.Router do
|
|||
get "/users/settings", UserSettingsController, :edit
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/data-export", UserDataExportController, :show
|
||||
post "/notifications/:id/opened", NotificationOpenController, :create
|
||||
get "/users/settings/confirm-email", UserSettingsController, :confirm_email_page
|
||||
post "/users/settings/confirm-email", UserSettingsController, :confirm_email
|
||||
post "/auth/google/link", GoogleAuthController, :start_link
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
|||
assert Repo.exists?(
|
||||
from event in AuditEvent,
|
||||
where:
|
||||
event.action == "support_request.created" and
|
||||
event.action == "support_request.contact_verified" and
|
||||
event.target_id == ^request.id
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -677,6 +677,33 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
|||
)
|
||||
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",
|
||||
context do
|
||||
admin = staff_user_fixture([:admin], display_name: "Administrator")
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
|||
event.target_id in ^request_ids
|
||||
),
|
||||
:count
|
||||
) == 12
|
||||
) == 0
|
||||
|
||||
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
||||
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 =~ "1 unread"
|
||||
|
||||
view |> element("button[phx-click='mark-all-read']") |> render_click()
|
||||
assert render(view) =~ "0 unread"
|
||||
assert Notifications.unread_count(scope) == 0
|
||||
assert Repo.get!(WhoNeedHelp.Notifications.Notification, notification.id).read_at
|
||||
assert has_element?(
|
||||
view,
|
||||
"a[href='/support'][phx-hook='NotificationLink'][data-open-url='/notifications/#{notification.id}/opened'][data-notification-id='#{notification.id}']"
|
||||
)
|
||||
|
||||
view
|
||||
|> element("a[href='/notifications?section=settings']")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user