Merge branch 'provision-api'

This commit is contained in:
Uku Taht 2021-04-15 12:47:58 +03:00
commit edffe64a39
15 changed files with 365 additions and 63 deletions

View File

@ -3,8 +3,11 @@ defmodule Plausible.Auth.ApiKey do
import Ecto.Changeset
@required [:user_id, :key, :name]
@optional [:scopes]
schema "api_keys" do
field :name, :string
field :scopes, {:array, :string}, default: ["stats:read:*"]
field :key, :string, virtual: true
field :key_hash, :string
field :key_prefix, :string
@ -16,7 +19,7 @@ defmodule Plausible.Auth.ApiKey do
def changeset(schema, attrs \\ %{}) do
schema
|> cast(attrs, @required)
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> process_key
end

View File

@ -4,9 +4,10 @@ defmodule Plausible.Site do
alias Plausible.Auth.User
alias Plausible.Site.GoogleAuth
@derive {Jason.Encoder, only: [:domain, :timezone]}
schema "sites" do
field :domain, :string
field :timezone, :string
field :timezone, :string, default: "Etc/UTC"
field :public, :boolean
many_to_many :members, User, join_through: Plausible.Site.Membership

View File

@ -17,6 +17,7 @@ defmodule Plausible.Site.SharedLink do
|> cast(attrs, [:slug, :password, :name])
|> validate_required([:slug, :name])
|> unique_constraint(:slug)
|> unique_constraint(:name, name: :shared_links_site_id_name_index)
|> hash_password()
end

View File

@ -1,15 +1,53 @@
defmodule Plausible.Sites do
use Plausible.Repo
alias Plausible.Site.CustomDomain
alias Plausible.Site.{CustomDomain, SharedLink}
def get_for_user!(user_id, domain) do
Repo.one!(
from s in Plausible.Site,
join: sm in Plausible.Site.Membership,
on: sm.site_id == s.id,
where: sm.user_id == ^user_id,
where: s.domain == ^domain,
select: s
def create(user_id, params) do
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)
Ecto.Multi.new()
|> Ecto.Multi.insert(:site, site_changeset)
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
membership_changeset =
Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{
site_id: site.id,
user_id: user_id
})
repo.insert(membership_changeset)
end)
|> Repo.transaction()
end
def create_shared_link(site, name) do
changes =
SharedLink.changeset(
%SharedLink{
site_id: site.id,
slug: Nanoid.generate()
},
%{name: name}
)
Repo.insert(changes)
end
def shared_link_url(site, link) do
base = PlausibleWeb.Endpoint.url()
domain = "/share/#{URI.encode_www_form(site.domain)}"
base <> domain <> "?auth=" <> link.slug
end
def get_for_user!(user_id, domain), do: Repo.one!(get_for_user_q(user_id, domain))
def get_for_user(user_id, domain), do: Repo.one(get_for_user_q(user_id, domain))
def get_for_user_q(user_id, domain) do
from(s in Plausible.Site,
join: sm in Plausible.Site.Membership,
on: sm.site_id == s.id,
where: sm.user_id == ^user_id,
where: s.domain == ^domain,
select: s
)
end

View File

@ -0,0 +1,72 @@
defmodule PlausibleWeb.Api.ExternalSitesController do
use PlausibleWeb, :controller
use Plausible.Repo
use Plug.ErrorHandler
alias Plausible.Sites
alias PlausibleWeb.Api.Helpers, as: H
def create_site(conn, params) do
user_id = conn.assigns[:current_user_id]
case Sites.create(user_id, params) do
{:ok, %{site: site}} ->
json(conn, site)
{:error, :site, changeset, _} ->
conn
|> put_status(400)
|> json(serialize_errors(changeset))
end
end
defp expect_param_key(params, key) do
case Map.fetch(params, key) do
:error -> {:missing, key}
res -> res
end
end
def find_or_create_shared_link(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, link_name} <- expect_param_key(params, "name"),
site when not is_nil(site) <- Sites.get_for_user(conn.assigns[:current_user_id], site_id) 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)
})
end
else
nil ->
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(400, "Something went wrong: #{inspect(e)}")
end
end
defp serialize_errors(changeset) do
{field, {msg, _opts}} = List.first(changeset.errors)
error_msg = Atom.to_string(field) <> " " <> msg
%{"error" => error_msg}
end
def handle_errors(conn, %{kind: kind, reason: reason}) do
json(conn, %{error: Exception.format_banner(kind, reason)})
end
end

View File

@ -0,0 +1,24 @@
defmodule PlausibleWeb.Api.Helpers do
import Plug.Conn
def unauthorized(conn, msg) do
conn
|> put_status(401)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
def bad_request(conn, msg) do
conn
|> put_status(400)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
def not_found(conn, msg) do
conn
|> put_status(404)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
end

View File

@ -41,7 +41,7 @@ defmodule PlausibleWeb.SiteController do
def create_site(conn, %{"site" => site_params}) do
user = conn.assigns[:current_user]
case insert_site(user.id, site_params) do
case Sites.create(user.id, site_params) do
{:ok, %{site: site}} ->
Plausible.Slack.notify("#{user.name} created #{site.domain} [email=#{user.email}]")
@ -517,16 +517,7 @@ defmodule PlausibleWeb.SiteController do
def create_shared_link(conn, %{"website" => website, "shared_link" => link}) do
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
changes =
Plausible.Site.SharedLink.changeset(
%Plausible.Site.SharedLink{
site_id: site.id,
slug: Nanoid.generate()
},
link
)
case Repo.insert(changes) do
case Sites.create_shared_link(site, link["name"]) do
{:ok, _created} ->
redirect(conn, to: "/#{URI.encode_www_form(site.domain)}/settings/visibility")
@ -652,21 +643,4 @@ defmodule PlausibleWeb.SiteController do
|> put_flash(:success, "Custom domain deleted successfully")
|> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/custom-domain")
end
defp insert_site(user_id, params) do
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)
Ecto.Multi.new()
|> Ecto.Multi.insert(:site, site_changeset)
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
membership_changeset =
Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{
site_id: site.id,
user_id: user_id
})
repo.insert(membership_changeset)
end)
|> Repo.transaction()
end
end

View File

@ -0,0 +1,56 @@
defmodule PlausibleWeb.AuthorizeSitesApiPlug do
import Plug.Conn
use Plausible.Repo
alias Plausible.Auth.ApiKey
alias PlausibleWeb.Api.Helpers, as: H
def init(options) do
options
end
def call(conn, _opts) do
with {:ok, raw_api_key} <- get_bearer_token(conn),
{:ok, api_key} <- verify_access(raw_api_key) do
assign(conn, :current_user_id, api_key.user_id)
else
{:error, :missing_api_key} ->
H.unauthorized(
conn,
"Missing API key. Please use a valid Plausible API key as a Bearer Token."
)
{:error, :invalid_api_key} ->
H.unauthorized(
conn,
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
)
end
end
defp verify_access(api_key) do
hashed_key = ApiKey.do_hash(api_key)
found_key =
Repo.one(
from a in ApiKey,
where: a.key_hash == ^hashed_key,
where: fragment("? @> ?", a.scopes, ["sites:provision:*"])
)
cond do
found_key -> {:ok, found_key}
true -> {:error, :invalid_api_key}
end
end
defp get_bearer_token(conn) do
authorization_header =
Plug.Conn.get_req_header(conn, "authorization")
|> List.first()
case authorization_header do
"Bearer " <> token -> {:ok, String.trim(token)}
_ -> {:error, :missing_api_key}
end
end
end

View File

@ -1,7 +1,8 @@
defmodule PlausibleWeb.AuthorizeApiStatsPlug do
defmodule PlausibleWeb.AuthorizeStatsApiPlug do
import Plug.Conn
use Plausible.Repo
alias Plausible.Auth.ApiKey
alias PlausibleWeb.Api.Helpers, as: H
def init(options) do
options
@ -13,19 +14,19 @@ defmodule PlausibleWeb.AuthorizeApiStatsPlug do
assign(conn, :site, site)
else
{:error, :missing_api_key} ->
unauthorized(
H.unauthorized(
conn,
"Missing API key. Please use a valid Plausible API key as a Bearer Token."
)
{:error, :missing_site_id} ->
bad_request(
H.bad_request(
conn,
"Missing site ID. Please provide the required site_id parameter with your request."
)
{:error, :invalid_api_key} ->
unauthorized(
H.unauthorized(
conn,
"Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested."
)
@ -56,18 +57,4 @@ defmodule PlausibleWeb.AuthorizeApiStatsPlug do
_ -> {:error, :missing_api_key}
end
end
defp bad_request(conn, msg) do
conn
|> put_status(400)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
defp unauthorized(conn, msg) do
conn
|> put_status(401)
|> Phoenix.Controller.json(%{error: msg})
|> halt()
end
end

View File

@ -36,10 +36,9 @@ defmodule PlausibleWeb.Router do
plug PlausibleWeb.AuthorizeStatsPlug
end
pipeline :external_stats_api do
pipeline :public_api do
plug :accepts, ["json"]
plug PlausibleWeb.Firewall
plug PlausibleWeb.AuthorizeApiStatsPlug
end
if Application.get_env(:plausible, :environment) == "dev" do
@ -73,7 +72,7 @@ defmodule PlausibleWeb.Router do
end
scope "/api/v1/stats", PlausibleWeb.Api do
pipe_through :external_stats_api
pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug]
get "/realtime/visitors", ExternalStatsController, :realtime_visitors
get "/aggregate", ExternalStatsController, :aggregate
@ -81,6 +80,13 @@ defmodule PlausibleWeb.Router do
get "/timeseries", ExternalStatsController, :timeseries
end
scope "/api/v1/sites", PlausibleWeb.Api do
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
post "/", ExternalSitesController, :create_site
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
end
scope "/api", PlausibleWeb do
pipe_through :api

View File

@ -8,5 +8,5 @@
</div>
</div>
<%= submit "Edit shared link", class: "button mt-4 w-full" %>
<%= submit "Update", class: "button mt-4 w-full" %>
<% end %>

View File

@ -22,8 +22,7 @@ defmodule PlausibleWeb.SiteView do
end
def shared_link_dest(site, link) do
domain = "/share/#{URI.encode_www_form(site.domain)}"
plausible_url() <> domain <> "?auth=" <> link.slug
Plausible.Sites.shared_link_url(site, link)
end
def snippet(site) do

View File

@ -0,0 +1,7 @@
defmodule Plausible.Repo.Migrations.AddUniqueIndexToSharedLinkName do
use Ecto.Migration
def change do
create unique_index(:shared_links, [:site_id, :name], name: :shared_links_site_id_name_index)
end
end

View File

@ -0,0 +1,24 @@
defmodule Plausible.Repo.Migrations.AddApiKeyScopes do
use Ecto.Migration
def up do
alter table(:api_keys) do
add :scopes, {:array, :text}
end
execute "UPDATE api_keys SET scopes='{stats:read:*}'"
alter table(:api_keys) do
modify :scopes, {:array, :text}, null: false
end
# https://stackoverflow.com/a/4059785
create index(:api_keys, [:scopes], using: "GIN")
end
def down do
alter table(:api_keys) do
remove :scopes
end
end
end

View File

@ -0,0 +1,110 @@
defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
use PlausibleWeb.ConnCase
import Plausible.TestUtils
setup %{conn: conn} do
user = insert(:user)
api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
{:ok, user: user, api_key: api_key, conn: conn}
end
describe "POST /api/v1/sites" do
test "can create a site", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
}
end
test "timezone defaults to Etc/UTC", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Etc/UTC"
}
end
test "domain is required", %{conn: conn} do
conn = post(conn, "/api/v1/sites", %{})
assert json_response(conn, 400) == %{
"error" => "domain can't be blank"
}
end
test "cannot access with a bad API key scope", %{conn: conn, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> post("/api/v1/sites", %{"site" => %{"domain" => "domain.com"}})
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "PUT /api/v1/sites/shared-links" do
setup :create_site
test "can add a shared link to a site", %{conn: conn, site: site} do
conn = put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "Wordpress"
})
res = json_response(conn, 200)
assert res["name"] == "Wordpress"
assert String.starts_with?(res["url"], "http://")
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn = put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "Wordpress"
})
%{"url" => url} = json_response(conn, 200)
conn = put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "Wordpress"
})
assert %{"url" => ^url} = json_response(conn, 200)
end
test "returns 400 when site id missing", %{conn: conn} do
conn = put(conn, "/api/v1/sites/shared-links", %{
name: "Wordpress"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `site_id` is required to create a shared link"
end
test "returns 404 when site id is non existent", %{conn: conn} do
conn = put(conn, "/api/v1/sites/shared-links", %{
name: "Wordpress",
site_id: "bad"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
end
end