Packages

phoenix_kit

2.60.1
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage audit.ex
Raw

lib/modules/storage/audit.ex

defmodule PhoenixKit.Modules.Storage.Audit do
  @moduledoc """
  The "who changed what" half of Media's history
  (`dev_docs/plans/2026-10-03-job-runs.md`, §7): every change to the site's storage
  *configuration* is written to the Activity log, and the action names live here, in
  one place. Settings → Media → **History** reads them back, together with the
  entries of the storage job runs.

  | action | resource |
  |---|---|
  | `storage.profile.created` / `updated` / `deleted` | `storage_profile` |
  | `storage.profile.bucket_added` / `bucket_changed` / `bucket_removed` | `storage_profile` |
  | `storage.library.created` / `renamed` / `deleted` | `storage_library` |
  | `storage.library.profile_changed` / `variant_set_changed` / `setting_changed` | `storage_library` |
  | `storage.variant_set.created` / `updated` / `deleted` / `remade` | `storage_variant_set` |
  | `storage.variant_set.size_created` / `size_updated` / `size_deleted` / `sizes_reset` | `storage_variant_set` |
  | `storage.bucket.created` / `updated` / `deleted` | `storage_bucket` |
  | `storage.copy.damaged` | `bucket` — a copy found missing or not matching its checksum |
  | `storage.file.repaired` | `file` — what a repair of one file did |

  The last two are the trail of storage that went wrong: a bucket that keeps
  losing or changing objects is a disk to look at, and `damaged_copies/2` is how a
  bucket's page says so.

  An entry carries the acting user (`actor_uuid:` in the context's options; the
  LiveViews pass `PhoenixKitWeb.Actor.opts(socket)`), a mode (`manual` when a person
  acted, `auto` when nothing did) and, for a change, the Activity log's own
  `"changes"` shape — `%{"copies_originals" => %{"from" => 1, "to" => 2}}` — which the
  feed already renders as "from → to". Entries are **permanent**: they are not pruned
  by `activity_retention_days`, because "who changed this bucket last year" is exactly
  what an audit is asked.

  Only the **site's** configuration is recorded. A user's own library, profile and
  bucket (V203–V206) are theirs and private; their changes are not written here. And
  nothing secret is ever put in an entry: bucket changes name only the fields in
  `bucket_fields/0`, never a key or a secret.
  """

  import Ecto.Query
  require Logger

  alias PhoenixKit.Activity
  alias PhoenixKit.Modules.Storage.Endpoint
  alias PhoenixKit.Modules.Storage.Libraries

  @module_key "storage"

  @bucket_fields ~w(name provider region endpoint bucket_name enabled priority integration_uuid cdn_url access_type max_size_mb)a
  @entries_key {__MODULE__, :entries}
  @callbacks_key {__MODULE__, :callbacks}
  @external_key {__MODULE__, :external_transaction}

  @doc "The Activity module key every storage entry (configuration and runs) is filed under."
  @spec module_key() :: String.t()
  def module_key, do: @module_key

  @doc """
  Writes one `storage.copy.damaged` entry for each result of a verification
  (`FileReport.verify/1`) that is a copy missing from its bucket or differing from
  its checksum, against that bucket. A copy that could not be read is not damage
  (the bucket may only be unreachable), and a user's own bucket is private.
  `opts` are the audit options (`:actor_uuid`) and `:found_by` (`"verify"` or
  `"repair"`). Never raises.
  """
  @spec log_damage(PhoenixKit.Modules.Storage.File.t(), [map()], keyword()) :: :ok
  def log_damage(file, results, opts) do
    results = if site_file?(file), do: results, else: []

    for %{bucket: %{owner_uuid: nil} = bucket, result: result} = row <- results,
        problem = damage(result) do
      log("storage.copy.damaged", "bucket", bucket.uuid, opts, %{
        "bucket" => bucket.name,
        "bucket_uuid" => bucket.uuid,
        "file_uuid" => file.uuid,
        "file_name" => file.original_file_name || file.file_name,
        "library_uuid" => file.library_uuid && to_string(file.library_uuid),
        "rendition" => row.name,
        "key" => row.path,
        "problem" => problem,
        "found_by" => opts[:found_by] || "verify"
      })
    end

    :ok
  end

  # A file in a user's private library is theirs: its name, library and buckets are
  # not written into the site's permanent log. A lookup that fails counts as private
  # (the entry is skipped, never leaked).
  defp site_file?(file) do
    not Libraries.private_file?(file)
  rescue
    _ -> false
  catch
    :exit, _ -> false
  end

  defp damage(:not_found), do: "missing"
  defp damage({:mismatch, _recorded, _actual}), do: "checksum_mismatch"
  defp damage(_result), do: nil

  @doc """
  Writes the `storage.file.repaired` entry of a repair of `file`: the actions it
  took (rendition, what, which bucket) and how many problems were left. Nothing
  is written when nothing was done. Never raises.
  """
  @spec log_repair(PhoenixKit.Modules.Storage.File.t(), [map()], non_neg_integer(), keyword()) ::
          :ok
  def log_repair(file, actions, problems_left, opts) do
    done = Enum.reject(actions, &(&1.kind == :reconciled))

    if Enum.any?(
         done,
         &(&1.kind in [:restored, :regenerated, :recorded, :made, :copied, :removed])
       ) and site_file?(file) do
      log("storage.file.repaired", "file", file.uuid, opts, %{
        "file_name" => file.original_file_name || file.file_name,
        "library_uuid" => file.library_uuid && to_string(file.library_uuid),
        "bucket_uuids" =>
          done
          |> Enum.flat_map(&[&1[:bucket_uuid], &1[:from_uuid]])
          |> Enum.reject(&is_nil/1)
          |> Enum.map(&to_string/1)
          |> Enum.uniq(),
        "problems_left" => problems_left,
        "actions" =>
          Enum.map(done, fn a ->
            %{
              "rendition" => a.name,
              "kind" => Atom.to_string(a.kind),
              "bucket" => a.bucket,
              "bucket_uuid" => a[:bucket_uuid] && to_string(a[:bucket_uuid]),
              "from" => a.from,
              "from_uuid" => a[:from_uuid] && to_string(a[:from_uuid])
            }
          end)
      })
    end

    :ok
  end

  @doc """
  How many damaged copies were found in the bucket in the last `days` days
  (distinct object keys among the `storage.copy.damaged` entries, so verifying the
  same copy twice counts it once), and when the last was: `%{count:, last_at:}`.
  """
  @spec damaged_copies(term(), pos_integer()) :: %{count: non_neg_integer(), last_at: term()}
  def damaged_copies(bucket_uuid, days \\ 30) do
    since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)

    {count, last_at} =
      from(e in PhoenixKit.Activity.Entry,
        where:
          e.action == "storage.copy.damaged" and e.resource_uuid == ^to_string(bucket_uuid) and
            e.inserted_at >= ^since,
        select:
          {fragment("count(DISTINCT coalesce(? ->> 'key', ?::text))", e.metadata, e.uuid),
           max(e.inserted_at)}
      )
      |> repo().one()

    %{count: count, last_at: last_at}
  rescue
    _ -> %{count: 0, last_at: nil}
  catch
    :exit, _ -> %{count: 0, last_at: nil}
  end

  @doc "The bucket fields whose changes are recorded: never a key or a secret."
  @spec bucket_fields() :: [atom()]
  def bucket_fields, do: @bucket_fields

  @doc """
  Runs a configuration mutation and its audit inserts in one transaction, then
  announces the entries after commit. Nested audited mutations share the entries.
  A failed mutation or audit insert rolls everything back.

  Inside a caller's own repo transaction, entries commit with that transaction
  but are not announced: this module cannot know when the caller commits. The
  History tab's periodic refresh picks them up. Call this wrapper at the outer
  boundary when immediate announcements are wanted.
  """
  @spec transaction((-> result)) :: result | {:error, term()} when result: var
  def transaction(fun) do
    if Process.get(@entries_key), do: fun.(), else: transact(fun)
  end

  defp transact(fun) do
    nested? = repo().in_transaction?()

    result =
      repo().transaction(fn ->
        Process.put(@entries_key, [])
        Process.put(@callbacks_key, [])
        Process.put(@external_key, nested?)

        try do
          case fun.() do
            {:error, reason} ->
              repo().rollback(reason)

            value ->
              {value, Enum.reverse(Process.get(@entries_key)),
               Enum.reverse(Process.get(@callbacks_key))}
          end
        after
          Process.delete(@entries_key)
          Process.delete(@callbacks_key)
          Process.delete(@external_key)
        end
      end)

    case result do
      {:ok, {value, entries, callbacks}} ->
        Enum.each(callbacks, &run_callback/1)
        unless nested?, do: Enum.each(entries, &Activity.broadcast/1)
        value

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Defers a cache invalidation or compatibility-settings sync until this module's
  outer transaction commits. Callbacks are best effort. Inside a caller-owned
  repo transaction it retains the callback's existing immediate behavior; callers
  needing commit ordering must use `transaction/1` as their outer boundary.
  """
  @spec after_commit((-> term())) :: :ok
  def after_commit(fun) do
    if Process.get(@callbacks_key) && not Process.get(@external_key) do
      Process.put(@callbacks_key, [fun | Process.get(@callbacks_key)])
    else
      run_callback(fun)
    end

    :ok
  end

  defp run_callback(fun) do
    fun.()
  rescue
    error ->
      Logger.warning("Storage audit post-commit callback failed: #{Exception.message(error)}")
  catch
    :exit, reason ->
      Logger.warning("Storage audit post-commit callback exited: #{inspect(reason)}")
  end

  @doc "Locks and reloads an audited resource so diffs describe its actual preceding state."
  @spec change(struct(), (struct() -> result)) :: result | {:error, term()} when result: var
  def change(%{__struct__: schema, uuid: uuid}, fun) do
    transaction(fn ->
      case repo().one(from(r in schema, where: r.uuid == ^uuid, lock: "FOR NO KEY UPDATE")) do
        nil -> {:error, :not_found}
        current -> fun.(current)
      end
    end)
  end

  @doc """
  Writes one configuration entry. Insert failures roll back an enclosing audited
  mutation; a standalone call returns the error. Entries are announced only after
  the transaction owned by this module commits.

  `opts` are the context call's: `:actor_uuid`, and `:mode` (default `"manual"` with an
  actor, `"auto"` without). `audit: false` writes nothing (`:skipped`) — for a change
  one context makes to another's rows as a consequence of the one that is recorded.
  `metadata` is a map of string keys.
  """
  @spec log(String.t(), String.t(), String.t() | nil, keyword(), map()) ::
          {:ok, PhoenixKit.Activity.Entry.t()} | {:error, term()} | :skipped
  def log(action, resource_type, resource_uuid, opts, metadata \\ %{}) do
    if Keyword.get(opts, :audit, true),
      do: transaction(fn -> write(action, resource_type, resource_uuid, opts, metadata) end),
      else: :skipped
  rescue
    error -> failed(error)
  catch
    :exit, reason -> failed(reason)
  end

  defp write(action, resource_type, resource_uuid, opts, metadata) do
    actor = Keyword.get(opts, :actor_uuid)

    %{
      module: @module_key,
      action: action,
      actor_uuid: actor,
      mode: Keyword.get(opts, :mode, if(actor, do: "manual", else: "auto")),
      resource_type: resource_type,
      resource_uuid: resource_uuid && to_string(resource_uuid),
      metadata: metadata,
      permanent: true
    }
    |> Activity.entry_changeset()
    |> repo().insert(mode: :savepoint)
    |> case do
      {:ok, entry} ->
        Process.put(@entries_key, [entry | Process.get(@entries_key)])
        {:ok, entry}

      {:error, reason} ->
        repo().rollback(reason)
    end
  end

  defp failed(reason) do
    if Process.get(@entries_key), do: repo().rollback(reason), else: {:error, reason}
  end

  defp repo, do: PhoenixKit.RepoHelper.repo()

  @doc """
  The `"changes"` map of an update: for each of `fields` that the changeset changes,
  `%{"field" => %{"from" => old, "to" => new}}` (values made loggable). Empty when
  nothing in `fields` changed.
  """
  @spec changes(Ecto.Changeset.t(), [atom()]) :: %{String.t() => map()}
  def changes(%Ecto.Changeset{} = changeset, fields) do
    for field <- fields, Map.has_key?(changeset.changes, field), into: %{} do
      {Atom.to_string(field),
       %{
         "from" => loggable(field, Map.get(changeset.data, field)),
         "to" => loggable(field, Map.fetch!(changeset.changes, field))
       }}
    end
  end

  @doc """
  The `"changes"` map between two values of the same struct (`before`, `after`) for
  `fields`. Empty when none differs.
  """
  @spec diff(map(), map(), [atom()]) :: %{String.t() => map()}
  def diff(before, later, fields) do
    for field <- fields, Map.get(before, field) != Map.get(later, field), into: %{} do
      {Atom.to_string(field),
       %{
         "from" => loggable(field, Map.get(before, field)),
         "to" => loggable(field, Map.get(later, field))
       }}
    end
  end

  @doc "Logs an update, when `changes` is not empty; `extra` is merged into the metadata."
  @spec log_update(String.t(), String.t(), String.t() | nil, keyword(), map(), map()) :: :ok
  def log_update(action, resource_type, uuid, opts, changes, extra \\ %{})

  def log_update(_action, _type, _uuid, _opts, changes, _extra) when map_size(changes) == 0,
    do: :ok

  def log_update(action, resource_type, uuid, opts, changes, extra) do
    log(action, resource_type, uuid, opts, Map.put(extra, Activity.changes_key(), changes))
    :ok
  end

  defp loggable(:endpoint, value), do: Endpoint.audit_value(value)
  defp loggable(:cdn_url, value), do: Endpoint.audit_value(value, local_path: false)

  defp loggable(_field, value), do: loggable(value)

  # Keep lists as JSON arrays rather than inspected Elixir source.
  defp loggable(value) when is_list(value), do: Enum.map(value, &loggable/1)
  defp loggable(nil), do: nil
  defp loggable(value) when is_boolean(value) or is_number(value) or is_binary(value), do: value
  defp loggable(value) when is_atom(value), do: Atom.to_string(value)
  defp loggable(value), do: inspect(value)
end