Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: always generate site endpoints #740

Merged
merged 11 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,19 @@ Demo.Repo.start_link()
Ecto.Migrator.run(Demo.Repo, path, :up, all: true, log_migrations_sql: true)
Demo.Repo.stop()

Application.put_env(:beacon, DemoWeb.Endpoint,
Application.put_env(:beacon, DemoWeb.ProxyEndpoint,
adapter: Bandit.PhoenixAdapter,
check_origin: {DemoWeb.ProxyEndpoint, :check_origin, []},
url: [port: 4001, scheme: "http"],
http: [ip: {0, 0, 0, 0}, port: 4001],
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64),
server: true
)

Application.put_env(:beacon, DemoWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
http: [ip: {0, 0, 0, 0}, port: 4100],
server: true,
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64),
Expand Down Expand Up @@ -87,12 +97,21 @@ defmodule DemoWeb.Router do
end
end

defmodule DemoWeb.ProxyEndpoint do
use Beacon.ProxyEndpoint,
otp_app: :beacon,
session_options: [store: :cookie, key: "_beacon_dev_key", signing_salt: "pMQYsz0UKEnwxJnQrVwovkBAKvU3MiuL"],
fallback: DemoWeb.Endpoint
end

defmodule DemoWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :beacon

@session_options [store: :cookie, key: "_beacon_dev_key", signing_salt: "pMQYsz0UKEnwxJnQrVwovkBAKvU3MiuL"]

socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with this removed from the existing Endpoint, will this break pages outside of beacon? can the ProxyEndpoint work for non-beacon pages?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint is supposed to be one of the site endpoints, probably should rename it to DevEndooint and create a new DyEndpoint. No need to have an "app endooint" in dev.exs tho because we wouldn't use it for anything.

# TODO: add this function in the `gen.site` generator
def proxy_endpoint, do: DemoWeb.ProxyEndpoint
Comment on lines +112 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is supposed to represent the "default" Endpoint that Phoenix gives you, then this should go in mix beacon.gen.proxy_endpoint in the update_existing_endpoints/3 because that's the only place we touch that module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the other way around actually. From the site endpoint we need to know the proxy endpoint in order to fetch the scheme and port to generate URLs. I did include that function mostly to avoid adding a new required option in Beacon.Config. I believe that's okay because that endpoint is generated by gen.site and used only by beacon sites. Sounds good?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the confusion was, I thought this was supposed to represent the "default" phoenix endpoint, and not a generated beacon endpoint. So my comment is no longer relevant 🙂


socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket

plug Phoenix.LiveReloader
Expand Down Expand Up @@ -1246,7 +1265,8 @@ Task.start(fn ->
Demo.Repo,
{Phoenix.PubSub, [name: Demo.PubSub]},
{Beacon, sites: [dev_site, dy_site]},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should these sites get their own endpoint now instead of using DemoWeb.Endpoint?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally yes to simulate the same config used in a real app. I created the Proxy endpoint but forgot to create the site endpoints.

DemoWeb.Endpoint
DemoWeb.Endpoint,
DemoWeb.ProxyEndpoint
]

{:ok, _} = Supervisor.start_link(children, strategy: :one_for_one)
Expand Down
48 changes: 39 additions & 9 deletions lib/beacon/loader/routes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,29 +39,59 @@ defmodule Beacon.Loader.Routes do

# TODO: secure cross site assets
# TODO: media_path sigil
@doc """
Media path relative to host
"""
def beacon_media_path(file_name) when is_binary(file_name) do
prefix = @router.__beacon_scoped_prefix_for_site__(@site)
sanitize_path("#{prefix}/__beacon_media__/#{file_name}")
end

# TODO: media_url sigil
def beacon_media_url(file_name) when is_binary(file_name) do
@endpoint.url() <> beacon_media_path(file_name)
public_site_host() <> beacon_media_path(file_name)
end

def beacon_page_url(conn, %{path: path} = page) do
def public_site_host do
uri = Beacon.ProxyEndpoint.public_uri(@site)
String.Chars.URI.to_string(%URI{scheme: uri.scheme, host: uri.host, port: uri.port})
end

@doc """
Returns the full public-facing site URL, including the prefix.

Scheme and port are fetched from the Proxy Endpoint, if available,
since that's the entry point for all requests.

Host is fetched from the site endpoint.
"""
def public_site_url do
uri =
case Beacon.ProxyEndpoint.public_uri(@site) do
# remove path: "/" to build URL without the / suffix
%{path: "/"} = uri -> %{uri | path: nil}
uri -> uri
end

String.Chars.URI.to_string(uri)
end

def public_page_url(%{site: site} = page) do
site == @site || raise Beacon.RuntimeError, message: "failed to generate public page url "
prefix = @router.__beacon_scoped_prefix_for_site__(@site)
path = Path.join([@endpoint.url(), prefix, path])
Phoenix.VerifiedRoutes.unverified_path(conn, conn.private.phoenix_router, path)
path = sanitize_path("#{prefix}#{page.path}")
String.Chars.URI.to_string(%{Beacon.ProxyEndpoint.public_uri(@site) | path: path})
end

def beacon_sitemap_url(conn) do
if prefix = @router.__beacon_scoped_prefix_for_site__(@site) do
path = Path.join([@endpoint.url(), prefix, "sitemap.xml"])
Phoenix.VerifiedRoutes.unverified_path(conn, conn.private.phoenix_router, path)
end
def public_sitemap_url do
public_site_url() <> "/sitemap.xml"
end

def public_css_config_url do
public_site_url() <> "/__beacon_assets__/css_config"
end

# TODO: remove sanitize_path/1
defp sanitize_path(path) do
String.replace(path, "//", "/")
end
Expand Down
2 changes: 2 additions & 0 deletions lib/beacon/private.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,6 @@ defmodule Beacon.Private do
# https://github.com/phoenixframework/phoenix/blob/4ebefb9d1f710c576f08c517f5852498dd9b935c/lib/phoenix/endpoint/supervisor.ex#L301-L302
defp host_to_binary({:system, env_var}), do: host_to_binary(System.get_env(env_var))
defp host_to_binary(host), do: host

def router(%{private: %{phoenix_router: router}}), do: router
end
58 changes: 58 additions & 0 deletions lib/beacon/proxy_endpoint.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Beacon.ProxyEndpoint do
@moduledoc false

require Logger

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"
Expand Down Expand Up @@ -78,4 +80,60 @@ defmodule Beacon.ProxyEndpoint do
defp check_origin(_, _), do: false
end
end

# https://github.com/phoenixframework/phoenix/blob/2614f2a0d95a3b4b745bdf88ccd9f3b7f6d5966a/lib/phoenix/endpoint/supervisor.ex#L386
@doc """
Similar to `public_url/1` but returns a `%URI{}` instead.
"""
@spec public_uri(Beacon.Types.Site.t()) :: URI.t()
def public_uri(site) do
site_endpoint = Beacon.Config.fetch!(site).endpoint
proxy_endpoint = site_endpoint.proxy_endpoint()
router = Beacon.Config.fetch!(site).router

proxy_url = proxy_endpoint.config(:url)
site_url = site_endpoint.config(:url)

https = proxy_endpoint.config(:https)
http = proxy_endpoint.config(:http)

{scheme, port} =
cond do
https -> {"https", https[:port] || 443}
http -> {"http", http[:port] || 80}
true -> {"http", 80}
end

scheme = proxy_url[:scheme] || scheme
host = host_to_binary(site_url[:host] || "localhost")
port = port_to_integer(proxy_url[:port] || port)
path = router.__beacon_scoped_prefix_for_site__(site)

if host =~ ~r"[^:]:\d" do
Logger.warning("url: [host: ...] configuration value #{inspect(host)} for #{inspect(site_endpoint)} is invalid")
end

%URI{scheme: scheme, host: host, port: port, path: path}
end

@doc """
Returns the public URL of a given `site`.

Scheme and port are fetched from the Proxy Endpoint to resolve the URL correctly
"""
@spec public_url(Beacon.Types.Site.t()) :: String.t()
def public_url(site) do
site
|> public_uri()
|> String.Chars.URI.to_string()
end

# TODO: Remove the first function clause once {:system, env_var} tuples are removed
defp host_to_binary({:system, env_var}), do: host_to_binary(System.get_env(env_var))
defp host_to_binary(host), do: host

# TODO: Remove the first function clause once {:system, env_var} tuples are removed
defp port_to_integer({:system, env_var}), do: port_to_integer(System.get_env(env_var))
defp port_to_integer(port) when is_binary(port), do: String.to_integer(port)
defp port_to_integer(port) when is_integer(port), do: port
end
3 changes: 3 additions & 0 deletions lib/beacon/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ defmodule Beacon.Router do
Then Beacon will reference both of those sitemaps in the top-level index:
* `my_domain.com/sitemap_index.xml`

Note that `beacon_sitemap_index` will include the sitemap URL of all mounted sites
in the router, so that macro should be at the root and not duplicated.

Comment on lines +188 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does it mean that it should be at the root? is it still OK for e.g.

scope "/admin", as: :beacon_live_admin do
  pipe_through [:admin_browser, ...]

  get "/session/sign_out", SessionController, :sign_out

  beacon_live_admin "/"
end

scope "/" do
  pipe_through [:browser, ...]
  beacon_site "/", site: :dockyard_com
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just bad wording here (that can be improved). Since the sitemap index is per-host, then there's no need to duplicate it, thus it makes more sense to include it in a "root scope" instead of a nested scope. That's also related to the hierarchy that's described in the existing docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok just checking there wasn't an additional meaning that needed documentation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait... giving a second thought we can't render all sites in the sitemap index. Giving 2 different scopes on different :host it should include only the sites within that scope.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would mean multiple sitemap indexes, right? we don't currently have a way to do that 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eg:

scope host: foo.com
  beacon_site :site_a  
  beacon_site :site_b 
  sitemap_index   

scope host: bar.com
  beacon_site :site_c 
  sitemap_index

Should foo.com/sitemap_index.xml include site_c?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in order to properly implement this, we need some data model "in-between"

[otp_app] --- [ ??? ] --- [Beacon :site]

where that ??? would represent a "host"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's the same issue with robots.txt - in the current model, users can technically set different files for the same host, because the only place we can model this type of configuration is per-site

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sitemap.xml and robots.txt scoped by site are fine, served at {host}/{prefix}/{file}, but the sitemap index it seems like should only include the sites running in the same scope where it's defined or have an option to define which sites should be included in the index.

So either an option like:

scope host: foo.com
  beacon_site :site_a  
  beacon_site :site_b 
  sitemap_index [:site_a, :site_b]

scope host: bar.com
  beacon_site :site_c 
  sitemap_index [:site_c]

Where the default could be "all" sites.

Or introspect the router to accumulate sites per scope, similar to how we accumulate sites in __beacon_sites__ in the router.

Although that's not responsibility of this PR.

## Requirements

Note that your sitemap index cannot have a path which is "deeper" in the directory structure than
Expand Down
9 changes: 4 additions & 5 deletions lib/beacon/runtime_css.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ defmodule Beacon.RuntimeCSS do
@doc """
Returns the URL to fetch the CSS config used to generate the site stylesheet.
"""
@spec asset_url(Site.t()) :: String.t()
def asset_url(site) do
%{endpoint: endpoint, router: router} = Beacon.Config.fetch!(site)
prefix = router.__beacon_scoped_prefix_for_site__(site)
endpoint.url() <> Beacon.Router.sanitize_path("#{prefix}/__beacon_assets__/css_config")
@spec css_config_url(Site.t()) :: String.t()
def css_config_url(site) do
routes_module = Beacon.Loader.fetch_routes_module(site)
Beacon.apply_mfa(site, routes_module, :public_css_config_url, [])
end

@doc false
Expand Down
6 changes: 3 additions & 3 deletions lib/beacon/web/controllers/robots_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ defmodule Beacon.Web.RobotsController do
|> put_view(Beacon.Web.RobotsTxt)
|> put_resp_content_type("text/txt")
|> put_resp_header("cache-control", "public max-age=300")
|> render(:robots, sitemap_url: get_sitemap_url(conn, site))
|> render(:robots, sitemap_url: get_sitemap_url(site))
end

defp get_sitemap_url(conn, site) do
defp get_sitemap_url(site) do
routes_module = Beacon.Loader.fetch_routes_module(site)
Beacon.apply_mfa(site, routes_module, :beacon_sitemap_url, [conn])
Beacon.apply_mfa(site, routes_module, :public_sitemap_url, [])
end
end
18 changes: 10 additions & 8 deletions lib/beacon/web/controllers/sitemap_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ defmodule Beacon.Web.SitemapController do
def init(action) when action in [:index, :show], do: action

def call(conn, :index) do
sites = Beacon.Private.router(conn).__beacon_sites__()

conn
|> accepts(["xml"])
|> put_view(Beacon.Web.SitemapXML)
|> put_resp_content_type("text/xml")
|> put_resp_header("cache-control", "public max-age=300")
|> render(:sitemap_index, urls: get_sitemap_urls(conn))
|> render(:sitemap_index, urls: get_sitemap_urls(sites))
end

def call(%{assigns: %{site: site}} = conn, :show) do
Expand All @@ -19,27 +21,27 @@ defmodule Beacon.Web.SitemapController do
|> put_view(Beacon.Web.SitemapXML)
|> put_resp_content_type("text/xml")
|> put_resp_header("cache-control", "public max-age=300")
|> render(:sitemap, pages: get_pages(conn, site))
|> render(:sitemap, pages: get_pages(site))
end

defp get_sitemap_urls(conn) do
Beacon.Registry.running_sites()
|> Enum.map(fn site ->
defp get_sitemap_urls(sites) do
sites
|> Enum.map(fn {site, _} ->
routes_module = Beacon.Loader.fetch_routes_module(site)
Beacon.apply_mfa(site, routes_module, :beacon_sitemap_url, [conn])
Beacon.apply_mfa(site, routes_module, :public_sitemap_url, [])
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
end

defp get_pages(conn, site) do
defp get_pages(site) do
routes_module = Beacon.Loader.fetch_routes_module(site)

site
|> Beacon.Content.list_published_pages()
|> Enum.map(fn page ->
%{
loc: Beacon.apply_mfa(site, routes_module, :beacon_page_url, [conn, page]),
loc: Beacon.apply_mfa(site, routes_module, :public_page_url, [page]),
lastmod: DateTime.to_iso8601(page.updated_at)
}
end)
Expand Down
4 changes: 2 additions & 2 deletions lib/beacon/web/sitemap/sitemap_index.xml.eex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<%= for url <- @urls do %><sitemap>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><%= for url <- @urls do %>
<sitemap>
Comment on lines -2 to +3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

<loc><%= url %></loc>
</sitemap><% end %>
</sitemapindex>
2 changes: 1 addition & 1 deletion lib/mix/tasks/beacon.gen.proxy_endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ defmodule Mix.Tasks.Beacon.Gen.ProxyEndpoint do

case Igniter.Project.Module.module_exists(igniter, proxy_endpoint_module_name) do
{true, igniter} ->
Igniter.add_notice(igniter, """
Igniter.add_warning(igniter, """
Module #{inspect(proxy_endpoint_module_name)} already exists. Skipping.
""")

Expand Down
Loading
Loading