defmodule Forum.PublicSite do @moduledoc """ A per-network process for serving public static websites on separate ports. """ use GenServer def child_spec(opts) do network = opts |> Keyword.fetch!(:network) |> normalize_network() %{ id: {:public_site, network}, start: {__MODULE__, :start_link, [[network: network]]}, restart: :permanent, shutdown: 5_000, type: :worker } end def start_link(opts) do network = Keyword.fetch!(opts, :network) GenServer.start_link(__MODULE__, network, name: via(network)) end def normalize_network(network) when is_binary(network) do network |> String.downcase() |> String.split(":", parts: 2) |> hd() end def serve(pid, request) do GenServer.call(pid, {:serve, request}) end @impl true def init(network) do {:ok, %{network: normalize_network(network), started_at: DateTime.utc_now()}} end @impl true def handle_call({:serve, request}, _from, state) do response = do_serve(state, request) {:reply, response, state} end defp do_serve(%{network: network}, request) do roots = public_roots(network) case roots do [] -> %{ status: 404, content_type: "text/plain", body: "No public website configured for #{network}" } [primary | fallbacks] -> static_response(primary, request.path, fallbacks) end end defp static_response(root, path, fallback_roots \\ []) do file_path = resolve_path(root, path) cond do file_path == :invalid -> %{status: 403, content_type: "text/plain", body: "Forbidden"} File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) -> send_file(Path.join(file_path, "index.html")) File.regular?(file_path) -> send_file(file_path) versioned = find_versioned_file([root | fallback_roots], path) -> send_file(versioned) fallback = find_fallback_file(fallback_roots, path) -> send_file(fallback) true -> %{status: 404, content_type: "text/plain", body: "Not found"} end end defp find_versioned_file(roots, path) do case path |> String.trim_leading("/") |> Path.split() do [_version | rest] when rest != [] -> stripped_path = "/" <> Path.join(rest) find_fallback_file(roots, stripped_path) _ -> nil end end defp find_fallback_file(roots, path) do Enum.find_value(roots, fn root -> file_path = resolve_path(root, path) cond do file_path == :invalid -> nil File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) -> Path.join(file_path, "index.html") File.regular?(file_path) -> file_path true -> nil end end) end defp resolve_path(root, path) do relative = path |> String.trim_leading("/") |> URI.decode() candidate = Path.expand(relative, root) rel_to_root = Path.relative_to(candidate, Path.expand(root)) if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do :invalid else candidate end end defp send_file(path) do %{status: 200, content_type: content_type(path), body: File.read!(path)} end defp content_type(path) do case Path.extname(path) do ".html" -> "text/html" ".css" -> "text/css" ".js" -> "application/javascript" ".json" -> "application/json" ".svg" -> "image/svg+xml" ".png" -> "image/png" ".jpg" -> "image/jpeg" ".jpeg" -> "image/jpeg" ".webp" -> "image/webp" ".gif" -> "image/gif" ".ico" -> "image/x-icon" ".txt" -> "text/plain" _ -> "application/octet-stream" end end defp public_roots("cs") do ["networks/cs/website"] |> Enum.map(&Forum.Assets.path/1) |> Enum.filter(&File.dir?/1) end defp public_roots("comalyr") do [ "networks/comalyr/comalyr.com/ui/desktop", "networks/comalyr/comalyr.com/ui" ] |> Enum.map(&Forum.Assets.path/1) |> Enum.filter(&File.dir?/1) end defp public_roots(network) do [ Path.join(["networks", network, "website"]), Path.join(["networks", network, "public"]), Path.join(["networks", network, "www"]) ] |> Enum.map(&Forum.Assets.path/1) |> Enum.filter(&File.dir?/1) end defp via(network), do: {:via, Registry, {Forum.ProcessRegistry, {:public_site, normalize_network(network)}}} end