| defmodule PlausibleWeb.Live.Shields.IPRules do |
| @moduledoc """ |
| LiveView allowing IP Rules management |
| """ |
|
|
| use PlausibleWeb, :live_component |
|
|
| alias PlausibleWeb.Live.Components.PrimaModal |
| alias Plausible.Shields |
| alias Plausible.Shield |
|
|
| def update(assigns, socket) do |
| socket = |
| socket |
| |> assign( |
| ip_rules_count: assigns.ip_rules_count, |
| remote_ip: assigns.remote_ip, |
| site: assigns.site, |
| current_user: assigns.current_user, |
| form: new_form() |
| ) |
| |> assign_new(:ip_rules, fn %{site: site} -> |
| Shields.list_ip_rules(site) |
| end) |
|
|
| {:ok, socket} |
| end |
|
|
| def render(assigns) do |
| ~H""" |
| <div> |
| <.settings_tiles> |
| <.tile docs="excluding"> |
| <:title>IP block list</:title> |
| <:subtitle :if={not Enum.empty?(@ip_rules)}> |
| Reject incoming traffic from specific IP addresses. |
| </:subtitle> |
| |
| <%= if Enum.empty?(@ip_rules) do %> |
| <div class="flex flex-col items-center justify-center pt-5 pb-6 max-w-md mx-auto"> |
| <h3 class="text-center text-base font-medium text-gray-900 dark:text-gray-100 leading-7"> |
| Block an IP address |
| </h3> |
| <p class="text-center text-sm mt-1 text-gray-500 dark:text-gray-400 leading-5 text-pretty"> |
| Reject incoming traffic from specific IP addresses. |
| <.styled_link href="https://plausible.io/docs/excluding" target="_blank"> |
| Learn more |
| </.styled_link> |
| </p> |
| <.button |
| :if={@ip_rules_count < Shields.maximum_ip_rules()} |
| id="add-ip-rule" |
| phx-click={Prima.Modal.JS.open("ip-rule-form-modal")} |
| > |
| Add IP address |
| </.button> |
| </div> |
| <% else %> |
| <.filter_bar :if={@ip_rules_count < Shields.maximum_ip_rules()} filtering_enabled?={false}> |
| <.button |
| id="add-ip-rule" |
| phx-click={Prima.Modal.JS.open("ip-rule-form-modal")} |
| mt?={false} |
| > |
| Add IP address |
| </.button> |
| </.filter_bar> |
| |
| <.notice |
| :if={@ip_rules_count >= Shields.maximum_ip_rules()} |
| class="mt-4" |
| title="Maximum number of addresses reached" |
| theme={:gray} |
| > |
| <p> |
| You've reached the maximum number of IP addresses you can block ({Shields.maximum_ip_rules()}). Please remove one before adding another. |
| </p> |
| </.notice> |
| |
| <div class="mt-6"> |
| <.table rows={@ip_rules}> |
| <:thead> |
| <.th>IP address</.th> |
| <.th hide_on_mobile>Status</.th> |
| <.th hide_on_mobile>Description</.th> |
| <.th invisible>Actions</.th> |
| </:thead> |
| <:tbody :let={rule}> |
| <.td max_width="max-w-40"> |
| <div class="flex items-center truncate"> |
| <span |
| :if={to_string(rule.inet) == @remote_ip} |
| class="inline-flex items-center gap-x-1.5 rounded-md px-2 mr-2 py-1 text-xs font-medium text-gray-700 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700" |
| > |
| <svg class="h-1.5 w-1.5 fill-green-400" viewBox="0 0 6 6" aria-hidden="true"> |
| <circle cx="3" cy="3" r="3" /> |
| </svg> |
| YOU |
| </span> |
| <span |
| id={"inet- |
| class="cursor-help" |
| title={"Added at #{format_added_at(rule.inserted_at, @site.timezone)} by #{rule.added_by}"} |
| > |
| {rule.inet} |
| </span> |
| </div> |
| </.td> |
| <.td hide_on_mobile> |
| <span :if={rule.action == :deny}> |
| Blocked |
| </span> |
| <span :if={rule.action == :allow}> |
| Allowed |
| </span> |
| </.td> |
| <.td hide_on_mobile truncate> |
| <span :if={rule.description} title={rule.description}> |
| {rule.description} |
| </span> |
| <span :if={!rule.description} class="text-gray-400 dark:text-gray-600"> |
| -- |
| </span> |
| </.td> |
| <.td actions> |
| <.delete_button |
| id={"remove-ip-rule-#{rule.id}"} |
| phx-target={@myself} |
| phx-click="remove-ip-rule" |
| phx-value-rule-id={rule.id} |
| data-confirm="Are you sure you want to revoke this rule?" |
| /> |
| </.td> |
| </:tbody> |
| </.table> |
| </div> |
| <% end %> |
|
|
| <PrimaModal.modal id="ip-rule-form-modal"> |
| <div class="p-5 sm:p-6 max-w-md"> |
| <div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4"> |
| <button |
| phx-click={Prima.Modal.JS.close()} |
| class="text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400" |
| > |
| <span class="sr-only">Close</span> |
| <Heroicons.x_mark class="size-6" /> |
| </button> |
| </div> |
| <div class="flex flex-col gap-y-4 text-center sm:text-left"> |
| <PrimaModal.modal_title> |
| Add IP to block list |
| </PrimaModal.modal_title> |
| <.form |
| :let={f} |
| for={@form} |
| phx-submit="save-ip-rule" |
| phx-target={@myself} |
| > |
| <div> |
| <p |
| :if={not ip_rule_present?(@ip_rules, @remote_ip)} |
| class="text-sm text-gray-500 dark:text-gray-400 mb-4" |
| > |
| Your current IP address is: <span class="font-mono"><%= @remote_ip %></span>. |
| <.styled_link phx-target={@myself} phx-click="prefill-own-ip-rule"> |
| Click here |
| </.styled_link> |
| to block your own traffic, or enter a custom address below. |
| </p> |
|
|
| <.input |
| data-autofocus |
| field={f[:inet]} |
| label="IP address" |
| placeholder="e.g. 192.168.127.12" |
| /> |
| </div> |
|
|
| <.input |
| field={f[:description]} |
| label="Description (optional)" |
| placeholder="e.g. The Office" |
| /> |
|
|
| <p class="mt-4 text-sm text-gray-500 dark:text-gray-400"> |
| Once added, we will start rejecting traffic from this IP within a few minutes. |
| </p> |
| <.button type="submit" class="w-full"> |
| Add IP address |
| </.button> |
| </.form> |
| </div> |
| </div> |
| </PrimaModal.modal> |
| </.tile> |
| </.settings_tiles> |
| </div> |
| """ |
| end |
| |
| def handle_event("prefill-own-ip-rule", %{}, socket) do |
| form = |
| %Plausible.Shield.IPRule{} |
| |> Plausible.Shield.IPRule.changeset(%{ |
| inet: socket.assigns.remote_ip, |
| description: socket.assigns.current_user.name |
| }) |
| |> to_form() |
| |
| {:noreply, assign(socket, form: form)} |
| end |
| |
| def handle_event("save-ip-rule", %{"ip_rule" => params}, socket) do |
| user = socket.assigns.current_user |
| |
| case Shields.add_ip_rule( |
| socket.assigns.site.id, |
| params, |
| added_by: user |
| ) do |
| {:ok, rule} -> |
| socket = |
| socket |
| |> Prima.Modal.push_close("ip-rule-form-modal") |
| |> assign( |
| form: new_form(), |
| ip_rules: [rule | socket.assigns.ip_rules], |
| ip_rules_count: socket.assigns.ip_rules_count + 1 |
| ) |
| |
| send_flash( |
| :success, |
| "IP rule added successfully. Traffic will be rejected within a few minutes." |
| ) |
| |
| {:noreply, socket} |
| |
| {:error, %Ecto.Changeset{} = changeset} -> |
| {:noreply, assign(socket, form: to_form(changeset))} |
| end |
| end |
| |
| def handle_event("remove-ip-rule", %{"rule-id" => rule_id}, socket) do |
| Shields.remove_ip_rule(socket.assigns.site.id, rule_id) |
| |
| send_flash( |
| :success, |
| "IP rule removed successfully. Traffic will be resumed within a few minutes." |
| ) |
| |
| {:noreply, |
| socket |
| |> assign( |
| ip_rules_count: socket.assigns.ip_rules_count - 1, |
| ip_rules: Enum.reject(socket.assigns.ip_rules, &(&1.id == rule_id)) |
| )} |
| end |
| |
| def send_flash(kind, message) do |
| send(self(), {:flash, kind, message}) |
| end |
| |
| defp new_form() do |
| %Shield.IPRule{} |
| |> Shield.IPRule.changeset(%{}) |
| |> to_form() |
| end |
| |
| defp ip_rule_present?(rules, ip) do |
| not is_nil(Enum.find(rules, &(to_string(&1.inet) == ip))) |
| end |
| |
| defp format_added_at(dt, tz) do |
| dt |
| |> Plausible.Timezones.to_datetime_in_timezone(tz) |
| |> Calendar.strftime("%Y-%m-%d %H:%M:%S") |
| end |
| end |
| |