Skip to content

Commit

Permalink
Add user auth tokens & create APIs for project sources (#263)
Browse files Browse the repository at this point in the history
  • Loading branch information
loicknuchel authored Nov 12, 2023
1 parent 5fc0d94 commit 91fa2e9
Show file tree
Hide file tree
Showing 91 changed files with 1,820 additions and 921 deletions.
2 changes: 1 addition & 1 deletion backend/.credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
# keep in sync with line_length in .formatter.exs
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 140]},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 180]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
Expand Down
2 changes: 1 addition & 1 deletion backend/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"],
# keep in sync with Credo.Check.Readability.MaxLineLength in .credo.exs
line_length: 140
line_length: 180
]
60 changes: 58 additions & 2 deletions backend/lib/azimutt/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Azimutt.Accounts do
@moduledoc "The Accounts context."
import Ecto.Query, warn: false
alias Azimutt.Repo
alias Azimutt.Accounts.{User, UserNotifier, UserProfile, UserToken}
alias Azimutt.Accounts.{User, UserAuthToken, UserNotifier, UserProfile, UserToken}
alias Azimutt.Organizations
alias Azimutt.Organizations.OrganizationMember
alias Azimutt.Tracking
Expand Down Expand Up @@ -266,6 +266,51 @@ defmodule Azimutt.Accounts do
|> Repo.update()
end

## Auth tokens

def list_auth_tokens(%User{} = current_user, now) do
UserAuthToken
|> where([t], t.user_id == ^current_user.id and is_nil(t.deleted_at) and (is_nil(t.expire_at) or t.expire_at > ^now))
|> Repo.all()
end

def change_auth_token(%User{} = current_user, attrs \\ %{}) do
%UserAuthToken{}
|> UserAuthToken.create_changeset(%{
name: attrs["name"],
expire_at: attrs["expire_at"],
user_id: current_user.id
})
end

def create_auth_token(%User{} = current_user, now, attrs) do
%UserAuthToken{}
|> UserAuthToken.create_changeset(%{
name: attrs["name"],
expire_at:
case attrs["expire_at"] do
"hour" -> Timex.shift(now, hours: 1)
"day" -> Timex.shift(now, days: 1)
"month" -> Timex.shift(now, months: 1)
_ -> nil
end,
user_id: current_user.id
})
|> Repo.insert()
end

def delete_auth_token(token_id, %User{} = current_user, now) do
UserAuthToken
|> where([t], t.id == ^token_id and t.user_id == ^current_user.id and is_nil(t.deleted_at))
|> Repo.one()
|> Result.from_nillable()
|> Result.flat_map(fn token ->
token
|> UserAuthToken.delete_changeset(now)
|> Repo.update()
end)
end

## Session

def generate_user_session_token(user) do
Expand All @@ -276,7 +321,18 @@ defmodule Azimutt.Accounts do

def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
Repo.one(query) |> Result.from_nillable()
end

def get_user_by_auth_token(token_id, now) do
UserAuthToken
|> where([t], t.id == ^token_id and is_nil(t.deleted_at) and (is_nil(t.expire_at) or t.expire_at > ^now))
|> Repo.one()
|> Result.from_nillable()
|> Result.flat_map(fn token ->
token |> UserAuthToken.access_changeset(now) |> Repo.update()
User |> Repo.get(token.user_id) |> Result.from_nillable()
end)
end

def delete_session_token(token) do
Expand Down
35 changes: 35 additions & 0 deletions backend/lib/azimutt/accounts/user_auth_token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Azimutt.Accounts.UserAuthToken do
@moduledoc "Auth tokens allowing to identify a user simply by passing this token"
use Ecto.Schema
use Azimutt.Schema
import Ecto.Changeset
alias Azimutt.Accounts.User

schema "user_auth_tokens" do
belongs_to :user, User
field :name, :string
field :nb_access, :integer
field :last_access, :utc_datetime_usec
field :expire_at, :utc_datetime_usec
timestamps(updated_at: false)
field :deleted_at, :utc_datetime_usec
end

def create_changeset(token, attrs) do
token
|> cast(attrs, [:name, :expire_at])
|> put_change(:user_id, attrs.user_id)
|> put_change(:nb_access, 0)
|> validate_required([:name])
end

def access_changeset(token, now) do
token
|> cast(%{last_access: now, nb_access: token.nb_access + 1}, [:last_access, :nb_access])
end

def delete_changeset(token, now) do
token
|> cast(%{deleted_at: now}, [:deleted_at])
end
end
5 changes: 1 addition & 4 deletions backend/lib/azimutt/accounts/user_token.ex
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
defmodule Azimutt.Accounts.UserToken do
@moduledoc """
base user token module generate by `mix phx.gen.auth`
"""
@moduledoc "base user token module generate by `mix phx.gen.auth`"
use Ecto.Schema
use Azimutt.Schema
import Ecto.Query

alias Azimutt.Accounts.User
alias Azimutt.Accounts.UserToken

Expand Down
32 changes: 32 additions & 0 deletions backend/lib/azimutt/projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Azimutt.Projects do
alias Azimutt.Organizations.OrganizationMember
alias Azimutt.Projects.Project
alias Azimutt.Projects.Project.Storage
alias Azimutt.Projects.ProjectFile
alias Azimutt.Projects.ProjectToken
alias Azimutt.Repo
alias Azimutt.Tracking
Expand Down Expand Up @@ -92,6 +93,22 @@ defmodule Azimutt.Projects do
end
end

def update_project_file(%Project{} = project, content, %User{} = current_user, now) do
can_update =
project_query()
|> where([p, _, om], p.id == ^project.id and p.storage_kind == :remote and (om.user_id == ^current_user.id or p.visibility != :write))
|> Repo.exists?()

if can_update do
project
|> Project.update_project_file_changeset(content, current_user, now)
|> Repo.update()
|> Result.tap(fn p -> Tracking.project_updated(current_user, p) end)
else
{:error, :forbidden}
end
end

def delete_project(%Project{} = project, %User{} = current_user) do
can_delete =
project_query()
Expand All @@ -115,6 +132,21 @@ defmodule Azimutt.Projects do
end
end

def get_project_content(%Project{} = project) do
if project.storage_kind == Storage.remote() do
# FIXME: handle spaces in name
file_url = ProjectFile.url({project.file, project}, signed: true)

if Application.get_env(:waffle, :storage) == Waffle.Storage.Local do
File.read("./#{file_url}")
else
HTTPoison.get(file_url) |> Result.map(fn resp -> resp.body end)
end
else
{:error, :not_remote}
end
end

def list_project_tokens(project_id, %User{} = current_user, now) do
project_query()
|> where([p, _, om], p.id == ^project_id and p.storage_kind == :remote and om.user_id == ^current_user.id)
Expand Down
16 changes: 15 additions & 1 deletion backend/lib/azimutt/projects/project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Azimutt.Projects.Project do
alias Azimutt.Accounts.User
alias Azimutt.Organizations.Organization
alias Azimutt.Projects.Project
alias Azimutt.Projects.ProjectFile
alias Azimutt.Utils.Slugme

schema "projects" do
Expand All @@ -16,7 +17,7 @@ defmodule Azimutt.Projects.Project do
field :description, :string
field :encoding_version, :integer
field :storage_kind, Ecto.Enum, values: [:local, :remote]
field :file, Azimutt.Projects.ProjectFile.Type
field :file, ProjectFile.Type
belongs_to :local_owner, User, source: :local_owner
field :visibility, Ecto.Enum, values: [:none, :read, :write]
field :nb_sources, :integer
Expand Down Expand Up @@ -160,4 +161,17 @@ defmodule Azimutt.Projects.Project do
|> put_change(:updated_at, now)
|> validate_required(required)
end

def update_project_file_changeset(%Project{} = project, content, %User{} = current_user, now) do
upload = %{
content_type: "application/json",
filename: project.file.file_name,
binary: content
}

project
|> cast_attachments(%{file: upload}, [:file])
|> put_change(:updated_by_id, current_user.id)
|> put_change(:updated_at, now)
end
end
6 changes: 1 addition & 5 deletions backend/lib/azimutt/projects/project_file.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
defmodule Azimutt.Projects.ProjectFile do
@moduledoc """
The file containing the content of a project, when project is stored in Azimutt
"""
@moduledoc "The file containing the content of a project, when project is stored in Azimutt"
use Waffle.Definition

# Include ecto support (requires package waffle_ecto installed):
use Waffle.Ecto.Definition

@versions [:original]
Expand Down
3 changes: 1 addition & 2 deletions backend/lib/azimutt/services/cockpit_srv.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,7 @@ defmodule Azimutt.Services.CockpitSrv do
admins: User |> where([u], u.is_admin == true) |> Repo.aggregate(:count),
organizations: Organization |> Repo.aggregate(:count),
np_organizations: Organization |> where([o], o.is_personal == false) |> Repo.aggregate(:count),
active_organizations:
Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.organization_id, :distinct)) |> Repo.one(),
active_organizations: Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.organization_id, :distinct)) |> Repo.one(),
projects: Project |> Repo.aggregate(:count),
active_projects: Event |> where([e], e.created_at > ^last_30_days) |> select([e], count(e.project_id, :distinct)) |> Repo.one(),
events: Event |> Repo.aggregate(:count),
Expand Down
3 changes: 1 addition & 2 deletions backend/lib/azimutt/tracking.ex
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,7 @@ defmodule Azimutt.Tracking do
stripe_customer_id: org.stripe_customer_id,
stripe_subscription_id: org.stripe_subscription_id,
is_personal: org.is_personal,
clever_cloud:
if(Ecto.assoc_loaded?(org.clever_cloud_resource) && org.clever_cloud_resource, do: org.clever_cloud_resource.id, else: nil),
clever_cloud: if(Ecto.assoc_loaded?(org.clever_cloud_resource) && org.clever_cloud_resource, do: org.clever_cloud_resource.id, else: nil),
heroku: if(Ecto.assoc_loaded?(org.heroku_resource) && org.heroku_resource, do: org.heroku_resource.id, else: nil),
members: if(Ecto.assoc_loaded?(org.members), do: org.members |> length, else: nil),
projects: if(Ecto.assoc_loaded?(org.projects), do: org.projects |> length, else: nil),
Expand Down
16 changes: 16 additions & 0 deletions backend/lib/azimutt/utils/mapx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ defmodule Azimutt.Utils.Mapx do
"""
def map_values(enumerable, f), do: enumerable |> Enum.map(fn {k, v} -> {k, f.(v)} end) |> Map.new()

@doc """
Same as `put` but if value is `nil` it removes the key.
## Examples
iex> %{foo: "bar", bob: "alice"} |> Mapx.put_no_nil(:bob, "claude")
%{foo: "bar", bob: "claude"}
iex> %{foo: "bar", bob: "alice"} |> Mapx.put_no_nil(:bob, nil)
%{foo: "bar"}
"""
def put_no_nil(enumerable, key, value) do
if value == nil do
enumerable |> Map.delete(key)
else
enumerable |> Map.put(key, value)
end
end

@doc """
Remove a key if it's present with the expected value, or set it
## Examples
Expand Down
3 changes: 1 addition & 2 deletions backend/lib/azimutt/utils/page.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ defmodule Azimutt.Utils.Page do
query
|> Map.filter(fn {k, _v} -> k |> String.starts_with?("#{prefix}f-") end)
|> Mapx.map_keys(fn k -> k |> String.replace_leading("#{prefix}f-", "") end),
sort:
(query["#{prefix}sort"] || opts[:sort] || "") |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(fn s -> s != "" end)
sort: (query["#{prefix}sort"] || opts[:sort] || "") |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(fn s -> s != "" end)
}
end

Expand Down
52 changes: 52 additions & 0 deletions backend/lib/azimutt/utils/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,58 @@ defmodule Azimutt.Utils.Result do
def flat_map_with({:ok, val}, f), do: f.(val) |> map(fn r -> {val, r} end)
def flat_map_with({:error, _err} = res, _f), do: res

@doc """
Associate the Result value with the given value in a tuple.
## Examples
iex> {:ok, 1} |> Result.zip(2)
{:ok, {1, 2}}
iex> {:error, 1} |> Result.zip(2)
{:error, 1}
"""
def zip(:ok, v), do: ok({nil, v})
def zip(:error = res, _v), do: res
def zip({:ok, val}, v), do: ok({val, v})
def zip({:error, _err} = res, _v), do: res

@doc """
Same as `zip` but in the reverse order for the tuple.
## Examples
iex> {:ok, 1} |> Result.zip_left(2)
{:ok, {2, 1}}
iex> {:error, 1} |> Result.zip_left(2)
{:error, 1}
"""
def zip_left(:ok, v), do: ok({v, nil})
def zip_left(:error = res, _v), do: res
def zip_left({:ok, val}, v), do: ok({v, val})
def zip_left({:error, _err} = res, _v), do: res

@doc """
Same as `zip` but for the error value instead of the success one.
## Examples
iex> {:ok, 1} |> Result.zip_error(2)
{:ok, 1}
iex> {:error, 1} |> Result.zip_error(2)
{:error, {1, 2}}
"""
def zip_error(:ok = res, _v), do: res
def zip_error(:error, v), do: error({nil, v})
def zip_error({:ok, _val} = res, _v), do: res
def zip_error({:error, err}, v), do: error({err, v})

@doc """
Same as `zip_error` but in the reverse order for the tuple.
## Examples
iex> {:ok, 1} |> Result.zip_error_left(2)
{:ok, 1}
iex> {:error, 1} |> Result.zip_error_left(2)
{:error, {2, 1}}
"""
def zip_error_left(:ok = res, _v), do: res
def zip_error_left(:error, v), do: error({v, nil})
def zip_error_left({:ok, _val} = res, _v), do: res
def zip_error_left({:error, err}, v), do: error({v, err})

@doc """
Execute the function on :ok but does not change the result
## Examples
Expand Down
3 changes: 1 addition & 2 deletions backend/lib/azimutt_web/admin/project/project_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ defmodule AzimuttWeb.Admin.ProjectController do
conn
|> render("show.html",
project: project,
activity:
Dataset.chartjs_daily_data([Admin.daily_project_activity(project) |> Dataset.from_values("Daily events")], start_stats, now),
activity: Dataset.chartjs_daily_data([Admin.daily_project_activity(project) |> Dataset.from_values("Daily events")], start_stats, now),
events: Admin.get_project_events(project, events_page)
)
end
Expand Down
Loading

0 comments on commit 91fa2e9

Please sign in to comment.