defmodule Forum.Network do @moduledoc """ A per-network process for serving the app's forms view. Requests to `/` use the root forms. Requests to `/` use the matching network forms from `priv/networks//index.html`. """ use GenServer @default_hosts MapSet.new(["localhost", "127.0.0.1", "0.0.0.0", ""]) def child_spec(opts) do slug = opts |> Keyword.fetch!(:slug) |> normalize_slug() %{ id: {:network, slug}, start: {__MODULE__, :start_link, [[slug: slug]]}, restart: :permanent, shutdown: 5_000, type: :worker } end def start_link(opts) do slug = Keyword.fetch!(opts, :slug) GenServer.start_link(__MODULE__, slug, name: via(slug)) end def normalize_slug(slug) when is_binary(slug) do slug |> String.downcase() |> String.split(":", parts: 2) |> hd() end def serve(pid, request) do GenServer.call(pid, {:serve, request}) end @impl true def init(slug) do {:ok, %{slug: normalize_slug(slug), root: site_root(slug), 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(%{slug: slug} = state, request) do path = Map.fetch!(request, :path) cond do default_site?(slug) and path in ["", "/"] -> desktop_response() network_site?(slug) and path in ["", "/"] -> network_forms_response(slug) File.dir?(state.root) -> static_response(state.root, path) true -> %{ status: 404, content_type: "text/plain", body: "No network configured for #{slug}. Create files under #{Path.relative_to_cwd(state.root)}." } end end defp desktop_response do body = Forum.Assets.path("ui/desktop.html") |> File.read!() |> String.replace("", Forum.Forms.html()) %{status: 200, content_type: "text/html", body: body} end defp network_forms_response(slug) do body = Forum.Assets.path("ui/desktop.html") |> File.read!() |> String.replace("", Forum.Forms.html(slug)) %{status: 200, content_type: "text/html", body: body} end defp static_response(root, path) 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) true -> %{status: 404, content_type: "text/plain", body: "Not found"} 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 site_root(slug) do source_path = Path.expand(Path.join(["priv", "networks", normalize_slug(slug)]), File.cwd!()) if File.exists?(source_path) do source_path else Application.app_dir(:forum, Path.join(["priv", "networks", normalize_slug(slug)])) end end defp default_site?(slug), do: MapSet.member?(@default_hosts, slug) defp network_site?(slug), do: slug in Forum.Networks.network_slugs() defp via(slug), do: {:via, Registry, {Forum.ProcessRegistry, {:network, normalize_slug(slug)}}} end