diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cb56e01..62321c5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,20 @@ ## Unreleased +### Enhancements + + - Added `--host` option for `mix beacon.gen.site` to serve your site at an alternative domain + ### Fixes - Fixed a bug where MediaLibrary could check for file contents on the wrong node in multi-node deployments +### Breaking Changes + + - `beacon.install` - removed command aliases `-s` and `-p` + - `beacon.gen.site` - removed command aliases `-s` and `-p` + - `beacon.gen.tailwind_config` - removed command alias `-s` + ## 0.3.3 (2024-12-13) ### Fixes diff --git a/guides/deployment/deployment-topologies.md b/guides/deployment/deployment-topologies.md index 8f85e8537..4e45896f4 100644 --- a/guides/deployment/deployment-topologies.md +++ b/guides/deployment/deployment-topologies.md @@ -4,7 +4,7 @@ The routing system in Phoenix combined with the OTP distribution model opens man Code examples might be abbreviated and infrastructure details like load balancers are not covered in this guide to keep it short. -Clustering your application is also not covered in this guide but you can find the documentation on the platform's site you're using, +Clustering your application is also not covered in this guide but you can find the documentation on the platform's docs, for example https://fly.io/docs/elixir/the-basics/clustering and https://www.gigalixir.com/docs/cluster. ## Core Concepts @@ -32,7 +32,7 @@ end Results in: ``` -http://mysite.com/2024/campaigns/christmas` +http://mysite.com/2024/campaigns/christmas ^ ^ ^ ^ | | | | endpoint | | page path @@ -59,7 +59,7 @@ scope host: "siteb.com" do end ``` -The downside of having such flexibility is creating a configuration that is either invalid or not optimized. Take for instance this configuration: +The downside of having such flexibility is that it's also easy to create a configuration that is invalid or not optimized. Take for instance this configuration: ```elixir scope host: "sitea.com" do @@ -76,7 +76,7 @@ You can already tell that starting `:site_b` in the node that is hosting `sitea. which is not a big problem when you have a couple of small sites, but that becomes a problem as your environment grows. To avoid this problem, Beacon will selectively boot only the sites that are reachable in the current host, so in the example above, -only `:site_a` will be booted in the node hosting `sitea.com` and only `:site_b` in the node hosting `siteb.com`. +only `:site_a` will boot in the node hosting `sitea.com` and only `:site_b` in the node hosting `siteb.com`. Or this other example: @@ -91,7 +91,8 @@ The macro `beacon_site` creates a catch-all route `/*` so the second site will n is a valid route for the first site. Those might look obvious but that's a common source of confusion, especially in long and more complex router files. -So Beacon won't try to boot sites that can't be reached, but a warning will be displayed. + +For these cases, whenever possible, Beacon will emit warnings during the boot process. ### Admin Sites Discovery @@ -153,7 +154,7 @@ With these constraints in mind, let's check some deployment strategies. Below we'll describe some common deployment strategies but Beacon is not limited to the strategies below, you can adapt to your needs. -### 1. Single application on same host +### 1. Single application on the same host The most simple strategy is a single project with one or more sites and the admin interface in the same host. ```elixir @@ -193,6 +194,8 @@ flowchart TD ### 2. Clustered single applications Same project as the previous strategy but with multiple nodes deployed in the same cluster. +Gives more capacity to serve more requests but still sharing the same Endpoint and same host. + ```mermaid flowchart TD subgraph Node1["Node1"] @@ -236,7 +239,9 @@ if you start booting more site and more nodes. So an optimization is to move the Admin interface into its own project and node (a new Phoenix project), and keep the sites in their own projects. -Note that in order to Admin find the sites, all the apps must be connected in the same cluster. +That scenario also opens the possibility to deploy the admin interface behind a VPN to increase security. + +Note that in order for BeaconLiveAdmin to find all running sites, all the apps must be connected in the same cluster. ```elixir # endpoint @@ -281,126 +286,35 @@ flowchart TD A huge benefit of this topology is the flexibility to protect the Admin interface behind a VPN or scale it independently from the main applications. -### 4. Multiple hosts in single project, separated hosting apps -Still a single project but now serving multiple sites at the root path for different dynamic hosts. - -In this case we're still deploying just one application but serving multiple domains for each site: - -- :campaigns -> campaigns.mysite.com -- :root -> mysite.com - -TODO: diagram - -TODO: gen task and constraints +### 4. Multiple hosts in single project +Still a single project but now it will serve each site on its own host (domain). -### 5. Multiple hosts in single project, separated hosting apps -Similar to the previous strategy but this time we're splitting each domain into its own app: - -- App1 -> mysite.com -- App2 -> campaigns.mysite.com - -That means deploying isolated apps for each domain/site, not connected to each other, -but still sharing the same codebase. - -```elixir -# endpoint -host = System.get_env("PHX_HOST") -config :my_app, MyAppWeb.Endpoint, url: [host: host] - -# router -scope "/", host: "campaigns.mysite.com" do - beacon_live_admin "/admin" - beacon_site "/", site: :campaigns -end +This scenario introduces a [Proxy Endpoint](https://hexdocs.pm/beacon/Beacon.ProxyEndpoint.html) to route requests to the appropriate Endpoint serving the site, +this configuration can be generated with `mix beacon.gen.site --site my_site --host mysite.com` - see [docs](https://hexdocs.pm/beacon/Mix.Tasks.Beacon.Gen.Site.html) for more info. -scope "/", host: "mysite.com" do - beacon_live_admin "/admin" - beacon_site "/", site: :root -end -``` +In this case we're still deploying just one application but serving multiple domains for each site: ```mermaid flowchart TD subgraph Node1["Node1"] - n1_site["/, site: :root"] - subgraph Node1Admin["/admin"] - n1_admin_site[":root"] - end - end - subgraph Node2["Node1"] - n2_site["/, site: :campaigns"] - subgraph Node2Admin["/admin"] - n2_admin_site[":campaigns"] + proxy["ProxyEndpoint"] + site_a["site: :my_site, endpoint: MySiteEndpoint"] + site_b["site: :campaigns, endpoint: CampaignsEndpoint"] + subgraph Admin["/admin"] + admin_site_a[":my_site"] + admin_site_b[":campaigns"] end end - subgraph App1["App mysite.com"] - subgraph Cluster1["Cluster"] - Node1 - end - end - subgraph App2["App campaigns.mysite.com"] - subgraph Cluster2["Cluster"] - Node2 - end - end - - n1_site --> n1_admin_site - n2_site --> n2_admin_site - r1["mysite.com/campaigns/christmas"] --> n2_site - r2["mysite.com/contact"] --> n1_site - r3["mysite.com/admin"] --> Node1Admin - r4["campaigns.mysite.com/admin"] --> Node2Admin -``` - -### 6. Multiple hosts in single project, connected hosting apps -Similar setup as the previous strategy but now connecting the apps in the same cluster with a separated admin interface. - -```elixir -# endpoint -host = System.get_env("PHX_HOST") -config :my_app, MyAppWeb.Endpoint, url: [host: host] - -# router -scope "/admin", host: "admin.mysite.com" do - beacon_live_admin "/" -end - -scope "/", host: "campaigns.mysite.com" do - beacon_site "/", site: :campaigns -end - -scope "/", host: "mysite.com" do - beacon_site "/", site: :root -end -``` - -```mermaid -flowchart TD - subgraph Node1["Node1"] - n1_site["/, site: :root"] - end - subgraph Node2["Node2"] - n2_site["/, site: :campaigns"] - end - subgraph Admin["NodeAdmin"] - admin_site_a[":root"] - admin_site_b[":campaigns"] - end subgraph Cluster["Cluster"] - subgraph App1["App1 mysite.com"] - Node1 - end - subgraph App2["App2 campaigns.mysite.com"] - Node2 - end - subgraph AppAdmin["App admin.mysite.com"] - Admin - end + Node1 + Admin end - n1_site --> admin_site_a - n2_site --> admin_site_b - r1["mysite.com/campaigns/christmas"] --> n2_site - r2["mysite.com/contact"] --> n1_site - r3["admin.mysite.com"] --> Admin + site_a --> admin_site_a + site_b --> admin_site_b + r1["mysite.com"] --> proxy + r2["campaigns.mysite.com"] --> proxy + proxy --> site_a + proxy --> site_b + r3["mysite.com/admin"] --> Admin ``` diff --git a/lib/beacon/igniter.ex b/lib/beacon/igniter.ex index 81ed161c1..47230a009 100644 --- a/lib/beacon/igniter.ex +++ b/lib/beacon/igniter.ex @@ -33,4 +33,58 @@ defmodule Beacon.Igniter do found -> found end end + + def move_to_constant(zipper, name) do + case Sourceror.Zipper.find(zipper, &match?({:@, _, [{^name, _, _}]}, &1)) do + nil -> :error + value -> {:ok, value} + end + end + + def move_to_variable(zipper, name) do + case Sourceror.Zipper.find(zipper, &match?({:=, _, [{^name, _, _}, _]}, &1)) do + nil -> :error + value -> {:ok, value} + end + end + + def move_to_variable!(zipper, name) do + {:ok, zipper} = move_to_variable(zipper, name) + zipper + end + + def move_to_import(zipper, name) when is_atom(name) do + module_as_list = + name + |> inspect() + |> String.split(".") + |> Enum.map(&String.to_atom/1) + + move_to_import(zipper, module_as_list) + end + + def move_to_import(zipper, name) when is_binary(name) do + module_as_list = + name + |> String.split(".") + |> Enum.map(&String.to_atom/1) + + move_to_import(zipper, module_as_list) + end + + def move_to_import(zipper, module_list) when is_list(module_list) do + with nil <- Sourceror.Zipper.find(zipper, &match?({:import, _, [{_, _, ^module_list}]}, &1)), + nil <- Sourceror.Zipper.find(zipper, &match?({:import, _, [{_, _, ^module_list}, _]}, &1)) do + :error + else + value -> {:ok, value} + end + end + + def diff_file(igniter, file) do + igniter.rewrite.sources + |> Map.fetch!(file) + |> Rewrite.Source.diff() + |> IO.iodata_to_binary() + end end diff --git a/lib/beacon/proxy_endpoint.ex b/lib/beacon/proxy_endpoint.ex index 01cb3a654..6c7f82c56 100644 --- a/lib/beacon/proxy_endpoint.ex +++ b/lib/beacon/proxy_endpoint.ex @@ -1,12 +1,6 @@ defmodule Beacon.ProxyEndpoint do @moduledoc false - # Proxy Endpoint to redirect requests to each site endpoint in a multiple domains setup. - # - # TODO: beacon.deploy.add_domain fobar - # - # TODO: use Beacon.ProxyEndpoint, otp_app: :my_app, endpoints: [MyAppWeb.EndpointSiteA, MyAppWeb.EndpointSiteB] - defmacro __using__(opts) do quote location: :keep, generated: true do otp_app = Keyword.get(unquote(opts), :otp_app) || raise Beacon.RuntimeError, "missing required option :otp_app in Beacon.ProxyEndpoint" @@ -27,11 +21,9 @@ defmodule Beacon.ProxyEndpoint do plug :proxy - def proxy(conn, opts) do - %{host: host} = conn - - # TODO: cache endpoint resolver - endpoint = + # TODO: cache endpoint resolver + def proxy(%{host: host} = conn, opts) do + matching_endpoint = fn -> Enum.reduce_while(Beacon.Registry.running_sites(), @__beacon_proxy_fallback__, fn site, default -> %{endpoint: endpoint} = Beacon.Config.fetch!(site) @@ -41,9 +33,49 @@ defmodule Beacon.ProxyEndpoint do {:cont, default} end end) + end + + # fallback endpoint has higher priority in case of conflicts, + # for eg when all endpoints' host are localhost + endpoint = + if @__beacon_proxy_fallback__.host() == host do + @__beacon_proxy_fallback__ + else + matching_endpoint.() + end endpoint.call(conn, endpoint.init(opts)) end + + @doc """ + Check origin dynamically. + + Used in the ProxyEndpoint `:check_origin` config to check the origin request + against the fallback endpoint and all running site's endpoints. + + It checks if the requested scheme://host is the same as any of the available endpoints. + + It doesn't check the scheme if not available, so in some cases it might check only the host. + Port is never checked since the proxied (children) endpoints don't use the same port as + as the requested URI. + """ + def check_origin(%URI{} = uri) do + check_origin_fallback_endpoint = fn -> + url = @__beacon_proxy_fallback__.config(:url) + check_origin(uri, url[:scheme], url[:host]) + end + + Enum.any?(Beacon.Registry.running_sites(), fn site -> + url = Beacon.Config.fetch!(site).endpoint.config(:url) + check_origin(uri, url[:scheme], url[:host]) + end) || check_origin_fallback_endpoint.() + end + + def check_origin(_), do: false + + defp check_origin(%{scheme: scheme, host: host}, scheme, host) when is_binary(scheme) and is_binary(host), do: true + defp check_origin(%{host: host}, nil, host) when is_binary(host), do: true + defp check_origin(_, _), do: false end end end diff --git a/lib/mix/tasks/beacon.gen.proxy_endpoint.ex b/lib/mix/tasks/beacon.gen.proxy_endpoint.ex index c7325b7e0..22477def8 100644 --- a/lib/mix/tasks/beacon.gen.proxy_endpoint.ex +++ b/lib/mix/tasks/beacon.gen.proxy_endpoint.ex @@ -1,8 +1,10 @@ defmodule Mix.Tasks.Beacon.Gen.ProxyEndpoint do use Igniter.Mix.Task + require Igniter.Code.Common + @example "mix beacon.gen.proxy_endpoint" - @shortdoc "TODO" + @shortdoc "Generates a ProxyEndpoint in the current project, enabling Beacon to serve sites at multiple hosts." @moduledoc """ #{@shortdoc} @@ -12,6 +14,18 @@ defmodule Mix.Tasks.Beacon.Gen.ProxyEndpoint do ```bash #{@example} ``` + + ## Options + + * `--secret-key-base` (optional) - The value to use for secret_key_base in your app config. + By default, Beacon will generate a new value and update all existing config to match that value. + If you don't want this behavior, copy the secret_key_base from your app config and provide it here. + * `--signing-salt` (optional) - The value to use for signing_salt in your app config. + By default, Beacon will generate a new value and update all existing config to match that value. + but in order to avoid connection errors for existing clients, it's recommened to copy the `signing_salt` from your app config and provide it here. + * `--session-key` (optional) - The value to use for key in the session config. Defaults to `"_your_app_name_key"` + * `--same-site` (optional) - Set the cookie session SameSite attributes. Defaults to "Lax" + """ @doc false @@ -19,47 +33,93 @@ defmodule Mix.Tasks.Beacon.Gen.ProxyEndpoint do %Igniter.Mix.Task.Info{ group: :beacon, example: @example, - schema: [key: :string, signing_salt: :string, same_site: :string], - defaults: [same_site: "Lax"] + schema: [ + secret_key_base: :string, + signing_salt: :string, + session_key: :string, + session_same_site: :string + ], + defaults: [session_same_site: "Lax"] } end @doc false def igniter(igniter) do - otp_app = Igniter.Project.Application.app_name(igniter) - {igniter, router} = Beacon.Igniter.select_router!(igniter) - {igniter, fallback_endpoint} = Beacon.Igniter.select_endpoint(igniter, router, "Select a fallback endpoint (default app Endpoint):") + options = igniter.args.options proxy_endpoint_module_name = Igniter.Libs.Phoenix.web_module_name(igniter, "ProxyEndpoint") - signing_salt = Keyword.get_lazy(igniter.args.options, :signing_salt, fn -> random_string(8) end) - igniter - |> create_proxy_endpoint_module(otp_app, fallback_endpoint, proxy_endpoint_module_name) - |> add_session_options_config(otp_app, signing_salt, igniter.args.options) - |> add_proxy_endpoint_config(otp_app, proxy_endpoint_module_name, signing_salt) - |> update_fallback_endpoint_signing_salt(otp_app, fallback_endpoint, signing_salt) - |> Igniter.add_notice(""" - TODO - """) + case Igniter.Project.Module.module_exists(igniter, proxy_endpoint_module_name) do + {true, igniter} -> + Igniter.add_notice(igniter, """ + Module #{inspect(proxy_endpoint_module_name)} already exists. Skipping. + """) + + {false, igniter} -> + otp_app = Igniter.Project.Application.app_name(igniter) + {igniter, router} = Beacon.Igniter.select_router!(igniter) + {igniter, fallback_endpoint} = Beacon.Igniter.select_endpoint(igniter, router, "Select a fallback endpoint (default app Endpoint):") + {igniter, existing_endpoints} = Igniter.Libs.Phoenix.endpoints_for_router(igniter, router) + signing_salt = Keyword.get_lazy(options, :signing_salt, fn -> random_string(8) end) + secret_key_base = Keyword.get_lazy(options, :secret_key_base, fn -> random_string(64) end) + + igniter + |> create_proxy_endpoint_module(otp_app, fallback_endpoint, proxy_endpoint_module_name) + |> add_endpoint_to_application(fallback_endpoint, proxy_endpoint_module_name) + |> add_signing_salt_to_config_exs(signing_salt) + |> add_session_options_to_config_exs(otp_app, igniter.args.options) + |> add_secret_key_base_to_dev_exs(secret_key_base) + |> update_existing_endpoints(otp_app, existing_endpoints) + |> configure_proxy_endpoint(otp_app, proxy_endpoint_module_name) + |> Igniter.add_notice(""" + ProxyEndpoint generated successfully. + + This enables your application to serve sites at multiple hosts, each with their own Endpoint. + """) + end end defp create_proxy_endpoint_module(igniter, otp_app, fallback_endpoint, proxy_endpoint_module_name) do - if Igniter.Project.Module.module_exists(igniter, proxy_endpoint_module_name) do - Igniter.add_notice(igniter, """ - Module #{inspect(proxy_endpoint_module_name)} already exists. Skipping. - """) - else - Igniter.Project.Module.create_module(igniter, proxy_endpoint_module_name, """ - use Beacon.ProxyEndpoint, - otp_app: #{inspect(otp_app)}, - session_options: Application.compile_env!(#{inspect(otp_app)}, :session_options), - fallback: #{inspect(fallback_endpoint)} - """) - end + Igniter.Project.Module.create_module(igniter, proxy_endpoint_module_name, """ + use Beacon.ProxyEndpoint, + otp_app: #{inspect(otp_app)}, + session_options: Application.compile_env!(#{inspect(otp_app)}, :session_options), + fallback: #{inspect(fallback_endpoint)} + """) + end + + defp add_endpoint_to_application(igniter, fallback_endpoint, proxy_endpoint_module_name) do + Igniter.Project.Application.add_new_child(igniter, proxy_endpoint_module_name, after: [fallback_endpoint, Beacon]) + end + + defp add_signing_salt_to_config_exs(igniter, signing_salt) do + Igniter.update_elixir_file(igniter, "config/config.exs", fn zipper -> + case Beacon.Igniter.move_to_variable(zipper, :signing_salt) do + {:ok, _already_exists} -> + zipper + + :error -> + {:ok, at_import} = Beacon.Igniter.move_to_import(zipper, Config) + Igniter.Code.Common.add_code(at_import, "signing_salt = \"#{signing_salt}\"", placement: :after) + end + end) end - def add_session_options_config(igniter, otp_app, signing_salt, options) do - key = Keyword.get_lazy(options, :key, fn -> "_#{otp_app}_key" end) - same_site = Keyword.get(options, :same_site, "Lax") + defp add_secret_key_base_to_dev_exs(igniter, secret_key_base) do + Igniter.update_elixir_file(igniter, "config/dev.exs", fn zipper -> + case Beacon.Igniter.move_to_variable(zipper, :secret_key_base) do + {:ok, _already_exists} -> + zipper + + :error -> + {:ok, at_import} = Beacon.Igniter.move_to_import(zipper, Config) + Igniter.Code.Common.add_code(at_import, "secret_key_base = \"#{secret_key_base}\"", placement: :after) + end + end) + end + + defp add_session_options_to_config_exs(igniter, otp_app, options) do + session_key = Keyword.get_lazy(options, :session_key, fn -> "_#{otp_app}_key" end) + session_same_site = Keyword.get(options, :session_same_site, "Lax") Igniter.Project.Config.configure( igniter, @@ -70,44 +130,174 @@ defmodule Mix.Tasks.Beacon.Gen.ProxyEndpoint do Sourceror.parse_string!(""" [ store: :cookie, - key: "#{key}", - signing_salt: "#{signing_salt}", - same_site: "#{same_site}" + key: "#{session_key}", + signing_salt: signing_salt, + same_site: "#{session_same_site}" ] """)} ) end - def add_proxy_endpoint_config(igniter, otp_app, proxy_endpoint_module_name, signing_salt) do + defp configure_proxy_endpoint(igniter, otp_app, proxy_endpoint_module_name) do igniter - |> Igniter.Project.Config.configure( - "config.exs", + |> Igniter.update_elixir_file("config/config.exs", fn zipper -> + {:ok, + zipper + |> Beacon.Igniter.move_to_variable!(:signing_salt) + |> Igniter.Project.Config.modify_configuration_code( + [proxy_endpoint_module_name], + otp_app, + Sourceror.parse_string!(""" + [adapter: Bandit.PhoenixAdapter, live_view: [signing_salt: signing_salt]] + """) + )} + end) + |> Igniter.update_elixir_file("config/dev.exs", fn zipper -> + {:ok, + zipper + |> Beacon.Igniter.move_to_variable!(:secret_key_base) + |> Igniter.Project.Config.modify_configuration_code( + [proxy_endpoint_module_name], + otp_app, + Sourceror.parse_string!(""" + [ + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + debug_errors: true, + secret_key_base: secret_key_base + ] + """) + )} + end) + |> Igniter.Project.Config.configure_runtime_env( + :prod, otp_app, - [proxy_endpoint_module_name, :adapter], - {:code, Sourceror.parse_string!("Bandit.PhoenixAdapter")} + [proxy_endpoint_module_name, :check_origin], + {:code, Sourceror.parse_string!("{#{inspect(proxy_endpoint_module_name)}, :check_origin, []}")} ) - |> Igniter.Project.Config.configure( - "config.exs", + |> Igniter.Project.Config.configure_runtime_env( + :prod, otp_app, - [proxy_endpoint_module_name, :live_view, :signing_salt], - signing_salt + [proxy_endpoint_module_name, :url], + {:code, Sourceror.parse_string!("[port: 443, scheme: \"https\"]")} + ) + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [proxy_endpoint_module_name, :http], + {:code, Sourceror.parse_string!("[ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port]")} + ) + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [proxy_endpoint_module_name, :secret_key_base], + {:code, Sourceror.parse_string!("secret_key_base")} + ) + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [proxy_endpoint_module_name, :server], + {:code, Sourceror.parse_string!("!!System.get_env(\"PHX_SERVER\")")} ) end - defp update_fallback_endpoint_signing_salt(igniter, otp_app, fallback_endpoint, signing_salt) do - fallback_endpoint = String.to_atom("#{fallback_endpoint}") - dbg(fallback_endpoint) + defp update_existing_endpoints(igniter, otp_app, existing_endpoints) do + Enum.reduce(existing_endpoints, igniter, fn endpoint, acc -> + acc + |> Igniter.Project.Config.configure( + "config.exs", + otp_app, + [endpoint, :live_view, :signing_salt], + {:code, Sourceror.parse_string!("signing_salt")} + ) + |> Igniter.Project.Config.configure("dev.exs", otp_app, [endpoint, :secret_key_base], {:code, Sourceror.parse_string!("secret_key_base")}) + |> Igniter.Project.Config.configure("dev.exs", otp_app, [endpoint, :http], [], + updater: fn zipper -> + if port_matches_value?(zipper, 4000) do + {:ok, Igniter.Code.Common.replace_code(zipper, update_port(zipper, 4100))} + else + {:ok, zipper} + end + end + ) + |> Igniter.Project.Config.configure_runtime_env(:prod, otp_app, [endpoint, :http], [], + updater: fn zipper -> + if port_matches_variable?(zipper) do + {:ok, Igniter.Code.Common.replace_code(zipper, update_port(zipper, 4100))} + else + {:ok, zipper} + end + end + ) + |> Igniter.Project.Config.configure_runtime_env(:prod, otp_app, [endpoint, :url], [], + updater: fn zipper -> + if port_matches_value?(zipper, 443) do + {:ok, Igniter.Code.Common.replace_code(zipper, update_port(zipper, 8443))} + else + {:ok, zipper} + end + end + ) + |> Igniter.Project.Module.find_and_update_module!(endpoint, fn zipper -> + case Igniter.Code.Function.move_to_function_call(zipper, :socket, 3) do + {:ok, zipper} -> + # TODO: replace the node with a commented-out version of itself + # blocked by https://github.com/ash-project/igniter/pull/200 + {:ok, Sourceror.Zipper.remove(zipper)} - Igniter.Project.Config.configure( - igniter, - "config.exs", - otp_app, - [fallback_endpoint, :live_view, :signing_salt], - signing_salt - ) + _ -> + {:ok, zipper} + end + end) + |> Igniter.Project.Module.find_and_update_module!(endpoint, fn zipper -> + case Beacon.Igniter.move_to_constant(zipper, :session_options) do + {:ok, zipper} -> + new = Sourceror.parse_string!("@session_options Application.compile_env!(#{inspect(otp_app)}, :session_options)") + {:ok, Sourceror.Zipper.replace(zipper, new)} + + _ -> + {:ok, zipper} + end + end) + end) + end + + defp port_matches_value?(zipper, value) do + ast = + zipper + |> Igniter.Code.Common.maybe_move_to_single_child_block() + |> Sourceror.Zipper.node() + + Enum.any?(ast, &match?({{:__block__, _, [:port]}, {:__block__, _, [^value]}}, &1)) + end + + defp port_matches_variable?(zipper) do + ast = + zipper + |> Igniter.Code.Common.maybe_move_to_single_child_block() + |> Sourceror.Zipper.node() + + Enum.any?(ast, &match?({{:__block__, _, [:port]}, {:port, _, nil}}, &1)) + end + + defp update_port(zipper, value) do + {opts, _} = + zipper + |> Igniter.Code.Common.maybe_move_to_single_child_block() + |> Sourceror.Zipper.node() + |> Code.eval_quoted(port: nil, host: :__host_placeholder__) + + opts + |> Keyword.replace(:port, value) + |> inspect() + |> String.replace(":__host_placeholder__", "host") end # https://github.com/phoenixframework/phoenix/blob/c9b431f3a5d3bdc6a1d0ff3c29a229226e991195/installer/lib/phx_new/generator.ex#L451 - defp random_string(length), - do: :crypto.strong_rand_bytes(length) |> Base.encode64() |> binary_part(0, length) + defp random_string(length) do + length + |> :crypto.strong_rand_bytes() + |> Base.encode64(padding: false) + |> binary_part(0, length) + end end diff --git a/lib/mix/tasks/beacon.gen.site.ex b/lib/mix/tasks/beacon.gen.site.ex index 00443a7b3..87476eac5 100644 --- a/lib/mix/tasks/beacon.gen.site.ex +++ b/lib/mix/tasks/beacon.gen.site.ex @@ -1,7 +1,7 @@ defmodule Mix.Tasks.Beacon.Gen.Site do use Igniter.Mix.Task - @example "mix beacon.gen.site --site my_site --path /" + @example "mix beacon.gen.site --site my_site --path / --host my_site.com" @shortdoc "Generates a new Beacon site in the current project." @test? Beacon.Config.env_test?() @@ -20,8 +20,19 @@ defmodule Mix.Tasks.Beacon.Gen.Site do ## Options - * `--site` or `-s` (required) - The name of your site. Should not contain special characters nor start with "beacon_" - * `--path` or `-p` (optional, defaults to "/") - Where your site will be mounted. Follows the same convention as Phoenix route prefixes. + * `--site` (required) - The name of your site. Should not contain special characters nor start with "beacon_" + * `--path` (optional) - Where your site will be mounted. Follows the same convention as Phoenix route prefixes. Defaults to `"/"` + * `--host` (optional) - If provided, a new endpoint will be created for this site with the given URL. + * `--port` (optional) - The port to use for http requests. Only needed when `--host` is provided. If no port is given, one will be chosen at random. + * `--secure-port` (optional) - The port to use for https requests. Only needed when `--host` is provided. If no port is given, one will be chosen at random. + * `--secret-key-base` (optional) - The value to use for secret_key_base in your app config. + By default, Beacon will generate a new value and update all existing config to match that value. + If you don't want this behavior, copy the secret_key_base from your app config and provide it here. + * `--signing-salt` (optional) - The value to use for signing_salt in your app config. + By default, Beacon will generate a new value and update all existing config to match that value. + but in order to avoid connection errors for existing clients, it's recommened to copy the `signing_salt` from your app config and provide it here. + * `--session-key` (optional) - The value to use for key in the session config. Defaults to `"_your_app_name_key"` + * `--session-same-site` (optional) - Set the cookie session SameSite attributes. Defaults to "Lax" """ @@ -30,8 +41,17 @@ defmodule Mix.Tasks.Beacon.Gen.Site do %Igniter.Mix.Task.Info{ group: :beacon, example: @example, - schema: [site: :string, path: :string], - aliases: [s: :site, p: :path], + schema: [ + site: :string, + path: :string, + host: :string, + port: :integer, + secure_port: :integer, + secret_key_base: :string, + signing_salt: :string, + session_key: :string, + session_same_site: :string + ], defaults: [path: "/"], required: [:site] } @@ -40,21 +60,34 @@ defmodule Mix.Tasks.Beacon.Gen.Site do @doc false def igniter(igniter) do options = igniter.args.options - site = Keyword.fetch!(options, :site) |> String.to_atom() - path = Keyword.fetch!(options, :path) - validate_options!(site, path) + argv = igniter.args.argv + site = Keyword.fetch!(options, :site) |> validate_site!() + path = Keyword.fetch!(options, :path) |> validate_path!() + host = Keyword.get(options, :host) |> validate_host!() + + port = Keyword.get_lazy(options, :port, fn -> Enum.random(4101..4999) end) + secure_port = Keyword.get_lazy(options, :secure_port, fn -> Enum.random(8444..8999) end) + + otp_app = Igniter.Project.Application.app_name(igniter) + web_module = Igniter.Libs.Phoenix.web_module(igniter) {igniter, router} = Beacon.Igniter.select_router!(igniter) - {igniter, endpoint} = Beacon.Igniter.select_endpoint!(igniter, router) + {igniter, existing_endpoints} = Igniter.Libs.Phoenix.endpoints_for_router(igniter, router) repo = Igniter.Project.Module.module_name(igniter, "Repo") igniter |> create_migration(repo) |> add_use_beacon_in_router(router) |> add_beacon_pipeline_in_router(router) - |> mount_site_in_router(router, site, path) - |> add_site_config_in_config_runtime(site, repo, router, endpoint) - |> add_beacon_config_in_app_supervisor(site, repo, endpoint) + |> mount_site_in_router(router, site, path, host) + |> add_site_config_in_config_runtime(site, repo, router, host) + |> add_beacon_config_in_app_supervisor(site, repo) + |> maybe_create_proxy_endpoint(host, argv) + |> maybe_create_new_endpoint(host, otp_app, web_module) + |> maybe_configure_new_endpoint(host, otp_app, port, secure_port) + |> maybe_update_existing_endpoints(host, otp_app, existing_endpoints) + |> maybe_update_session_options(host, otp_app) + |> maybe_add_new_endpoint_to_application(host, repo) |> Igniter.add_notice(""" Site #{inspect(site)} generated successfully. @@ -68,31 +101,56 @@ defmodule Mix.Tasks.Beacon.Gen.Site do """) end - defp validate_options!(site, path) do - cond do - !Beacon.Types.Site.valid?(site) -> raise_with_help!("Invalid site name. It should not contain special characters.", site, path) - !Beacon.Types.Site.valid_name?(site) -> raise_with_help!("Invalid site name. The site name can't start with \"beacon_\".", site, path) - !Beacon.Types.Site.valid_path?(path) -> raise_with_help!("Invalid path value. It should start with /.", site, path) - :else -> :ok - end + defp validate_site!(site) do + Beacon.Types.Site.valid?(site) || + Mix.raise(""" + invalid site + + It should not contain special characters + """) + + Beacon.Types.Site.valid_name?(site) || + Mix.raise(""" + invalid site + + The site name can't start with \"beacon_\". + """) + + String.to_atom(site) end - defp raise_with_help!(msg, site, path) do - Mix.raise(""" - #{msg} + defp validate_path!(path) do + Beacon.Types.Site.valid_path?(path) || + Mix.raise(""" + invalid path - mix beacon.install expects a valid site name, for example: + It should start with / + """) - mix beacon.install --site blog - or - mix beacon.install --site blog --path "/blog_path" + path + end - Got: + defp validate_host!(nil = host), do: host - site: #{inspect(site)} - path: #{inspect(path)} + defp validate_host!(host) do + case domain_prefix(host) do + {:ok, _} -> + host - """) + _ -> + Mix.raise(""" + invalid host + """) + end + end + + defp domain_prefix(host) do + with {:ok, %{host: host}} <- URI.new("//" <> host), + [prefix, _] <- String.split(host, ".", trim: 2, parts: 2) do + {:ok, prefix} + else + _ -> :error + end end defp add_use_beacon_in_router(igniter, router) do @@ -135,7 +193,7 @@ defmodule Mix.Tasks.Beacon.Gen.Site do ) end - defp mount_site_in_router(igniter, router, site, path) do + defp mount_site_in_router(igniter, router, site, path, host) do case Igniter.Project.Module.find_module(igniter, router) do {:ok, {_igniter, _source, zipper}} -> exists? = @@ -150,16 +208,27 @@ defmodule Mix.Tasks.Beacon.Gen.Site do "Site already exists: #{site}, skipping creation." ) else - Igniter.Libs.Phoenix.append_to_scope( - igniter, - "/", + content = """ beacon_site #{inspect(path)}, site: #{inspect(site)} - """, - with_pipelines: [:browser, :beacon], - router: router, - arg2: Igniter.Libs.Phoenix.web_module(igniter) - ) + """ + + opts = + if host do + [ + with_pipelines: [:browser, :beacon], + router: router, + arg2: [alias: Igniter.Libs.Phoenix.web_module(igniter), host: ["localhost", host]] + ] + else + [ + with_pipelines: [:browser, :beacon], + router: router, + arg2: [alias: Igniter.Libs.Phoenix.web_module(igniter)] + ] + end + + Igniter.Libs.Phoenix.append_to_scope(igniter, "/", content, opts) end _ -> @@ -167,7 +236,13 @@ defmodule Mix.Tasks.Beacon.Gen.Site do end end - defp add_site_config_in_config_runtime(igniter, site, repo, router, endpoint) do + defp add_site_config_in_config_runtime(igniter, site, repo, router, host) do + {igniter, endpoint} = + case host do + nil -> Beacon.Igniter.select_endpoint!(igniter, router) + host -> {igniter, new_endpoint_module!(igniter, host)} + end + Igniter.Project.Config.configure( igniter, "runtime.exs", @@ -180,7 +255,7 @@ defmodule Mix.Tasks.Beacon.Gen.Site do ) end - defp add_beacon_config_in_app_supervisor(igniter, site, repo, endpoint) do + defp add_beacon_config_in_app_supervisor(igniter, site, repo) do Igniter.Project.Application.add_new_child( igniter, {Beacon, @@ -189,7 +264,6 @@ defmodule Mix.Tasks.Beacon.Gen.Site do [sites: [Application.fetch_env!(:beacon, unquote(site))]] end}}, after: [repo], - before: [endpoint], opts_updater: fn zipper -> with {:ok, zipper} <- Igniter.Code.Keyword.put_in_keyword( @@ -220,4 +294,190 @@ defmodule Mix.Tasks.Beacon.Gen.Site do end ) end + + defp maybe_create_proxy_endpoint(igniter, nil, _argv), do: igniter + + defp maybe_create_proxy_endpoint(igniter, _host, argv), + do: Igniter.compose_task(igniter, "beacon.gen.proxy_endpoint", argv) + + defp maybe_create_new_endpoint(igniter, nil, _, _), do: igniter + + defp maybe_create_new_endpoint(igniter, host, otp_app, web_module) do + Igniter.Project.Module.create_module( + igniter, + new_endpoint_module!(igniter, host), + """ + use Phoenix.Endpoint, otp_app: #{inspect(otp_app)} + + @session_options Application.compile_env!(#{inspect(otp_app)}, :session_options) + + # socket /live must be in the proxy endpoint + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: #{inspect(otp_app)}, + gzip: false, + only: #{inspect(web_module)}.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: #{inspect(otp_app)} + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug #{inspect(web_module)}.Router + """ + ) + end + + defp maybe_configure_new_endpoint(igniter, nil, _, _, _), do: igniter + + defp maybe_configure_new_endpoint(igniter, host, otp_app, port, secure_port) do + new_endpoint = new_endpoint_module!(igniter, host) + error_html = Igniter.Libs.Phoenix.web_module_name(igniter, "ErrorHTML") + error_json = Igniter.Libs.Phoenix.web_module_name(igniter, "ErrorJSON") + pubsub = Igniter.Project.Module.module_name(igniter, "PubSub") + + # TODO: replace the first two steps with `configure/6` once the `:after` option is allowed + igniter + |> then( + &if(Igniter.Project.Config.configures_key?(&1, "config.exs", otp_app, new_endpoint), + do: &1, + else: + Igniter.update_elixir_file(&1, "config/config.exs", fn zipper -> + {:ok, + zipper + |> Beacon.Igniter.move_to_variable!(:signing_salt) + |> Igniter.Project.Config.modify_configuration_code( + [new_endpoint], + otp_app, + Sourceror.parse_string!(""" + [ + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: #{inspect(error_html)}, json: #{inspect(error_json)}], + layout: false + ], + pubsub_server: #{inspect(pubsub)}, + live_view: [signing_salt: signing_salt] + ] + """) + )} + end) + ) + ) + |> then( + &if(Igniter.Project.Config.configures_key?(&1, "dev.exs", otp_app, new_endpoint), + do: &1, + else: + Igniter.update_elixir_file(&1, "config/dev.exs", fn zipper -> + {:ok, + zipper + |> Beacon.Igniter.move_to_variable!(:secret_key_base) + |> Igniter.Project.Config.modify_configuration_code( + [new_endpoint], + otp_app, + Sourceror.parse_string!(""" + [ + http: [ip: {127, 0, 0, 1}, port: #{port}], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: secret_key_base, + watchers: [ + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + ] + ] + """) + )} + end) + ) + ) + |> Igniter.Project.Config.configure_runtime_env(:prod, otp_app, [new_endpoint, :url, :host], host) + |> Igniter.Project.Config.configure_runtime_env(:prod, otp_app, [new_endpoint, :url, :port], secure_port) + |> Igniter.Project.Config.configure_runtime_env(:prod, otp_app, [new_endpoint, :url, :scheme], "https") + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [new_endpoint, :http], + {:code, Sourceror.parse_string!("[ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: #{port}]")} + ) + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [new_endpoint, :secret_key_base], + {:code, Sourceror.parse_string!("secret_key_base")} + ) + |> Igniter.Project.Config.configure_runtime_env( + :prod, + otp_app, + [new_endpoint, :server], + {:code, Sourceror.parse_string!("!!System.get_env(\"PHX_SERVER\")")} + ) + end + + defp maybe_update_existing_endpoints(igniter, nil, _, _), do: igniter + + defp maybe_update_existing_endpoints(igniter, _host, otp_app, existing_endpoints) do + Enum.reduce(existing_endpoints, igniter, fn endpoint, acc -> + acc + |> Igniter.Project.Config.configure( + "config.exs", + otp_app, + [endpoint, :live_view, :signing_salt], + {:code, Sourceror.parse_string!("signing_salt")} + ) + |> Igniter.Project.Config.configure("dev.exs", otp_app, [endpoint, :secret_key_base], {:code, Sourceror.parse_string!("secret_key_base")}) + end) + end + + defp maybe_update_session_options(igniter, nil, _), do: igniter + + defp maybe_update_session_options(igniter, _host, otp_app) do + Igniter.Project.Config.configure( + igniter, + "config.exs", + otp_app, + [:session_options, :signing_salt], + {:code, Sourceror.parse_string!("signing_salt")} + ) + end + + defp maybe_add_new_endpoint_to_application(igniter, nil, _), do: igniter + + defp maybe_add_new_endpoint_to_application(igniter, host, repo) do + Igniter.Project.Application.add_new_child(igniter, new_endpoint_module!(igniter, host), after: [repo, Phoenix.PubSub, Finch, Beacon]) + end + + defp new_endpoint_module!(igniter, host) do + {:ok, prefix} = domain_prefix(host) + + suffix = + prefix + |> String.split(~r/[^[:alnum:]]+/) + |> Enum.map_join("", &String.capitalize/1) + |> Kernel.<>("Endpoint") + + Igniter.Libs.Phoenix.web_module_name(igniter, suffix) + end end diff --git a/lib/mix/tasks/beacon.gen.tailwind_config.ex b/lib/mix/tasks/beacon.gen.tailwind_config.ex index ff1ba14c3..8479d00cd 100644 --- a/lib/mix/tasks/beacon.gen.tailwind_config.ex +++ b/lib/mix/tasks/beacon.gen.tailwind_config.ex @@ -24,7 +24,6 @@ defmodule Mix.Tasks.Beacon.Gen.TailwindConfig do %Igniter.Mix.Task.Info{ example: @example, schema: [site: :string], - aliases: [s: :site], required: [:site] } end diff --git a/lib/mix/tasks/beacon.install.ex b/lib/mix/tasks/beacon.install.ex index 963a3f465..b86218bbe 100644 --- a/lib/mix/tasks/beacon.install.ex +++ b/lib/mix/tasks/beacon.install.ex @@ -39,7 +39,6 @@ defmodule Mix.Tasks.Beacon.Install do example: @example, composes: ["beacon.gen.site"], schema: [site: :string, path: :string], - aliases: [s: :site, p: :path], defaults: [path: "/"] } end diff --git a/test/beacon/igniter_test.exs b/test/beacon/igniter_test.exs new file mode 100644 index 000000000..bc900dbf4 --- /dev/null +++ b/test/beacon/igniter_test.exs @@ -0,0 +1,104 @@ +defmodule Beacon.IgniterTest do + use ExUnit.Case, async: true + + test "move_to_constant" do + module = ~s""" + defmodule Endpoint do + @session_options [ + store: :cookie, + key: "_test_key" + ] + end + """ + + zipper = + module + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + + assert {:ok, zipper} = Beacon.Igniter.move_to_constant(zipper, :session_options) + + assert Igniter.Util.Debug.code_at_node(zipper) == + ~s""" + @session_options [ + store: :cookie, + key: "_test_key" + ] + """ + |> String.trim() + end + + test "move_to_variable/2" do + module = ~s""" + import Config + + host = 4000 + + config :my_app, foo: :bar + """ + + zipper = + module + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + + assert {:ok, zipper} = Beacon.Igniter.move_to_variable(zipper, :host) + + assert Igniter.Util.Debug.code_at_node(zipper) == "host = 4000" + end + + describe "move_to_import/2" do + test "simple module" do + module = ~s""" + import Config + + host = 4000 + + config :my_app, foo: :bar + """ + + zipper = + module + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + + assert {:ok, zipper} = Beacon.Igniter.move_to_import(zipper, Config) + + assert Igniter.Util.Debug.code_at_node(zipper) == "import Config" + end + + test "nested module" do + module = ~s""" + import Ecto.Query + + def get_user_query(id), do: from(u in "users", where: u.id == ^id) + """ + + zipper = + module + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + + assert {:ok, zipper} = Beacon.Igniter.move_to_import(zipper, Ecto.Query) + + assert Igniter.Util.Debug.code_at_node(zipper) == "import Ecto.Query" + end + + test "nested module as string" do + module = ~s""" + import Ecto.Query + + def get_user_query(id), do: from(u in "users", where: u.id == ^id) + """ + + zipper = + module + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + + assert {:ok, zipper} = Beacon.Igniter.move_to_import(zipper, "Ecto.Query") + + assert Igniter.Util.Debug.code_at_node(zipper) == "import Ecto.Query" + end + end +end diff --git a/test/mix/tasks/gen_proxy_endpoint_test.exs b/test/mix/tasks/gen_proxy_endpoint_test.exs new file mode 100644 index 000000000..7da1b06c9 --- /dev/null +++ b/test/mix/tasks/gen_proxy_endpoint_test.exs @@ -0,0 +1,154 @@ +defmodule Mix.Tasks.Beacon.GenProxyEndpointTest do + use Beacon.CodeGenCase + + import Igniter.Test + + @signing_salt "SNUXnTNM" + @secret_key_base "A0DSgxjGCYZ6fCIrBlg6L+qC/cdoFq5Rmomm53yacVmN95Wcpl57Gv0sTJjKjtIp" + + @opts ~w(--signing-salt #{@signing_salt} --secret-key-base #{@secret_key_base}) + + setup do + [project: phoenix_project()] + end + + test "do not duplicate files and configs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + |> apply_igniter!() + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + |> assert_unchanged() + end + + test "create proxy endpoint module", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + |> assert_creates("lib/test_web/proxy_endpoint.ex", """ + defmodule TestWeb.ProxyEndpoint do + use Beacon.ProxyEndpoint, + otp_app: :test, + session_options: Application.compile_env!(:test, :session_options), + fallback: TestWeb.Endpoint + end + """) + end + + test "update existing endpoint module", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + |> assert_has_patch("lib/test_web/endpoint.ex", """ + 14 - | socket("/live", Phoenix.LiveView.Socket, + 15 - | websocket: [connect_info: [session: @session_options]], + 16 - | longpoll: [connect_info: [session: @session_options]] + 17 - | ) + 18 - | + """) + |> assert_has_patch("lib/test_web/endpoint.ex", """ + 3 + | @session_options Application.compile_env!(:test, :session_options) + 3 4 | + 4 - | # The session will be stored in the cookie and signed, + 5 - | # this means its contents can be read but not tampered with. + 6 - | # Set :encryption_salt if you would also like to encrypt it. + 7 - | @session_options [ + """) + end + + test "add endpoint to application.ex", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + |> assert_has_patch("lib/test/application.ex", """ + 20 - | TestWeb.Endpoint + 20 + | TestWeb.Endpoint, + 21 + | TestWeb.ProxyEndpoint + """) + end + + test "update config.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + # add signing_salt variable + |> assert_has_patch("config/config.exs", """ + 10 + |signing_salt = "#{@signing_salt}" + """) + # add proxy endpoint config + |> assert_has_patch("config/config.exs", """ + 12 + |config :test, TestWeb.ProxyEndpoint, adapter: Bandit.PhoenixAdapter, live_view: [signing_salt: signing_salt] + 13 + | + """) + # add session options config + |> assert_has_patch("config/config.exs", """ + 10 14 |config :test, + 11 15 | ecto_repos: [Test.Repo], + 12 - | generators: [timestamp_type: :utc_datetime] + 16 + | generators: [timestamp_type: :utc_datetime], + 17 + | session_options: [ + 18 + | store: :cookie, + 19 + | key: "_test_key", + 20 + | signing_salt: signing_salt, + 21 + | same_site: "Lax" + 22 + | ] + """) + # update fallback endpoint signing salt + |> assert_has_patch("config/config.exs", """ + 33 + | live_view: [signing_salt: signing_salt] + """) + end + + test "update dev.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + # add secret_key_base variable + |> assert_has_patch("config/dev.exs", """ + 2 + |secret_key_base = "#{@secret_key_base}" + """) + # add proxy endpoint config + |> assert_has_patch("config/dev.exs", """ + 4 + |config :test, + 5 + | TestWeb.ProxyEndpoint, + 6 + | http: [ip: {127, 0, 0, 1}, port: 4000], + 7 + | check_origin: false, + 8 + | debug_errors: true, + 9 + | secret_key_base: secret_key_base + """) + # update existing endpoint to port 4100 + |> assert_has_patch("config/dev.exs", """ + 12 - | http: [ip: {127, 0, 0, 1}, port: 4000], + 20 + | http: [ip: {127, 0, 0, 1}, port: 4100], + """) + # update existing endpoint secret_key_base + |> assert_has_patch("config/dev.exs", """ + 24 + | secret_key_base: secret_key_base, + """) + end + + test "update runtime.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.proxy_endpoint", @opts) + # update existing endpoint config + |> assert_has_patch("config/runtime.exs", """ + 41 41 | config :test, TestWeb.Endpoint, + 42 - | url: [host: host, port: 443, scheme: "https"], + 43 - | http: [ + 44 - | # Enable IPv6 and bind on all interfaces. + 45 - | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + 46 - | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + 47 - | # for details about using IPv6 vs IPv4 and loopback vs public addresses. + 48 - | ip: {0, 0, 0, 0, 0, 0, 0, 0}, + 49 - | port: port + 50 - | ], + 42 + | url: [host: host, port: 8443, scheme: "https"], + 43 + | http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: 4100], + 51 44 | secret_key_base: secret_key_base + """) + # add proxy endpoint config + |> assert_has_patch("config/runtime.exs", """ + 46 + |config :test, TestWeb.ProxyEndpoint, + 47 + | check_origin: {TestWeb.ProxyEndpoint, :check_origin, []}, + 48 + | url: [port: 443, scheme: "https"], + 49 + | http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port], + 50 + | secret_key_base: secret_key_base, + 51 + | server: !!System.get_env("PHX_SERVER") + 52 + | + """) + end +end diff --git a/test/mix/tasks/gen_site_test.exs b/test/mix/tasks/gen_site_test.exs index 170dff352..f720db2fd 100644 --- a/test/mix/tasks/gen_site_test.exs +++ b/test/mix/tasks/gen_site_test.exs @@ -2,8 +2,14 @@ defmodule Mix.Tasks.Beacon.GenSiteTest do use Beacon.CodeGenCase import Igniter.Test + @secret_key_base "A0DSgxjGCYZ6fCIrBlg6L+qC/cdoFq5Rmomm53yacVmN95Wcpl57Gv0sTJjKjtIo" + @signing_salt "O68x1k5B" + @port 4041 + @secure_port 8445 + @opts_my_site ~w(--site my_site --path /) @opts_other_site ~w(--site other --path /other) + @opts_host ~w(--site my_site --path / --host example.com --port #{@port} --secure-port #{@secure_port} --secret-key-base #{@secret_key_base} --signing-salt #{@signing_salt}) describe "options validation" do test "validates site" do @@ -31,6 +37,14 @@ defmodule Mix.Tasks.Beacon.GenSiteTest do |> assert_unchanged() end + test "host option does not duplicate files and configs" do + phoenix_project() + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> apply_igniter!() + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_unchanged() + end + describe "migration" do setup do [project: phoenix_project()] @@ -82,8 +96,10 @@ defmodule Mix.Tasks.Beacon.GenSiteTest do project |> Igniter.compose_task("beacon.gen.site", @opts_my_site) |> assert_has_patch("lib/test_web/router.ex", """ + 23 + | scope "/", alias: TestWeb do 24 + | pipe_through [:browser, :beacon] 25 + | beacon_site "/", site: :my_site + 26 + | end """) end @@ -93,9 +109,15 @@ defmodule Mix.Tasks.Beacon.GenSiteTest do |> apply_igniter!() |> Igniter.compose_task("beacon.gen.site", @opts_other_site) |> assert_has_patch("lib/test_web/router.ex", """ + 23 23 | scope "/", alias: TestWeb do 24 24 | pipe_through [:browser, :beacon] - 25 25 | beacon_site "/", site: :my_site - 26 + | beacon_site "/other", site: :other + 25 + | beacon_site "/other", site: :other + 26 + | end + 27 + | + 28 + | scope "/", alias: TestWeb do + 29 + | pipe_through [:browser, :beacon] + 25 30 | beacon_site "/", site: :my_site + 26 31 | end """) end end @@ -158,4 +180,155 @@ defmodule Mix.Tasks.Beacon.GenSiteTest do """) end end + + describe "--host option" do + setup do + [project: phoenix_project()] + end + + test "creates endpoint", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_creates("lib/test_web/example_endpoint.ex", """ + defmodule TestWeb.ExampleEndpoint do + use Phoenix.Endpoint, otp_app: :test + + @session_options Application.compile_env!(:test, :session_options) + + # socket /live must be in the proxy endpoint + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :test, + gzip: false, + only: TestWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :test + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug TestWeb.Router + end + """) + end + + test "updates config.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_has_patch("config/config.exs", """ + 10 + |signing_salt = "#{@signing_salt}" + """) + # add config for new endpoint + |> assert_has_patch("config/config.exs", """ + 10 12 |config :test, + 13 + | TestWeb.ExampleEndpoint, + 14 + | url: [host: "localhost"], + 15 + | adapter: Bandit.PhoenixAdapter, + 16 + | render_errors: [ + 17 + | formats: [html: TestWeb.ErrorHTML, json: TestWeb.ErrorJSON], + 18 + | layout: false + 19 + | ], + 20 + | pubsub_server: Test.PubSub, + 21 + | live_view: [signing_salt: signing_salt] + 22 + | + """) + # update signing salt for host app session_options + |> assert_has_patch("config/config.exs", """ + 31 + | signing_salt: signing_salt, + """) + # update signing salt for existing endpoint + |> assert_has_patch("config/config.exs", """ + 44 + | live_view: [signing_salt: signing_salt] + """) + end + + test "updates dev.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_has_patch("config/dev.exs", """ + 2 + |secret_key_base = "#{@secret_key_base}" + """) + # add config for new endpoint + |> assert_has_patch("config/dev.exs", """ + 4 + |config :test, + 5 + | TestWeb.ExampleEndpoint, + 6 + | http: [ip: {127, 0, 0, 1}, port: 4041], + 7 + | check_origin: false, + 8 + | code_reloader: true, + 9 + | debug_errors: true, + 10 + | secret_key_base: secret_key_base, + 11 + | watchers: [ + 12 + | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + 13 + | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + 14 + | ] + 15 + | + """) + # update secret key base for existing endpoint + |> assert_has_patch("config/dev.exs", """ + 36 + | secret_key_base: secret_key_base, + """) + end + + test "updates runtime.exs", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + # add beacon site config + |> assert_has_patch("config/runtime.exs", """ + 2 + |config :beacon, my_site: [site: :my_site, repo: Test.Repo, endpoint: TestWeb.ExampleEndpoint, router: TestWeb.Router] + """) + # configure check_origin for ProxyEndpoint + |> assert_has_patch("config/runtime.exs", """ + 48 + | check_origin: {TestWeb.ProxyEndpoint, :check_origin, []}, + """) + # add config for new endpoint + |> assert_has_patch("config/runtime.exs", """ + 54 + |config :test, TestWeb.ExampleEndpoint, + 55 + | url: [host: "example.com", port: #{@secure_port}, scheme: "https"], + 56 + | http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: #{@port}], + 57 + | secret_key_base: secret_key_base, + 58 + | server: !!System.get_env("PHX_SERVER") + 59 + | + """) + end + + test "updates application.ex", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_has_patch("lib/test/application.ex", """ + 22 + | TestWeb.ExampleEndpoint, + """) + end + + test "updates router", %{project: project} do + project + |> Igniter.compose_task("beacon.gen.site", @opts_host) + |> assert_has_patch("lib/test_web/router.ex", """ + 23 + | scope "/", alias: TestWeb, host: ["localhost", "example.com"] do + 24 + | pipe_through [:browser, :beacon] + 25 + | beacon_site "/", site: :my_site + 26 + | end + 27 + | + """) + end + end end