Add prometheus metrics to router
authorFloatingGhost <hannah@coffee-and-dreams.uk>
Thu, 15 Dec 2022 02:02:07 +0000 (02:02 +0000)
committerFloatingGhost <hannah@coffee-and-dreams.uk>
Thu, 15 Dec 2022 02:02:07 +0000 (02:02 +0000)
lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex [new file with mode: 0644]
lib/pleroma/web/endpoint.ex
lib/pleroma/web/plugs/csp_nonce_plug.ex [new file with mode: 0644]
lib/pleroma/web/plugs/http_security_plug.ex
lib/pleroma/web/router.ex
lib/pleroma/web/telemetry.ex
mix.exs
mix.lock

diff --git a/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex
new file mode 100644 (file)
index 0000000..c8d3d89
--- /dev/null
@@ -0,0 +1,16 @@
+defmodule Pleroma.Web.AkkomaAPI.MetricsController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+  @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+  plug(:skip_auth)
+
+
+  def show(conn, _params) do
+    stats = TelemetryMetricsPrometheus.Core.scrape()
+
+    conn
+    |> text(stats)
+  end
+end
index baf0c5651ee43b80e19f37f308c423870ded69a2..e3a251ca196be2138a60a8c832915f8aec0e748c 100644 (file)
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.Endpoint do
 
   plug(Pleroma.Web.Plugs.SetLocalePlug)
   plug(CORSPlug)
+  plug(Pleroma.Web.Plugs.CSPNoncePlug)
   plug(Pleroma.Web.Plugs.HTTPSecurityPlug)
   plug(Pleroma.Web.Plugs.UploadedMedia)
 
diff --git a/lib/pleroma/web/plugs/csp_nonce_plug.ex b/lib/pleroma/web/plugs/csp_nonce_plug.ex
new file mode 100644 (file)
index 0000000..bc2c6fc
--- /dev/null
@@ -0,0 +1,21 @@
+defmodule Pleroma.Web.Plugs.CSPNoncePlug do
+  import Plug.Conn
+
+  def init(opts) do
+    opts
+  end
+
+  def call(conn, _opts) do
+    assign_csp_nonce(conn)
+  end
+
+  defp assign_csp_nonce(conn) do
+    nonce =
+      :crypto.strong_rand_bytes(128)
+      |> Base.url_encode64()
+      |> binary_part(0, 15)
+
+    conn
+    |> assign(:csp_nonce, nonce)
+  end
+end
index 47874a980147561439e1894a4bcddd2134edc4b3..6593347caf2a6d8281e701fccaea64bcbca7c0f8 100644 (file)
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
   def call(conn, _options) do
     if Config.get([:http_security, :enabled]) do
       conn
-      |> merge_resp_headers(headers())
+      |> merge_resp_headers(headers(conn))
       |> maybe_send_sts_header(Config.get([:http_security, :sts]))
     else
       conn
@@ -36,7 +36,8 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     end
   end
 
-  def headers do
+  @spec headers(Plug.Conn.t()) :: [{String.t(), String.t()}]
+  def headers(conn) do
     referrer_policy = Config.get([:http_security, :referrer_policy])
     report_uri = Config.get([:http_security, :report_uri])
     custom_http_frontend_headers = custom_http_frontend_headers()
@@ -47,7 +48,7 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
       {"x-frame-options", "DENY"},
       {"x-content-type-options", "nosniff"},
       {"referrer-policy", referrer_policy},
-      {"content-security-policy", csp_string()},
+      {"content-security-policy", csp_string(conn)},
       {"permissions-policy", "interest-cohort=()"}
     ]
 
@@ -77,19 +78,18 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     "default-src 'none'",
     "base-uri 'none'",
     "frame-ancestors 'none'",
-    "style-src 'self' 'unsafe-inline'",
-    "font-src 'self'",
     "manifest-src 'self'"
   ]
 
   @csp_start [Enum.join(static_csp_rules, ";") <> ";"]
 
-  defp csp_string do
+  defp csp_string(conn) do
     scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme]
     static_url = Pleroma.Web.Endpoint.static_url()
     websocket_url = Pleroma.Web.Endpoint.websocket_url()
     report_uri = Config.get([:http_security, :report_uri])
-
+    %{assigns: %{csp_nonce: nonce}} = conn
+    nonce_tag = "nonce-" <> nonce
     img_src = "img-src 'self' data: blob:"
     media_src = "media-src 'self'"
 
@@ -111,11 +111,14 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
         ["connect-src 'self' blob: ", static_url, ?\s, websocket_url]
       end
 
+    style_src = "style-src 'self' '#{nonce_tag}'"
+    font_src = "font-src 'self' '#{nonce_tag}' data:"
+
     script_src =
       if Config.get(:env) == :dev do
-        "script-src 'self' 'unsafe-eval'"
+        "script-src 'self' 'unsafe-eval' '#{nonce_tag}'"
       else
-        "script-src 'self'"
+        "script-src 'self' '#{nonce_tag}'"
       end
 
     report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"]
@@ -126,6 +129,8 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do
     |> add_csp_param(media_src)
     |> add_csp_param(connect_src)
     |> add_csp_param(script_src)
+    |> add_csp_param(font_src)
+    |> add_csp_param(style_src)
     |> add_csp_param(insecure)
     |> add_csp_param(report)
     |> :erlang.iolist_to_binary()
index bcb5fb15eeda186f29600870e0862067507e3a5a..c0ce645c44e08bd2cd5f38c57ea70daa400c9fae 100644 (file)
@@ -467,6 +467,7 @@ defmodule Pleroma.Web.Router do
 
   scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
     pipe_through(:authenticated_api)
+    get("/metrics", MetricsController, :show)
     get("/translation/languages", TranslationController, :languages)
 
     get("/frontend_settings/:frontend_name", FrontendSettingsController, :list_profiles)
@@ -867,7 +868,7 @@ defmodule Pleroma.Web.Router do
 
   scope "/" do
     pipe_through([:pleroma_html, :authenticate, :require_admin])
-    live_dashboard("/phoenix/live_dashboard", metrics: Pleroma.Web.Telemetry)
+    live_dashboard("/phoenix/live_dashboard", metrics: {Pleroma.Web.Telemetry, :live_dashboard_metrics}, csp_nonce_assign_key: :csp_nonce)
   end
 
   # Test-only routes needed to test action dispatching and plug chain execution
index 73ce6a85cdda2ce425994afc4f536d0602cae80d..435f557992c233fbdfa0bcb4ff8db3032e705bae 100644 (file)
@@ -10,27 +10,19 @@ defmodule Pleroma.Web.Telemetry do
   @impl true
   def init(_arg) do
     children = [
-      # Telemetry poller will execute the given period measurements
-      # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
       {:telemetry_poller, measurements: periodic_measurements(), period: 10_000},
-      # Add reporters as children of your supervision tree.
-      # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()},
-      {TelemetryMetricsPrometheus, metrics: metrics(), plug_cowboy_opts: [ip: {127, 0, 0, 1}]}
+      {TelemetryMetricsPrometheus, metrics: prometheus_metrics(), plug_cowboy_opts: [ip: {127, 0, 0, 1}]}
     ]
 
     Supervisor.init(children, strategy: :one_for_one)
   end
 
-  def metrics do
+  @doc """
+  A seperate set of metrics for distributions because phoenix dashboard does NOT handle
+  them well
+  """
+  defp distribution_metrics do
     [
-      # Phoenix Metrics
-      summary("phoenix.endpoint.stop.duration",
-        unit: {:native, :millisecond}
-      ),
-      summary("phoenix.router_dispatch.stop.duration",
-        tags: [:route],
-        unit: {:native, :millisecond}
-      ),
       distribution(
         "phoenix.router_dispatch.stop.duration",
         # event_name: [:pleroma, :repo, :query, :total_time],
@@ -61,20 +53,9 @@ defmodule Pleroma.Web.Telemetry do
           buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
         ]
       ),
-      summary("pleroma.repo.query.total_time", unit: {:native, :millisecond}),
-      summary("pleroma.repo.query.decode_time", unit: {:native, :millisecond}),
-      summary("pleroma.repo.query.query_time", unit: {:native, :millisecond}),
-      summary("pleroma.repo.query.queue_time", unit: {:native, :millisecond}),
-      summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}),
-
-      # VM Metrics
-      summary("vm.memory.total", unit: {:byte, :kilobyte}),
-      summary("vm.total_run_queue_lengths.total"),
-      summary("vm.total_run_queue_lengths.cpu"),
-      summary("vm.total_run_queue_lengths.io"),
       distribution(
-        "oban_job_completion",
-        event_name: [:oban, :job, :stop],
+        "oban_job_exception",
+        event_name: [:oban, :job, :exception],
         measurement: :duration,
         tags: [:worker],
         tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end,
@@ -84,33 +65,62 @@ defmodule Pleroma.Web.Telemetry do
         ]
       ),
       distribution(
-        "oban_job_exception",
-        event_name: [:oban, :job, :exception],
+        "tesla_request_completed",
+        event_name: [:tesla, :request, :stop],
         measurement: :duration,
-        tags: [:worker],
-        tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end,
+        tags: [:response_code],
+        tag_values: fn tags -> Map.put(tags, :response_code, tags.env.status) end,
         unit: {:native, :second},
         reporter_options: [
           buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
         ]
       ),
       distribution(
-        "tesla_request_completed",
-        event_name: [:tesla, :request, :stop],
+        "oban_job_completion",
+        event_name: [:oban, :job, :stop],
         measurement: :duration,
-        tags: [:response_code],
-        tag_values: fn tags -> Map.put(tags, :response_code, tags.env.status) end,
+        tags: [:worker],
+        tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end,
         unit: {:native, :second},
         reporter_options: [
           buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10]
         ]
+      )
+    ]
+  end
+
+  defp summary_metrics do
+    [
+      # Phoenix Metrics
+      summary("phoenix.endpoint.stop.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.stop.duration",
+        tags: [:route],
+        unit: {:native, :millisecond}
       ),
+      summary("pleroma.repo.query.total_time", unit: {:native, :millisecond}),
+      summary("pleroma.repo.query.decode_time", unit: {:native, :millisecond}),
+      summary("pleroma.repo.query.query_time", unit: {:native, :millisecond}),
+      summary("pleroma.repo.query.queue_time", unit: {:native, :millisecond}),
+      summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}),
+
+      # VM Metrics
+      summary("vm.memory.total", unit: {:byte, :kilobyte}),
+      summary("vm.total_run_queue_lengths.total"),
+      summary("vm.total_run_queue_lengths.cpu"),
+      summary("vm.total_run_queue_lengths.io"),
+
+
       last_value("pleroma.local_users.total"),
       last_value("pleroma.domains.total"),
       last_value("pleroma.local_statuses.total")
     ]
   end
 
+  def prometheus_metrics, do: summary_metrics() ++ distribution_metrics()
+  def live_dashboard_metrics, do: summary_metrics()
+
   defp periodic_measurements do
     [
       {__MODULE__, :instance_stats, []}
diff --git a/mix.exs b/mix.exs
index cb46e8da30f783c581fcb553f2bf3dd1db444e2d..b5b7efd0c5b98783933b4018e68710d705c55191 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -185,7 +185,7 @@ defmodule Pleroma.Mixfile do
        git: "https://github.com/FloatingGhost/pleroma-contrib-search-parser.git",
        ref: "08971a81e68686f9ac465cfb6661d51c5e4e1e7f"},
       {:nimble_parsec, "~> 1.0", override: true},
-      {:phoenix_live_dashboard, "~> 0.6.2"},
+      {:phoenix_live_dashboard, "~> 0.7.2"},
       {:ecto_psql_extras, "~> 0.6"},
       {:elasticsearch,
        git: "https://akkoma.dev/AkkomaGang/elasticsearch-elixir.git", ref: "main"},
index 62f481d83bb248a561e681ffc295682e94f7b249..4fa4c05ec999032daa5ccb4ae9204b0b63354f6c 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -17,7 +17,7 @@
   "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
   "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
   "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
-  "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
+  "credo": {:git, "https://github.com/rrrene/credo.git", "1c1b99ea41a457761383d81aaf6a606913996fe7", [ref: "1c1b99ea41a457761383d81aaf6a606913996fe7"]},
   "crypt": {:git, "https://github.com/msantos/crypt.git", "f75cd55325e33cbea198fb41fe41871392f8fb76", [ref: "f75cd55325e33cbea198fb41fe41871392f8fb76"]},
   "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"},
   "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"},
@@ -85,8 +85,8 @@
   "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"},
   "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
   "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
-  "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"},
-  "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.12", "74f4c0ad02d7deac2d04f50b52827a5efdc5c6e7fac5cede145f5f0e4183aedc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af6dd5e0aac16ff43571f527a8e0616d62cb80b10eb87aac82170243e50d99c8"},
+  "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"},
+  "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.3", "2e3d009422addf8b15c3dccc65ce53baccbe26f7cfd21d264680b5867789a9c1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8845177a866e017dcb7083365393c8f00ab061b8b6b2bda575891079dce81b2"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
   "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.4", "615f8f393135de7e0cbb4bd00ba238b1e0cd324b0d90efbaee613c2f02ca5e5c", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "3971221846232021ab5e3c7489fd62ec5bfd6a2e01cae10a317ccf6fb350571c"},
   "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},