251 lines
7.2 KiB
Elixir
251 lines
7.2 KiB
Elixir
defmodule Plausible.Site do
|
|
@moduledoc """
|
|
Site schema
|
|
"""
|
|
use Ecto.Schema
|
|
use Plausible
|
|
import Ecto.Changeset
|
|
import Ecto.Query, only: [from: 2]
|
|
alias Plausible.Site.GoogleAuth
|
|
|
|
@type t() :: %__MODULE__{}
|
|
|
|
@derive {Jason.Encoder, only: [:domain, :timezone]}
|
|
schema "sites" do
|
|
field :domain, :string
|
|
field :timezone, :string, default: "Etc/UTC"
|
|
field :public, :boolean
|
|
field :stats_start_date, :date
|
|
field :native_stats_start_at, :naive_datetime
|
|
field :allowed_event_props, {:array, :string}
|
|
field :conversions_enabled, :boolean, default: true
|
|
field :props_enabled, :boolean, default: true
|
|
field :funnels_enabled, :boolean, default: true
|
|
field :legacy_time_on_page_cutoff, :date, default: ~D[1970-01-01]
|
|
|
|
field :consolidated, :boolean, default: false
|
|
|
|
field :ingest_rate_limit_scale_seconds, :integer, default: 60
|
|
# default is set via changeset/2
|
|
field :ingest_rate_limit_threshold, :integer
|
|
|
|
field :domain_changed_from, :string
|
|
field :domain_changed_at, :naive_datetime
|
|
|
|
# NOTE: needed by `SiteImports` data migration script
|
|
embeds_one :imported_data, Plausible.Site.ImportedData, on_replace: :update
|
|
|
|
# NOTE: new teams relations
|
|
belongs_to :team, Plausible.Teams.Team
|
|
has_many :guest_memberships, Plausible.Teams.GuestMembership
|
|
has_many :guest_invitations, Plausible.Teams.GuestInvitation
|
|
|
|
has_one :tracker_script_configuration, Plausible.Site.TrackerScriptConfiguration
|
|
|
|
has_many :goals, Plausible.Goal, preload_order: [desc: :id]
|
|
has_many :revenue_goals, Plausible.Goal, where: [currency: {:not, nil}]
|
|
has_one :google_auth, GoogleAuth
|
|
has_one :weekly_report, Plausible.Site.WeeklyReport
|
|
has_one :monthly_report, Plausible.Site.MonthlyReport
|
|
has_many :ownerships, through: [:team, :ownerships], preload_order: [asc: :id]
|
|
has_many :owners, through: [:team, :owners]
|
|
|
|
# If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Cache`.
|
|
# Use `Plausible.Repo.reload!(cached_site)` to pre-fill missing fields if
|
|
# strictly necessary.
|
|
field :from_cache?, :boolean, virtual: true, default: false
|
|
|
|
# Used in the context of paginated sites list to order in relation to
|
|
# user's membership state. Currently it can be either "invitation",
|
|
# "pinned_site" or "site", where invitations are first.
|
|
field :entry_type, :string, virtual: true
|
|
field :memberships, {:array, :map}, virtual: true
|
|
field :invitations, {:array, :map}, virtual: true
|
|
field :pinned_at, :naive_datetime, virtual: true
|
|
|
|
has_many :completed_imports, Plausible.Imported.SiteImport, where: [status: :completed]
|
|
|
|
timestamps()
|
|
end
|
|
|
|
def regular(q \\ __MODULE__) do
|
|
from s in q, where: not s.consolidated
|
|
end
|
|
|
|
def new_for_team(team, params) do
|
|
params
|
|
|> new()
|
|
|> put_assoc(:team, team)
|
|
end
|
|
|
|
def new(params), do: changeset(%__MODULE__{}, params)
|
|
|
|
on_ee do
|
|
@domain_unique_error """
|
|
This domain cannot be registered. Perhaps one of your colleagues registered it? If that's not the case, please contact support@plausible.io
|
|
"""
|
|
else
|
|
@domain_unique_error """
|
|
This domain cannot be registered. Perhaps one of your colleagues registered it?
|
|
"""
|
|
end
|
|
|
|
on_ee do
|
|
@changeset_cast_fields [:domain, :consolidated, :timezone, :legacy_time_on_page_cutoff]
|
|
else
|
|
@changeset_cast_fields [:domain, :timezone, :legacy_time_on_page_cutoff]
|
|
end
|
|
|
|
def changeset(site, attrs \\ %{}) do
|
|
site
|
|
|> cast(attrs, @changeset_cast_fields)
|
|
|> clean_domain()
|
|
|> validate_required([:domain, :timezone])
|
|
|> validate_timezone()
|
|
|> validate_domain_format()
|
|
|> validate_domain_reserved_characters()
|
|
|> unique_constraint(:domain,
|
|
message: @domain_unique_error
|
|
)
|
|
|> unique_constraint(:domain,
|
|
name: "domain_change_disallowed",
|
|
message: @domain_unique_error
|
|
)
|
|
|> put_change(
|
|
:ingest_rate_limit_threshold,
|
|
Application.get_env(:plausible, __MODULE__)[:default_ingest_threshold]
|
|
)
|
|
end
|
|
|
|
def update_changeset(site, attrs \\ %{}, opts \\ []) do
|
|
at =
|
|
opts
|
|
|> Keyword.get(:at, NaiveDateTime.utc_now())
|
|
|> NaiveDateTime.truncate(:second)
|
|
|
|
site
|
|
|> changeset(attrs)
|
|
|> handle_domain_change(at)
|
|
end
|
|
|
|
def crm_changeset(site, attrs) do
|
|
site
|
|
|> cast(attrs, [
|
|
:timezone,
|
|
:public,
|
|
:native_stats_start_at,
|
|
:ingest_rate_limit_threshold,
|
|
:ingest_rate_limit_scale_seconds
|
|
])
|
|
|> validate_required([:timezone, :public])
|
|
|> validate_number(:ingest_rate_limit_scale_seconds,
|
|
greater_than_or_equal_to: 1,
|
|
message: "must be at least 1 second"
|
|
)
|
|
|> validate_number(:ingest_rate_limit_threshold,
|
|
greater_than_or_equal_to: 0,
|
|
message: "must be empty, zero or positive"
|
|
)
|
|
end
|
|
|
|
def tz_offset(site, utc_now \\ DateTime.utc_now()) do
|
|
case DateTime.shift_zone(utc_now, site.timezone) do
|
|
{:ok, datetime} ->
|
|
datetime.utc_offset + datetime.std_offset
|
|
|
|
res ->
|
|
Sentry.capture_message("Unable to determine timezone offset for",
|
|
extra: %{site: site, result: res}
|
|
)
|
|
|
|
0
|
|
end
|
|
end
|
|
|
|
def make_public(site) do
|
|
change(site, public: true)
|
|
end
|
|
|
|
def make_private(site) do
|
|
change(site, public: false)
|
|
end
|
|
|
|
def set_stats_start_date(site, val) do
|
|
change(site, stats_start_date: val)
|
|
end
|
|
|
|
def set_native_stats_start_at(site, val) do
|
|
change(site, native_stats_start_at: val)
|
|
end
|
|
|
|
defp clean_domain(changeset) do
|
|
clean_domain =
|
|
(get_field(changeset, :domain) || "")
|
|
|> String.downcase()
|
|
|> String.trim()
|
|
|> String.replace_leading("http://", "")
|
|
|> String.replace_leading("https://", "")
|
|
|> String.trim("/")
|
|
|> String.replace_leading("www.", "")
|
|
|
|
change(changeset, %{domain: clean_domain})
|
|
end
|
|
|
|
# https://tools.ietf.org/html/rfc3986#section-2.2
|
|
@uri_reserved_chars ~w(: ? # [ ] @ ! $ & ' \( \) * + , ; =)
|
|
defp validate_domain_reserved_characters(changeset) do
|
|
domain = get_field(changeset, :domain) || ""
|
|
|
|
if String.contains?(domain, @uri_reserved_chars) do
|
|
add_error(
|
|
changeset,
|
|
:domain,
|
|
"must not contain URI reserved characters #{@uri_reserved_chars}"
|
|
)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_domain_format(changeset) do
|
|
validate_format(changeset, :domain, ~r/^[-\.\\\/:\p{L}\d]*$/u,
|
|
message: "only letters, numbers, slashes and period allowed"
|
|
)
|
|
end
|
|
|
|
defp handle_domain_change(changeset, at) do
|
|
new_domain = get_change(changeset, :domain)
|
|
|
|
if new_domain do
|
|
changeset
|
|
|> put_change(:domain_changed_from, changeset.data.domain)
|
|
|> put_change(:domain_changed_at, at)
|
|
|> unique_constraint(:domain,
|
|
name: "domain_change_disallowed",
|
|
message: @domain_unique_error
|
|
)
|
|
|> unique_constraint(:domain_changed_from,
|
|
message: @domain_unique_error
|
|
)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_timezone(changeset) do
|
|
tz = get_field(changeset, :timezone)
|
|
|
|
if Plausible.Timezones.valid?(tz) do
|
|
changeset
|
|
else
|
|
add_error(changeset, :timezone, "is invalid")
|
|
end
|
|
end
|
|
end
|
|
|
|
defimpl FunWithFlags.Actor, for: Plausible.Site do
|
|
def id(%{domain: domain}) do
|
|
"site:#{domain}"
|
|
end
|
|
end
|