File size: 6,516 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
defmodule Plausible.Google.API do
  @moduledoc """
  API to Google services.
  """

  alias Plausible.Google.HTTP
  alias Plausible.Google.SearchConsole
  alias Plausible.Stats.Query

  @search_console_scope URI.encode_www_form(
                          "email https://www.googleapis.com/auth/webmasters.readonly"
                        )
  @import_scope URI.encode_www_form("email https://www.googleapis.com/auth/analytics.readonly")

  @verified_permission_levels ["siteOwner", "siteFullUser", "siteRestrictedUser"]

  @valid_oauth_contexts ["search-console", "import"]

  @oauth_salt "google-oauth-state"

  def search_console_authorize_url(site) do
    "https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@search_console_scope}&state=" <>
      sign_oauth_state(site, "search-console")
  end

  def import_authorize_url(site) do
    "https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@import_scope}&state=" <>
      sign_oauth_state(site, "import")
  end

  @doc false
  def sign_oauth_state(site, context) do
    Phoenix.Token.sign(PlausibleWeb.Endpoint, @oauth_salt, [site.id, context])
  end

  def verify_oauth_state(state) do
    case Phoenix.Token.verify(PlausibleWeb.Endpoint, @oauth_salt, state, max_age: 3600) do
      {:ok, [site_id, context]} when context in @valid_oauth_contexts ->
        if site = Plausible.Repo.get(Plausible.Site, site_id) do
          {:ok, %{site: site, context: context}}
        else
          {:error, {:invalid_oauth_state, :missing_site}}
        end

      {:error, token_error} ->
        {:error, {:invalid_oauth_state, token_error}}
    end
  end

  def fetch_access_token!(code) do
    HTTP.fetch_access_token!(code)
  end

  def list_properties(access_token) do
    Plausible.Google.GA4.API.list_properties(access_token)
  end

  def get_property(access_token, property) do
    Plausible.Google.GA4.API.get_property(access_token, property)
  end

  def get_analytics_start_date(access_token, property) do
    Plausible.Google.GA4.API.get_analytics_start_date(access_token, property)
  end

  def get_analytics_end_date(access_token, property) do
    Plausible.Google.GA4.API.get_analytics_end_date(access_token, property)
  end

  def fetch_verified_properties(auth) do
    with {:ok, access_token} <- maybe_refresh_token(auth),
         {:ok, sites} <- Plausible.Google.HTTP.list_sites(access_token) do
      sites
      |> Map.get("siteEntry", [])
      |> Enum.filter(fn site -> site["permissionLevel"] in @verified_permission_levels end)
      |> Enum.map(fn site -> site["siteUrl"] end)
      |> Enum.map(fn url -> String.trim_trailing(url, "/") end)
      |> then(&{:ok, &1})
    end
  end

  def fetch_stats(site, query, pagination, search) do
    with {:ok, site} <- ensure_search_console_property(site),
         {:ok, access_token} <- maybe_refresh_token(site.google_auth),
         {:ok, gsc_filters} <-
           SearchConsole.Filters.transform(site.google_auth.property, query.filters, search),
         {:ok, stats} <-
           HTTP.list_stats(
             access_token,
             site.google_auth.property,
             Query.date_range(query),
             pagination,
             gsc_filters
           ) do
      stats
      |> Map.get("rows", [])
      |> Enum.map(&search_console_row/1)
      |> then(&{:ok, &1})
    else
      :google_property_not_configured -> {:error, :google_property_not_configured}
      :unsupported_filters -> {:error, :unsupported_filters}
      {:error, error} -> {:error, error}
    end
  end

  def maybe_refresh_token(%Plausible.Site.GoogleAuth{} = auth) do
    with true <- needs_to_refresh_token?(auth.expires),
         {:ok, {new_access_token, expires_at}} <- do_refresh_token(auth.refresh_token),
         changeset <-
           Plausible.Site.GoogleAuth.changeset(auth, %{
             access_token: new_access_token,
             expires: expires_at
           }),
         {:ok, _google_auth} <- Plausible.Repo.update(changeset) do
      {:ok, new_access_token}
    else
      false -> {:ok, auth.access_token}
      {:error, cause} -> {:error, cause}
    end
  end

  def maybe_refresh_token({access_token, refresh_token, expires_at}) do
    with true <- needs_to_refresh_token?(expires_at),
         {:ok, {new_access_token, _expires_at}} <- do_refresh_token(refresh_token) do
      {:ok, new_access_token}
    else
      false -> {:ok, access_token}
      {:error, cause} -> {:error, cause}
    end
  end

  def property?(value), do: String.starts_with?(value, "properties/")

  defp do_refresh_token(refresh_token) do
    case HTTP.refresh_auth_token(refresh_token) do
      {:ok, %{"access_token" => new_access_token, "expires_in" => expires_in}} ->
        expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in)
        {:ok, {new_access_token, expires_at}}

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

  defp needs_to_refresh_token?(expires_at) when is_binary(expires_at) do
    expires_at
    |> NaiveDateTime.from_iso8601!()
    |> needs_to_refresh_token?()
  end

  defp needs_to_refresh_token?(%NaiveDateTime{} = expires_at) do
    thirty_seconds_ago = DateTime.shift(DateTime.utc_now(), second: 30)
    NaiveDateTime.before?(expires_at, thirty_seconds_ago)
  end

  defp ensure_search_console_property(site) do
    site = Plausible.Repo.preload(site, :google_auth)

    if site.google_auth && site.google_auth.property do
      {:ok, site}
    else
      :google_property_not_configured
    end
  end

  defp search_console_row(row) do
    %{
      # We always request just one dimension at a time (`query`)
      name: row["keys"] |> List.first(),
      visitors: row["clicks"],
      impressions: row["impressions"],
      ctr: rounded_ctr(row["ctr"]),
      position: rounded_position(row["position"])
    }
  end

  defp rounded_ctr(ctr) do
    {:ok, decimal} = Decimal.cast(ctr)

    decimal
    |> Decimal.mult(100)
    |> Decimal.round(1)
    |> Decimal.to_float()
  end

  defp rounded_position(position) do
    {:ok, decimal} = Decimal.cast(position)

    decimal
    |> Decimal.round(1)
    |> Decimal.to_float()
  end

  defp client_id() do
    Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
  end

  defp redirect_uri() do
    PlausibleWeb.Endpoint.url() <> "/auth/google/callback"
  end
end