defmodule PlausibleWeb.Api.ExternalSitesController do use PlausibleWeb, :controller use Plausible.Repo use PlausibleWeb.Plugs.ErrorHandler import Plausible.Pagination alias Plausible.Sites alias Plausible.Goal alias Plausible.Goals alias Plausible.Props alias Plausible.Teams alias PlausibleWeb.Api.Helpers, as: H @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000] def index(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team || Teams.get(params["team_id"]) page = user |> Sites.for_user_query(team) |> paginate(params, @pagination_opts) json(conn, %{ sites: page.entries |> Enum.map(&get_site_response_for_index/1), meta: pagination_meta(page.metadata) }) end def guests_index(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do opts = [cursor_fields: [sort_index: :asc], limit: 100, maximum_limit: 1000] page = site |> Sites.list_guests_query() |> paginate(params, opts) json(conn, %{ guests: Enum.map(page.entries, fn entry -> Map.take(entry, [:email, :role, :status]) end), meta: pagination_meta(page.metadata) }) else {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to list goals") {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") end end def teams_index(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team page = user |> Teams.Users.teams_query(identifier: team && team.identifier, order_by: :id_desc) |> paginate(params, @pagination_opts) json(conn, %{ teams: Enum.map(page.entries, fn team -> api_available? = Plausible.Billing.Feature.StatsAPI.check_availability(team) == :ok %{ id: team.identifier, name: Teams.name(team), api_available: api_available? } end), meta: pagination_meta(page.metadata) }) end def goals_index(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do page = site |> Plausible.Goals.for_site_query() |> paginate(params, @pagination_opts) json(conn, %{ goals: Enum.map(page.entries, fn goal -> %{ id: goal.id, display_name: goal.display_name, goal_type: Goal.type(goal), event_name: goal.event_name, page_path: goal.page_path, custom_props: goal.custom_props } end), meta: pagination_meta(page.metadata) }) else {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to list goals") {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") end end def create_site(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team || Teams.get(params["team_id"]) case Repo.transact(fn -> with {:ok, %{site: site}} <- Sites.create(user, params, team), {:ok, tracker_script_configuration} <- get_or_create_config(site, params["tracker_script_configuration"] || %{}) do {:ok, struct(site, tracker_script_configuration: tracker_script_configuration )} else # Translates Multi error format to Repo.transact error format {:error, step_id, output, context} -> {:error, {step_id, output, context}} # already in Repo.transact error format {:error, reason} -> {:error, reason} end end) do {:ok, site} -> json(conn, get_site_response(site)) {:error, {_, {:over_limit, limit}, _}} -> conn |> put_status(402) |> json(%{ error: "Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription." }) {:error, {_, :permission_denied, _}} -> conn |> put_status(403) |> json(%{ error: "You can't add sites to the selected team." }) {:error, {_, :multiple_teams, _}} -> conn |> put_status(400) |> json(%{ error: "You must select a team with 'team_id' parameter." }) {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> conn |> put_status(400) |> json(serialize_errors(changeset, "tracker_script_configuration.")) {:error, {_, %Ecto.Changeset{} = changeset, _}} -> conn |> put_status(400) |> json(serialize_errors(changeset)) end end def get_site(conn, %{"site_id" => site_id}) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]), {:ok, tracker_script_configuration} <- get_or_create_config(site, %{}) do site = struct(site, tracker_script_configuration: tracker_script_configuration) json(conn, get_site_response(site)) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") end end def delete_site(conn, %{"site_id" => site_id}) do user = conn.assigns.current_user team = conn.assigns.current_team case find_site(user, team, site_id, [:owner]) do {:ok, site} -> {:ok, _} = Plausible.Site.Removal.run(site) json(conn, %{"deleted" => true}) {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") end end def update_site(conn, %{"site_id" => site_id} = params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, params} <- validate_update_payload(params), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), {:ok, site} <- do_update_site(site, params) do json(conn, get_site_response(site)) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:error, :no_changes} -> H.bad_request( conn, "Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'" ) {:error, {:domain_change_invalid, %Ecto.Changeset{} = changeset}} -> conn |> put_status(400) |> json(serialize_errors(changeset)) {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> conn |> put_status(400) |> json(serialize_errors(changeset, "tracker_script_configuration.")) end end defp validate_update_payload(params) do params = params |> Map.take(["domain", "tracker_script_configuration"]) |> Map.drop([nil]) if map_size(params) == 0 do {:error, :no_changes} else {:ok, params} end end defp do_update_site(site, params) do Repo.transact(fn -> with {:ok, site} <- if(params["domain"], do: change_domain(site, params["domain"]), else: {:ok, site} ), {:ok, tracker_script_configuration} <- if(params["tracker_script_configuration"], do: update_config(site, params["tracker_script_configuration"]), else: get_or_create_config(site, %{}) ) do {:ok, struct(site, tracker_script_configuration: tracker_script_configuration)} end end) end defp change_domain(site, domain) do case Plausible.Site.Domain.change(site, domain) do {:ok, site} -> {:ok, site} {:error, %Ecto.Changeset{} = changeset} -> {:error, {:domain_change_invalid, changeset}} end end def find_or_create_guest(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, email} <- expect_param_key(params, "email"), {:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do existing = Repo.one(Sites.list_guests_query(site, email: email)) if existing do json(conn, %{ role: existing.role, email: existing.email, status: existing.status }) else case Teams.Invitations.InviteToSite.invite( site, conn.assigns.current_user, email, role ) do {:ok, invitation} -> json(conn, %{ role: invitation.role, email: invitation.team_invitation.email, status: "invited" }) end end else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, "role"} -> H.bad_request( conn, "Parameter `role` is required to create guest. Possible values: `viewer` or `editor`" ) {:missing, param} -> H.bad_request(conn, "Parameter `#{param}` is required to create guest") end end def delete_guest(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, email} <- expect_param_key(params, "email"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do existing = Repo.one(Sites.list_guests_query(site, email: email)) case existing do %{status: "invited", id: id} -> with guest_invitation when not is_nil(guest_invitation) <- Repo.get(Teams.GuestInvitation, id) do Teams.Invitations.remove_guest_invitation(guest_invitation) end %{status: "accepted", email: email} -> with %{} = user <- Repo.get_by(Plausible.Auth.User, email: email) do Teams.Memberships.remove(site, user) end _ -> :ignore end json(conn, %{"deleted" => true}) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, param} -> H.bad_request(conn, "Parameter `#{param}` is required to delete a guest") end end def find_or_create_shared_link(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, link_name} <- expect_param_key(params, "name"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]) do shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = case shared_link do nil -> Sites.create_shared_link(site, link_name) link -> {:ok, link} end case shared_link do {:ok, link} -> json(conn, %{ name: link.name, url: Sites.shared_link_url(site, link) }) {:error, %Ecto.Changeset{} = changeset} -> {msg, _} = changeset.errors[:name] H.bad_request(conn, msg) {:error, :upgrade_required} -> H.payment_required(conn, "Your current subscription plan does not include Shared Links") end else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to create a shared link") {:missing, "name"} -> H.bad_request(conn, "Parameter `name` is required to create a shared link") e -> H.bad_request(conn, "Something went wrong: #{inspect(e)}") end end def find_or_create_goal(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, _} <- expect_param_key(params, "goal_type"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), {:ok, goal} <- Goals.find_or_create(site, params) do json(conn, goal) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, param} -> H.bad_request(conn, "Parameter `#{param}` is required to create a goal") {:error, %Ecto.Changeset{} = changeset} -> message = Enum.map_join(changeset.errors, ", ", &translate_error/1) H.bad_request(conn, message) {:error, :upgrade_required} -> H.payment_required( conn, "Your current subscription plan does not include Custom Properties" ) e -> H.bad_request(conn, "Something went wrong: #{inspect(e)}") end end def delete_goal(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, goal_id} <- expect_param_key(params, "goal_id"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), :ok <- Goals.delete(goal_id, site) do json(conn, %{"deleted" => true}) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:error, :not_found} -> H.not_found(conn, "Goal could not be found") {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to delete a goal") {:missing, "goal_id"} -> H.bad_request(conn, "Parameter `goal_id` is required to delete a goal") e -> H.bad_request(conn, "Something went wrong: #{inspect(e)}") end end def custom_props_index(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do properties = (site.allowed_event_props || []) |> Enum.sort() |> Enum.map(fn prop -> %{property: prop} end) json(conn, %{custom_properties: properties}) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to list custom properties") end end def add_custom_prop(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, property} <- expect_param_key(params, "property"), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), {:ok, _} <- Props.allow(site, property) do json(conn, %{"created" => true}) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, param} -> H.bad_request(conn, "Parameter `#{param}` is required to create a custom property") {:error, :upgrade_required} -> H.payment_required( conn, "Your current subscription plan does not include Custom Properties" ) {:error, changeset} -> %{allowed_event_props: [error | _]} = Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} -> cond do opts[:type] == :list and opts[:validation] == :length -> "Can't add any more custom properties" opts[:type] == :string and opts[:validation] == :length -> "Parameter `property` is too long" true -> "Parameter `property` is invalid" end end) H.bad_request(conn, error) end end def delete_custom_prop(conn, params) do user = conn.assigns.current_user team = conn.assigns.current_team with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, property} <- expect_param_key(params, "property"), # Property name is extracted from route URL via wildcard, # which returns a list. property = Path.join(property), {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), {:ok, _} <- Props.disallow(site, property) do json(conn, %{"deleted" => true}) else {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, param} -> H.bad_request(conn, "Parameter `#{param}` is required to delete a custom property") e -> H.bad_request(conn, "Something went wrong: #{inspect(e)}") end end defp pagination_meta(meta) do %{ after: meta.after, before: meta.before, limit: meta.limit } end defp find_site(user, team, site_id, roles) do case Plausible.Sites.get_for_user(user, site_id, roles: roles) do nil -> {:error, :site_not_found} site -> site = Repo.preload(site, :team) if team && team.id != site.team_id do {:error, :site_not_found} else {:ok, site} end end end defp serialize_errors(changeset, field_prefix \\ "") do {field, {msg, _opts}} = List.first(changeset.errors) error_msg = field_prefix <> Atom.to_string(field) <> ": " <> msg %{"error" => error_msg} end defp expect_param_key(params, key, inclusion \\ []) defp expect_param_key(params, key, []) do case Map.fetch(params, key) do :error -> {:missing, key} res -> res end end defp expect_param_key(params, key, inclusion) do case expect_param_key(params, key, []) do {:ok, value} = ok -> if value in inclusion, do: ok, else: {:missing, key} _ -> {:missing, key} end end defp get_or_create_config(site, params) do case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do {:ok, tracker_script_configuration} -> {:ok, tracker_script_configuration} {:error, %Ecto.Changeset{} = changeset} -> {:error, {:tracker_script_configuration_invalid, changeset}} end end defp update_config(site, params) do case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do {:ok, tracker_script_configuration} -> {:ok, tracker_script_configuration} {:error, %Ecto.Changeset{} = changeset} -> {:error, {:tracker_script_configuration_invalid, changeset}} end end defp get_site_response_for_index(site) do site |> Map.take([:domain, :timezone]) end defp get_site_response(site) do site |> Map.take([:domain, :timezone, :tracker_script_configuration]) # remap to `custom_properties` |> Map.put(:custom_properties, site.allowed_event_props || []) end defp translate_error({field, {msg, opts}}) do Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc -> String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) end) end end