diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 8ab0e5b..04860db 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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 = () => { diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex index 8290bad..97c5a9c 100644 --- a/lib/who_need_help/trust.ex +++ b/lib/who_need_help/trust.ex @@ -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) diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex index a0a4e7a..53430bd 100644 --- a/lib/who_need_help/workers/expire_requests.ex +++ b/lib/who_need_help/workers/expire_requests.ex @@ -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) diff --git a/lib/who_need_help_web/controllers/notification_open_controller.ex b/lib/who_need_help_web/controllers/notification_open_controller.ex new file mode 100644 index 0000000..c402a11 --- /dev/null +++ b/lib/who_need_help_web/controllers/notification_open_controller.ex @@ -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 diff --git a/lib/who_need_help_web/live/notification_live.ex b/lib/who_need_help_web/live/notification_live.ex index 6af1b99..c817441 100644 --- a/lib/who_need_help_web/live/notification_live.ex +++ b/lib/who_need_help_web/live/notification_live.ex @@ -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