Commit 6e715abf authored by Alex Castaño's avatar Alex Castaño

Add emails, reset passwords and email verification

parent a4be0f85
......@@ -11,25 +11,27 @@ help:
build: ## Build the Docker image
@echo APP_NAME=$(APP_NAME)
@echo APP_VSN=$(APP_VSN)
@echo APP_BUILD=$(APP_BUILD)
docker build \
--no-cache \
--build-arg APP_NAME=$(APP_NAME) \
--build-arg APP_VSN=$(APP_VSN) \
-t moodlenet:$(APP_VSN)-$(APP_BUILD) \
-t moodlenet:latest .
@echo moodlenet:$(APP_VSN)-$(APP_BUILD)
@echo moodlenet:latest
-t moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD) .
@echo moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD)
build_with_cache: ## Build the Docker image
@echo APP_NAME=$(APP_NAME)
@echo APP_VSN=$(APP_VSN)
@echo APP_BUILD=$(APP_BUILD)
docker build \
--build-arg APP_NAME=$(APP_NAME) \
--build-arg APP_VSN=$(APP_VSN) \
-t moodlenet:$(APP_VSN)-$(APP_BUILD) \
-t moodlenet:latest .
@echo moodlenet:$(APP_VSN)-$(APP_BUILD)
@echo moodlenet:latest
-t moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD) .
@echo moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD)
tag_latest:
@echo docker tag moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD) moodlenet/moodlenet:latest
docker tag moodlenet/moodlenet:$(APP_VSN)-$(APP_BUILD) moodlenet/moodlenet:latest
run: ## Run the app in Docker
docker run\
......
defmodule ActivityPub.EntityTest do
use MoodleNet.DataCase, async: true
alias ActivityPub.Entity
describe "parse" do
test "simple objects" do
map = %{
attributed_to: [
%{
id: "https://alex.gitlab.com",
type: "Person",
name: "Alex",
inbox: "https://alex.gitlab.com/inbox",
outbox: "https://alex.gitlab.com/outbox"
},
"https://doug.gitlab.com"
],
type: "Object",
content: "This is a content",
name: "This is my name",
end_time: "2015-01-01T06:00:00-08:00",
new_field: "extra",
url: "https://alex.gitlab.com/profile"
}
assert {:ok, entity} = Entity.parse(map)
assert entity[:content] == %{"und" => map.content}
assert entity[:name] == %{"und" => map.name}
assert entity["content"] == %{"und" => map.content}
assert entity["name"] == %{"und" => map.name}
assert hd(entity[:attributed_to])[:inbox] == "https://alex.gitlab.com/inbox"
assert hd(entity["attributed_to"])["inbox"] == "https://alex.gitlab.com/inbox"
assert entity[:new_field] == map.new_field
assert entity["new_field"] == map.new_field
assert entity[:url] == [map.url]
end
test "activities" do
map = %{
"summary" => "John followed Sally",
"id" => "http://example.org/activities/123",
"type" => "Follow",
"actor" => "https://john.example.org",
"object" => "https://sally.example.org",
"context" => "http://example.org/connections/123"
}
assert {:ok, entity} = Entity.parse(map)
assert ["https://john.example.org"] == entity[:actor]
assert ["https://sally.example.org"] == entity[:object]
assert ["http://example.org/connections/123"] == entity[:context]
assert entity[:summary] == %{"und" => map["summary"]}
assert entity[:id] == map["id"]
end
test "actor" do
map = %{
"@context": ["https://www.w3.org/ns/activitystreams", %{"@language": "ja"}],
type: "Person",
id: "https://kenzoishii.example.com/",
following: "https://kenzoishii.example.com/following.json",
followers: "https://kenzoishii.example.com/followers.json",
liked: "https://kenzoishii.example.com/liked.json",
inbox: "https://kenzoishii.example.com/inbox.json",
outbox: "https://kenzoishii.example.com/feed.json",
preferredUsername: "kenzoishii",
name: "石井健蔵",
summary: "この方はただの例です",
icon: [
"https://kenzoishii.example.com/image/165987aklre4"
]
}
assert {:ok, entity} = Entity.parse(map)
assert entity[:type] == ~w[Object Actor Person]
assert entity[:id] == map[:id]
assert entity[:following] == map[:following]
assert entity[:liked] == map[:liked]
assert entity[:inbox] == map[:inbox]
assert entity[:outbox] == map[:outbox]
assert entity[:preferred_username] == map[:preferredUsername]
assert entity[:name] == %{"und" => map[:name]}
assert entity[:summary] == %{"und" => map[:summary]}
assert entity[:icon] == map[:icon]
end
test "collection page" do
map = %{
"@context": "https://www.w3.org/ns/activitystreams",
summary: "Page 1 of Sally's notes",
type: "CollectionPage",
id: "http://example.org/collection?page=1",
partOf: "http://example.org/collection",
items: [
%{
type: "Note",
name: "Pizza Toppings to Try"
},
%{
type: "Note",
name: "Thought about California"
}
]
}
assert {:ok, entity} = Entity.parse(map)
# FIXME
# assert entity[:"@context"] == map[:"@context"]
assert entity[:type] == ~w[Object Collection CollectionPage]
assert entity[:id] == map[:id]
assert entity[:summary] == %{"und" => map[:summary]}
# FIXME
assert entity[:part_of] == ["http://example.org/collection"]
assert [item_1, item_2] = entity[:items]
assert item_1[:type] == ~w(Object Note)
assert item_1[:name] == %{"und" => "Pizza Toppings to Try"}
assert item_2[:type] == ~w(Object Note)
assert item_2[:name] == %{"und" => "Thought about California"}
end
test "link" do
map = %{
"@context": "https://www.w3.org/ns/activitystreams",
type: "Link",
href: "http://example.org/abc",
hreflang: "en",
mediaType: "text/html",
name: "An example link"
}
assert {:ok, entity} = Entity.parse(map)
# FIXME
# assert entity[:"@context"] == map[:"@context"]
assert entity[:type] == ~w[Link]
assert entity[:href] == map[:href]
assert entity[:hreflang] == map[:hreflang]
assert entity[:media_type] == map[:mediaType]
assert entity[:name] == map[:name]
end
end
end
defmodule ActivityPubWeb.ActorControllerTest do
use MoodleNetWeb.ConnCase, async: true
describe "show" do
@tag format: :json_ld
test "it works with json", %{conn: conn} do
user = Factory.user()
assert resp =
conn
|> get("/actors/#{user.primary_actor_id}")
|> json_response(200)
assert resp["id"] == user.primary_actor.uri
end
@tag format: :html
test "it works with html", %{conn: conn} do
user = Factory.user()
assert conn
|> get("/actors/#{user.primary_actor_id}")
|> html_response(200) =~ user.primary_actor.uri
end
end
end
defmodule MoodleNetWeb.FederatorTest do
alias MoodleNetWeb.Federator
use MoodleNet.DataCase
test "enqueues an element according to priority" do
queue = [%{item: 1, priority: 2}]
new_queue = Federator.enqueue_sorted(queue, 2, 1)
assert new_queue == [%{item: 2, priority: 1}, %{item: 1, priority: 2}]
new_queue = Federator.enqueue_sorted(queue, 2, 3)
assert new_queue == [%{item: 1, priority: 2}, %{item: 2, priority: 3}]
end
test "pop first item" do
queue = [%{item: 2, priority: 1}, %{item: 1, priority: 2}]
assert {2, [%{item: 1, priority: 2}]} = Federator.queue_pop(queue)
end
end
defmodule MoodleNetTest do
use MoodleNet.DataCase
describe "create_community" do
test "works" do
attrs = Factory.attributes(:community)
assert {:ok, community} = MoodleNet.create_community(attrs)
assert community[:name] == %{"und" => attrs["name"]}
assert community[:content] == %{"und" => attrs["content"]}
assert community[:followers_count] == 0
assert [icon] = community[:icon]
assert [url] = icon[:url]
assert url
import Ecto.Query
from( a in "activity_pub_icons",
select: {a.target_id, a.subject_id})
|> Repo.all()
|> IO.inspect()
a = ActivityPub.SQL.get_by_local_id(community[:local_id])
|> ActivityPub.SQL.preload(:icon)
|> IO.inspect()
end
end
describe "create_collection" do
test "works" do
community = Factory.community()
attrs = Factory.attributes(:collection)
assert {:ok, collection} = MoodleNet.create_collection(community, attrs)
assert collection[:name] == %{"und" => attrs["name"]}
assert collection[:content] == %{"und" => attrs["content"]}
assert [icon] = collection[:icon]
assert [url] = icon[:url]
assert url
end
end
describe "create_resource" do
test "works" do
community = Factory.community()
collection = Factory.collection(community)
attrs = Factory.attributes(:resource)
assert {:ok, resource} = MoodleNet.create_resource(collection, attrs)
assert resource[:name] == %{"und" => attrs["name"]}
assert resource[:content] == %{"und" => attrs["content"]}
end
end
describe "create_comment" do
test "works" do
community = Factory.community()
actor = Factory.actor()
attrs = Factory.attributes(:comment)
assert {:ok, comment} = MoodleNet.create_comment(actor, community, attrs)
assert comment[:content] == %{"und" => attrs["content"]}
end
end
describe "list_communities" do
test "works" do
community = Factory.community()
collection = Factory.collection(community)
assert [loaded_com] = MoodleNet.list_communities()
assert loaded_com[:name] == community[:name]
assert loaded_com[:content] == community[:content]
end
test "paginates" do
community = Factory.community()
community_2 = Factory.community()
assert [loaded_com] = MoodleNet.list_communities(limit: 1)
assert loaded_com[:name] == community_2[:name]
assert loaded_com[:content] == community_2[:content]
assert [loaded_com] = MoodleNet.list_communities(limit: 1, order: :asc)
assert loaded_com[:name] == community[:name]
assert loaded_com[:content] == community[:content]
# assert [loaded_com] = MoodleNet.list_communities(limit: 1, order: :desc, starting_after: community_2[:local_id])
# assert loaded_com[:name] == community[:name]
# assert loaded_com[:content] == community[:content]
end
end
describe "list_collections" do
test "works" do
community = Factory.community()
collection = Factory.collection(community)
community_2 = Factory.community()
collection_2 = Factory.collection(community_2)
assert [loaded_col] = MoodleNet.list_collections(community)
assert collection[:local_id] == loaded_col[:local_id]
assert [loaded_col] = MoodleNet.list_collections(community_2)
assert collection_2[:local_id] == loaded_col[:local_id]
assert [loaded_com] = MoodleNet.list_communities_with_collection(collection)
assert community[:local_id] == loaded_com[:local_id]
end
end
describe "list_resources" do
test "works" do
community = Factory.community()
collection = Factory.collection(community)
collection_2 = Factory.collection(community)
resource = Factory.resource(collection)
resource_2 = Factory.resource(collection_2)
assert [loaded_col] = MoodleNet.list_resources(collection)
assert resource[:local_id] == loaded_col[:local_id]
assert [loaded_col] = MoodleNet.list_resources(collection_2)
assert resource_2[:local_id] == loaded_col[:local_id]
end
end
describe "list_comments" do
test "works" do
community = Factory.community()
community_2 = Factory.community()
actor = Factory.actor()
actor_2 = Factory.actor()
comment = Factory.comment(actor, community)
comment_2 = Factory.comment(actor_2, community_2)
assert [loaded_com] = MoodleNet.list_comments(%{context: community[:local_id]})
assert comment[:local_id] == loaded_com[:local_id]
assert [loaded_com] = MoodleNet.list_comments(%{context: community_2[:local_id]})
assert comment_2[:local_id] == loaded_com[:local_id]
assert [loaded_com] = MoodleNet.list_comments(%{attributed_to: actor[:local_id]})
assert comment[:local_id] == loaded_com[:local_id]
assert [loaded_com] = MoodleNet.list_comments(%{attributed_to: actor_2[:local_id]})
assert comment_2[:local_id] == loaded_com[:local_id]
end
end
end
......@@ -47,6 +47,10 @@ config :mime, :types, %{
config :moodle_net, :httpoison, MoodleNet.HTTP
config :moodle_net, MoodleNet.Mailer,
adapter: Bamboo.LocalAdapter,
open_email_in_browser_url: "http://localhost:4000/sent_emails" # optional
version =
with {version, 0} <- System.cmd("git", ["rev-parse", "HEAD"]) do
"MoodleNet #{Mix.Project.config()[:version]} #{String.trim(version)}"
......
......@@ -28,3 +28,6 @@ config :moodle_net, :httpoison, HTTPoison
config :phoenix_integration,
endpoint: MoodleNetWeb.Endpoint
config :moodle_net, MoodleNet.Mailer,
adapter: Bamboo.TestAdapter
......@@ -2,11 +2,10 @@ version: '3.5'
services:
web:
# Pull the image from dockerhub
# You can build your own image from the source running:
# $ make build
# $ make tag_latest
image: "moodlenet/moodlenet:latest"
# Build the image first with: make build
#image: "moodlenet:latest"
ports:
- "4000:4000"
env_file:
......
......@@ -4,8 +4,10 @@ defmodule ActivityPub do
defdelegate update(entity, changes), to: ActivityPub.SQLEntity
defdelegate delete(entity), to: ActivityPub.SQLEntity
defdelegate delete(entity, assocs), to: ActivityPub.SQLEntity
defdelegate get_by_local_id(params), to: ActivityPub.SQLEntity
defdelegate get_by_id(params), to: ActivityPub.SQLEntity
defdelegate get_by_local_id(params), to: ActivityPub.SQL.Query
defdelegate get_by_local_id(params, opts), to: ActivityPub.SQL.Query
defdelegate get_by_id(params), to: ActivityPub.SQL.Query
defdelegate get_by_id(params, opts), to: ActivityPub.SQL.Query
defdelegate reload(params), to: ActivityPub.SQL.Query
defdelegate apply(params), to: ActivityPub.ApplyAction
......
defmodule ActivityPub.SQL.Query do
alias ActivityPub.{SQLEntity, Entity}
alias ActivityPub.{SQLEntity, Entity, UrlBuilder}
import SQLEntity, only: [to_entity: 1]
import Ecto.Query, only: [from: 2]
require ActivityPub.Guards, as: APG
......@@ -43,9 +43,25 @@ defmodule ActivityPub.SQL.Query do
|> one()
end
def reload(entity) when APG.is_entity(entity) and APG.has_status(entity, :loaded) do
Entity.aspects(entity)
def get_by_local_id(id, opts \\ []) when is_integer(id) do
new()
|> where(local_id: id)
|> preload_aspect(Keyword.get(opts, :aspect, []))
|> one()
end
def get_by_id(id, opts \\ []) when is_binary(id) do
case UrlBuilder.get_local_id(id) do
{:ok, local_id} -> get_by_local_id(local_id, opts)
:error ->
new()
|> where(id: id)
|> preload_aspect(Keyword.get(opts, :aspect, []))
|> one()
end
end
def reload(entity) when APG.is_entity(entity) and APG.has_status(entity, :loaded) do
new()
|> where(local_id: Entity.local_id(entity))
|> preload_aspect(Entity.aspects(entity))
......
......@@ -30,13 +30,6 @@ defmodule ActivityPub.SQLEntity do
|> to_entity()
end
def get_by_id(id) when is_binary(id) do
case UrlBuilder.get_local_id(id) do
{:ok, local_id} -> get_by_local_id(local_id)
:error -> Repo.get_by(__MODULE__, id: id) |> to_entity()
end
end
def reload(entity) when APG.is_entity(entity) and APG.has_status(entity, :loaded) do
entity |> Entity.local_id() |> get_by_local_id()
end
......
......@@ -7,7 +7,8 @@ defmodule MoodleNet.Accounts do
alias MoodleNet.Repo
alias Ecto.Multi
alias MoodleNet.Accounts.{User, PasswordAuth}
alias MoodleNet.Accounts.{User, PasswordAuth, ResetPasswordToken, EmailConfirmationToken}
alias MoodleNet.{Mailer, Email}
alias ActivityPub.SQL.Query
......@@ -24,7 +25,7 @@ defmodule MoodleNet.Accounts do
"""
def register_user(attrs \\ %{}) do
# FIXME this should be a only transaction
# FIXME this should be a only one transaction
actor_attrs =
attrs
|> Map.put("type", "Person")
......@@ -32,21 +33,34 @@ defmodule MoodleNet.Accounts do
|> Map.delete("password")
with {:ok, actor} <- ActivityPub.new(actor_attrs),
{:ok, actor} <- ActivityPub.insert(actor) do
actor_local_id = ActivityPub.Entity.local_id(actor)
ch = User.changeset(actor_local_id, attrs)
{:ok, actor} <- ActivityPub.insert(actor),
{:ok, ret = %{user: user, email_confirmation_token: token}} <-
register_user_operation(actor, attrs) do
Email.welcome(user, token.token)
|> Mailer.deliver_later()
Multi.new()
|> Multi.run(:actor, fn _, _ -> {:ok, actor} end)
|> Multi.insert(:user, ch)
|> Multi.run(
:password_auth,
&(PasswordAuth.create_changeset(&2.user.id, attrs) |> &1.insert())
)
|> Repo.transaction()
{:ok, ret}
end
end
defp register_user_operation(actor, attrs) do
password = attrs[:password] || attrs["password"]
ch = User.changeset(actor, attrs)
Multi.new()
|> Multi.run(:actor, fn _, _ -> {:ok, actor} end)
|> Multi.insert(:user, ch)
|> Multi.run(
:password_auth,
&(PasswordAuth.create_changeset(&2.user.id, password) |> &1.insert())
)
|> Multi.run(
:email_confirmation_token,
&(EmailConfirmationToken.build_changeset(&2.user.id) |> &1.insert())
)
|> Repo.transaction()
end
def update_user(actor, changes) do
{icon_url, changes} = Map.pop(changes, :icon)
{location_content, changes} = Map.pop(changes, :location)
......@@ -97,4 +111,86 @@ defmodule MoodleNet.Accounts do
select: {u, p}
)
end
def reset_password_request(email) do
with user when not is_nil(user) <- Repo.get_by(User, email: email) do
{:ok, reset_password_token} = renew_reset_password_token(user)
Email.reset_password_request(user, reset_password_token.token)
|> Mailer.deliver_later()
{:ok, reset_password_token}
else
nil -> {:error, :not_found}
end
end
defp renew_reset_password_token(user) do
changeset = MoodleNet.Accounts.ResetPasswordToken.build_changeset(user)
opts = [returning: true, on_conflict: :replace_all, conflict_target: :user_id]
Repo.insert(changeset, opts)
end
def reset_password(token, new_password) do
with {:ok, reset_password_token} <- get_reset_password_token(token) do
password_ch = PasswordAuth.create_changeset(reset_password_token.user_id, new_password)
opts = [
returning: true,
on_conflict: {:replace, [:password_hash, :updated_at]},
conflict_target: :user_id
]
Multi.new()
|> Multi.delete(:reset_password_token, reset_password_token)
|> Multi.insert(:password_hash, password_ch, opts)
|> Multi.run(:email, fn repo, _ ->
User
|> repo.get(reset_password_token.user_id)
|> Email.password_reset()
|> Mailer.deliver_later()
{:ok, nil}
end)
|> Repo.transaction()
end
end
defp get_reset_password_token(full_token) do
with {:ok, {user_id, _}} <- MoodleNet.Token.split_id_and_token(full_token),
ret = %{token: rp_token} <- Repo.get_by(ResetPasswordToken, user_id: user_id),
false <- expired_token?(ret),
^full_token <- rp_token do
{:ok, ret}
else
_ -> {:error, :not_found}
end
end
@two_days 60 * 60 * 24 * 2
defp expired_token?(%{inserted_at: date}) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), date) > @two_days
end
def confirm_email(token) do
with {:ok, email_confirmation_token} <- get_email_confirmation_token(token) do
user = Repo.get(User, email_confirmation_token.user_id)
user_ch = User.confirm_email_changeset(user)
Multi.new()
|> Multi.delete(:email_confirmation_token, email_confirmation_token)
|> Multi.update(:user, user_ch)
|> Repo.transaction()
end
end
defp get_email_confirmation_token(full_token) do
with {:ok, {user_id, _}} <- MoodleNet.Token.split_id_and_token(full_token),
ret = %{token: ec_token} <- Repo.get_by(EmailConfirmationToken, user_id: user_id),
^full_token <- ec_token do
{:ok, ret}
else
_ -> {:error, :not_found}
end
end
end
defmodule MoodleNet.Accounts.EmailConfirmationToken do
use Ecto.Schema
alias MoodleNet.Accounts.User
schema "accounts_email_confirmation_tokens" do
belongs_to(:user, User)
field(:token, :string)
timestamps(updated_at: false)
end
def build_changeset(user_id) do
token = MoodleNet.Token.random_key_with_id(user_id)
Ecto.Changeset.change(%__MODULE__{}, user_id: user_id, token: token)
end
end
......@@ -11,23 +11,12 @@ defmodule MoodleNet.Accounts.PasswordAuth do
import Ecto.Changeset
def create_changeset(user_id, attrs) do
def create_changeset(user_id, password) do
attrs = %{password: password}
%__MODULE__{}
|> cast(attrs, [:password])
|> change(user_id: user_id)
|> foreign_key_constraint(:user_id)
|> common_changeset()
end
def update_password_changeset(%__MODULE__{} = password_hash, password) do
attrs = %{password: password}
password_hash
|> cast(attrs, [:password])
|> common_changeset()
end
defp common_changeset(changeset) do
changeset
|> validate_required([:password, :user_id])
|> validate_length(:password, min: 6)
|> hash()
......
defmodule MoodleNet.PasswordResetToken do
@moduledoc """