From 2ace17bbe2c4544ade0cb137ba4639fee3dea581 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 4 Sep 2026 21:55:08 -0400 Subject: [PATCH 1/2] Refactor CSP machinery --- config/dev.exs | 4 +- lib/philomena_web/config.ex | 18 +- lib/philomena_web/content_security_policy.ex | 182 ++++++++++++++++++ lib/philomena_web/frontend_assets.ex | 20 ++ lib/philomena_web/plugs/captcha_plug.ex | 9 +- .../plugs/content_security_policy_plug.ex | 105 +++------- .../templates/layout/app.html.slime | 8 +- lib/philomena_web/views/layout_view.ex | 5 +- .../content_security_policy_plug_test.exs | 122 ++++++++++++ 9 files changed, 368 insertions(+), 105 deletions(-) create mode 100644 lib/philomena_web/content_security_policy.ex create mode 100644 lib/philomena_web/frontend_assets.ex create mode 100644 test/philomena_web/plugs/content_security_policy_plug_test.exs diff --git a/config/dev.exs b/config/dev.exs index 8412f64d5..2caf1a410 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -77,8 +77,8 @@ config :philomena, PhilomenaWeb.Endpoint, # Disable Pwned Passwords API check in development config :philomena, pwned_passwords: false -# Relax CSP rules in development -config :philomena, csp_relaxed: true +# Relax CSP on development error pages so Plug.Debugger can render +config :philomena, csp_relax_on_error: true # Enable Vite HMR config :philomena, vite_reload: true diff --git a/lib/philomena_web/config.ex b/lib/philomena_web/config.ex index 7061ef265..51b6465e6 100644 --- a/lib/philomena_web/config.ex +++ b/lib/philomena_web/config.ex @@ -1,16 +1,8 @@ defmodule PhilomenaWeb.Config do - # Dialyzer only analyzes beam files directly and cannot see the compile-time variance in - # the associated values, so it flags a false positive here. - @dialyzer [:no_match] + @moduledoc """ + Runtime accessors for web configuration. + """ - @reload_enabled Application.compile_env(:philomena, :vite_reload, false) - @csp_relaxed Application.compile_env(:philomena, :csp_relaxed, false) - - defmacro vite_hmr?(do: do_clause, else: else_clause) do - if(@reload_enabled, do: do_clause, else: else_clause) - end - - defmacro csp_relaxed?(do: do_clause, else: else_clause) do - if(@csp_relaxed, do: do_clause, else: else_clause) - end + def vite_hmr?, do: Application.get_env(:philomena, :vite_reload, false) + def csp_relax_on_error?, do: Application.get_env(:philomena, :csp_relax_on_error, false) end diff --git a/lib/philomena_web/content_security_policy.ex b/lib/philomena_web/content_security_policy.ex new file mode 100644 index 000000000..9a6c59199 --- /dev/null +++ b/lib/philomena_web/content_security_policy.ex @@ -0,0 +1,182 @@ +defmodule PhilomenaWeb.ContentSecurityPolicy do + @moduledoc """ + Builds and serializes the application's Content Security Policy (CSP). + + Policies are represented as maps whose keys are directive names (for example, + `:script_src`) and whose values are lists of CSP source expressions. The + policy produced by `conn_policy/2` starts with the application's restrictive + baseline and then incorporates request-specific and environment-specific + sources: + + * same-origin sources are allowed for document, script, connection, form, + manifest, image, and media requests; + * objects and frame ancestors are denied, while `frame-src` is denied until + a source is explicitly added; + * `:cdn_host` and `:camo_host` configuration values are added as HTTPS image + and media origins; and + * Vite development origins are added when Vite hot-module reloading is + enabled. + + Use `merge_policy/2` to append source expressions to an existing policy, and + `serialize/1` to turn the policy into the value of a + `content-security-policy` response header. + """ + + alias PhilomenaWeb.Config + alias PhilomenaWeb.FrontendAssets + + @directives [ + default_src: "default-src", + script_src: "script-src", + connect_src: "connect-src", + style_src: "style-src", + object_src: "object-src", + frame_ancestors: "frame-ancestors", + frame_src: "frame-src", + form_action: "form-action", + manifest_src: "manifest-src", + img_src: "img-src", + media_src: "media-src" + ] + + # An entry of 'none' is not permitted to be extended by any request. + # Other entries can be extended. + @base_policy %{ + default_src: ["'self'"], + script_src: ["'self'"], + connect_src: ["'self'"], + style_src: ["'self'"], + object_src: ["'none'"], + frame_ancestors: ["'none'"], + frame_src: [], + form_action: ["'self'"], + manifest_src: ["'self'"], + img_src: ["'self'", "blob:", "data:"], + media_src: ["'self'", "blob:", "data:"] + } + + @type policy :: %{optional(atom()) => [String.t()]} + + @doc """ + Builds the CSP policy applicable to a `m:Plug.Conn`. + + The returned policy begins with the module's baseline policy, adds the + configured CDN and Camo hosts to `img-src` and `media-src`, and adds the Vite + development and websocket origins when Vite hot-module reloading is enabled. + + `additions` contains request-specific directive sources and is merged last, + so callers can extend the baseline for a particular response. It defaults to + an empty policy. + + Hosts configured through `:cdn_host` and `:camo_host` are interpreted as host + names and serialized as HTTPS origins. `conn` is used to derive the Vite + origins, so it should contain the request host when hot reloading is in use. + + ## Examples + + iex> conn = Plug.Test.conn(:get, "/") + iex> policy = PhilomenaWeb.ContentSecurityPolicy.conn_policy(conn) + iex> policy.default_src + ["'self'"] + + """ + @spec conn_policy(Plug.Conn.t(), policy()) :: policy() + def conn_policy(conn, additions \\ %{}) do + @base_policy + |> maybe_media_origin(Application.get_env(:philomena, :cdn_host)) + |> maybe_media_origin(Application.get_env(:philomena, :camo_host)) + |> maybe_vite_hmr(conn, Config.vite_hmr?()) + |> merge_policy(additions) + end + + @doc """ + Appends source expressions from `additions` to a CSP `policy`. + + Entries with the same directive are concatenated in their existing order; + directives present only in `additions` are added to the map. This function + can be used repeatedly while a request accumulates permissions. + + Unknown directive keys are preserved in the returned map, although + `serialize/1` emits only the directives supported by this module. + + ## Examples + + iex> policy = %{script_src: ["'self'"]} + iex> PhilomenaWeb.ContentSecurityPolicy.merge_policy(policy, %{script_src: ["https://cdn.example"]}) + %{script_src: ["'self'", "https://cdn.example"]} + + """ + @spec merge_policy(policy(), policy()) :: policy() + def merge_policy(policy, additions) do + Map.merge(policy, additions, fn _key, old_sources, new_sources -> + old_sources ++ new_sources + end) + end + + @doc """ + Serializes a policy map as a `Content-Security-Policy` header value. + + Directives are emitted in a stable order. Source expressions for each + directive are de-duplicated while preserving their first-seen order; a + directive with no sources is emitted as ` 'none'`. Only the + directives supported by this module are emitted, even when `policy` contains + additional keys. + + ## Examples + + iex> policy = %{default_src: ["'self'"], frame_src: ["https://frame.example"]} + iex> serialized = PhilomenaWeb.ContentSecurityPolicy.serialize(policy) + iex> String.split(serialized, "; ") |> Enum.take(2) + ["default-src 'self'", "script-src 'none'"] + + """ + @spec serialize(policy()) :: String.t() + def serialize(policy) do + @directives + |> Enum.map(fn {directive, name} -> + policy + |> effective_values(directive) + |> then(&Enum.join([name | &1], " ")) + end) + |> Enum.join("; ") + end + + defp effective_values(policy, directive) do + policy + |> Map.get(directive, []) + |> Enum.uniq() + |> case do + [] -> + ["'none'"] + + values -> + values + end + end + + defp maybe_vite_hmr(policy, _conn, false), + do: policy + + defp maybe_vite_hmr(policy, conn, true) do + origin = FrontendAssets.vite_origin(conn) + websocket_origin = FrontendAssets.vite_websocket_origin(conn) + + merge_policy(policy, %{ + script_src: [origin], + connect_src: [origin, websocket_origin], + style_sources: ["'unsafe-inline'"] + }) + end + + defp maybe_media_origin(policy, origin) when origin in [nil, ""], + do: policy + + defp maybe_media_origin(policy, origin) do + origin = URI.to_string(%URI{scheme: "https", host: origin}) + + merge_policy(policy, %{ + img_src: [origin], + media_src: [origin] + }) + end +end diff --git a/lib/philomena_web/frontend_assets.ex b/lib/philomena_web/frontend_assets.ex new file mode 100644 index 000000000..ea63851c3 --- /dev/null +++ b/lib/philomena_web/frontend_assets.ex @@ -0,0 +1,20 @@ +defmodule PhilomenaWeb.FrontendAssets do + @moduledoc "Helpers for development and compiled frontend asset URLs." + + @vite_port 5173 + + @spec vite_origin(Plug.Conn.t()) :: String.t() + def vite_origin(%Plug.Conn{host: host}) do + URI.to_string(%URI{scheme: "http", host: host, port: @vite_port}) + end + + @spec vite_asset_url(Plug.Conn.t(), String.t()) :: String.t() + def vite_asset_url(conn, path) when is_binary(path) do + vite_origin(conn) <> "/" <> String.trim_leading(path, "/") + end + + @spec vite_websocket_origin(Plug.Conn.t()) :: String.t() + def vite_websocket_origin(%Plug.Conn{host: host}) do + URI.to_string(%URI{scheme: "ws", host: host, port: @vite_port}) + end +end diff --git a/lib/philomena_web/plugs/captcha_plug.ex b/lib/philomena_web/plugs/captcha_plug.ex index 0dcb99486..6ec9f708e 100644 --- a/lib/philomena_web/plugs/captcha_plug.ex +++ b/lib/philomena_web/plugs/captcha_plug.ex @@ -17,10 +17,11 @@ defmodule PhilomenaWeb.CaptchaPlug do end defp maybe_assign_csp_headers(conn, nil) do - conn - |> ContentSecurityPolicyPlug.permit_source(:script_src, @hcaptcha_url) - |> ContentSecurityPolicyPlug.permit_source(:frame_src, @hcaptcha_url) - |> ContentSecurityPolicyPlug.permit_source(:style_src, @hcaptcha_url) + ContentSecurityPolicyPlug.permit_sources(conn, %{ + script_src: @hcaptcha_url, + frame_src: @hcaptcha_url, + style_src: @hcaptcha_url + }) end defp maybe_assign_csp_headers(conn, _user) do diff --git a/lib/philomena_web/plugs/content_security_policy_plug.ex b/lib/philomena_web/plugs/content_security_policy_plug.ex index 32ff15e47..89ae5dded 100644 --- a/lib/philomena_web/plugs/content_security_policy_plug.ex +++ b/lib/philomena_web/plugs/content_security_policy_plug.ex @@ -1,93 +1,42 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do - import PhilomenaWeb.Config import Plug.Conn - @allowed_sources [ - :script_src, - :frame_src, - :style_src - ] + alias PhilomenaWeb.Config + alias PhilomenaWeb.ContentSecurityPolicy - def init(opts) do - opts - end + @conn_key :csp_sources + @csp_header "content-security-policy" - def call(conn, _opts) do - cdn_uri = cdn_uri() - camo_uri = camo_uri() + def init(_opts), do: [] + def call(conn, _opts) do register_before_send(conn, fn conn -> - config = get_config(conn) - - script_src = Keyword.get(config, :script_src, []) - style_src = Keyword.get(config, :style_src, []) - frame_src = Keyword.get(config, :frame_src, nil) - - csp_config = [ - {:default_src, ["'self'"]}, - {:script_src, [default_script_src(conn.host) | script_src]}, - {:connect_src, [default_connect_src(conn.host)]}, - {:style_src, [default_style_src() | style_src]}, - {:object_src, ["'none'"]}, - {:frame_ancestors, ["'none'"]}, - {:frame_src, frame_src || ["'none'"]}, - {:form_action, ["'self'"]}, - {:manifest_src, ["'self'"]}, - {:img_src, ["'self'", "blob:", "data:", cdn_uri, camo_uri]}, - {:media_src, ["'self'", "blob:", "data:", cdn_uri, camo_uri]} - ] + if conn.status == 500 and Config.csp_relax_on_error?() do + # Allow Plug.Debugger to function in development + delete_resp_header(conn, @csp_header) + else + additions = Map.get(conn.private, @conn_key, %{}) - csp_value = Enum.map_join(csp_config, "; ", &cspify_element/1) + csp_value = + conn + |> ContentSecurityPolicy.conn_policy(additions) + |> ContentSecurityPolicy.serialize() - csp_relaxed? do - if conn.status == 500 do - # Allow Plug.Debugger to function in this case - delete_resp_header(conn, "content-security-policy") - else - # Enforce CSP otherwise - put_resp_header(conn, "content-security-policy", csp_value) - end - else - put_resp_header(conn, "content-security-policy", csp_value) + put_resp_header(conn, @csp_header, csp_value) end end) end - def permit_source(conn, key, value) when key in @allowed_sources do - conn - |> get_config() - |> Keyword.update(key, value, &(value ++ &1)) - |> set_config(conn) - end - - defp get_config(conn), do: conn.private[:csp] || [] - defp set_config(value, conn), do: put_private(conn, :csp, value) - - defp cdn_uri, do: Application.get_env(:philomena, :cdn_host) |> to_uri() - defp camo_uri, do: Application.get_env(:philomena, :camo_host) |> to_uri() - - # Use the "current host" in vite HMR mode for whatever the "current host" is. - # Usually it's `localhost`, but it may be some other private IP address, that - # you use to test the frontend on a mobile device connected via a local Wi-Fi. - vite_hmr? do - defp default_script_src(host), do: "'self' #{host}:5173" - defp default_connect_src(host), do: "'self' #{host}:5173 ws://#{host}:5173" - defp default_style_src, do: "'self' 'unsafe-inline'" - else - defp default_connect_src(_host), do: "'self'" - defp default_script_src(_host), do: "'self'" - defp default_style_src, do: "'self'" - end - - defp to_uri(host) when host in [nil, ""], do: "" - defp to_uri(host), do: URI.to_string(%URI{scheme: "https", host: host}) - - defp cspify_element({key, value}) do - key = - key - |> Atom.to_string() - |> String.replace("_", "-") - - Enum.join([key | value], " ") + @doc """ + Adds request-specific sources to the CSP for the current request. + """ + @spec permit_sources(Plug.Conn.t(), %{optional(atom()) => [String.t()]}) :: Plug.Conn.t() + def permit_sources(conn, additions) do + sources = + conn.private + |> Map.get(@conn_key, %{}) + |> ContentSecurityPolicy.merge_policy(additions) + + put_private(conn, @conn_key, sources) end end diff --git a/lib/philomena_web/templates/layout/app.html.slime b/lib/philomena_web/templates/layout/app.html.slime index 4f9a86239..c6d34394e 100644 --- a/lib/philomena_web/templates/layout/app.html.slime +++ b/lib/philomena_web/templates/layout/app.html.slime @@ -21,13 +21,13 @@ html lang="en" link rel="search" type="application/opensearchdescription+xml" title="Derpibooru" href="/opensearch.xml" = csrf_meta_tag() - = vite_hmr? do - script type="module" src="http://#{@conn.host}:5173/@vite/client" - script type="module" src="http://#{@conn.host}:5173/js/app.ts" + = if vite_hmr?() do + script type="module" src=vite_asset_url(@conn, "/@vite/client") + script type="module" src=vite_asset_url(@conn, "/js/app.ts") - else script type="text/javascript" src=~p"/js/app.js" async="async" = render PhilomenaWeb.LayoutView, "_opengraph.html", assigns - body data-theme=theme_name(@current_user) data-vite-reload=to_string(vite_reload?()) data-hide-staff-tools=hide_staff_tools_attribute(@conn) + body data-theme=theme_name(@current_user) data-vite-reload=to_string(vite_hmr?()) data-hide-staff-tools=hide_staff_tools_attribute(@conn) = render PhilomenaWeb.LayoutView, "_burger.html", assigns #container class=container_class(@current_user) = render PhilomenaWeb.LayoutView, "_header.html", assigns diff --git a/lib/philomena_web/views/layout_view.ex b/lib/philomena_web/views/layout_view.ex index 10af33360..d51fed06f 100644 --- a/lib/philomena_web/views/layout_view.ex +++ b/lib/philomena_web/views/layout_view.ex @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.LayoutView do use PhilomenaWeb, :view import PhilomenaWeb.Config + import PhilomenaWeb.FrontendAssets alias PhilomenaWeb.ImageView alias Philomena.Config alias Philomena.Users.User @@ -33,10 +34,6 @@ defmodule PhilomenaWeb.LayoutView do Application.get_env(:philomena, :cdn_host) end - def vite_reload? do - Application.get_env(:philomena, :vite_reload) - end - def generator_name do if hide_version() do "Philomena" diff --git a/test/philomena_web/plugs/content_security_policy_plug_test.exs b/test/philomena_web/plugs/content_security_policy_plug_test.exs new file mode 100644 index 000000000..5bee738ed --- /dev/null +++ b/test/philomena_web/plugs/content_security_policy_plug_test.exs @@ -0,0 +1,122 @@ +defmodule PhilomenaWeb.ContentSecurityPolicyPlugTest do + use ExUnit.Case, async: false + + alias PhilomenaWeb.ContentSecurityPolicy + alias PhilomenaWeb.ContentSecurityPolicyPlug + alias PhilomenaWeb.FrontendAssets + + @config_keys [:cdn_host, :camo_host, :vite_reload, :csp_relax_on_error] + + setup do + values = Map.new(@config_keys, &{&1, Application.get_env(:philomena, &1)}) + + on_exit(fn -> + Enum.each(values, fn + {key, nil} -> Application.delete_env(:philomena, key) + {key, value} -> Application.put_env(:philomena, key, value) + end) + end) + + Enum.each(@config_keys, &Application.delete_env(:philomena, &1)) + :ok + end + + test "builds a deterministic policy and omits unset origins" do + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "localhost") + + policy = + conn + |> ContentSecurityPolicy.conn_policy() + |> ContentSecurityPolicy.serialize() + + assert policy =~ "default-src 'self'" + assert policy =~ "frame-src 'none'" + assert policy =~ "img-src 'self' blob: data:" + refute policy =~ " " + refute policy =~ "https://" + end + + test "merges and deduplicates request-specific sources" do + conn = + Plug.Test.conn(:get, "/") + |> Map.put(:host, "localhost") + |> ContentSecurityPolicyPlug.call([]) + |> ContentSecurityPolicyPlug.permit_sources(%{ + frame_src: ["https://hcaptcha.com", "https://hcaptcha.com"] + }) + |> Plug.Conn.send_resp(200, "ok") + + [policy] = Plug.Conn.get_resp_header(conn, "content-security-policy") + assert policy =~ "frame-src https://hcaptcha.com" + refute policy =~ "frame-src https://hcaptcha.com https://hcaptcha.com" + end + + test "uses the shared Vite origin in both policy and asset URLs" do + Application.put_env(:philomena, :vite_reload, true) + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "192.168.1.10") + + origin = FrontendAssets.vite_origin(conn) + + policy = + conn + |> ContentSecurityPolicy.conn_policy() + |> ContentSecurityPolicy.serialize() + + assert FrontendAssets.vite_asset_url(conn, "/js/app.ts") == origin <> "/js/app.ts" + assert policy =~ "script-src 'self' #{origin}" + assert policy =~ "connect-src 'self' #{origin} ws://192.168.1.10:5173" + end + + test "adds media-specific hosts to media-src and img-src when provided" do + # With neither + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "localhost") + + policy = + conn + |> ContentSecurityPolicy.conn_policy() + |> ContentSecurityPolicy.serialize() + + assert policy =~ "img-src 'self' blob: data:" + assert policy =~ "media-src 'self' blob: data:" + + # With one + Application.put_env(:philomena, :camo_host, "philomena-camo-host.example") + + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "localhost") + + policy = + conn + |> ContentSecurityPolicy.conn_policy() + |> ContentSecurityPolicy.serialize() + + assert policy =~ "img-src 'self' blob: data: https://philomena-camo-host.example" + assert policy =~ "media-src 'self' blob: data: https://philomena-camo-host.example" + + # With both + Application.put_env(:philomena, :cdn_host, "philomena-cdn-host.example") + + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "localhost") + + policy = + conn + |> ContentSecurityPolicy.conn_policy() + |> ContentSecurityPolicy.serialize() + + assert policy =~ + "img-src 'self' blob: data: https://philomena-cdn-host.example https://philomena-camo-host.example" + + assert policy =~ + "media-src 'self' blob: data: https://philomena-cdn-host.example https://philomena-camo-host.example" + end + + test "only relaxes CSP on errors when explicitly enabled" do + conn = Plug.Test.conn(:get, "/") |> Map.put(:host, "localhost") + + strict_conn = conn |> ContentSecurityPolicyPlug.call([]) |> Plug.Conn.send_resp(500, "error") + refute Plug.Conn.get_resp_header(strict_conn, "content-security-policy") == [] + + Application.put_env(:philomena, :csp_relax_on_error, true) + relaxed_conn = conn |> ContentSecurityPolicyPlug.call([]) |> Plug.Conn.send_resp(500, "error") + assert Plug.Conn.get_resp_header(relaxed_conn, "content-security-policy") == [] + end +end From bc8b40f928aa511d16b271a36137205c4bb8a506 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 4 Sep 2026 22:16:04 -0400 Subject: [PATCH 2/2] Sentry frontend/backend tracing support --- config/runtime.exs | 28 ++++++++++++++ lib/philomena/application.ex | 7 ++++ lib/philomena_web/config.ex | 1 + lib/philomena_web/content_security_policy.ex | 38 +++++++++++++------ lib/philomena_web/endpoint.ex | 2 + lib/philomena_web/frontend_assets.ex | 5 +++ .../templates/layout/app.html.slime | 2 + mix.exs | 16 +++++++- mix.lock | 20 ++++++++++ 9 files changed, 106 insertions(+), 13 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 461f28723..cb454419c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -115,6 +115,34 @@ if config_env() != :test do queue_interval: 20_000 end +sentry_dsn = System.get_env("SENTRY_DSN") + +if not is_nil(sentry_dsn) do + config :sentry, + dsn: sentry_dsn, + environment_name: config_env(), + enable_source_code_context: true, + root_source_code_paths: [app_dir] + + loader_script_url = System.fetch_env!("SENTRY_LOADER_SCRIPT_URL") + + loader_script_src = + loader_script_url + |> URI.parse() + |> then(&%URI{scheme: &1.scheme, host: &1.host, port: &1.port}) + + loader_connect_src = + sentry_dsn + |> URI.parse() + |> then(&%URI{scheme: &1.scheme, host: &1.host, port: &1.port}) + + config :philomena, + sentry_enabled: true, + sentry_loader_script_url: loader_script_url, + sentry_loader_script_src: to_string(loader_script_src), + sentry_loader_connect_src: to_string(loader_connect_src) +end + if config_env() == :prod do # Production mailer config config :philomena, Philomena.Mailer, diff --git a/lib/philomena/application.ex b/lib/philomena/application.ex index c92401286..4b05854ed 100644 --- a/lib/philomena/application.ex +++ b/lib/philomena/application.ex @@ -7,6 +7,7 @@ defmodule Philomena.Application do def start(_type, _args) do configure_logging() + configure_tracing() # List all child processes to be supervised children = [ @@ -67,6 +68,12 @@ defmodule Philomena.Application do defp valid_node_name(node), do: node + defp configure_tracing do + OpentelemetryBandit.setup() + OpentelemetryEcto.setup([:philomena, :repo]) + OpentelemetryPhoenix.setup(adapter: :bandit) + end + defp configure_logging() do # Log filtering design is borrowed from the Rust's `tracing` observability framework. # Specifically from the `EnvFilter` syntax: diff --git a/lib/philomena_web/config.ex b/lib/philomena_web/config.ex index 51b6465e6..fa9170ed3 100644 --- a/lib/philomena_web/config.ex +++ b/lib/philomena_web/config.ex @@ -5,4 +5,5 @@ defmodule PhilomenaWeb.Config do def vite_hmr?, do: Application.get_env(:philomena, :vite_reload, false) def csp_relax_on_error?, do: Application.get_env(:philomena, :csp_relax_on_error, false) + def sentry_enabled?, do: Application.get_env(:philomena, :sentry_enabled, false) end diff --git a/lib/philomena_web/content_security_policy.ex b/lib/philomena_web/content_security_policy.ex index 9a6c59199..1d96095a3 100644 --- a/lib/philomena_web/content_security_policy.ex +++ b/lib/philomena_web/content_security_policy.ex @@ -85,6 +85,7 @@ defmodule PhilomenaWeb.ContentSecurityPolicy do @base_policy |> maybe_media_origin(Application.get_env(:philomena, :cdn_host)) |> maybe_media_origin(Application.get_env(:philomena, :camo_host)) + |> maybe_sentry(Config.sentry_enabled?()) |> maybe_vite_hmr(conn, Config.vite_hmr?()) |> merge_policy(additions) end @@ -154,29 +155,42 @@ defmodule PhilomenaWeb.ContentSecurityPolicy do end end - defp maybe_vite_hmr(policy, _conn, false), + defp maybe_media_origin(policy, origin) when origin in [nil, ""], do: policy - defp maybe_vite_hmr(policy, conn, true) do - origin = FrontendAssets.vite_origin(conn) - websocket_origin = FrontendAssets.vite_websocket_origin(conn) + defp maybe_media_origin(policy, origin) do + origin = URI.to_string(%URI{scheme: "https", host: origin}) merge_policy(policy, %{ - script_src: [origin], - connect_src: [origin, websocket_origin], - style_sources: ["'unsafe-inline'"] + img_src: [origin], + media_src: [origin] }) end - defp maybe_media_origin(policy, origin) when origin in [nil, ""], + defp maybe_sentry(policy, false), do: policy - defp maybe_media_origin(policy, origin) do - origin = URI.to_string(%URI{scheme: "https", host: origin}) + defp maybe_sentry(policy, true) do + script_src = Application.fetch_env!(:philomena, :sentry_loader_script_src) + connect_src = Application.fetch_env!(:philomena, :sentry_loader_connect_src) merge_policy(policy, %{ - img_src: [origin], - media_src: [origin] + script_src: [script_src], + connect_src: [connect_src] + }) + end + + defp maybe_vite_hmr(policy, _conn, false), + do: policy + + defp maybe_vite_hmr(policy, conn, true) do + origin = FrontendAssets.vite_origin(conn) + websocket_origin = FrontendAssets.vite_websocket_origin(conn) + + merge_policy(policy, %{ + script_src: [origin], + connect_src: [origin, websocket_origin], + style_sources: ["'unsafe-inline'"] }) end end diff --git a/lib/philomena_web/endpoint.ex b/lib/philomena_web/endpoint.ex index b33310ebf..791f2ccc9 100644 --- a/lib/philomena_web/endpoint.ex +++ b/lib/philomena_web/endpoint.ex @@ -32,6 +32,8 @@ defmodule PhilomenaWeb.Endpoint do pass: ["*/*"], json_decoder: Phoenix.json_library() + plug Sentry.PlugContext + plug Plug.MethodOverride plug Plug.Head diff --git a/lib/philomena_web/frontend_assets.ex b/lib/philomena_web/frontend_assets.ex index ea63851c3..e127f4794 100644 --- a/lib/philomena_web/frontend_assets.ex +++ b/lib/philomena_web/frontend_assets.ex @@ -17,4 +17,9 @@ defmodule PhilomenaWeb.FrontendAssets do def vite_websocket_origin(%Plug.Conn{host: host}) do URI.to_string(%URI{scheme: "ws", host: host, port: @vite_port}) end + + @spec sentry_loader_script_url() :: String.t() + def sentry_loader_script_url do + Application.fetch_env!(:philomena, :sentry_loader_script_url) + end end diff --git a/lib/philomena_web/templates/layout/app.html.slime b/lib/philomena_web/templates/layout/app.html.slime index c6d34394e..897f772e2 100644 --- a/lib/philomena_web/templates/layout/app.html.slime +++ b/lib/philomena_web/templates/layout/app.html.slime @@ -21,6 +21,8 @@ html lang="en" link rel="search" type="application/opensearchdescription+xml" title="Derpibooru" href="/opensearch.xml" = csrf_meta_tag() + = if sentry_enabled?() do + script src=sentry_loader_script_url() crossorigin="anonymous" = if vite_hmr?() do script type="module" src=vite_asset_url(@conn, "/@vite/client") script type="module" src=vite_asset_url(@conn, "/js/app.ts") diff --git a/mix.exs b/mix.exs index 5b5378287..0f3ed360c 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,12 @@ defmodule Philomena.MixProject do plt_add_apps: [:ex_unit, :mix] ], docs: [formatters: ["html"]], - listeners: [Phoenix.CodeReloader] + listeners: [Phoenix.CodeReloader], + releases: [ + philomena: [ + applications: [opentelemetry: :temporary] + ] + ] ] end @@ -75,6 +80,15 @@ defmodule Philomena.MixProject do {:sweet_xml, "~> 0.7"}, {:inet_cidr, "~> 1.0"}, + # Tracing + {:opentelemetry, "~> 1.7"}, + {:opentelemetry_api, "~> 1.5"}, + {:opentelemetry_bandit, "~> 0.3"}, + {:opentelemetry_ecto, "~> 1.2"}, + {:opentelemetry_exporter, "~> 1.10"}, + {:opentelemetry_phoenix, "~> 2.0"}, + {:sentry, "~> 13.0"}, + # SMTP {:swoosh, "~> 1.19"}, {:mua, "~> 0.2"}, diff --git a/mix.lock b/mix.lock index 966f78afd..0707eeaf9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,15 +1,18 @@ %{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"}, "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "canada": {:hex, :canada, "2.0.0", "ce5e058f576a0625959fc5427fcde15311fb28a5ebc13775eafd13468ad16553", [:mix], [], "hexpm", "49a648c48d8b0864380f38f02a7f316bd30fd45602205c48197432b5225d8596"}, "canary": {:hex, :canary, "1.2.0", "6decbf704685ef655ff5d27d3556aaccad6f4f9823289162d514d5b82459be28", [:mix], [{:canada, "~> 2.0.0", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "919f68b2a09488063475f04ee82bb26c83f1abb7201de7dacbda36c866d86745"}, + "chatterbox": {:hex, :ts_chatterbox, "0.16.0", "9d062f566235b6deb5ff94c4a4eb332b7cff28d9ccf281b91de10fe74e1f59fe", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "34c145c702f3a8d22f49a189eb34579ef3db68f9a98a82d19b5cf6e390aad54f"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, @@ -28,6 +31,9 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, + "gproc": {:hex, :gproc, "1.2.0", "b06dc2a5aaa78f8902e6c0032f29362ea1c1cda941216f085a04a76fc0cdf7cc", [:rebar3], [], "hexpm", "70c6f8c91fa5974296cd87974949d8eab953230414f31c4a623ff75131e0827a"}, + "grpcbox": {:hex, :grpcbox, "0.18.0", "ccc14ec546509e0952d406ef626c5856b68d36a40be6040fa444b375b33bd14f", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.16.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 1.2.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "5ec9f8fe664ab51201b32c117a61511a1f9d6316771e3891ba8a88d289a732ab"}, + "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, @@ -42,8 +48,19 @@ "mua": {:hex, :mua, "0.2.6", "e7b05ade4eed6c99a6fecdd8b7f160c8bfcb78432583fdb0fc04d41a72908bee", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "c8bd1417dc18208eed3a3e0f1b847930e7b649e3e71165d2934f3b1ba62b3c18"}, "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], [], "hexpm", "2da322b9b1567ffa0706a7f30f6bbbde70835ae44a1050615f4b4a3d436e0f28"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, + "opentelemetry_bandit": {:hex, :opentelemetry_bandit, "0.3.0", "2c242dfdaabd747c75f4d8331fc9c17cfc9fb1db0638309762a4fcfa6d49a147", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.15.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5aa12378f5ff7cc3368f02905693571833f9449df86211fd99f4d764720cff60"}, + "opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"}, + "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"}, + "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, + "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, "pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]}, "phoenix": {:hex, :phoenix, "1.8.13", "e33192826d9bed4022bdb3f5a7b36c04362049d9390fd1b581d5ad6261779268", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ad14e24d10e5a52d5f80429053bbe3a5d124311a2868fceb0a01a2e859c44539"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, @@ -67,12 +84,15 @@ "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:git, "https://github.com/krns/scrivener_ecto.git", "eaad1ddd86a9c8ffa422479417221265a0673777", [ref: "eaad1ddd86a9c8ffa422479417221265a0673777"]}, "secure_compare": {:hex, :secure_compare, "0.1.0", "01b3c93c8edb696e8a5b38397ed48e10958c8a5ec740606656445bcbec0aadb8", [:mix], [], "hexpm", "6391a49eb4a6182f0d7425842fc774bbed715e78b2bfb0c83b99c94e02c78b5c"}, + "sentry": {:hex, :sentry, "13.5.1", "a46cb0bfc80c86e589a3e5686b0e2114b52db13ad5b443d251bdfd6bb25c9c19", [:mix], [{:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, ">= 1.8.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c8e104a67a72e0353c88a865baa3a163067e13aeb443a45258a43823a5acbf6e"}, "slime": {:hex, :slime, "1.3.1", "d6781854092a638e451427c33e67be348352651a7917a128155b8a41ac88d0a2", [:mix], [{:neotoma, "~> 1.7", [hex: :neotoma, repo: "hexpm", optional: false]}], "hexpm", "099b09280297e0c6c8d1f56b0033b885fc4eb541ad3c4a75f88a589354e2501b"}, "sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.28.0", "30d9f3519a150128e90d97f9878a8a5cd8325ed03d618d1c626201496fc20baf", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bb5c0b7c988beb53786254a61580597fe1017a652039061ee2b4c28b5097b10e"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.35.0", "6237b9cb9632b1f52f00ef852cd04d4af70319c308d4f65be1e45da637578867", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "36fd91d635761daffa12e75b0c784b24510a69ad53348b8276a553d2d665b579"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},