aboutsummaryrefslogtreecommitdiffstats
path: root/lib/util/public_suffix.ex
blob: e34994a202cee9ec924397aae2edaa507e086643 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
defmodule Lithium.Util.PublicSuffix do
  use GenServer

  require Logger

  @moduledoc """
  A GenServer that loads the public suffix list from a file and provides
  functionality to find the organisational domain of a given domain name.
  """

  @public_suffix_list_path Application.app_dir(:lithium, "priv/public_suffix_list.dat")
  @public_suffix_list_url "https://publicsuffix.org/list/public_suffix_list.dat"
  @public_suffix_max_age :timer.hours(7 * 24)

  def start_link(_) do
    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  @impl true
  def init(:ok) do
    if File.exists?(@public_suffix_list_path) do
      {:ok, load_public_suffix_list()}
    else
      {:ok, []}
    end
  end

  @impl true
  def handle_info(:update_public_suffix_list, state) do
    case fetch_public_suffix_list() do
      {:ok, new_list} ->
        {:noreply, new_list}

      {:error, reason} ->
        Logger.error("Failed to update public suffix list: #{reason}")
        {:noreply, state}
    end
  end

  @impl true
  def handle_call({:get_domain, domain}, _from, state) do
    {:reply, find_organizational_domain(domain, state), state}
  end

  def load_public_suffix_list do
    case File.read(@public_suffix_list_path) do
      {:ok, content} ->
        content
        |> String.split("\n", trim: true)
        |> Enum.reject(&String.starts_with?(&1, "//"))
        |> Enum.map(&String.trim/1)
        |> Enum.map(&String.downcase/1)
        |> Enum.map(&String.replace(&1, "*.", ""))

      {:error, reason} ->
        Logger.error("Failed to load public suffix list: #{reason}")
        []
    end
  end

  def fetch_public_suffix_list do
    request = :httpc.request(:get, {@public_suffix_list_url, []}, [], [])

    case request do
      {:ok, {{_, 200, _}, _, body}} ->
        File.write(@public_suffix_list_path, body)
        {:ok, load_public_suffix_list()}

      {:error, reason} ->
        {:error, reason}
    end
  end

  def find_organizational_domain(domain, public_suffix_list) do
    # x.y.z.jb3.dev -> jb3.dev

    # ["com", "jb3", "example"]
    domain_parts = String.split(domain, ".") |> Enum.reverse()

    # [["com", "public-suffix"], ...]
    suffix_parts =
      Enum.map(public_suffix_list, &String.split(&1, ".")) |> Enum.map(&Enum.reverse/1)

    # Add domain parts together until it is no longer in the suffix_parts list

    Enum.reduce_while(domain_parts, [], fn part, acc ->
      new_acc = acc ++ [part]

      if Enum.member?(suffix_parts, new_acc) do
        {:cont, new_acc}
      else
        {:halt, new_acc}
      end
    end)
    |> Enum.reverse()
    |> Enum.join(".")
    |> String.downcase()
    |> String.trim()
  end

  def get_domain(domain) do
    GenServer.call(__MODULE__, {:get_domain, domain})
  end
end