Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2018-05-17 11:32:23 +02:00
parent 2c1abe5e19
commit e14007bac5
45 changed files with 2916 additions and 111 deletions

View File

@@ -4,9 +4,15 @@ defmodule Eventos.Accounts.Account do
"""
use Ecto.Schema
import Ecto.Changeset
alias Eventos.Accounts
alias Eventos.Accounts.{Account, User}
alias Eventos.Groups.{Group, Member, Request}
alias Eventos.Events.Event
alias Eventos.Service.ActivityPub
import Logger
@type t :: %Account{description: String.t, id: integer(), inserted_at: DateTime.t, updated_at: DateTime.t, display_name: String.t, domain: String.t, private_key: String.t, public_key: String.t, suspended: boolean(), url: String.t, username: String.t, organized_events: list(), groups: list(), group_request: list(), user: User.t}
schema "accounts" do
field :description, :string
@@ -15,7 +21,6 @@ defmodule Eventos.Accounts.Account do
field :private_key, :string
field :public_key, :string
field :suspended, :boolean, default: false
field :uri, :string
field :url, :string
field :username, :string
field :avatar_url, :string
@@ -31,15 +36,78 @@ defmodule Eventos.Accounts.Account do
@doc false
def changeset(%Account{} = account, attrs) do
account
|> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :uri, :url, :avatar_url, :banner_url])
|> validate_required([:username, :public_key, :suspended, :uri, :url])
|> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :url])
|> validate_required([:username, :public_key, :suspended, :url])
|> unique_constraint(:username, name: :accounts_username_domain_index)
end
def registration_changeset(%Account{} = account, attrs) do
account
|> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :uri, :url, :avatar_url, :banner_url])
|> validate_required([:username, :public_key, :suspended, :uri, :url])
|> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :url])
|> validate_required([:username, :public_key, :suspended, :url])
|> unique_constraint(:username)
end
@email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
def remote_account_creation(params) do
changes =
%Account{}
|> cast(params, [:description, :display_name, :url, :username, :public_key])
|> validate_required([:url, :username, :public_key])
|> unique_constraint(:username)
|> validate_format(:username, @email_regex)
|> validate_length(:description, max: 5000)
|> validate_length(:display_name, max: 100)
|> put_change(:local, false)
Logger.debug("Remote account creation")
Logger.debug(inspect changes)
changes
# if changes.valid? do
# case changes.changes[:info]["source_data"] do
# %{"followers" => followers} ->
# changes
# |> put_change(:follower_address, followers)
#
# _ ->
# followers = User.ap_followers(%User{nickname: changes.changes[:nickname]})
#
# changes
# |> put_change(:follower_address, followers)
# end
# else
# changes
# end
end
def get_or_fetch_by_url(url) do
if user = Accounts.get_account_by_url(url) do
user
else
case ActivityPub.make_account_from_url(url) do
{:ok, user} ->
user
_ -> {:error, "Could not fetch by AP id"}
end
end
end
@spec get_public_key_for_url(Account.t) :: {:ok, String.t}
def get_public_key_for_url(url) do
with %Account{} = account <- get_or_fetch_by_url(url) do
get_public_key_for_account(account)
else
_ -> :error
end
end
@spec get_public_key_for_account(Account.t) :: {:ok, String.t}
def get_public_key_for_account(%Account{} = account) do
{:ok, account.public_key}
end
@spec get_private_key_for_account(Account.t) :: {:ok, String.t}
def get_private_key_for_account(%Account{} = account) do
account.private_key
end
end

View File

@@ -8,6 +8,9 @@ defmodule Eventos.Accounts do
alias Eventos.Repo
alias Eventos.Accounts.Account
alias Eventos.Accounts
alias Eventos.Service.ActivityPub
@doc """
Returns the list of accounts.
@@ -110,6 +113,20 @@ defmodule Eventos.Accounts do
Account.changeset(account, %{})
end
@doc """
Returns a text representation of a local account like user@domain.tld
"""
def account_to_local_username_and_domain(account) do
"#{account.username}@#{Application.get_env(:my, EventosWeb.Endpoint)[:url][:host]}"
end
@doc """
Returns a webfinger representation of an account
"""
def account_to_webfinger_s(account) do
"acct:#{account_to_local_username_and_domain(account)}"
end
alias Eventos.Accounts.User
@doc """
@@ -130,6 +147,34 @@ defmodule Eventos.Accounts do
Repo.preload(users, :account)
end
defp blank?(""), do: nil
defp blank?(n), do: n
def insert_or_update_account(data) do
data =
data
|> Map.put(:name, blank?(data[:display_name]) || data[:username])
cs = Account.remote_account_creation(data)
Repo.insert(cs, on_conflict: [set: [public_key: data.public_key]], conflict_target: [:username, :domain])
end
# def increase_event_count(%Account{} = account) do
# event_count = (account.info["event_count"] || 0) + 1
# new_info = Map.put(account.info, "note_count", note_count)
#
# cs = info_changeset(account, %{info: new_info})
#
# update_and_set_cache(cs)
# end
def count_users() do
Repo.one(
from u in User,
select: count(u.id)
)
end
@doc """
Gets a single user.
@@ -151,6 +196,29 @@ defmodule Eventos.Accounts do
Repo.preload(user, :account)
end
def get_account_by_url(url) do
Repo.get_by(Account, url: url)
end
def get_account_by_username(username) do
Repo.get_by!(Account, username: username)
end
def get_or_fetch_by_url(url) do
if account = get_account_by_url(url) do
account
else
ap_try = ActivityPub.make_account_from_url(url)
case ap_try do
{:ok, account} ->
account
_ -> {:error, "Could not fetch by AP id"}
end
end
end
@doc """
Get an user by email
"""
@@ -191,18 +259,17 @@ defmodule Eventos.Accounts do
Register user
"""
def register(%{email: email, password: password, username: username}) do
{:ok, {privkey, pubkey}} = RsaEx.generate_keypair("4096")
#{:ok, {privkey, pubkey}} = RsaEx.generate_keypair("4096")
{:ok, rsa_priv_key} = ExPublicKey.generate_key()
{:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key)
avatar = gravatar(email)
account = Eventos.Accounts.Account.registration_changeset(%Eventos.Accounts.Account{}, %{
username: username,
domain: nil,
private_key: privkey,
public_key: pubkey,
uri: "h",
url: "h",
avatar_url: avatar,
private_key: rsa_priv_key |> ExPublicKey.pem_encode(),
public_key: rsa_pub_key |> ExPublicKey.pem_encode(),
url: EventosWeb.Endpoint.url() <> "/@" <> username,
})
user = Eventos.Accounts.User.registration_changeset(%Eventos.Accounts.User{}, %{

7
lib/eventos/activity.ex Normal file
View File

@@ -0,0 +1,7 @@
defmodule Eventos.Activity do
@moduledoc """
Represents an activity
"""
defstruct [:id, :data, :local, :actor, :recipients, :notifications]
end

View File

@@ -17,7 +17,8 @@ defmodule Eventos.Application do
supervisor(EventosWeb.Endpoint, []),
# Start your own worker by calling: Eventos.Worker.start_link(arg1, arg2, arg3)
# worker(Eventos.Worker, [arg1, arg2, arg3]),
worker(Guardian.DB.Token.SweeperServer, [])
worker(Guardian.DB.Token.SweeperServer, []),
worker(Eventos.Service.Federator, []),
]
# See https://hexdocs.pm/elixir/Supervisor.html

View File

@@ -0,0 +1,27 @@
defmodule Eventos.Events.Comment do
use Ecto.Schema
import Ecto.Changeset
alias Eventos.Events.Event
alias Eventos.Accounts.Account
alias Eventos.Accounts.Comment
schema "comments" do
field :text, :string
field :url, :string
field :local, :boolean, default: true
belongs_to :account, Account, [foreign_key: :account_id]
belongs_to :event, Event, [foreign_key: :event_id]
belongs_to :in_reply_to_comment, Comment, [foreign_key: :in_reply_to_comment_id]
belongs_to :origin_comment, Comment, [foreign_key: :origin_comment_id]
timestamps()
end
@doc false
def changeset(comment, attrs) do
comment
|> cast(attrs, [:url, :text, :account_id, :event_id, :in_reply_to_comment_id])
|> validate_required([:url, :text, :account_id])
end
end

View File

@@ -39,6 +39,8 @@ defmodule Eventos.Events.Event do
alias Eventos.Addresses.Address
schema "events" do
field :url, :string
field :local, :boolean, default: true
field :begins_on, Timex.Ecto.DateTimeWithTimezone
field :description, :string
field :ends_on, Timex.Ecto.DateTimeWithTimezone
@@ -60,13 +62,13 @@ defmodule Eventos.Events.Event do
has_many :sessions, Session
belongs_to :address, Address
timestamps()
timestamps(type: :utc_datetime)
end
@doc false
def changeset(%Event{} = event, attrs) do
event
|> cast(attrs, [:title, :description, :begins_on, :ends_on, :organizer_account_id, :organizer_group_id, :category_id, :state, :status, :public, :thumbnail, :large_image, :publish_at])
|> cast(attrs, [:title, :description, :url, :begins_on, :ends_on, :organizer_account_id, :organizer_group_id, :category_id, :state, :status, :public, :thumbnail, :large_image, :publish_at])
|> cast_assoc(:tags)
|> cast_assoc(:address)
|> validate_required([:title, :description, :begins_on, :ends_on, :organizer_account_id, :category_id])

View File

@@ -7,6 +7,7 @@ defmodule Eventos.Events do
alias Eventos.Repo
alias Eventos.Events.Event
alias Eventos.Events.Comment
alias Eventos.Accounts.Account
@doc """
@@ -22,6 +23,36 @@ defmodule Eventos.Events do
Repo.all(Event)
end
def get_events_for_account(%Account{id: account_id} = _account, page \\ 1, limit \\ 10) do
start = (page - 1) * limit
query = from e in Event,
where: e.organizer_account_id == ^account_id,
limit: ^limit,
order_by: [desc: :id],
offset: ^start,
preload: [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address]
events = Repo.all(query)
count_events = Repo.one(from e in Event, select: count(e.id))
{:ok, events, count_events}
end
def count_local_events do
Repo.one(
from e in Event,
select: count(e.id),
where: e.local == ^true
)
end
def count_local_comments do
Repo.one(
from c in Comment,
select: count(c.id),
where: c.local == ^true
)
end
@doc """
Gets a single event.
@@ -38,6 +69,13 @@ defmodule Eventos.Events do
"""
def get_event!(id), do: Repo.get!(Event, id)
@doc """
Gets an event by it's URL
"""
def get_event_by_url!(url) do
Repo.get_by(Event, url: url)
end
@doc """
Gets a single event, with all associations loaded.
"""
@@ -46,6 +84,25 @@ defmodule Eventos.Events do
Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address])
end
@doc """
Gets an event by it's URL
"""
def get_event_full_by_url!(url) do
event = Repo.get_by(Event, url: url)
Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address])
end
@spec get_event_full_by_username_and_slug!(String.t, String.t) :: Event.t
def get_event_full_by_username_and_slug!(username, slug) do
event = Repo.one(
from e in Event,
join: a in Account,
on: a.id == e.organizer_account_id and a.username == ^username,
where: e.slug == ^slug
)
Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address])
end
@doc """
Creates a event.
@@ -706,4 +763,100 @@ defmodule Eventos.Events do
def change_track(%Track{} = track) do
Track.changeset(track, %{})
end
alias Eventos.Events.Comment
@doc """
Returns the list of comments.
## Examples
iex> list_comments()
[%Comment{}, ...]
"""
def list_comments do
Repo.all(Comment)
end
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the Comment does not exist.
## Examples
iex> get_comment!(123)
%Comment{}
iex> get_comment!(456)
** (Ecto.NoResultsError)
"""
def get_comment!(id), do: Repo.get!(Comment, id)
@doc """
Creates a comment.
## Examples
iex> create_comment(%{field: value})
{:ok, %Comment{}}
iex> create_comment(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_comment(attrs \\ %{}) do
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a comment.
## Examples
iex> update_comment(comment, %{field: new_value})
{:ok, %Comment{}}
iex> update_comment(comment, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a Comment.
## Examples
iex> delete_comment(comment)
{:ok, %Comment{}}
iex> delete_comment(comment)
{:error, %Ecto.Changeset{}}
"""
def delete_comment(%Comment{} = comment) do
Repo.delete(comment)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking comment changes.
## Examples
iex> change_comment(comment)
%Ecto.Changeset{source: %Comment{}}
"""
def change_comment(%Comment{} = comment) do
Comment.changeset(comment, %{})
end
end

View File

@@ -43,7 +43,6 @@ defmodule Eventos.Groups.Group do
field :suspended, :boolean, default: false
field :title, :string
field :slug, TitleSlug.Type
field :uri, :string
field :url, :string
many_to_many :members, Account, join_through: Member
has_many :organized_events, Event, [foreign_key: :organizer_group_id]
@@ -56,8 +55,8 @@ defmodule Eventos.Groups.Group do
@doc false
def changeset(%Group{} = group, attrs) do
group
|> cast(attrs, [:title, :description, :suspended, :url, :uri, :address_id])
|> validate_required([:title, :description, :suspended, :url, :uri])
|> cast(attrs, [:title, :description, :suspended, :url, :address_id])
|> validate_required([:title, :description, :suspended, :url])
|> TitleSlug.maybe_generate_slug()
|> TitleSlug.unique_constraint()
end

View File

@@ -0,0 +1,106 @@
defmodule EventosWeb.ActivityPubController do
use EventosWeb, :controller
alias Eventos.{Accounts, Accounts.Account, Events, Events.Event}
alias EventosWeb.ActivityPub.{ObjectView, AccountView}
alias Eventos.Service.ActivityPub
alias Eventos.Service.Federator
require Logger
action_fallback(:errors)
def account(conn, %{"username" => username}) do
with %Account{} = account <- Accounts.get_account_by_username(username) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(AccountView.render("account.json", %{account: account}))
end
end
def event(conn, %{"username" => username, "slug" => slug}) do
with %Event{} = event <- Events.get_event_full_by_username_and_slug!(username, slug) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ObjectView.render("event.json", %{event: event}))
end
end
# def following(conn, %{"username" => username, "page" => page}) do
# with %Account{} = account <- Accounts.get_account_by_username(username) do
# {page, _} = Integer.parse(page)
#
# conn
# |> put_resp_header("content-type", "application/activity+json")
# |> json(UserView.render("following.json", %{account: account, page: page}))
# end
# end
#
# def following(conn, %{"nickname" => nickname}) do
# with %User{} = user <- User.get_cached_by_nickname(nickname),
# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do
# conn
# |> put_resp_header("content-type", "application/activity+json")
# |> json(UserView.render("following.json", %{user: user}))
# end
# end
#
# def followers(conn, %{"nickname" => nickname, "page" => page}) do
# with %User{} = user <- User.get_cached_by_nickname(nickname),
# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do
# {page, _} = Integer.parse(page)
#
# conn
# |> put_resp_header("content-type", "application/activity+json")
# |> json(UserView.render("followers.json", %{user: user, page: page}))
# end
# end
#
# def followers(conn, %{"nickname" => nickname}) do
# with %User{} = user <- User.get_cached_by_nickname(nickname),
# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do
# conn
# |> put_resp_header("content-type", "application/activity+json")
# |> json(UserView.render("followers.json", %{user: user}))
# end
# end
def outbox(conn, %{"username" => username, "page" => page}) do
with {page, ""} = Integer.parse(page),
%Account{} = account <- Accounts.get_account_by_username(username) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(AccountView.render("outbox.json", %{account: account, page: page}))
end
end
def outbox(conn, %{"username" => username}) do
outbox(conn, %{"username" => username, "page" => "0"})
end
# TODO: Ensure that this inbox is a recipient of the message
def inbox(%{assigns: %{valid_signature: true}} = conn, params) do
Federator.enqueue(:incoming_ap_doc, params)
json(conn, "ok")
end
def inbox(conn, params) do
headers = Enum.into(conn.req_headers, %{})
if !String.contains?(headers["signature"] || "", params["actor"]) do
Logger.info("Signature not from author, relayed message, fetching from source")
ActivityPub.fetch_event_from_url(params["object"]["id"])
else
Logger.info("Signature error")
Logger.info("Could not validate #{params["actor"]}")
Logger.info(inspect(conn.req_headers))
end
json(conn, "ok")
end
def errors(conn, _e) do
conn
|> put_status(500)
|> json("error")
end
end

View File

@@ -0,0 +1,42 @@
defmodule EventosWeb.CommentController do
use EventosWeb, :controller
alias Eventos.Events
alias Eventos.Events.Comment
action_fallback EventosWeb.FallbackController
def index(conn, _params) do
comments = Events.list_comments()
render(conn, "index.json", comments: comments)
end
def create(conn, %{"comment" => comment_params}) do
with {:ok, %Comment{} = comment} <- Events.create_comment(comment_params) do
conn
|> put_status(:created)
|> put_resp_header("location", comment_path(conn, :show, comment))
|> render("show.json", comment: comment)
end
end
def show(conn, %{"id" => id}) do
comment = Events.get_comment!(id)
render(conn, "show.json", comment: comment)
end
def update(conn, %{"id" => id, "comment" => comment_params}) do
comment = Events.get_comment!(id)
with {:ok, %Comment{} = comment} <- Events.update_comment(comment, comment_params) do
render(conn, "show.json", comment: comment)
end
end
def delete(conn, %{"id" => id}) do
comment = Events.get_comment!(id)
with {:ok, %Comment{}} <- Events.delete_comment(comment) do
send_resp(conn, :no_content, "")
end
end
end

View File

@@ -15,7 +15,6 @@ defmodule EventosWeb.GroupController do
end
def create(conn, %{"group" => group_params}) do
group_params = Map.put(group_params, "uri", "h")
group_params = Map.put(group_params, "url", "h")
with {:ok, %Group{} = group} <- Groups.create_group(group_params) do
conn

View File

@@ -0,0 +1,8 @@
defmodule EventosWeb.InboxesController do
use EventosWeb, :controller
def create(conn) do
end
end

View File

@@ -0,0 +1,67 @@
defmodule EventosWeb.NodeinfoController do
use EventosWeb, :controller
alias EventosWeb
alias Eventos.{Accounts, Events}
@instance Application.get_env(:eventos, :instance)
def schemas(conn, _params) do
response = %{
links: [
%{
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
href: EventosWeb.Endpoint.url() <> "/nodeinfo/2.0.json"
}
]
}
json(conn, response)
end
# Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json
def nodeinfo(conn, %{"version" => "2.0.json"}) do
import Logger
Logger.debug(inspect @instance)
#stats = Stats.get_stats()
response = %{
version: "2.0",
software: %{
name: "eventos",
version: Keyword.get(@instance, :version)
},
protocols: ["activitypub"],
services: %{
inbound: [],
outbound: []
},
openRegistrations: Keyword.get(@instance, :registrations_open),
usage: %{
users: %{
#total: stats.user_count || 0
total: Accounts.count_users()
},
localPosts: Events.count_local_events(),
localComments: Events.count_local_comments(),
#localPosts: stats.status_count || 0
},
metadata: %{
nodeName: Keyword.get(@instance, :name)
}
}
conn
|> put_resp_header(
"content-type",
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8"
)
|> json(response)
end
def nodeinfo(conn, _) do
conn
|> put_status(404)
|> json(%{error: "Nodeinfo schema version not handled"})
end
end

View File

@@ -0,0 +1,11 @@
defmodule EventosWeb.OutboxesController do
use EventosWeb, :controller
def show(conn) do
account = Guardian.Plug.current_resource(conn).account
events = account.events
render(conn, "index.json", events: events)
end
end

View File

@@ -0,0 +1,21 @@
defmodule EventosWeb.WebFingerController do
use EventosWeb, :controller
alias Eventos.Service.WebFinger
def host_meta(conn, _params) do
xml = WebFinger.host_meta()
conn
|> put_resp_content_type("application/xrd+xml")
|> send_resp(200, xml)
end
def webfinger(conn, %{"resource" => resource}) do
with {:ok, response} <- WebFinger.webfinger(resource, "JSON") do
json(conn, response)
else
_e -> send_resp(conn, 404, "Couldn't find user")
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule EventosWeb.HTTPSignaturePlug do
alias Eventos.Service.HTTPSignatures
import Plug.Conn
require Logger
def init(options) do
options
end
def call(%{assigns: %{valid_signature: true}} = conn, _opts) do
conn
end
def call(conn, _opts) do
user = conn.params["actor"]
Logger.debug("Checking sig for #{user}")
with [signature | _] <- get_req_header(conn, "signature") do
cond do
signature && String.contains?(signature, user) ->
conn =
conn
|> put_req_header(
"(request-target)",
String.downcase("#{conn.method}") <> " #{conn.request_path}"
)
assign(conn, :valid_signature, HTTPSignatures.validate_conn(conn))
signature ->
Logger.debug("Signature not from actor")
assign(conn, :valid_signature, false)
true ->
Logger.debug("No signature header!")
conn
end
else
_ ->
Logger.debug("No signature header!")
conn
end
end
end

View File

@@ -8,6 +8,15 @@ defmodule EventosWeb.Router do
plug :accepts, ["json"]
end
pipeline :well_known do
plug :accepts, ["json/application"]
end
pipeline :activity_pub do
plug :accepts, ["activity-json"]
plug(EventosWeb.HTTPSignaturePlug)
end
pipeline :api_auth do
plug :accepts, ["json"]
plug EventosWeb.AuthPipeline
@@ -24,43 +33,73 @@ defmodule EventosWeb.Router do
scope "/api", EventosWeb do
pipe_through :api
post "/users", UserController, :register
post "/login", UserSessionController, :sign_in
resources "/groups", GroupController, only: [:index, :show]
resources "/events", EventController, only: [:index, :show]
get "/events/:id/ics", EventController, :export_to_ics
get "/events/:id/tracks", TrackController, :show_tracks_for_event
get "/events/:id/sessions", SessionController, :show_sessions_for_event
resources "/accounts", AccountController, only: [:index, :show]
resources "/tags", TagController, only: [:index, :show]
resources "/categories", CategoryController, only: [:index, :show]
resources "/sessions", SessionController, only: [:index, :show]
resources "/tracks", TrackController, only: [:index, :show]
resources "/addresses", AddressController, only: [:index, :show]
scope "/v1" do
post "/users", UserController, :register
post "/login", UserSessionController, :sign_in
resources "/groups", GroupController, only: [:index, :show]
resources "/events", EventController, only: [:index, :show]
resources "/comments", CommentController, only: [:show]
get "/events/:id/ics", EventController, :export_to_ics
get "/events/:id/tracks", TrackController, :show_tracks_for_event
get "/events/:id/sessions", SessionController, :show_sessions_for_event
resources "/accounts", AccountController, only: [:index, :show]
resources "/tags", TagController, only: [:index, :show]
resources "/categories", CategoryController, only: [:index, :show]
resources "/sessions", SessionController, only: [:index, :show]
resources "/tracks", TrackController, only: [:index, :show]
resources "/addresses", AddressController, only: [:index, :show]
end
end
# Other scopes may use custom stacks.
scope "/api", EventosWeb do
pipe_through :api_auth
get "/user", UserController, :show_current_account
post "/sign-out", UserSessionController, :sign_out
resources "/users", UserController, except: [:new, :edit, :show]
resources "/accounts", AccountController, except: [:new, :edit]
resources "/events", EventController
post "/events/:id/request", EventRequestController, :create_for_event
resources "/participants", ParticipantController
resources "/requests", EventRequestController
resources "/groups", GroupController, except: [:index, :show]
post "/groups/:id/request", GroupRequestController, :create_for_group
resources "/members", MemberController
resources "/requests", GroupRequestController
resources "/sessions", SessionController, except: [:index, :show]
resources "/tracks", TrackController, except: [:index, :show]
get "/tracks/:id/sessions", SessionController, :show_sessions_for_track
resources "/categories", CategoryController
resources "/tags", TagController
resources "/addresses", AddressController, except: [:index, :show]
scope "/v1" do
get "/user", UserController, :show_current_account
post "/sign-out", UserSessionController, :sign_out
resources "/users", UserController, except: [:new, :edit, :show]
resources "/accounts", AccountController, except: [:new, :edit]
resources "/events", EventController
resources "/comments", CommentController, except: [:new, :edit]
post "/events/:id/request", EventRequestController, :create_for_event
resources "/participant", ParticipantController
resources "/requests", EventRequestController
resources "/groups", GroupController, except: [:index, :show]
post "/groups/:id/request", GroupRequestController, :create_for_group
resources "/members", MemberController
resources "/requests", GroupRequestController
resources "/sessions", SessionController, except: [:index, :show]
resources "/tracks", TrackController, except: [:index, :show]
get "/tracks/:id/sessions", SessionController, :show_sessions_for_track
resources "/categories", CategoryController
resources "/tags", TagController
resources "/addresses", AddressController, except: [:index, :show]
end
end
scope "/.well-known", EventosWeb do
pipe_through :well_known
get "/host-meta", WebFingerController, :host_meta
get "/webfinger", WebFingerController, :webfinger
get "/nodeinfo", NodeinfoController, :schemas
end
scope "/nodeinfo", EventosWeb do
get("/:version", NodeinfoController, :nodeinfo)
end
scope "/", EventosWeb do
pipe_through :activity_pub
get "/@:username", ActivityPubController, :account
get "/@:username/outbox", ActivityPubController, :outbox
get "/@:username/:slug", ActivityPubController, :event
post "/@:username/inbox", ActivityPubController, :inbox
post "/inbox", ActivityPubController, :inbox
end
scope "/", EventosWeb do

View File

@@ -25,7 +25,6 @@ defmodule EventosWeb.AccountView do
description: account.description,
# public_key: account.public_key,
suspended: account.suspended,
uri: account.uri,
url: account.url,
avatar_url: account.avatar_url,
banner_url: account.banner_url,
@@ -40,7 +39,6 @@ defmodule EventosWeb.AccountView do
description: account.description,
# public_key: account.public_key,
suspended: account.suspended,
uri: account.uri,
url: account.url,
avatar_url: account.avatar_url,
banner_url: account.banner_url,

View File

@@ -0,0 +1,117 @@
defmodule EventosWeb.ActivityPub.AccountView do
use EventosWeb, :view
alias EventosWeb.ActivityPub.AccountView
alias EventosWeb.ActivityPub.ObjectView
alias EventosWeb.WebFinger
alias Eventos.Accounts.Account
alias Eventos.Repo
alias Eventos.Service.ActivityPub
alias Eventos.Service.ActivityPub.Transmogrifier
alias Eventos.Service.ActivityPub.Utils
import Ecto.Query
def render("account.json", %{account: account}) do
{:ok, public_key} = Account.get_public_key_for_account(account)
%{
"id" => account.url,
"type" => "Person",
#"following" => "#{account.url}/following",
#"followers" => "#{account.url}/followers",
"inbox" => "#{account.url}/inbox",
"outbox" => "#{account.url}/outbox",
"preferredUsername" => account.username,
"name" => account.display_name,
"summary" => account.description,
"url" => account.url,
#"manuallyApprovesFollowers" => false,
"publicKey" => %{
"id" => "#{account.url}#main-key",
"owner" => account.url,
"publicKeyPem" => public_key
},
"endpoints" => %{
"sharedInbox" => "#{EventosWeb.Endpoint.url()}/inbox"
},
# "icon" => %{
# "type" => "Image",
# "url" => User.avatar_url(account)
# },
# "image" => %{
# "type" => "Image",
# "url" => User.banner_url(account)
# }
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("outbox.json", %{account: account, page: page}) do
{page, no_page} = if page == 0 do
{1, true}
else
{page, false}
end
{activities, total} = ActivityPub.fetch_public_activities_for_account(account, page)
collection =
Enum.map(activities, fn act ->
{:ok, data} = Transmogrifier.prepare_outgoing(act.data)
data
end)
iri = "#{account.url}/outbox"
page = %{
"id" => "#{iri}?page=#{page}",
"type" => "OrderedCollectionPage",
"partOf" => iri,
"totalItems" => total,
"orderedItems" => render_many(activities, AccountView, "activity.json", as: :activity),
"next" => "#{iri}?page=#{page + 1}"
}
if no_page do
%{
"id" => iri,
"type" => "OrderedCollection",
"totalItems" => total,
"first" => page
}
|> Map.merge(Utils.make_json_ld_header())
else
page |> Map.merge(Utils.make_json_ld_header())
end
end
def render("activity.json", %{activity: activity}) do
%{
"id" => activity.data.url <> "/activity",
"type" => "Create",
"actor" => activity.data.organizer_account.url,
"published" => Timex.now(),
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => render_one(activity.data, ObjectView, "event.json", as: :event)
}
end
def collection(collection, iri, page, total \\ nil) do
offset = (page - 1) * 10
items = Enum.slice(collection, offset, 10)
items = Enum.map(items, fn user -> user.ap_id end)
total = total || length(collection)
map = %{
"id" => "#{iri}?page=#{page}",
"type" => "OrderedCollectionPage",
"partOf" => iri,
"totalItems" => total,
"orderedItems" => items
}
if offset < total do
Map.put(map, "next", "#{iri}?page=#{page + 1}")
end
end
end

View File

@@ -0,0 +1,37 @@
defmodule EventosWeb.ActivityPub.ObjectView do
use EventosWeb, :view
alias Eventos.Service.ActivityPub.Transmogrifier
@base %{
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
%{
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"sensitive" => "as:sensitive",
"Hashtag" => "as:Hashtag",
"toot" => "http://joinmastodon.org/ns#",
"Emoji" => "toot:Emoji"
}
]
}
def render("event.json", %{event: event}) do
event = %{
"type" => "Event",
"id" => event.url,
"name" => event.title,
"category" => %{"title" => event.category.title},
"content" => event.description,
"mediaType" => "text/markdown",
"published" => Timex.format!(event.inserted_at, "{ISO:Extended}"),
"updated" => Timex.format!(event.updated_at, "{ISO:Extended}"),
}
Map.merge(event, @base)
end
def render("category.json", %{category: category}) do
category
end
end

View File

@@ -0,0 +1,18 @@
defmodule EventosWeb.CommentView do
use EventosWeb, :view
alias EventosWeb.CommentView
def render("index.json", %{comments: comments}) do
%{data: render_many(comments, CommentView, "comment.json")}
end
def render("show.json", %{comment: comment}) do
%{data: render_one(comment, CommentView, "comment.json")}
end
def render("comment.json", %{comment: comment}) do
%{id: comment.id,
url: comment.url,
text: comment.text}
end
end

View File

@@ -23,7 +23,6 @@ defmodule EventosWeb.GroupView do
description: group.description,
suspended: group.suspended,
url: group.url,
uri: group.uri
}
end
@@ -33,7 +32,6 @@ defmodule EventosWeb.GroupView do
description: group.description,
suspended: group.suspended,
url: group.url,
uri: group.uri,
members: render_many(group.members, AccountView, "acccount_basic.json"),
events: render_many(group.organized_events, EventView, "event_simple.json")
}

View File

@@ -0,0 +1,276 @@
defmodule Eventos.Service.ActivityPub do
alias Eventos.Events
alias Eventos.Events.Event
alias Eventos.Service.ActivityPub.Transmogrifier
alias Eventos.Service.WebFinger
alias Eventos.Activity
alias Eventos.Accounts
alias Eventos.Accounts.Account
alias Eventos.Service.Federator
import Logger
import Eventos.Service.ActivityPub.Utils
def get_recipients(data) do
(data["to"] || []) ++ (data["cc"] || [])
end
def insert(map, local \\ true) when is_map(map) do
with map <- lazy_put_activity_defaults(map),
:ok <- insert_full_object(map) do
map = Map.put(map, "id", Ecto.UUID.generate())
activity = %Activity{
data: map,
local: local,
actor: map["actor"],
recipients: get_recipients(map)
}
# Notification.create_notifications(activity)
#stream_out(activity)
{:ok, activity}
else
%Activity{} = activity -> {:ok, activity}
error -> {:error, error}
end
end
def fetch_event_from_url(url) do
if object = Events.get_event_by_url!(url) do
{:ok, object}
else
Logger.info("Fetching #{url} via AP")
with true <- String.starts_with?(url, "http"),
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
[Accept: "application/activity+json"],
follow_redirect: true,
timeout: 10000,
recv_timeout: 20000
),
{:ok, data} <- Jason.decode(body),
nil <- Events.get_event_by_url!(data["id"]),
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"],
"object" => data
},
{:ok, activity} <- Transmogrifier.handle_incoming(params) do
{:ok, Events.get_event_by_url!(activity.data["object"]["id"])}
else
object = %Event{} -> {:ok, object}
e -> e
end
end
end
def create(%{to: to, actor: actor, context: context, object: object} = params) do
additional = params[:additional] || %{}
# only accept false as false value
local = !(params[:local] == false)
published = params[:published]
with create_data <-
make_create_data(
%{to: to, actor: actor, published: published, context: context, object: object},
additional
),
{:ok, activity} <- insert(create_data, local),
:ok <- maybe_federate(activity) do
# {:ok, actor} <- Accounts.increase_event_count(actor) do
{:ok, activity}
end
end
def accept(%{to: to, actor: actor, object: object} = params) do
# only accept false as false value
local = !(params[:local] == false)
with data <- %{"to" => to, "type" => "Accept", "actor" => actor, "object" => object},
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def update(%{to: to, cc: cc, actor: actor, object: object} = params) do
# only accept false as false value
local = !(params[:local] == false)
with data <- %{
"to" => to,
"cc" => cc,
"type" => "Update",
"actor" => actor,
"object" => object
},
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def follow(follower, followed, activity_id \\ nil, local \\ true) do
with data <- make_follow_data(follower, followed, activity_id),
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
def delete(%Event{url: url, organizer_account: account} = event, local \\ true) do
data = %{
"type" => "Delete",
"actor" => account.url,
"object" => url,
"to" => [account.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
with Events.delete_event(event),
{:ok, activity} <- insert(data, local),
:ok <- maybe_federate(activity)
do
{:ok, activity}
end
end
def create_public_activities(%Account{} = account) do
end
def make_account_from_url(url) do
with {:ok, data} <- fetch_and_prepare_user_from_url(url) do
Accounts.insert_or_update_account(data)
else
e ->
Logger.error("Failed to make account from url")
Logger.error(inspect e)
{:error, e}
end
end
def make_account_from_nickname(nickname) do
with {:ok, %{"url" => url}} when not is_nil(url) <- WebFinger.finger(nickname) do
make_account_from_url(url)
else
_e -> {:error, "No ActivityPub URL found in WebFinger"}
end
end
def publish(actor, activity) do
# followers =
# if actor.follower_address in activity.recipients do
# {:ok, followers} = User.get_followers(actor)
# followers |> Enum.filter(&(!&1.local))
# else
# []
# end
followers = ["http://localhost:3000/users/tcit/inbox"]
remote_inboxes = followers
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
json = Jason.encode!(data)
Enum.each(remote_inboxes, fn inbox ->
Federator.enqueue(:publish_single_ap, %{
inbox: inbox,
json: json,
actor: actor,
id: activity.data["id"]
})
end)
end
def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do
Logger.info("Federating #{id} to #{inbox}")
host = URI.parse(inbox).host
signature =
Eventos.Service.HTTPSignatures.sign(actor, %{host: host, "content-length": byte_size(json)})
Logger.debug("signature")
Logger.debug(inspect signature)
{:ok, response} = HTTPoison.post(
inbox,
json,
[{"Content-Type", "application/activity+json"}, {"signature", signature}],
hackney: [pool: :default]
)
Logger.debug(inspect response)
end
def fetch_and_prepare_user_from_url(url) do
Logger.debug("Fetching and preparing user from url")
with {:ok, %{status_code: 200, body: body}} <-
HTTPoison.get(url, [Accept: "application/activity+json"], [follow_redirect: true]),
{:ok, data} <- Jason.decode(body) do
user_data_from_user_object(data)
else
e -> Logger.error("Could not decode user at fetch #{url}, #{inspect(e)}")
end
end
def user_data_from_user_object(data) do
avatar =
data["icon"]["url"] &&
%{
"type" => "Image",
"url" => [%{"href" => data["icon"]["url"]}]
}
banner =
data["image"]["url"] &&
%{
"type" => "Image",
"url" => [%{"href" => data["image"]["url"]}]
}
user_data = %{
url: data["id"],
info: %{
"ap_enabled" => true,
"source_data" => data,
"banner" => banner
},
avatar: avatar,
username: "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}",
display_name: data["name"],
follower_address: data["followers"],
description: data["summary"],
public_key: data["publicKey"]["publicKeyPem"],
}
{:ok, user_data}
end
@spec fetch_public_activities_for_account(Account.t, integer(), integer()) :: list()
def fetch_public_activities_for_account(%Account{} = account, page \\ 10, limit \\ 1) do
{:ok, events, total} = Events.get_events_for_account(account, page, limit)
activities = Enum.map(events, fn event ->
{:ok, activity} = event_to_activity(event)
activity
end)
{activities, total}
end
defp event_to_activity(%Event{} = event) do
activity = %Activity{
data: event,
local: true,
actor: event.organizer_account.url,
recipients: ["https://www.w3.org/ns/activitystreams#Public"]
}
# Notification.create_notifications(activity)
#stream_out(activity)
{:ok, activity}
end
end

View File

@@ -0,0 +1,482 @@
defmodule Eventos.Service.ActivityPub.Transmogrifier do
@moduledoc """
A module to handle coding from internal to wire ActivityPub and back.
"""
alias Eventos.Accounts.Account
alias Eventos.Accounts
alias Eventos.Events.Event
alias Eventos.Service.ActivityPub
import Ecto.Query
require Logger
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
def fix_object(object) do
object
|> Map.put("actor", object["attributedTo"])
|> fix_attachments
|> fix_context
#|> fix_in_reply_to
|> fix_emoji
|> fix_tag
end
# def fix_in_reply_to(%{"inReplyTo" => in_reply_to_id} = object)
# when not is_nil(in_reply_to_id) do
# case ActivityPub.fetch_object_from_id(in_reply_to_id) do
# {:ok, replied_object} ->
# activity = Activity.get_create_activity_by_object_ap_id(replied_object.data["id"])
#
# object
# |> Map.put("inReplyTo", replied_object.data["id"])
# |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
# |> Map.put("inReplyToStatusId", activity.id)
# |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
# |> Map.put("context", replied_object.data["context"] || object["conversation"])
#
# e ->
# Logger.error("Couldn't fetch #{object["inReplyTo"]} #{inspect(e)}")
# object
# end
# end
def fix_in_reply_to(object), do: object
def fix_context(object) do
object
|> Map.put("context", object["conversation"])
end
def fix_attachments(object) do
attachments =
(object["attachment"] || [])
|> Enum.map(fn data ->
url = [%{"type" => "Link", "mediaType" => data["mediaType"], "href" => data["url"]}]
Map.put(data, "url", url)
end)
object
|> Map.put("attachment", attachments)
end
def fix_emoji(object) do
tags = object["tag"] || []
emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
emoji =
emoji
|> Enum.reduce(%{}, fn data, mapping ->
name = data["name"]
if String.starts_with?(name, ":") do
name = name |> String.slice(1..-2)
end
mapping |> Map.put(name, data["icon"]["url"])
end)
# we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
emoji = Map.merge(object["emoji"] || %{}, emoji)
object
|> Map.put("emoji", emoji)
end
def fix_tag(object) do
tags =
(object["tag"] || [])
|> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
|> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
combined = (object["tag"] || []) ++ tags
object
|> Map.put("tag", combined)
end
# TODO: validate those with a Ecto scheme
# - tags
# - emoji
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do
with %Account{} = account <- Account.get_or_fetch_by_url(data["actor"]) do
object = fix_object(data["object"])
params = %{
to: data["to"],
object: object,
actor: account,
context: object["conversation"],
local: false,
published: data["published"],
additional:
Map.take(data, [
"cc",
"id"
])
}
ActivityPub.create(params)
end
end
def handle_incoming(
%{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
) do
with %Account{} = followed <- Accounts.get_account_by_url(followed),
%Account{} = follower <- Accounts.get_or_fetch_by_url(follower),
{:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
ActivityPub.accept(%{to: [follower.url], actor: followed.url, object: data, local: true})
#Accounts.follow(follower, followed)
{:ok, activity}
else
_e -> :error
end
end
#
# def handle_incoming(
# %{"type" => "Like", "object" => object_id, "actor" => actor, "id" => id} = data
# ) do
# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
# {:ok, object} <-
# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
# {:ok, activity, object} <- ActivityPub.like(actor, object, id, false) do
# {:ok, activity}
# else
# _e -> :error
# end
# end
#
# def handle_incoming(
# %{"type" => "Announce", "object" => object_id, "actor" => actor, "id" => id} = data
# ) do
# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
# {:ok, object} <-
# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
# {:ok, activity, object} <- ActivityPub.announce(actor, object, id, false) do
# {:ok, activity}
# else
# _e -> :error
# end
# end
#
# def handle_incoming(
# %{"type" => "Update", "object" => %{"type" => "Person"} = object, "actor" => actor_id} =
# data
# ) do
# with %User{ap_id: ^actor_id} = actor <- User.get_by_ap_id(object["id"]) do
# {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
#
# banner = new_user_data[:info]["banner"]
#
# update_data =
# new_user_data
# |> Map.take([:name, :bio, :avatar])
# |> Map.put(:info, Map.merge(actor.info, %{"banner" => banner}))
#
# actor
# |> User.upgrade_changeset(update_data)
# |> User.update_and_set_cache()
#
# ActivityPub.update(%{
# local: false,
# to: data["to"] || [],
# cc: data["cc"] || [],
# object: object,
# actor: actor_id
# })
# else
# e ->
# Logger.error(e)
# :error
# end
# end
#
# # TODO: Make secure.
# def handle_incoming(
# %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data
# ) do
# object_id =
# case object_id do
# %{"id" => id} -> id
# id -> id
# end
#
# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
# {:ok, object} <-
# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
# {:ok, activity} <- ActivityPub.delete(object, false) do
# {:ok, activity}
# else
# e -> :error
# end
# end
#
# # TODO
# # Accept
# # Undo
#
# def handle_incoming(_), do: :error
#
# def get_obj_helper(id) do
# if object = Object.get_by_ap_id(id), do: {:ok, object}, else: nil
# end
#
# def set_reply_to_uri(%{"inReplyTo" => inReplyTo} = object) do
# with false <- String.starts_with?(inReplyTo, "http"),
# {:ok, %{data: replied_to_object}} <- get_obj_helper(inReplyTo) do
# Map.put(object, "inReplyTo", replied_to_object["external_url"] || inReplyTo)
# else
# _e -> object
# end
# end
#
# def set_reply_to_uri(obj), do: obj
#
# # Prepares the object of an outgoing create activity.
# def prepare_object(object) do
# object
# |> set_sensitive
# |> add_hashtags
# |> add_mention_tags
# |> add_emoji_tags
# |> add_attributed_to
# |> prepare_attachments
# |> set_conversation
# |> set_reply_to_uri
# end
@doc
"""
internal -> Mastodon
"""
def prepare_outgoing(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do
object =
object
#|> prepare_object
data =
data
|> Map.put("object", object)
|> Map.put("@context", "https://www.w3.org/ns/activitystreams")
{:ok, data}
end
def prepare_outgoing(%{"type" => type} = data) do
data =
data
#|> maybe_fix_object_url
|> Map.put("@context", "https://www.w3.org/ns/activitystreams")
{:ok, data}
end
def prepare_outgoing(%Event{} = event) do
event =
event
|> Map.from_struct
|> Map.drop([:"__meta__"])
|> Map.put(:"@context", "https://www.w3.org/ns/activitystreams")
{:ok, event}
end
#
# def maybe_fix_object_url(data) do
# if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
# case ActivityPub.fetch_object_from_id(data["object"]) do
# {:ok, relative_object} ->
# if relative_object.data["external_url"] do
# data =
# data
# |> Map.put("object", relative_object.data["external_url"])
# else
# data
# end
#
# e ->
# Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
# data
# end
# else
# data
# end
# end
#
# def add_hashtags(object) do
# tags =
# (object["tag"] || [])
# |> Enum.map(fn tag ->
# %{
# "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
# "name" => "##{tag}",
# "type" => "Hashtag"
# }
# end)
#
# object
# |> Map.put("tag", tags)
# end
#
# def add_mention_tags(object) do
# recipients = object["to"] ++ (object["cc"] || [])
#
# mentions =
# recipients
# |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
# |> Enum.filter(& &1)
# |> Enum.map(fn user ->
# %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
# end)
#
# tags = object["tag"] || []
#
# object
# |> Map.put("tag", tags ++ mentions)
# end
#
# # TODO: we should probably send mtime instead of unix epoch time for updated
# def add_emoji_tags(object) do
# tags = object["tag"] || []
# emoji = object["emoji"] || []
#
# out =
# emoji
# |> Enum.map(fn {name, url} ->
# %{
# "icon" => %{"url" => url, "type" => "Image"},
# "name" => ":" <> name <> ":",
# "type" => "Emoji",
# "updated" => "1970-01-01T00:00:00Z",
# "id" => url
# }
# end)
#
# object
# |> Map.put("tag", tags ++ out)
# end
#
# def set_conversation(object) do
# Map.put(object, "conversation", object["context"])
# end
#
# def set_sensitive(object) do
# tags = object["tag"] || []
# Map.put(object, "sensitive", "nsfw" in tags)
# end
#
# def add_attributed_to(object) do
# attributedTo = object["attributedTo"] || object["actor"]
#
# object
# |> Map.put("attributedTo", attributedTo)
# end
#
# def prepare_attachments(object) do
# attachments =
# (object["attachment"] || [])
# |> Enum.map(fn data ->
# [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
# %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
# end)
#
# object
# |> Map.put("attachment", attachments)
# end
#
# defp user_upgrade_task(user) do
# old_follower_address = User.ap_followers(user)
#
# q =
# from(
# u in User,
# where: ^old_follower_address in u.following,
# update: [
# set: [
# following:
# fragment(
# "array_replace(?,?,?)",
# u.following,
# ^old_follower_address,
# ^user.follower_address
# )
# ]
# ]
# )
#
# Repo.update_all(q, [])
#
# maybe_retire_websub(user.ap_id)
#
# # Only do this for recent activties, don't go through the whole db.
# # Only look at the last 1000 activities.
# since = (Repo.aggregate(Activity, :max, :id) || 0) - 1_000
#
# q =
# from(
# a in Activity,
# where: ^old_follower_address in a.recipients,
# where: a.id > ^since,
# update: [
# set: [
# recipients:
# fragment(
# "array_replace(?,?,?)",
# a.recipients,
# ^old_follower_address,
# ^user.follower_address
# )
# ]
# ]
# )
#
# Repo.update_all(q, [])
# end
#
# def upgrade_user_from_ap_id(ap_id, async \\ true) do
# with %User{local: false} = user <- User.get_by_ap_id(ap_id),
# {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id) do
# data =
# data
# |> Map.put(:info, Map.merge(user.info, data[:info]))
#
# already_ap = User.ap_enabled?(user)
#
# {:ok, user} =
# User.upgrade_changeset(user, data)
# |> Repo.update()
#
# if !already_ap do
# # This could potentially take a long time, do it in the background
# if async do
# Task.start(fn ->
# user_upgrade_task(user)
# end)
# else
# user_upgrade_task(user)
# end
# end
#
# {:ok, user}
# else
# e -> e
# end
# end
#
# def maybe_retire_websub(ap_id) do
# # some sanity checks
# if is_binary(ap_id) && String.length(ap_id) > 8 do
# q =
# from(
# ws in Pleroma.Web.Websub.WebsubClientSubscription,
# where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
# )
#
# Repo.delete_all(q)
# end
# end
end

View File

@@ -0,0 +1,304 @@
defmodule Eventos.Service.ActivityPub.Utils do
alias Eventos.Repo
alias Eventos.Accounts
alias Eventos.Accounts.Account
alias Eventos.Events.Event
alias Eventos.Events
alias Eventos.Activity
alias EventosWeb
alias EventosWeb.Router.Helpers
alias EventosWeb.Endpoint
alias Ecto.{Changeset, UUID}
import Ecto.Query
def make_json_ld_header do
%{
"@context" => [
"https://www.w3.org/ns/activitystreams",
%{
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"sensitive" => "as:sensitive",
"Hashtag" => "as:Hashtag",
"toot" => "http://joinmastodon.org/ns#",
"Emoji" => "toot:Emoji"
}
]
}
end
def make_date do
DateTime.utc_now() |> DateTime.to_iso8601()
end
def generate_activity_id do
generate_id("activities")
end
def generate_context_id do
generate_id("contexts")
end
# def generate_object_id do
# Helpers.o_status_url(Endpoint, :object, UUID.generate())
# end
def generate_id(type) do
"#{EventosWeb.Endpoint.url()}/#{type}/#{UUID.generate()}"
end
# def create_context(context) do
# context = context || generate_id("contexts")
# changeset = Object.context_mapping(context)
#
# case Repo.insert(changeset) do
# {:ok, object} ->
# object
#
# # This should be solved by an upsert, but it seems ecto
# # has problems accessing the constraint inside the jsonb.
# {:error, _} ->
# Events.get_cached_by_url(context)
# end
# end
@doc """
Enqueues an activity for federation if it's local
"""
def maybe_federate(%Activity{local: true} = activity) do
priority =
case activity.data["type"] do
"Delete" -> 10
"Create" -> 1
_ -> 5
end
Eventos.Service.Federator.enqueue(:publish, activity, priority)
:ok
end
def maybe_federate(_), do: :ok
@doc """
Adds an id and a published data if they aren't there,
also adds it to an included object
"""
def lazy_put_activity_defaults(map) do
# %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
#
# map =
# map
# |> Map.put_new_lazy("id", &generate_activity_id/0)
# |> Map.put_new_lazy("published", &make_date/0)
# |> Map.put_new("context", context)
# |> Map.put_new("context_id", context_id)
if is_map(map["object"]) do
object = lazy_put_object_defaults(map["object"], map)
%{map | "object" => object}
else
map
end
end
@doc """
Adds an id and published date if they aren't there.
"""
def lazy_put_object_defaults(map, activity \\ %{}) do
map
#|> Map.put_new_lazy("id", &generate_object_id/0)
|> Map.put_new_lazy("published", &make_date/0)
|> Map.put_new("context", activity["context"])
|> Map.put_new("context_id", activity["context_id"])
end
@doc """
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(%{"object" => %{"type" => type} = object_data})
when is_map(object_data) and type == "Event" do
with {:ok, _} <- Events.create_event(object_data) do
:ok
end
end
@doc """
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(%{"object" => %{"type" => type} = object_data})
when is_map(object_data) and type == "Note" do
account = Accounts.get_account_by_url(object_data["actor"])
data = %{"text" => object_data["content"], "url" => object_data["url"], "account_id" => account.id, "in_reply_to_comment_id" => object_data["inReplyTo"]}
with {:ok, _} <- Events.create_comment(data) do
:ok
end
end
def insert_full_object(_), do: :ok
# def update_object_in_activities(%{data: %{"id" => id}} = object) do
# # TODO
# # Update activities that already had this. Could be done in a seperate process.
# # Alternatively, just don't do this and fetch the current object each time. Most
# # could probably be taken from cache.
# relevant_activities = Activity.all_by_object_url(id)
#
# Enum.map(relevant_activities, fn activity ->
# new_activity_data = activity.data |> Map.put("object", object.data)
# changeset = Changeset.change(activity, data: new_activity_data)
# Repo.update(changeset)
# end)
# end
#### Like-related helpers
# @doc """
# Returns an existing like if a user already liked an object
# """
# def get_existing_like(actor, %{data: %{"id" => id}}) do
# query =
# from(
# activity in Activity,
# where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
# # this is to use the index
# where:
# fragment(
# "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
# activity.data,
# activity.data,
# ^id
# ),
# where: fragment("(?)->>'type' = 'Like'", activity.data)
# )
#
# Repo.one(query)
# end
def make_like_data(%Account{url: url} = actor, %{data: %{"id" => id}} = object, activity_id) do
data = %{
"type" => "Like",
"actor" => url,
"object" => id,
"to" => [actor.follower_address, object.data["actor"]],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
"context" => object.data["context"]
}
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
def update_element_in_object(property, element, object) do
with new_data <-
object.data
|> Map.put("#{property}_count", length(element))
|> Map.put("#{property}s", element),
changeset <- Changeset.change(object, data: new_data),
{:ok, object} <- Repo.update(changeset) do
{:ok, object}
end
end
# def update_likes_in_object(likes, object) do
# update_element_in_object("like", likes, object)
# end
#
# def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
# with likes <- [actor | object.data["likes"] || []] |> Enum.uniq() do
# update_likes_in_object(likes, object)
# end
# end
#
# def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
# with likes <- (object.data["likes"] || []) |> List.delete(actor) do
# update_likes_in_object(likes, object)
# end
# end
#### Follow-related helpers
@doc """
Makes a follow activity data for the given follower and followed
"""
def make_follow_data(%Account{url: follower_id}, %Account{url: followed_id}, activity_id) do
data = %{
"type" => "Follow",
"actor" => follower_id,
"to" => [followed_id],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => followed_id
}
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
# def fetch_latest_follow(%Account{url: follower_id}, %Account{url: followed_id}) do
# query =
# from(
# activity in Activity,
# where:
# fragment(
# "? @> ?",
# activity.data,
# ^%{type: "Follow", actor: follower_id, object: followed_id}
# ),
# order_by: [desc: :id],
# limit: 1
# )
#
# Repo.one(query)
# end
#### Announce-related helpers
@doc """
Make announce activity data for the given actor and object
"""
def make_announce_data(
%Account{url: url} = user,
%Event{id: id} = object,
activity_id
) do
data = %{
"type" => "Announce",
"actor" => url,
"object" => id,
"to" => [user.follower_address, object.data["actor"]],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
"context" => object.data["context"]
}
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
def add_announce_to_object(%Activity{data: %{"actor" => actor}}, object) do
with announcements <- [actor | object.data["announcements"] || []] |> Enum.uniq() do
update_element_in_object("announcement", announcements, object)
end
end
#### Unfollow-related helpers
def make_unfollow_data(follower, followed, follow_activity) do
%{
"type" => "Undo",
"actor" => follower.url,
"to" => [followed.url],
"object" => follow_activity.data["id"]
}
end
#### Create-related helpers
def make_create_data(params, additional) do
published = params.published || make_date()
%{
"type" => "Create",
"to" => params.to |> Enum.uniq(),
"actor" => params.actor.url,
"object" => params.object,
"published" => published,
"context" => params.context
}
|> Map.merge(additional)
end
end

126
lib/service/federator.ex Normal file
View File

@@ -0,0 +1,126 @@
defmodule Eventos.Service.Federator do
use GenServer
alias Eventos.Accounts
alias Eventos.Activity
alias Eventos.Service.ActivityPub
alias Eventos.Service.ActivityPub.Transmogrifier
require Logger
@max_jobs 20
def init(args) do
{:ok, args}
end
def start_link do
spawn(fn ->
# 1 minute
Process.sleep(1000 * 60 * 1)
end)
GenServer.start_link(
__MODULE__,
%{
in: {:sets.new(), []},
out: {:sets.new(), []}
},
name: __MODULE__
)
end
def handle(:publish, activity) do
Logger.debug(inspect activity)
Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end)
with actor when not is_nil(actor) <- Accounts.get_account_by_url(activity.data["actor"]) do
Logger.info(fn -> "Sending #{activity.data["id"]} out via AP" end)
ActivityPub.publish(actor, activity)
end
end
def handle(:incoming_ap_doc, params) do
Logger.info("Handling incoming AP activity")
Logger.debug(inspect params)
with {:ok, _activity} <- Transmogrifier.handle_incoming(params) do
else
%Activity{} ->
Logger.info("Already had #{params["id"]}")
_e ->
# Just drop those for now
Logger.info("Unhandled activity")
Logger.info(Poison.encode!(params, pretty: 2))
end
end
def handle(:publish_single_ap, params) do
ActivityPub.publish_one(params)
end
def handle(type, _) do
Logger.debug(fn -> "Unknown task: #{type}" end)
{:error, "Don't know what to do with this"}
end
def enqueue(type, payload, priority \\ 1) do
Logger.debug("enqueue")
if Mix.env() == :test do
handle(type, payload)
else
GenServer.cast(__MODULE__, {:enqueue, type, payload, priority})
end
end
def maybe_start_job(running_jobs, queue) do
if :sets.size(running_jobs) < @max_jobs && queue != [] do
{{type, payload}, queue} = queue_pop(queue)
{:ok, pid} = Task.start(fn -> handle(type, payload) end)
mref = Process.monitor(pid)
{:sets.add_element(mref, running_jobs), queue}
else
{running_jobs, queue}
end
end
def handle_cast({:enqueue, type, payload, _priority}, state)
when type in [:incoming_doc, :incoming_ap_doc] do
%{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state
i_queue = enqueue_sorted(i_queue, {type, payload}, 1)
{i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue)
{:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}}
end
def handle_cast({:enqueue, type, payload, _priority}, state) do
%{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state
o_queue = enqueue_sorted(o_queue, {type, payload}, 1)
{o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue)
{:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}}
end
def handle_cast(m, state) do
IO.inspect("Unknown: #{inspect(m)}, #{inspect(state)}")
{:noreply, state}
end
def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
%{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state
i_running_jobs = :sets.del_element(ref, i_running_jobs)
o_running_jobs = :sets.del_element(ref, o_running_jobs)
{i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue)
{o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue)
{:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}}
end
def enqueue_sorted(queue, element, priority) do
[%{item: element, priority: priority} | queue]
|> Enum.sort_by(fn %{priority: priority} -> priority end)
end
def queue_pop([%{item: element} | queue]) do
{element, queue}
end
end

View File

@@ -0,0 +1,112 @@
# https://tools.ietf.org/html/draft-cavage-http-signatures-08
defmodule Eventos.Service.HTTPSignatures do
alias Eventos.Accounts.Account
alias Eventos.Service.ActivityPub
require Logger
def split_signature(sig) do
default = %{"headers" => "date"}
sig =
sig
|> String.trim()
|> String.split(",")
|> Enum.reduce(default, fn part, acc ->
[key | rest] = String.split(part, "=")
value = Enum.join(rest, "=")
Map.put(acc, key, String.trim(value, "\""))
end)
Map.put(sig, "headers", String.split(sig["headers"], ~r/\s/))
end
def validate(headers, signature, public_key) do
sigstring = build_signing_string(headers, signature["headers"])
Logger.debug("Signature: #{signature["signature"]}")
Logger.debug("Sigstring: #{sigstring}")
{:ok, sig} = Base.decode64(signature["signature"])
Logger.debug(inspect sig)
Logger.debug(inspect public_key)
case ExPublicKey.verify(sigstring, sig, public_key) do
{:ok, sig_valid} ->
sig_valid
{:error, err} ->
Logger.error(err)
false
end
end
def validate_conn(conn) do
# TODO: How to get the right key and see if it is actually valid for that request.
# For now, fetch the key for the actor.
with actor_id <- conn.params["actor"],
{:ok, public_key} <- Account.get_public_key_for_url(actor_id) do
case HTTPSign.verify(conn, public_key) do
{:ok, conn} ->
true
_ ->
Logger.debug("Could not validate, re-fetching user and trying one more time")
# Fetch user anew and try one more time
with actor_id <- conn.params["actor"],
{:ok, _user} <- ActivityPub.make_account_from_url(actor_id),
{:ok, public_key} <- Account.get_public_key_for_url(actor_id) do
case HTTPSign.verify(conn, public_key) do
{:ok, conn} ->
true
{:error, :forbidden} ->
false
end
end
end
else
e ->
Logger.debug("Could not public key!")
Logger.debug(inspect e)
false
end
end
# def validate_conn(conn, public_key) do
# headers = Enum.into(conn.req_headers, %{})
# signature = split_signature(headers["signature"])
# validate(headers, signature, public_key)
# end
def build_signing_string(headers, used_headers) do
used_headers
|> Enum.map(fn header -> "#{header}: #{headers[header]}" end)
|> Enum.join("\n")
end
def sign(account, headers) do
sigstring = build_signing_string(headers, Map.keys(headers))
{:ok, private_key} = Account.get_private_key_for_account(account)
Logger.debug("private_key")
Logger.debug(inspect private_key)
Logger.debug("sigstring")
Logger.debug(inspect sigstring)
{:ok, signature} = HTTPSign.Crypto.sign(:rsa, sigstring, private_key)
Logger.debug(inspect signature)
signature = Base.encode64(signature)
sign = [
keyId: account.url <> "#main-key",
algorithm: "rsa-sha256",
headers: Map.keys(headers) |> Enum.join(" "),
signature: signature
]
|> Enum.map(fn {k, v} -> "#{k}=\"#{v}\"" end)
|> Enum.join(",")
Logger.debug("sign")
Logger.debug(inspect sign)
{:ok, public_key} = Account.get_public_key_for_account(account)
Logger.debug("inspect split signature inside sign")
Logger.debug(inspect split_signature(sign))
Logger.debug(inspect validate(headers, split_signature(sign), public_key))
sign
end
end

View File

@@ -0,0 +1,94 @@
defmodule Eventos.Service.WebFinger do
alias Eventos.Accounts
alias Eventos.Service.XmlBuilder
alias Eventos.Repo
require Jason
require Logger
def host_meta do
base_url = EventosWeb.Endpoint.url()
{
:XRD,
%{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"},
{
:Link,
%{
rel: "lrdd",
type: "application/xrd+xml",
template: "#{base_url}/.well-known/webfinger?resource={uri}"
}
}
}
|> XmlBuilder.to_doc()
end
def webfinger(resource, "JSON") do
host = EventosWeb.Endpoint.host()
regex = ~r/(acct:)?(?<username>\w+)@#{host}/
with %{"username" => username} <- Regex.named_captures(regex, resource) do
user = Accounts.get_account_by_username(username)
{:ok, represent_user(user, "JSON")}
else
_e ->
with user when not is_nil(user) <- Accounts.get_account_by_url(resource) do
{:ok, represent_user(user, "JSON")}
else
_e ->
{:error, "Couldn't find user"}
end
end
end
def represent_user(user, "JSON") do
%{
"subject" => "acct:#{user.username}@#{EventosWeb.Endpoint.host() <> ":4001"}",
"aliases" => [user.url],
"links" => [
%{"rel" => "self", "type" => "application/activity+json", "href" => user.url},
]
}
end
defp webfinger_from_json(doc) do
data =
Enum.reduce(doc["links"], %{"subject" => doc["subject"]}, fn link, data ->
case {link["type"], link["rel"]} do
{"application/activity+json", "self"} ->
Map.put(data, "url", link["href"])
_ ->
Logger.debug("Unhandled type: #{inspect(link["type"])}")
data
end
end)
{:ok, data}
end
def finger(account) do
account = String.trim_leading(account, "@")
domain =
with [_name, domain] <- String.split(account, "@") do
domain
else
_e ->
URI.parse(account).host
end
address = "http://#{domain}/.well-known/webfinger?resource=acct:#{account}"
with response <- HTTPoison.get(address, [Accept: "application/json"],follow_redirect: true),
{:ok, %{status_code: status_code, body: body}} when status_code in 200..299 <- response do
{:ok, doc} = Jason.decode(body)
webfinger_from_json(doc)
else
e ->
Logger.debug(fn -> "Couldn't finger #{account}" end)
Logger.debug(fn -> inspect(e) end)
{:error, e}
end
end
end

View File

@@ -0,0 +1,44 @@
defmodule Eventos.Service.XmlBuilder do
def to_xml({tag, attributes, content}) do
open_tag = make_open_tag(tag, attributes)
content_xml = to_xml(content)
"<#{open_tag}>#{content_xml}</#{tag}>"
end
def to_xml({tag, %{} = attributes}) do
open_tag = make_open_tag(tag, attributes)
"<#{open_tag} />"
end
def to_xml({tag, content}), do: to_xml({tag, %{}, content})
def to_xml(content) when is_binary(content) do
to_string(content)
end
def to_xml(content) when is_list(content) do
for element <- content do
to_xml(element)
end
|> Enum.join()
end
def to_xml(%NaiveDateTime{} = time) do
NaiveDateTime.to_iso8601(time)
end
def to_doc(content), do: ~s(<?xml version="1.0" encoding="UTF-8"?>) <> to_xml(content)
defp make_open_tag(tag, attributes) do
attributes_string =
for {attribute, value} <- attributes do
"#{attribute}=\"#{value}\""
end
|> Enum.join(" ")
[tag, attributes_string] |> Enum.join(" ") |> String.trim()
end
end