337 lines
10 KiB
Elixir
337 lines
10 KiB
Elixir
defmodule Mix.Tasks.Phx.Gen.Auth.Injector do
|
|
@moduledoc false
|
|
|
|
alias Mix.Phoenix.{Context, Schema}
|
|
alias Mix.Tasks.Phx.Gen.Auth.HashingLibrary
|
|
|
|
@type schema :: %Schema{}
|
|
@type context :: %Context{schema: schema}
|
|
|
|
@doc """
|
|
Injects a dependency into the contents of mix.exs
|
|
"""
|
|
@spec mix_dependency_inject(String.t(), String.t()) ::
|
|
{:ok, String.t()} | :already_injected | {:error, :unable_to_inject}
|
|
def mix_dependency_inject(mixfile, dependency) do
|
|
with :ok <- ensure_not_already_injected(mixfile, dependency),
|
|
{:ok, new_mixfile} <- do_mix_dependency_inject(mixfile, dependency) do
|
|
{:ok, new_mixfile}
|
|
end
|
|
end
|
|
|
|
@spec do_mix_dependency_inject(String.t(), String.t()) ::
|
|
{:ok, String.t()} | {:error, :unable_to_inject}
|
|
defp do_mix_dependency_inject(mixfile, dependency) do
|
|
string_to_split_on = """
|
|
defp deps do
|
|
[
|
|
"""
|
|
|
|
case split_with_self(mixfile, string_to_split_on) do
|
|
{beginning, splitter, rest} ->
|
|
new_mixfile =
|
|
IO.iodata_to_binary([beginning, splitter, " ", dependency, ?,, ?\n, rest])
|
|
|
|
{:ok, new_mixfile}
|
|
|
|
_ ->
|
|
{:error, :unable_to_inject}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Injects configuration for test environment into `file`.
|
|
"""
|
|
@spec test_config_inject(String.t(), HashingLibrary.t()) ::
|
|
{:ok, String.t()} | :already_injected | {:error, :unable_to_inject}
|
|
def test_config_inject(file, %HashingLibrary{} = hashing_library) when is_binary(file) do
|
|
code_to_inject =
|
|
hashing_library
|
|
|> test_config_code()
|
|
|> normalize_line_endings_to_file(file)
|
|
|
|
inject_unless_contains(
|
|
file,
|
|
code_to_inject,
|
|
# Matches the entire line and captures the line ending. In the
|
|
# replace string:
|
|
#
|
|
# * the entire matching line is inserted with \\0,
|
|
# * the actual code is injected with &2,
|
|
# * and the appropriate newlines are injected using \\2.
|
|
&Regex.replace(~r/(use Mix\.Config|import Config)(\r\n|\n|$)/, &1, "\\0\\2#{&2}\\2",
|
|
global: false
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Instructions to provide the user when `test_config_inject/2` fails.
|
|
"""
|
|
@spec test_config_help_text(String.t(), HashingLibrary.t()) :: String.t()
|
|
def test_config_help_text(file_path, %HashingLibrary{} = hashing_library) do
|
|
"""
|
|
Add the following to #{Path.relative_to_cwd(file_path)}:
|
|
|
|
#{hashing_library |> test_config_code() |> indent_spaces(4)}
|
|
"""
|
|
end
|
|
|
|
defp test_config_code(%HashingLibrary{test_config: test_config}) do
|
|
String.trim("""
|
|
# Only in tests, remove the complexity from the password hashing algorithm
|
|
#{test_config}
|
|
""")
|
|
end
|
|
|
|
@router_plug_anchor_line "plug :put_secure_browser_headers"
|
|
|
|
@doc """
|
|
Injects the fetch_current_<schema> plug into router's browser pipeline
|
|
"""
|
|
@spec router_plug_inject(String.t(), context) ::
|
|
{:ok, String.t()} | :already_injected | {:error, :unable_to_inject}
|
|
def router_plug_inject(file, %Context{schema: schema}) when is_binary(file) do
|
|
inject_unless_contains(
|
|
file,
|
|
router_plug_code(schema),
|
|
# Matches the entire line containing `anchor_line` and captures
|
|
# the whitespace before the anchor. In the replace string
|
|
#
|
|
# * the entire matching line is inserted with \\0,
|
|
# * the captured indent is inserted using \\1,
|
|
# * the actual code is injected with &2,
|
|
# * and the appropriate newline is injected using \\2
|
|
&Regex.replace(~r/^(\s*)#{@router_plug_anchor_line}.*(\r\n|\n|$)/Um, &1, "\\0\\1#{&2}\\2",
|
|
global: false
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Instructions to provide the user when `inject_router_plug/2` fails.
|
|
"""
|
|
@spec router_plug_help_text(String.t(), context) :: String.t()
|
|
def router_plug_help_text(file_path, %Context{schema: schema}) do
|
|
"""
|
|
Add the #{router_plug_name(schema)} plug to the :browser pipeline in #{Path.relative_to_cwd(file_path)}:
|
|
|
|
pipeline :browser do
|
|
...
|
|
#{@router_plug_anchor_line}
|
|
#{router_plug_code(schema)}
|
|
end
|
|
"""
|
|
end
|
|
|
|
defp router_plug_code(%Schema{} = schema) do
|
|
"plug " <> router_plug_name(schema)
|
|
end
|
|
|
|
defp router_plug_name(%Schema{} = schema) do
|
|
":fetch_current_#{schema.singular}"
|
|
end
|
|
|
|
@doc """
|
|
Injects a menu in the application layout
|
|
"""
|
|
def app_layout_menu_inject(%Schema{} = schema, template_str) do
|
|
with {:error, :unable_to_inject} <-
|
|
app_layout_menu_inject_at_end_of_nav_tag(template_str, schema),
|
|
{:error, :unable_to_inject} <-
|
|
app_layout_menu_inject_after_opening_body_tag(template_str, schema) do
|
|
{:error, :unable_to_inject}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Instructions to provide the user when `app_layout_menu_inject/2` fails.
|
|
"""
|
|
def app_layout_menu_help_text(file_path, %Schema{} = schema) do
|
|
{_dup_check, code} = app_layout_menu_code_to_inject(schema)
|
|
|
|
"""
|
|
Add the following #{schema.singular} menu items to your #{Path.relative_to_cwd(file_path)} layout file:
|
|
|
|
#{code}
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Menu code to inject into the application layout template.
|
|
"""
|
|
def app_layout_menu_code_to_inject(%Schema{} = schema, padding \\ 4, newline \\ "\n") do
|
|
already_injected_str = "#{schema.route_prefix}/log_in"
|
|
|
|
base_tailwind_classes = "text-[0.8125rem] leading-6 text-zinc-900"
|
|
link_tailwind_classes = "#{base_tailwind_classes} font-semibold hover:text-zinc-700"
|
|
|
|
template = """
|
|
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
|
|
<%= if @current_#{schema.singular} do %>
|
|
<li class="#{base_tailwind_classes}">
|
|
{@current_#{schema.singular}.email}
|
|
</li>
|
|
<li>
|
|
<.link
|
|
href={~p"#{schema.route_prefix}/settings"}
|
|
class="#{link_tailwind_classes}"
|
|
>
|
|
Settings
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link
|
|
href={~p"#{schema.route_prefix}/log_out"}
|
|
method="delete"
|
|
class="#{link_tailwind_classes}"
|
|
>
|
|
Log out
|
|
</.link>
|
|
</li>
|
|
<% else %>
|
|
<li>
|
|
<.link
|
|
href={~p"#{schema.route_prefix}/register"}
|
|
class="#{link_tailwind_classes}"
|
|
>
|
|
Register
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link
|
|
href={~p"#{schema.route_prefix}/log_in"}
|
|
class="#{link_tailwind_classes}"
|
|
>
|
|
Log in
|
|
</.link>
|
|
</li>
|
|
<% end %>
|
|
</ul>\
|
|
"""
|
|
|
|
{already_injected_str, indent_spaces(template, padding, newline)}
|
|
end
|
|
|
|
defp formatting_info(template, tag) do
|
|
{padding, newline} =
|
|
case Regex.run(~r/<?(([\r\n]{1})\s*)#{tag}/m, template, global: false) do
|
|
[_, pre, "\n"] -> {String.trim_leading(pre, "\n") <> " ", "\n"}
|
|
[_, "\r\n" <> pre, "\r"] -> {String.trim_leading(pre, "\r\n") <> " ", "\r\n"}
|
|
_ -> {"", "\n"}
|
|
end
|
|
|
|
{String.length(padding), newline}
|
|
end
|
|
|
|
defp app_layout_menu_inject_at_end_of_nav_tag(file, schema) do
|
|
{padding, newline} = formatting_info(file, "<\/nav>")
|
|
{dup_check, code} = app_layout_menu_code_to_inject(schema, padding, newline)
|
|
|
|
inject_unless_contains(
|
|
file,
|
|
dup_check,
|
|
code,
|
|
&Regex.replace(~r/(\s*)<\/nav>/m, &1, "#{newline}#{&2}\\0", global: false)
|
|
)
|
|
end
|
|
|
|
defp app_layout_menu_inject_after_opening_body_tag(file, schema) do
|
|
anchor_line = "<body"
|
|
{padding, newline} = formatting_info(file, anchor_line)
|
|
{dup_check, code} = app_layout_menu_code_to_inject(schema, padding, newline)
|
|
|
|
inject_unless_contains(
|
|
file,
|
|
dup_check,
|
|
code,
|
|
# Matches the entire line containing `anchor_line` and captures
|
|
# the whitespace before the anchor. In the replace string, the
|
|
# entire matching line is inserted with \\0, then a newline then
|
|
# the indent that was captured using \\1. &2 is the code to
|
|
# inject.
|
|
&Regex.replace(~r/^(\s*)#{anchor_line}.*(\r\n|\n|$)/Um, &1, "\\0#{&2}\\2", global: false)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Injects code unless the existing code already contains `code_to_inject`
|
|
"""
|
|
def inject_unless_contains(code, dup_check, inject_fn) do
|
|
inject_unless_contains(code, dup_check, dup_check, inject_fn)
|
|
end
|
|
|
|
def inject_unless_contains(code, dup_check, code_to_inject, inject_fn)
|
|
when is_binary(code) and is_binary(code_to_inject) and is_binary(dup_check) and
|
|
is_function(inject_fn, 2) do
|
|
with :ok <- ensure_not_already_injected(code, dup_check) do
|
|
new_code = inject_fn.(code, code_to_inject)
|
|
|
|
if code != new_code do
|
|
{:ok, new_code}
|
|
else
|
|
{:error, :unable_to_inject}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Injects snippet before the final end in a file
|
|
"""
|
|
@spec inject_before_final_end(String.t(), String.t()) :: {:ok, String.t()} | :already_injected
|
|
def inject_before_final_end(code, code_to_inject)
|
|
when is_binary(code) and is_binary(code_to_inject) do
|
|
if String.contains?(code, code_to_inject) do
|
|
:already_injected
|
|
else
|
|
new_code =
|
|
code
|
|
|> String.trim_trailing()
|
|
|> String.trim_trailing("end")
|
|
|> Kernel.<>(code_to_inject)
|
|
|> Kernel.<>("end\n")
|
|
|
|
{:ok, new_code}
|
|
end
|
|
end
|
|
|
|
@spec ensure_not_already_injected(String.t(), String.t()) :: :ok | :already_injected
|
|
defp ensure_not_already_injected(file, inject) do
|
|
if String.contains?(file, inject) do
|
|
:already_injected
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@spec split_with_self(String.t(), String.t()) :: {String.t(), String.t(), String.t()} | :error
|
|
defp split_with_self(contents, text) do
|
|
case :binary.split(contents, text) do
|
|
[left, right] -> {left, text, right}
|
|
[_] -> :error
|
|
end
|
|
end
|
|
|
|
@spec normalize_line_endings_to_file(String.t(), String.t()) :: String.t()
|
|
defp normalize_line_endings_to_file(code, file) do
|
|
String.replace(code, "\n", get_line_ending(file))
|
|
end
|
|
|
|
@spec get_line_ending(String.t()) :: String.t()
|
|
defp get_line_ending(file) do
|
|
case Regex.run(~r/\r\n|\n|$/, file) do
|
|
[line_ending] -> line_ending
|
|
[] -> "\n"
|
|
end
|
|
end
|
|
|
|
defp indent_spaces(string, number_of_spaces, newline \\ "\n")
|
|
when is_binary(string) and is_integer(number_of_spaces) do
|
|
indent = String.duplicate(" ", number_of_spaces)
|
|
|
|
string
|
|
|> String.split("\n")
|
|
|> Enum.map_join(newline, &(indent <> &1))
|
|
end
|
|
end
|