aboutsummaryrefslogtreecommitdiffstats
path: root/lib/util/public_suffix.ex
blob: 62f015e8e66661ea9f056f9e59e1816655e2bc57 (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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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 div(:timer.hours(7 * 24), 1000) # 7 days in seconds

  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) and psl_fresh?() do
      Logger.info("Loading public suffix list from disk cache.")
      Process.send_after(self(), :update_public_suffix_list,  psl_next_refresh() - :os.system_time(:seconds))
      {:ok, load_public_suffix_list()}
    else
      Logger.info("Public suffix list file not found or not fresh, fetching from remote.")
      case fetch_public_suffix_list() do
        {:ok, list} ->
          Process.send_after(self(), :update_public_suffix_list, @public_suffix_max_age)
          {:ok, list}

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

  @impl true
  def handle_info(:update_public_suffix_list, state) do
    case fetch_public_suffix_list() do
      {:ok, new_list} ->
        Process.send_after(self(), :update_public_suffix_list, @public_suffix_max_age)
        {: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, get_od_for_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
    Logger.info("Fetching public suffix list from #{@public_suffix_list_url}")

    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

  defp get_od_for_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

  defp psl_fresh?() do
    case File.stat(@public_suffix_list_path, time: :posix) do
      {:ok, stat} ->
        now = :os.system_time(:seconds)
        stat.mtime + @public_suffix_max_age > now

      {:error, _} ->
        false
    end
  end

  defp psl_next_refresh() do
    case File.stat(@public_suffix_list_path, time: :posix) do
      {:ok, stat} ->
        stat.mtime + @public_suffix_max_age

      {:error, _} ->
        nil
    end
  end
end