Merge branch 'develop' into dtluna/pleroma-feature/unfollow-activity

This commit is contained in:
Roger Braun 2017-05-07 19:28:23 +02:00
commit b403ea4d2b
91 changed files with 7808 additions and 328 deletions

View file

@ -5,6 +5,7 @@ defmodule Pleroma.Activity do
schema "activities" do
field :data, :map
field :local, :boolean, default: true
timestamps()
end
@ -18,4 +19,9 @@ defmodule Pleroma.Activity do
Repo.all(from activity in Activity,
where: fragment("? @> ?", activity.data, ^%{object: %{id: ap_id}}))
end
def get_create_activity_by_object_ap_id(ap_id) do
Repo.one(from activity in Activity,
where: fragment("? @> ?", activity.data, ^%{type: "Create", object: %{id: ap_id}}))
end
end

View file

@ -15,10 +15,11 @@ defmodule Pleroma.Application do
# Start your own worker by calling: Pleroma.Worker.start_link(arg1, arg2, arg3)
# worker(Pleroma.Worker, [arg1, arg2, arg3]),
worker(Cachex, [:user_cache, [
default_ttl: 5000,
default_ttl: 25000,
ttl_interval: 1000,
limit: 500
]])
limit: 2500
]]),
worker(Pleroma.Web.Federator, [])
]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html

View file

@ -13,4 +13,24 @@ defmodule Pleroma.Object do
Repo.one(from object in Object,
where: fragment("? @> ?", object.data, ^%{id: ap_id}))
end
def get_cached_by_ap_id(ap_id) do
if Mix.env == :test do
get_by_ap_id(ap_id)
else
key = "object:#{ap_id}"
Cachex.get!(:user_cache, key, fallback: fn(_) ->
object = get_by_ap_id(ap_id)
if object do
{:commit, object}
else
{:ignore, object}
end
end)
end
end
def context_mapping(context) do
%Object{data: %{"id" => context}}
end
end

View file

@ -1,4 +1,5 @@
defmodule Pleroma.Plugs.AuthenticationPlug do
alias Comeonin.Pbkdf2
import Plug.Conn
def init(options) do
@ -25,12 +26,12 @@ defmodule Pleroma.Plugs.AuthenticationPlug do
end
defp verify(nil, _password, _user_id) do
Comeonin.Pbkdf2.dummy_checkpw
Pbkdf2.dummy_checkpw
:error
end
defp verify(user, password, _user_id) do
if Comeonin.Pbkdf2.checkpw(password, user.password_hash) do
if Pbkdf2.checkpw(password, user.password_hash) do
{:ok, user}
else
:error
@ -42,7 +43,7 @@ defmodule Pleroma.Plugs.AuthenticationPlug do
{:ok, userinfo} <- Base.decode64(header),
[username, password] <- String.split(userinfo, ":")
do
{ :ok, username, password }
{:ok, username, password}
end
end

View file

@ -1,6 +1,8 @@
defmodule Pleroma.Upload do
alias Ecto.UUID
alias Pleroma.Web
def store(%Plug.Upload{} = file) do
uuid = Ecto.UUID.generate
uuid = UUID.generate
upload_folder = Path.join(upload_path(), uuid)
File.mkdir_p!(upload_folder)
result_file = Path.join(upload_folder, file.filename)
@ -21,7 +23,7 @@ defmodule Pleroma.Upload do
def store(%{"img" => "data:image/" <> image_data}) do
parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data)
data = Base.decode64!(parsed["data"])
uuid = Ecto.UUID.generate
uuid = UUID.generate
upload_folder = Path.join(upload_path(), uuid)
File.mkdir_p!(upload_folder)
filename = Base.encode16(:crypto.hash(:sha256, data)) <> ".#{parsed["filetype"]}"
@ -44,11 +46,11 @@ defmodule Pleroma.Upload do
end
defp upload_path do
Application.get_env(:pleroma, Pleroma.Upload)
|> Keyword.fetch!(:uploads)
settings = Application.get_env(:pleroma, Pleroma.Upload)
Keyword.fetch!(settings, :uploads)
end
defp url_for(file) do
"#{Pleroma.Web.base_url()}/media/#{file}"
"#{Web.base_url()}/media/#{file}"
end
end

View file

@ -1,8 +1,10 @@
defmodule Pleroma.User do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.{Repo, User, Object}
import Ecto.{Changeset, Query}
alias Pleroma.{Repo, User, Object, Web}
alias Comeonin.Pbkdf2
alias Pleroma.Web.{OStatus, Websub}
alias Pleroma.Web.ActivityPub.ActivityPub
schema "users" do
@ -13,9 +15,11 @@ defmodule Pleroma.User do
field :password_hash, :string
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
field :following, { :array, :string }, default: []
field :following, {:array, :string}, default: []
field :ap_id, :string
field :avatar, :map
field :local, :boolean, default: true
field :info, :map, default: %{}
timestamps()
end
@ -28,7 +32,7 @@ defmodule Pleroma.User do
end
def ap_id(%User{nickname: nickname}) do
"#{Pleroma.Web.base_url}/users/#{nickname}"
"#{Web.base_url}/users/#{nickname}"
end
def ap_followers(%User{} = user) do
@ -67,7 +71,7 @@ defmodule Pleroma.User do
|> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/)
if changeset.valid? do
hashed = Comeonin.Pbkdf2.hashpwsalt(changeset.changes[:password])
hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]})
changeset
@ -82,9 +86,13 @@ defmodule Pleroma.User do
def follow(%User{} = follower, %User{} = followed) do
ap_followers = User.ap_followers(followed)
if following?(follower, followed) do
{ :error,
"Could not follow user: #{followed.nickname} is already on your list." }
{:error,
"Could not follow user: #{followed.nickname} is already on your list."}
else
if !followed.local do
Websub.subscribe(follower, followed)
end
following = [ap_followers | follower.following]
|> Enum.uniq
@ -105,7 +113,7 @@ defmodule Pleroma.User do
|> Repo.update
{ :ok, follower, ActivityPub.fetch_latest_follow(follower, followed)}
else
{ :error, "Not subscribed!" }
{:error, "Not subscribed!"}
end
end
@ -120,6 +128,27 @@ defmodule Pleroma.User do
def get_cached_by_nickname(nickname) do
key = "nickname:#{nickname}"
Cachex.get!(:user_cache, key, fallback: fn(_) -> Repo.get_by(User, nickname: nickname) end)
Cachex.get!(:user_cache, key, fallback: fn(_) -> get_or_fetch_by_nickname(nickname) end)
end
def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname)
end
def get_cached_user_info(user) do
key = "user_info:#{user.id}"
Cachex.get!(:user_cache, key, fallback: fn(_) -> user_info(user) end)
end
def get_or_fetch_by_nickname(nickname) do
with %User{} = user <- get_by_nickname(nickname) do
user
else _e ->
with [nick, domain] <- String.split(nickname, "@"),
{:ok, user} <- OStatus.make_user(nickname) do
user
else _e -> nil
end
end
end
end

View file

@ -1,9 +1,9 @@
defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.Repo
alias Pleroma.{Activity, Object, Upload, User}
alias Pleroma.{Activity, Repo, Object, Upload, User, Web}
alias Ecto.{Changeset, UUID}
import Ecto.Query
def insert(map) when is_map(map) do
def insert(map, local \\ true) when is_map(map) do
map = map
|> Map.put_new_lazy("id", &generate_activity_id/0)
|> Map.put_new_lazy("published", &make_date/0)
@ -16,10 +16,32 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
map
end
Repo.insert(%Activity{data: map})
Repo.insert(%Activity{data: map, local: local})
end
def like(%User{ap_id: ap_id} = user, %Object{data: %{ "id" => id}} = object) do
def create(to, actor, context, object, additional \\ %{}, published \\ nil, local \\ true) do
published = published || make_date()
activity = %{
"type" => "Create",
"to" => to |> Enum.uniq,
"actor" => actor.ap_id,
"object" => object,
"published" => published,
"context" => context
}
|> Map.merge(additional)
with {:ok, activity} <- insert(activity, local) do
if actor.local do
Pleroma.Web.Federator.enqueue(:publish, activity)
end
{:ok, activity}
end
end
def like(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, local \\ true) do
cond do
# There's already a like here, so return the original activity.
ap_id in (object.data["likes"] || []) ->
@ -33,10 +55,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
"type" => "Like",
"actor" => ap_id,
"object" => id,
"to" => [User.ap_followers(user), object.data["actor"]]
"to" => [User.ap_followers(user), object.data["actor"]],
"context" => object.data["context"]
}
{:ok, activity} = insert(data)
{:ok, activity} = insert(data, local)
likes = [ap_id | (object.data["likes"] || [])] |> Enum.uniq
@ -44,11 +67,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Map.put("like_count", length(likes))
|> Map.put("likes", likes)
changeset = Ecto.Changeset.change(object, data: new_data)
changeset = Changeset.change(object, data: new_data)
{:ok, object} = Repo.update(changeset)
update_object_in_activities(object)
if user.local do
Pleroma.Web.Federator.enqueue(:publish, activity)
end
{:ok, activity, object}
end
end
@ -58,7 +85,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
relevant_activities = Activity.all_by_object_ap_id(id)
Enum.map(relevant_activities, fn (activity) ->
new_activity_data = activity.data |> Map.put("object", object.data)
changeset = Ecto.Changeset.change(activity, data: new_activity_data)
changeset = Changeset.change(activity, data: new_activity_data)
Repo.update(changeset)
end)
end
@ -79,7 +106,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Map.put("like_count", length(likes))
|> Map.put("likes", likes)
changeset = Ecto.Changeset.change(object, data: new_data)
changeset = Changeset.change(object, data: new_data)
{:ok, object} = Repo.update(changeset)
update_object_in_activities(object)
@ -99,11 +126,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
end
def generate_object_id do
generate_id("objects")
Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :object, Ecto.UUID.generate)
end
def generate_id(type) do
"#{Pleroma.Web.base_url()}/#{type}/#{Ecto.UUID.generate}"
"#{Web.base_url()}/#{type}/#{UUID.generate}"
end
def fetch_public_activities(opts \\ %{}) do
@ -127,6 +154,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
query = from activity in query,
where: activity.id > ^since_id
query = if opts["local_only"] do
from activity in query, where: activity.local == true
else
query
end
query = if opts["max_id"] do
from activity in query, where: activity.id < ^opts["max_id"]
else
@ -140,19 +173,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
query
end
Repo.all(query)
|> Enum.reverse
Enum.reverse(Repo.all(query))
end
def announce(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object) do
def announce(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, local \\ true) do
data = %{
"type" => "Announce",
"actor" => ap_id,
"object" => id,
"to" => [User.ap_followers(user), object.data["actor"]]
"to" => [User.ap_followers(user), object.data["actor"]],
"context" => object.data["context"]
}
{:ok, activity} = insert(data)
{:ok, activity} = insert(data, local)
announcements = [ap_id | (object.data["announcements"] || [])] |> Enum.uniq
@ -160,14 +193,56 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Map.put("announcement_count", length(announcements))
|> Map.put("announcements", announcements)
changeset = Ecto.Changeset.change(object, data: new_data)
changeset = Changeset.change(object, data: new_data)
{:ok, object} = Repo.update(changeset)
update_object_in_activities(object)
if user.local do
Pleroma.Web.Federator.enqueue(:publish, activity)
end
{:ok, activity, object}
end
def follow(%User{ap_id: follower_id, local: actor_local}, %User{ap_id: followed_id}, local \\ true) do
data = %{
"type" => "Follow",
"actor" => follower_id,
"to" => [followed_id],
"object" => followed_id,
"published" => make_date()
}
with {:ok, activity} <- insert(data, local) do
if actor_local do
Pleroma.Web.Federator.enqueue(:publish, activity)
end
{:ok, activity}
end
end
def unfollow(follower, followed, local \\ true) do
with follow_activity when not is_nil(follow_activity) <- fetch_latest_follow(follower, followed) do
data = %{
"type" => "Undo",
"actor" => follower.ap_id,
"to" => [followed.ap_id],
"object" => follow_activity.data["id"],
"published" => make_date()
}
with {:ok, activity} <- insert(data, local) do
if follower.local do
Pleroma.Web.Federator.enqueue(:publish, activity)
end
{:ok, activity}
end
end
end
def fetch_activities_for_context(context) do
query = from activity in Activity,
where: fragment("? @> ?", activity.data, ^%{ context: context })

View file

@ -0,0 +1,77 @@
defmodule Pleroma.Web.Federator do
use GenServer
alias Pleroma.User
alias Pleroma.Web.WebFinger
require Logger
@websub Application.get_env(:pleroma, :websub)
@ostatus Application.get_env(:pleroma, :ostatus)
@max_jobs 10
def start_link do
GenServer.start_link(__MODULE__, {:sets.new(), :queue.new()}, name: __MODULE__)
end
def handle(:publish, activity) do
Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end)
with actor when not is_nil(actor) <- User.get_cached_by_ap_id(activity.data["actor"]) do
Logger.debug(fn -> "Sending #{activity.data["id"]} out via websub" end)
Pleroma.Web.Websub.publish(Pleroma.Web.OStatus.feed_path(actor), actor, activity)
{:ok, actor} = WebFinger.ensure_keys_present(actor)
Logger.debug(fn -> "Sending #{activity.data["id"]} out via salmon" end)
Pleroma.Web.Salmon.publish(actor, activity)
end
end
def handle(:verify_websub, websub) do
Logger.debug(fn -> "Running websub verification for #{websub.id} (#{websub.topic}, #{websub.callback})" end)
@websub.verify(websub)
end
def handle(:incoming_doc, doc) do
Logger.debug("Got document, trying to parse")
@ostatus.handle_incoming(doc)
end
def handle(type, payload) do
Logger.debug(fn -> "Unknown task: #{type}" end)
{:error, "Don't know what do do with this"}
end
def enqueue(type, payload) do
if Mix.env == :test do
handle(type, payload)
else
GenServer.cast(__MODULE__, {:enqueue, type, payload})
end
end
def maybe_start_job(running_jobs, queue) do
if (:sets.size(running_jobs) < @max_jobs) && !:queue.is_empty(queue) do
{{:value, {type, payload}}, queue} = :queue.out(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}, {running_jobs, queue}) do
queue = :queue.in({type, payload}, queue)
{running_jobs, queue} = maybe_start_job(running_jobs, queue)
{:noreply, {running_jobs, queue}}
end
def handle_info({:DOWN, ref, :process, _pid, _reason}, {running_jobs, queue}) do
running_jobs = :sets.del_element(ref, running_jobs)
{running_jobs, queue} = maybe_start_job(running_jobs, queue)
{:noreply, {running_jobs, queue}}
end
def handle_cast(m, state) do
IO.inspect("Unknown: #{inspect(m)}, #{inspect(state)}")
{:noreply, state}
end
end

View file

@ -1,5 +1,31 @@
defmodule Pleroma.Web.OStatus.ActivityRepresenter do
def to_simple_form(%{data: %{"object" => %{"type" => "Note"}}} = activity, user) do
alias Pleroma.{Activity, User}
alias Pleroma.Web.OStatus.UserRepresenter
require Logger
defp get_in_reply_to(%{"object" => %{"inReplyTo" => in_reply_to}}) do
[{:"thr:in-reply-to", [ref: to_charlist(in_reply_to)], []}]
end
defp get_in_reply_to(_), do: []
defp get_mentions(to) do
Enum.map(to, fn (id) ->
cond do
# Special handling for the AP/Ostatus public collections
"https://www.w3.org/ns/activitystreams#Public" == id ->
{:link, [rel: "mentioned", "ostatus:object-type": "http://activitystrea.ms/schema/1.0/collection", href: "http://activityschema.org/collection/public"], []}
# Ostatus doesn't handle follower collections, ignore these.
Regex.match?(~r/^#{Pleroma.Web.base_url}.+followers$/, id) ->
[]
true ->
{:link, [rel: "mentioned", "ostatus:object-type": "http://activitystrea.ms/schema/1.0/person", href: id], []}
end
end)
end
def to_simple_form(activity, user, with_author \\ false)
def to_simple_form(%{data: %{"object" => %{"type" => "Note"}}} = activity, user, with_author) do
h = fn(str) -> [to_charlist(str)] end
updated_at = activity.updated_at
@ -12,16 +38,155 @@ defmodule Pleroma.Web.OStatus.ActivityRepresenter do
{:link, [rel: 'enclosure', href: to_charlist(url["href"]), type: to_charlist(url["mediaType"])], []}
end)
in_reply_to = get_in_reply_to(activity.data)
author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
mentions = activity.data["to"] |> get_mentions
[
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/note']},
{:"activity:verb", ['http://activitystrea.ms/schema/1.0/post']},
{:id, h.(activity.data["object"]["id"])},
{:id, h.(activity.data["object"]["id"])}, # For notes, federate the object id.
{:title, ['New note by #{user.nickname}']},
{:content, [type: 'html'], h.(activity.data["object"]["content"])},
{:published, h.(inserted_at)},
{:updated, h.(updated_at)}
] ++ attachments
{:updated, h.(updated_at)},
{:"ostatus:conversation", [], h.(activity.data["context"])},
{:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
{:link, [type: ['application/atom+xml'], href: h.(activity.data["object"]["id"]), rel: 'self'], []}
] ++ attachments ++ in_reply_to ++ author ++ mentions
end
def to_simple_form(_,_), do: nil
def to_simple_form(%{data: %{"type" => "Like"}} = activity, user, with_author) do
h = fn(str) -> [to_charlist(str)] end
updated_at = activity.updated_at
|> NaiveDateTime.to_iso8601
inserted_at = activity.inserted_at
|> NaiveDateTime.to_iso8601
in_reply_to = get_in_reply_to(activity.data)
author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
mentions = activity.data["to"] |> get_mentions
[
{:"activity:verb", ['http://activitystrea.ms/schema/1.0/favorite']},
{:id, h.(activity.data["id"])},
{:title, ['New favorite by #{user.nickname}']},
{:content, [type: 'html'], ['#{user.nickname} favorited something']},
{:published, h.(inserted_at)},
{:updated, h.(updated_at)},
{:"activity:object", [
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/note']},
{:id, h.(activity.data["object"])}, # For notes, federate the object id.
]},
{:"ostatus:conversation", [], h.(activity.data["context"])},
{:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
{:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
{:"thr:in-reply-to", [ref: to_charlist(activity.data["object"])], []}
] ++ author ++ mentions
end
def to_simple_form(%{data: %{"type" => "Announce"}} = activity, user, with_author) do
h = fn(str) -> [to_charlist(str)] end
updated_at = activity.updated_at
|> NaiveDateTime.to_iso8601
inserted_at = activity.inserted_at
|> NaiveDateTime.to_iso8601
in_reply_to = get_in_reply_to(activity.data)
author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
retweeted_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"])
retweeted_user = User.get_cached_by_ap_id(retweeted_activity.data["actor"])
retweeted_xml = to_simple_form(retweeted_activity, retweeted_user, true)
mentions = activity.data["to"] |> get_mentions
[
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
{:"activity:verb", ['http://activitystrea.ms/schema/1.0/share']},
{:id, h.(activity.data["id"])},
{:title, ['#{user.nickname} repeated a notice']},
{:content, [type: 'html'], ['RT #{retweeted_activity.data["object"]["content"]}']},
{:published, h.(inserted_at)},
{:updated, h.(updated_at)},
{:"ostatus:conversation", [], h.(activity.data["context"])},
{:link, [href: h.(activity.data["context"]), rel: 'ostatus:conversation'], []},
{:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
{:"activity:object", retweeted_xml}
] ++ mentions ++ author
end
def to_simple_form(%{data: %{"type" => "Follow"}} = activity, user, with_author) do
h = fn(str) -> [to_charlist(str)] end
updated_at = activity.updated_at
|> NaiveDateTime.to_iso8601
inserted_at = activity.inserted_at
|> NaiveDateTime.to_iso8601
author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
mentions = (activity.data["to"] || []) |> get_mentions
[
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
{:"activity:verb", ['http://activitystrea.ms/schema/1.0/follow']},
{:id, h.(activity.data["id"])},
{:title, ['#{user.nickname} started following #{activity.data["object"]}']},
{:content, [type: 'html'], ['#{user.nickname} started following #{activity.data["object"]}']},
{:published, h.(inserted_at)},
{:updated, h.(updated_at)},
{:"activity:object", [
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/person']},
{:id, h.(activity.data["object"])},
{:uri, h.(activity.data["object"])},
]},
{:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
] ++ mentions ++ author
end
# Only undos of follow for now. Will need to get redone once there are more
def to_simple_form(%{data: %{"type" => "Undo"}} = activity, user, with_author) do
h = fn(str) -> [to_charlist(str)] end
updated_at = activity.updated_at
|> NaiveDateTime.to_iso8601
inserted_at = activity.inserted_at
|> NaiveDateTime.to_iso8601
author = if with_author, do: [{:author, UserRepresenter.to_simple_form(user)}], else: []
follow_activity = Activity.get_by_ap_id(activity.data["object"])
mentions = (activity.data["to"] || []) |> get_mentions
[
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/activity']},
{:"activity:verb", ['http://activitystrea.ms/schema/1.0/unfollow']},
{:id, h.(activity.data["id"])},
{:title, ['#{user.nickname} stopped following #{follow_activity.data["object"]}']},
{:content, [type: 'html'], ['#{user.nickname} stopped following #{follow_activity.data["object"]}']},
{:published, h.(inserted_at)},
{:updated, h.(updated_at)},
{:"activity:object", [
{:"activity:object-type", ['http://activitystrea.ms/schema/1.0/person']},
{:id, h.(follow_activity.data["object"])},
{:uri, h.(follow_activity.data["object"])},
]},
{:link, [rel: 'self', type: ['application/atom+xml'], href: h.(activity.data["id"])], []},
] ++ mentions ++ author
end
def wrap_with_entry(simple_form) do
[{
:entry, [
xmlns: 'http://www.w3.org/2005/Atom',
"xmlns:thr": 'http://purl.org/syndication/thread/1.0',
"xmlns:activity": 'http://activitystrea.ms/spec/1.0/',
"xmlns:poco": 'http://portablecontacts.net/spec/1.0',
"xmlns:ostatus": 'http://ostatus.org/schema/1.0'
], simple_form
}]
end
def to_simple_form(_, _, _), do: nil
end

View file

@ -8,7 +8,8 @@ defmodule Pleroma.Web.OStatus.FeedRepresenter do
h = fn(str) -> [to_charlist(str)] end
entries = Enum.map(activities, fn(activity) ->
entries = activities
|> Enum.map(fn(activity) ->
{:entry, ActivityRepresenter.to_simple_form(activity, user)}
end)
|> Enum.filter(fn ({_, form}) -> form end)
@ -16,14 +17,17 @@ defmodule Pleroma.Web.OStatus.FeedRepresenter do
[{
:feed, [
xmlns: 'http://www.w3.org/2005/Atom',
"xmlns:thr": 'http://purl.org/syndication/thread/1.0',
"xmlns:activity": 'http://activitystrea.ms/spec/1.0/',
"xmlns:poco": 'http://portablecontacts.net/spec/1.0'
"xmlns:poco": 'http://portablecontacts.net/spec/1.0',
"xmlns:ostatus": 'http://ostatus.org/schema/1.0'
], [
{:id, h.(OStatus.feed_path(user))},
{:title, ['#{user.nickname}\'s timeline']},
{:updated, h.(most_recent_update)},
{:link, [rel: 'hub', href: h.(OStatus.pubsub_path(user))], []},
{:link, [rel: 'self', href: h.(OStatus.feed_path(user))], []},
{:link, [rel: 'salmon', href: h.(OStatus.salmon_path(user))], []},
{:link, [rel: 'self', href: h.(OStatus.feed_path(user)), type: 'application/atom+xml'], []},
{:author, UserRepresenter.to_simple_form(user)},
] ++ entries
}]

View file

@ -1,5 +1,13 @@
defmodule Pleroma.Web.OStatus do
alias Pleroma.Web
@httpoison Application.get_env(:pleroma, :httpoison)
import Ecto.Query
import Pleroma.Web.XML
require Logger
alias Pleroma.{Repo, User, Web, Object, Activity}
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.{WebFinger, Websub}
def feed_path(user) do
"#{user.ap_id}/feed.atom"
@ -9,6 +17,268 @@ defmodule Pleroma.Web.OStatus do
"#{Web.base_url}/push/hub/#{user.nickname}"
end
def user_path(user) do
def salmon_path(user) do
"#{user.ap_id}/salmon"
end
def handle_incoming(xml_string) do
doc = parse_document(xml_string)
entries = :xmerl_xpath.string('//entry', doc)
activities = Enum.map(entries, fn (entry) ->
{:xmlObj, :string, object_type} = :xmerl_xpath.string('string(/entry/activity:object-type[1])', entry)
{:xmlObj, :string, verb} = :xmerl_xpath.string('string(/entry/activity:verb[1])', entry)
case verb do
'http://activitystrea.ms/schema/1.0/share' ->
with {:ok, activity, retweeted_activity} <- handle_share(entry, doc), do: [activity, retweeted_activity]
'http://activitystrea.ms/schema/1.0/favorite' ->
with {:ok, activity, favorited_activity} <- handle_favorite(entry, doc), do: [activity, favorited_activity]
_ ->
case object_type do
'http://activitystrea.ms/schema/1.0/note' ->
with {:ok, activity} <- handle_note(entry, doc), do: activity
'http://activitystrea.ms/schema/1.0/comment' ->
with {:ok, activity} <- handle_note(entry, doc), do: activity
_ ->
Logger.error("Couldn't parse incoming document")
nil
end
end
end)
{:ok, activities}
end
def make_share(_entry, doc, retweeted_activity) do
with {:ok, actor} <- find_make_or_update_user(doc),
%Object{} = object <- Object.get_cached_by_ap_id(retweeted_activity.data["object"]["id"]),
{:ok, activity, _object} = ActivityPub.announce(actor, object, false) do
{:ok, activity}
end
end
def handle_share(entry, doc) do
with [object] <- :xmerl_xpath.string('/entry/activity:object', entry),
{:ok, retweeted_activity} <- handle_note(object, object),
{:ok, activity} <- make_share(entry, doc, retweeted_activity) do
{:ok, activity, retweeted_activity}
else
e -> {:error, e}
end
end
def make_favorite(_entry, doc, favorited_activity) do
with {:ok, actor} <- find_make_or_update_user(doc),
%Object{} = object <- Object.get_cached_by_ap_id(favorited_activity.data["object"]["id"]),
{:ok, activity, _object} = ActivityPub.like(actor, object, false) do
{:ok, activity}
end
end
def get_or_try_fetching(entry) do
with id when not is_nil(id) <- string_from_xpath("//activity:object[1]/id", entry),
%Activity{} = activity <- Activity.get_create_activity_by_object_ap_id(id) do
{:ok, activity}
else _e ->
with href when not is_nil(href) <- string_from_xpath("//activity:object[1]/link[@type=\"text/html\"]/@href", entry),
{:ok, [favorited_activity]} <- fetch_activity_from_html_url(href) do
{:ok, favorited_activity}
end
end
end
def handle_favorite(entry, doc) do
with {:ok, favorited_activity} <- get_or_try_fetching(entry),
{:ok, activity} <- make_favorite(entry, doc, favorited_activity) do
{:ok, activity, favorited_activity}
else
e -> {:error, e}
end
end
def get_attachments(entry) do
:xmerl_xpath.string('/entry/link[@rel="enclosure"]', entry)
|> Enum.map(fn (enclosure) ->
with href when not is_nil(href) <- string_from_xpath("/link/@href", enclosure),
type when not is_nil(type) <- string_from_xpath("/link/@type", enclosure) do
%{
"type" => "Attachment",
"url" => [%{
"type" => "Link",
"mediaType" => type,
"href" => href
}]
}
end
end)
|> Enum.filter(&(&1))
end
def handle_note(entry, doc \\ nil) do
content_html = string_from_xpath("//content[1]", entry)
[author] = :xmerl_xpath.string('//author[1]', doc)
{:ok, actor} = find_make_or_update_user(author)
inReplyTo = string_from_xpath("//thr:in-reply-to[1]/@ref", entry)
if !Object.get_cached_by_ap_id(inReplyTo) do
inReplyToHref = string_from_xpath("//thr:in-reply-to[1]/@href", entry)
if inReplyToHref do
fetch_activity_from_html_url(inReplyToHref)
end
end
context = (string_from_xpath("//ostatus:conversation[1]", entry) || "") |> String.trim
attachments = get_attachments(entry)
context = with %{data: %{"context" => context}} <- Object.get_cached_by_ap_id(inReplyTo) do
context
else _e ->
if String.length(context) > 0 do
context
else
ActivityPub.generate_context_id
end
end
to = [
"https://www.w3.org/ns/activitystreams#Public",
User.ap_followers(actor)
]
mentions = :xmerl_xpath.string('//link[@rel="mentioned" and @ostatus:object-type="http://activitystrea.ms/schema/1.0/person"]', entry)
|> Enum.map(fn(person) -> string_from_xpath("@href", person) end)
to = to ++ mentions
date = string_from_xpath("//published", entry)
id = string_from_xpath("//id", entry)
object = %{
"id" => id,
"type" => "Note",
"to" => to,
"content" => content_html,
"published" => date,
"context" => context,
"actor" => actor.ap_id,
"attachment" => attachments
}
object = if inReplyTo do
Map.put(object, "inReplyTo", inReplyTo)
else
object
end
# TODO: Bail out sooner and use transaction.
if Object.get_by_ap_id(id) do
{:error, "duplicate activity"}
else
ActivityPub.create(to, actor, context, object, %{}, date, false)
end
end
def find_make_or_update_user(doc) do
uri = string_from_xpath("//author/uri[1]", doc)
with {:ok, user} <- find_or_make_user(uri) do
avatar = make_avatar_object(doc)
if !user.local && user.avatar != avatar do
change = Ecto.Changeset.change(user, %{avatar: avatar})
Repo.update(change)
else
{:ok, user}
end
end
end
def find_or_make_user(uri) do
query = from user in User,
where: user.ap_id == ^uri
user = Repo.one(query)
if is_nil(user) do
make_user(uri)
else
{:ok, user}
end
end
def make_user(uri) do
with {:ok, info} <- gather_user_info(uri) do
data = %{
local: false,
name: info["name"],
nickname: info["nickname"] <> "@" <> info["host"],
ap_id: info["uri"],
info: info,
avatar: info["avatar"]
}
# TODO: Make remote user changeset
# SHould enforce fqn nickname
Repo.insert(Ecto.Changeset.change(%User{}, data))
end
end
# TODO: Just takes the first one for now.
def make_avatar_object(author_doc) do
href = string_from_xpath("//author[1]/link[@rel=\"avatar\"]/@href", author_doc)
type = string_from_xpath("//author[1]/link[@rel=\"avatar\"]/@type", author_doc)
if href do
%{
"type" => "Image",
"url" =>
[%{
"type" => "Link",
"mediaType" => type,
"href" => href
}]
}
else
nil
end
end
def gather_user_info(username) do
with {:ok, webfinger_data} <- WebFinger.finger(username),
{:ok, feed_data} <- Websub.gather_feed_data(webfinger_data["topic"]) do
{:ok, Map.merge(webfinger_data, feed_data) |> Map.put("fqn", username)}
else e ->
Logger.debug(fn -> "Couldn't gather info for #{username}" end)
{:error, e}
end
end
# Regex-based 'parsing' so we don't have to pull in a full html parser
# It's a hack anyway. Maybe revisit this in the future
@mastodon_regex ~r/<link href='(.*)' rel='alternate' type='application\/atom\+xml'>/
@gs_regex ~r/<link title=.* href="(.*)" type="application\/atom\+xml" rel="alternate">/
@gs_classic_regex ~r/<link rel="alternate" href="(.*)" type="application\/atom\+xml" title=.*>/
def get_atom_url(body) do
cond do
Regex.match?(@mastodon_regex, body) ->
[[_, match]] = Regex.scan(@mastodon_regex, body)
{:ok, match}
Regex.match?(@gs_regex, body) ->
[[_, match]] = Regex.scan(@gs_regex, body)
{:ok, match}
Regex.match?(@gs_classic_regex, body) ->
[[_, match]] = Regex.scan(@gs_classic_regex, body)
{:ok, match}
true ->
Logger.debug(fn -> "Couldn't find atom link in #{inspect(body)}" end)
{:error, "Couldn't find the atom link"}
end
end
def fetch_activity_from_html_url(url) do
with {:ok, %{body: body}} <- @httpoison.get(url, [], follow_redirect: true),
{:ok, atom_url} <- get_atom_url(body),
{:ok, %{status_code: code, body: body}} when code in 200..299 <- @httpoison.get(atom_url, [], follow_redirect: true) do
handle_incoming(body)
end
end
end

View file

@ -2,10 +2,16 @@ defmodule Pleroma.Web.OStatus.OStatusController do
use Pleroma.Web, :controller
alias Pleroma.{User, Activity}
alias Pleroma.Web.OStatus.FeedRepresenter
alias Pleroma.Web.OStatus.{FeedRepresenter, ActivityRepresenter}
alias Pleroma.Repo
alias Pleroma.Web.{OStatus, Federator}
import Ecto.Query
def feed_redirect(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname)
redirect conn, external: OStatus.feed_path(user)
end
def feed(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname)
query = from activity in Activity,
@ -16,7 +22,8 @@ defmodule Pleroma.Web.OStatus.OStatusController do
activities = query
|> Repo.all
response = FeedRepresenter.to_simple_form(user, activities, [user])
response = user
|> FeedRepresenter.to_simple_form(activities, [user])
|> :xmerl.export_simple(:xmerl_xml)
|> to_string
@ -25,7 +32,30 @@ defmodule Pleroma.Web.OStatus.OStatusController do
|> send_resp(200, response)
end
def temp(conn, params) do
IO.inspect(params)
def salmon_incoming(conn, params) do
{:ok, body, _conn} = read_body(conn)
{:ok, magic_key} = Pleroma.Web.Salmon.fetch_magic_key(body)
{:ok, doc} = Pleroma.Web.Salmon.decode_and_validate(magic_key, body)
Federator.enqueue(:incoming_doc, doc)
conn
|> send_resp(200, "")
end
def object(conn, %{"uuid" => uuid}) do
id = o_status_url(conn, :object, uuid)
activity = Activity.get_create_activity_by_object_ap_id(id)
user = User.get_cached_by_ap_id(activity.data["actor"])
response = activity
|> ActivityRepresenter.to_simple_form(user, true)
|> ActivityRepresenter.wrap_with_entry
|> :xmerl.export_simple(:xmerl_xml)
|> to_string
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, response)
end
end

View file

@ -7,14 +7,14 @@ defmodule Pleroma.Web.OStatus.UserRepresenter do
bio = to_charlist(user.bio)
avatar_url = to_charlist(User.avatar_url(user))
[
{ :id, [ap_id] },
{ :"activity:object", ['http://activitystrea.ms/schema/1.0/person'] },
{ :uri, [ap_id] },
{ :"poco:preferredUsername", [nickname] },
{ :"poco:displayName", [name] },
{ :"poco:note", [bio] },
{ :name, [nickname] },
{ :link, [rel: 'avatar', href: avatar_url], []}
{:id, [ap_id]},
{:"activity:object", ['http://activitystrea.ms/schema/1.0/person']},
{:uri, [ap_id]},
{:"poco:preferredUsername", [nickname]},
{:"poco:displayName", [name]},
{:"poco:note", [bio]},
{:name, [nickname]},
{:link, [rel: 'avatar', href: avatar_url], []}
]
end
end

View file

@ -1,7 +1,7 @@
defmodule Pleroma.Web.Router do
use Pleroma.Web, :router
alias Pleroma.{Repo, User}
alias Pleroma.{Repo, User, Web.Router}
def user_fetcher(username) do
{:ok, Repo.get_by(User, %{nickname: username})}
@ -10,13 +10,13 @@ defmodule Pleroma.Web.Router do
pipeline :api do
plug :accepts, ["json"]
plug :fetch_session
plug Pleroma.Plugs.AuthenticationPlug, %{fetcher: &Pleroma.Web.Router.user_fetcher/1, optional: true}
plug Pleroma.Plugs.AuthenticationPlug, %{fetcher: &Router.user_fetcher/1, optional: true}
end
pipeline :authenticated_api do
plug :accepts, ["json"]
plug :fetch_session
plug Pleroma.Plugs.AuthenticationPlug, %{fetcher: &Pleroma.Web.Router.user_fetcher/1}
plug Pleroma.Plugs.AuthenticationPlug, %{fetcher: &Router.user_fetcher/1}
end
pipeline :well_known do
@ -30,7 +30,8 @@ defmodule Pleroma.Web.Router do
get "/statusnet/config", TwitterAPI.Controller, :config
get "/statuses/public_timeline", TwitterAPI.Controller, :public_timeline
get "/statuses/public_and_external_timeline", TwitterAPI.Controller, :public_timeline
get "/statuses/public_and_external_timeline", TwitterAPI.Controller, :public_and_external_timeline
get "/statuses/networkpublic_timeline", TwitterAPI.Controller, :public_and_external_timeline
get "/statuses/user_timeline", TwitterAPI.Controller, :user_timeline
get "/statuses/show/:id", TwitterAPI.Controller, :fetch_status
@ -73,8 +74,14 @@ defmodule Pleroma.Web.Router do
scope "/", Pleroma.Web do
pipe_through :ostatus
get "/objects/:uuid", OStatus.OStatusController, :object
get "/users/:nickname/feed", OStatus.OStatusController, :feed
get "/users/:nickname", OStatus.OStatusController, :feed_redirect
post "/users/:nickname/salmon", OStatus.OStatusController, :salmon_incoming
post "/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request
get "/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation
post "/push/subscriptions/:id", Websub.WebsubController, :websub_incoming
end
scope "/.well-known", Pleroma.Web do
@ -92,5 +99,5 @@ end
defmodule Fallback.RedirectController do
use Pleroma.Web, :controller
def redirector(conn, _params), do: send_file(conn, 200, "priv/static/index.html")
def redirector(conn, _params), do: (if Mix.env != :test, do: send_file(conn, 200, "priv/static/index.html"))
end

View file

@ -1,8 +1,14 @@
defmodule Pleroma.Web.Salmon do
@httpoison Application.get_env(:pleroma, :httpoison)
use Bitwise
alias Pleroma.Web.XML
alias Pleroma.Web.OStatus.ActivityRepresenter
alias Pleroma.User
require Logger
def decode(salmon) do
{doc, _rest} = :xmerl_scan.string(to_charlist(salmon))
doc = XML.parse_document(salmon)
{:xmlObj, :string, data} = :xmerl_xpath.string('string(//me:data[1])', doc)
{:xmlObj, :string, sig} = :xmerl_xpath.string('string(//me:sig[1])', doc)
@ -10,7 +16,6 @@ defmodule Pleroma.Web.Salmon do
{:xmlObj, :string, encoding} = :xmerl_xpath.string('string(//me:encoding[1])', doc)
{:xmlObj, :string, type} = :xmerl_xpath.string('string(//me:data[1]/@type)', doc)
{:ok, data} = Base.url_decode64(to_string(data), ignore: :whitespace)
{:ok, sig} = Base.url_decode64(to_string(sig), ignore: :whitespace)
alg = to_string(alg)
@ -21,22 +26,12 @@ defmodule Pleroma.Web.Salmon do
end
def fetch_magic_key(salmon) do
[data, _, _, _, _] = decode(salmon)
{doc, _rest} = :xmerl_scan.string(to_charlist(data))
{:xmlObj, :string, uri} = :xmerl_xpath.string('string(//author[1]/uri)', doc)
uri = to_string(uri)
base = URI.parse(uri).host
# TODO: Find out if this endpoint is mandated by the standard.
{:ok, response} = HTTPoison.get(base <> "/.well-known/webfinger", ["Accept": "application/xrd+xml"], [params: [resource: uri]])
{doc, _rest} = :xmerl_scan.string(to_charlist(response.body))
{:xmlObj, :string, magickey} = :xmerl_xpath.string('string(//Link[@rel="magic-public-key"]/@href)', doc)
"data:application/magic-public-key," <> magickey = to_string(magickey)
magickey
with [data, _, _, _, _] <- decode(salmon),
doc <- XML.parse_document(data),
uri when not is_nil(uri) <- XML.string_from_xpath("/entry/author[1]/uri", doc),
{:ok, %{info: %{"magic_key" => magic_key}}} <- Pleroma.Web.OStatus.find_or_make_user(uri) do
{:ok, magic_key}
end
end
def decode_and_validate(magickey, salmon) do
@ -57,7 +52,7 @@ defmodule Pleroma.Web.Salmon do
end
end
defp decode_key("RSA." <> magickey) do
def decode_key("RSA." <> magickey) do
make_integer = fn(bin) ->
list = :erlang.binary_to_list(bin)
Enum.reduce(list, 0, fn (el, acc) -> (acc <<< 8) ||| el end)
@ -70,4 +65,98 @@ defmodule Pleroma.Web.Salmon do
{:RSAPublicKey, modulus, exponent}
end
def encode_key({:RSAPublicKey, modulus, exponent}) do
modulus_enc = :binary.encode_unsigned(modulus) |> Base.url_encode64
exponent_enc = :binary.encode_unsigned(exponent) |> Base.url_encode64
"RSA.#{modulus_enc}.#{exponent_enc}"
end
def generate_rsa_pem do
port = Port.open({:spawn, "openssl genrsa"}, [:binary])
{:ok, pem} = receive do
{^port, {:data, pem}} -> {:ok, pem}
end
Port.close(port)
if Regex.match?(~r/RSA PRIVATE KEY/, pem) do
{:ok, pem}
else
:error
end
end
def keys_from_pem(pem) do
[private_key_code] = :public_key.pem_decode(pem)
private_key = :public_key.pem_entry_decode(private_key_code)
{:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key
public_key = {:RSAPublicKey, modulus, exponent}
{:ok, private_key, public_key}
end
def encode(private_key, doc) do
type = "application/atom+xml"
encoding = "base64url"
alg = "RSA-SHA256"
signed_text = [doc, type, encoding, alg]
|> Enum.map(&Base.url_encode64/1)
|> Enum.join(".")
signature = signed_text
|> :public_key.sign(:sha256, private_key)
|> to_string
|> Base.url_encode64
doc_base64 = doc
|> Base.url_encode64
# Don't need proper xml building, these strings are safe to leave unescaped
salmon = """
<?xml version="1.0" encoding="UTF-8"?>
<me:env xmlns:me="http://salmon-protocol.org/ns/magic-env">
<me:data type="application/atom+xml">#{doc_base64}</me:data>
<me:encoding>#{encoding}</me:encoding>
<me:alg>#{alg}</me:alg>
<me:sig>#{signature}</me:sig>
</me:env>
"""
{:ok, salmon}
end
def remote_users(%{data: %{"to" => to}}) do
to
|> Enum.map(fn(id) -> User.get_cached_by_ap_id(id) end)
|> Enum.filter(fn(user) -> user && !user.local end)
end
defp send_to_user(%{info: %{"salmon" => salmon}}, feed, poster) do
poster.(salmon, feed, [{"Content-Type", "application/magic-envelope+xml"}])
end
defp send_to_user(_,_,_), do: nil
def publish(user, activity, poster \\ &@httpoison.post/3)
def publish(%{info: %{"keys" => keys}} = user, activity, poster) do
feed = ActivityRepresenter.to_simple_form(activity, user, true)
|> ActivityRepresenter.wrap_with_entry
|> :xmerl.export_simple(:xmerl_xml)
|> to_string
if feed do
{:ok, private, _} = keys_from_pem(keys)
{:ok, feed} = encode(private, feed)
remote_users(activity)
|> Enum.each(fn(remote_user) ->
Task.start(fn ->
Logger.debug(fn -> "sending salmon to #{remote_user.ap_id}" end)
send_to_user(remote_user, feed, poster)
end)
end)
end
end
def publish(%{id: id}, _, _), do: Logger.debug(fn -> "Keys missing for user #{id}" end)
end

View file

@ -1,17 +1,19 @@
defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
use Pleroma.Web.TwitterAPI.Representers.BaseRepresenter
alias Pleroma.Web.TwitterAPI.Representers.{UserRepresenter, ObjectRepresenter}
alias Pleroma.Activity
alias Pleroma.{Activity, User}
alias Calendar.Strftime
alias Pleroma.Web.TwitterAPI.TwitterAPI
alias Pleroma.Wi
defp user_by_ap_id(user_list, ap_id) do
Enum.find(user_list, fn (%{ap_id: user_id}) -> ap_id == user_id end)
end
def to_map(%Activity{data: %{"type" => "Announce", "actor" => actor}} = activity, %{users: users, announced_activity: announced_activity} = opts) do
def to_map(%Activity{data: %{"type" => "Announce", "actor" => actor, "published" => created_at}} = activity,
%{users: users, announced_activity: announced_activity} = opts) do
user = user_by_ap_id(users, actor)
created_at = get_in(activity.data, ["published"])
|> date_to_asctime
created_at = created_at |> date_to_asctime
text = "#{user.nickname} retweeted a status."
@ -26,20 +28,21 @@ defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
"is_post_verb" => false,
"uri" => "tag:#{activity.data["id"]}:objectType=note",
"created_at" => created_at,
"retweeted_status" => retweeted_status
"retweeted_status" => retweeted_status,
"statusnet_conversation_id" => conversation_id(announced_activity)
}
end
def to_map(%Activity{data: %{"type" => "Like"}} = activity, %{user: user, liked_activity: liked_activity} = opts) do
created_at = get_in(activity.data, ["published"])
|> date_to_asctime
def to_map(%Activity{data: %{"type" => "Like", "published" => created_at}} = activity,
%{user: user, liked_activity: liked_activity} = opts) do
created_at = created_at |> date_to_asctime
text = "#{user.nickname} favorited a status."
%{
"id" => activity.id,
"user" => UserRepresenter.to_map(user, opts),
"statusnet_html" => text, # TODO: add summary
"statusnet_html" => text,
"text" => text,
"is_local" => true,
"is_post_verb" => false,
@ -49,16 +52,17 @@ defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
}
end
def to_map(%Activity{data: %{"type" => "Follow"}} = activity, %{user: user} = opts) do
created_at = get_in(activity.data, ["published"])
|> date_to_asctime
def to_map(%Activity{data: %{"type" => "Follow", "published" => created_at, "object" => followed_id}} = activity, %{user: user} = opts) do
created_at = created_at |> date_to_asctime
followed = User.get_cached_by_ap_id(followed_id)
text = "#{user.nickname} started following #{followed.nickname}"
%{
"id" => activity.id,
"user" => UserRepresenter.to_map(user, opts),
"attentions" => [],
"statusnet_html" => "", # TODO: add summary
"text" => "",
"statusnet_html" => text,
"text" => text,
"is_local" => true,
"is_post_verb" => false,
"created_at" => created_at,
@ -66,14 +70,12 @@ defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
}
end
def to_map(%Activity{} = activity, %{user: user} = opts) do
content = get_in(activity.data, ["object", "content"])
created_at = get_in(activity.data, ["object", "published"])
|> date_to_asctime
like_count = get_in(activity.data, ["object", "like_count"]) || 0
announcement_count = get_in(activity.data, ["object", "announcement_count"]) || 0
favorited = opts[:for] && opts[:for].ap_id in (activity.data["object"]["likes"] || [])
repeated = opts[:for] && opts[:for].ap_id in (activity.data["object"]["announcements"] || [])
def to_map(%Activity{data: %{"object" => %{"content" => content} = object}} = activity, %{user: user} = opts) do
created_at = object["published"] |> date_to_asctime
like_count = object["like_count"] || 0
announcement_count = object["announcement_count"] || 0
favorited = opts[:for] && opts[:for].ap_id in (object["likes"] || [])
repeated = opts[:for] && opts[:for].ap_id in (object["announcements"] || [])
mentions = opts[:mentioned] || []
@ -82,6 +84,8 @@ defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
|> Enum.filter(&(&1))
|> Enum.map(fn (user) -> UserRepresenter.to_map(user, opts) end)
conversation_id = conversation_id(activity)
%{
"id" => activity.id,
"user" => UserRepresenter.to_map(user, opts),
@ -91,22 +95,41 @@ defmodule Pleroma.Web.TwitterAPI.Representers.ActivityRepresenter do
"is_local" => true,
"is_post_verb" => true,
"created_at" => created_at,
"in_reply_to_status_id" => activity.data["object"]["inReplyToStatusId"],
"statusnet_conversation_id" => activity.data["object"]["statusnetConversationId"],
"attachments" => (activity.data["object"]["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts),
"in_reply_to_status_id" => object["inReplyToStatusId"],
"statusnet_conversation_id" => conversation_id,
"attachments" => (object["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts),
"attentions" => attentions,
"fave_num" => like_count,
"repeat_num" => announcement_count,
"favorited" => !!favorited,
"repeated" => !!repeated,
"favorited" => to_boolean(favorited),
"repeated" => to_boolean(repeated),
}
end
def conversation_id(activity) do
with context when not is_nil(context) <- activity.data["context"] do
TwitterAPI.context_to_conversation_id(context)
else _e -> nil
end
end
defp date_to_asctime(date) do
with {:ok, date, _offset} <- date |> DateTime.from_iso8601 do
Calendar.Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
else _e ->
""
end
end
defp to_boolean(false) do
false
end
defp to_boolean(nil) do
false
end
defp to_boolean(_) do
true
end
end

View file

@ -11,7 +11,7 @@ defmodule Pleroma.Web.TwitterAPI.Representers.UserRepresenter do
false
end
user_info = User.user_info(user)
user_info = User.get_cached_user_info(user)
map = %{
"id" => user.id,
@ -28,7 +28,8 @@ defmodule Pleroma.Web.TwitterAPI.Representers.UserRepresenter do
"profile_image_url_https" => image,
"profile_image_url_profile_size" => image,
"profile_image_url_original" => image,
"rights" => %{}
"rights" => %{},
"statusnet_profile_url" => user.ap_id
}
map

View file

@ -5,34 +5,77 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
import Ecto.Query
def create_status(user = %User{}, data = %{}) do
attachments = Enum.map(data["media_ids"] || [], fn (media_id) ->
Repo.get(Object, media_id).data
end)
context = ActivityPub.generate_context_id
content = HtmlSanitizeEx.strip_tags(data["status"])
|> String.replace("\n", "<br>")
mentions = parse_mentions(content)
def to_for_user_and_mentions(user, mentions) do
default_to = [
User.ap_followers(user),
"https://www.w3.org/ns/activitystreams#Public"
]
to = default_to ++ Enum.map(mentions, fn ({_, %{ap_id: ap_id}}) -> ap_id end)
default_to ++ Enum.map(mentions, fn ({_, %{ap_id: ap_id}}) -> ap_id end)
end
content_html = add_user_links(content, mentions)
def format_input(text, mentions) do
HtmlSanitizeEx.strip_tags(text)
|> String.replace("\n", "<br>")
|> add_user_links(mentions)
end
def attachments_from_ids(ids) do
Enum.map(ids || [], fn (media_id) ->
Repo.get(Object, media_id).data
end)
end
def get_replied_to_activity(id) when not is_nil(id) do
Repo.get(Activity, id)
end
def get_replied_to_activity(_), do: nil
def add_attachments(text, attachments) do
attachment_text = Enum.map(attachments, fn
(%{"url" => [%{"href" => href} | _]}) ->
"<a href='#{href}'>#{href}</a>"
_ -> ""
end)
Enum.join([text | attachment_text], "<br>")
end
def create_status(%User{} = user, %{"status" => status} = data) do
attachments = attachments_from_ids(data["media_ids"])
context = ActivityPub.generate_context_id
mentions = parse_mentions(status)
content_html = status
|> format_input(mentions)
|> add_attachments(attachments)
to = to_for_user_and_mentions(user, mentions)
date = make_date()
activity = %{
"type" => "Create",
"to" => to,
"actor" => user.ap_id,
"object" => %{
inReplyTo = get_replied_to_activity(data["in_reply_to_status_id"])
# Wire up reply info.
[to, context, object, additional] =
if inReplyTo do
context = inReplyTo.data["context"]
to = to ++ [inReplyTo.data["actor"]]
object = %{
"type" => "Note",
"to" => to,
"content" => content_html,
"published" => date,
"context" => context,
"attachment" => attachments,
"actor" => user.ap_id,
"inReplyTo" => inReplyTo.data["object"]["id"],
"inReplyToStatusId" => inReplyTo.id,
}
additional = %{}
[to, context, object, additional]
else
object = %{
"type" => "Note",
"to" => to,
"content" => content_html,
@ -40,36 +83,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
"context" => context,
"attachment" => attachments,
"actor" => user.ap_id
},
"published" => date,
"context" => context
}
# Wire up reply info.
activity = with inReplyToId when not is_nil(inReplyToId) <- data["in_reply_to_status_id"],
inReplyTo <- Repo.get(Activity, inReplyToId),
context <- inReplyTo.data["context"]
do
to = activity["to"] ++ [inReplyTo.data["actor"]]
activity
|> put_in(["to"], to)
|> put_in(["context"], context)
|> put_in(["object", "context"], context)
|> put_in(["object", "inReplyTo"], inReplyTo.data["object"]["id"])
|> put_in(["object", "inReplyToStatusId"], inReplyToId)
|> put_in(["statusnetConversationId"], inReplyTo.data["statusnetConversationId"])
|> put_in(["object", "statusnetConversationId"], inReplyTo.data["statusnetConversationId"])
else _e ->
activity
end
with {:ok, activity} <- ActivityPub.insert(activity) do
{:ok, activity} = add_conversation_id(activity)
Pleroma.Web.Websub.publish(Pleroma.Web.OStatus.feed_path(user), user, activity)
{:ok, activity}
}
[to, context, object, %{}]
end
ActivityPub.create(to, user, context, object, additional, data)
end
def fetch_friend_statuses(user, opts \\ %{}) do
@ -78,6 +96,12 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
end
def fetch_public_statuses(user, opts \\ %{}) do
opts = Map.put(opts, "local_only", true)
ActivityPub.fetch_public_activities(opts)
|> activities_to_statuses(%{for: user})
end
def fetch_public_and_external_statuses(user, opts \\ %{}) do
ActivityPub.fetch_public_activities(opts)
|> activities_to_statuses(%{for: user})
end
@ -93,18 +117,12 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
end
def fetch_conversation(user, id) do
query = from activity in Activity,
where: fragment("? @> ?", activity.data, ^%{ statusnetConversationId: id}),
limit: 1
with %Activity{} = activity <- Repo.one(query),
context <- activity.data["context"],
with context when is_binary(context) <- conversation_id_to_context(id),
activities <- ActivityPub.fetch_activities_for_context(context),
statuses <- activities |> activities_to_statuses(%{for: user})
do
statuses
else e ->
IO.inspect(e)
else _e ->
[]
end
end
@ -116,28 +134,23 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
end
def follow(%User{} = follower, params) do
with { :ok, %User{} = followed } <- get_user(params),
{ :ok, follower } <- User.follow(follower, followed),
{ :ok, activity } <- ActivityPub.insert(%{
"type" => "Follow",
"actor" => follower.ap_id,
"object" => followed.ap_id,
"published" => make_date()
})
with {:ok, %User{} = followed} <- get_user(params),
{:ok, follower} <- User.follow(follower, followed),
{:ok, activity} <- ActivityPub.follow(follower, followed)
do
{ :ok, follower, followed, activity }
{:ok, follower, followed, activity}
else
err -> err
end
end
def unfollow(%User{} = follower, params) do
def unfollow(%User{} = follower, params) do
with { :ok, %User{} = unfollowed } <- get_user(params),
{ :ok, follower, follow_activity } <- User.unfollow(follower, unfollowed),
{ :ok, _activity } <- ActivityPub.insert(%{
"type" => "Undo",
"actor" => follower.ap_id,
"object" => follow_activity, # get latest Follow for these users
"object" => follow_activity.data["id"], # get latest Follow for these users
"published" => make_date()
})
do
@ -232,24 +245,6 @@ def unfollow(%User{} = follower, params) do
Enum.reduce(mentions, text, fn ({match, %User{ap_id: ap_id}}, text) -> String.replace(text, match, "<a href='#{ap_id}'>#{match}</a>") end)
end
defp add_conversation_id(activity) do
if is_integer(activity.data["statusnetConversationId"]) do
{:ok, activity}
else
data = activity.data
|> put_in(["object", "statusnetConversationId"], activity.id)
|> put_in(["statusnetConversationId"], activity.id)
object = Object.get_by_ap_id(activity.data["object"]["id"])
changeset = Ecto.Changeset.change(object, data: data["object"])
Repo.update(changeset)
changeset = Ecto.Changeset.change(activity, data: data)
Repo.update(changeset)
end
end
def register_user(params) do
params = %{
nickname: params["nickname"],
@ -268,20 +263,20 @@ def unfollow(%User{} = follower, params) do
{:error, changeset} ->
errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|> Poison.encode!
{:error, %{error: errors}}
{:error, %{error: errors}}
end
end
def get_user(user \\ nil, params) do
case params do
%{ "user_id" => user_id } ->
%{"user_id" => user_id} ->
case target = Repo.get(User, user_id) do
nil ->
{:error, "No user with such user_id"}
_ ->
{:ok, target}
end
%{ "screen_name" => nickname } ->
%{"screen_name" => nickname} ->
case target = Repo.get_by(User, nickname: nickname) do
nil ->
{:error, "No user with such screen_name"}
@ -337,4 +332,22 @@ def unfollow(%User{} = follower, params) do
defp make_date do
DateTime.utc_now() |> DateTime.to_iso8601
end
def context_to_conversation_id(context) do
with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
id
else _e ->
changeset = Object.context_mapping(context)
{:ok, %{id: id}} = Repo.insert(changeset)
id
end
end
def conversation_id_to_context(id) do
with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
context
else _e ->
{:error, "No such conversation"}
end
end
end

View file

@ -2,8 +2,9 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
use Pleroma.Web, :controller
alias Pleroma.Web.TwitterAPI.TwitterAPI
alias Pleroma.Web.TwitterAPI.Representers.{UserRepresenter, ActivityRepresenter}
alias Pleroma.{Repo, Activity}
alias Pleroma.{Web, Repo, Activity}
alias Pleroma.Web.ActivityPub.ActivityPub
alias Ecto.Changeset
def verify_credentials(%{assigns: %{user: user}} = conn, _params) do
response = user |> UserRepresenter.to_json(%{for: user})
@ -15,7 +16,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
def status_update(%{assigns: %{user: user}} = conn, %{"status" => status_text} = status_data) do
if status_text |> String.trim |> String.length != 0 do
media_ids = extract_media_ids(status_data)
{:ok, activity} = TwitterAPI.create_status(user, Map.put(status_data, "media_ids", media_ids ))
{:ok, activity} = TwitterAPI.create_status(user, Map.put(status_data, "media_ids", media_ids))
conn
|> json_reply(200, ActivityRepresenter.to_json(activity, %{user: user}))
else
@ -41,6 +42,14 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
end
end
def public_and_external_timeline(%{assigns: %{user: user}} = conn, params) do
statuses = TwitterAPI.fetch_public_and_external_statuses(user, params)
{:ok, json} = Poison.encode(statuses)
conn
|> json_reply(200, json)
end
def public_timeline(%{assigns: %{user: user}} = conn, params) do
statuses = TwitterAPI.fetch_public_statuses(user, params)
{:ok, json} = Poison.encode(statuses)
@ -79,34 +88,34 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
def follow(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.follow(user, params) do
{ :ok, user, followed, _activity } ->
{:ok, user, followed, _activity} ->
response = followed |> UserRepresenter.to_json(%{for: user})
conn
|> json_reply(200, response)
{ :error, msg } -> forbidden_json_reply(conn, msg)
{:error, msg} -> forbidden_json_reply(conn, msg)
end
end
def unfollow(%{assigns: %{user: user}} = conn, params) do
case TwitterAPI.unfollow(user, params) do
{ :ok, user, unfollowed, } ->
{:ok, user, unfollowed} ->
response = unfollowed |> UserRepresenter.to_json(%{for: user})
conn
|> json_reply(200, response)
{ :error, msg } -> forbidden_json_reply(conn, msg)
{:error, msg} -> forbidden_json_reply(conn, msg)
end
end
def fetch_status(%{assigns: %{user: user}} = conn, %{ "id" => id }) do
response = TwitterAPI.fetch_status(user, id) |> Poison.encode!
def fetch_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
response = Poison.encode!(TwitterAPI.fetch_status(user, id))
conn
|> json_reply(200, response)
end
def fetch_conversation(%{assigns: %{user: user}} = conn, %{ "id" => id }) do
def fetch_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do
id = String.to_integer(id)
response = TwitterAPI.fetch_conversation(user, id) |> Poison.encode!
response = Poison.encode!(TwitterAPI.fetch_conversation(user, id))
conn
|> json_reply(200, response)
@ -132,8 +141,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
def config(conn, _params) do
response = %{
site: %{
name: Pleroma.Web.base_url,
server: Pleroma.Web.base_url,
name: Web.base_url,
server: Web.base_url,
textlimit: -1
}
}
@ -188,11 +197,10 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
def update_avatar(%{assigns: %{user: user}} = conn, params) do
{:ok, object} = ActivityPub.upload(params)
change = Ecto.Changeset.change(user, %{avatar: object.data})
change = Changeset.change(user, %{avatar: object.data})
{:ok, user} = Repo.update(change)
response = UserRepresenter.to_map(user, %{for: user})
|> Poison.encode!
response = Poison.encode!(UserRepresenter.to_map(user, %{for: user}))
conn
|> json_reply(200, response)

View file

@ -20,8 +20,7 @@ defmodule Pleroma.Web do
quote do
use Phoenix.Controller, namespace: Pleroma.Web
import Plug.Conn
import Pleroma.Web.Router.Helpers
import Pleroma.Web.Gettext
import Pleroma.Web.{Gettext, Router.Helpers}
end
end
@ -33,9 +32,7 @@ defmodule Pleroma.Web do
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]
import Pleroma.Web.Router.Helpers
import Pleroma.Web.ErrorHelpers
import Pleroma.Web.Gettext
import Pleroma.Web.{ErrorHelpers, Gettext, Router.Helpers}
end
end
@ -61,27 +58,7 @@ defmodule Pleroma.Web do
apply(__MODULE__, which, [])
end
def host do
settings = Application.get_env(:pleroma, Pleroma.Web.Endpoint)
settings
|> Keyword.fetch!(:url)
|> Keyword.fetch!(:host)
end
def base_url do
settings = Application.get_env(:pleroma, Pleroma.Web.Endpoint)
host = host()
protocol = settings |> Keyword.fetch!(:protocol)
port_fragment = with {:ok, protocol_info} <- settings |> Keyword.fetch(String.to_atom(protocol)),
{:ok, port} <- protocol_info |> Keyword.fetch(:port)
do
":#{port}"
else _e ->
""
end
"#{protocol}://#{host}#{port_fragment}"
Pleroma.Web.Endpoint.url
end
end

View file

@ -1,39 +1,111 @@
defmodule Pleroma.Web.WebFinger do
alias Pleroma.XmlBuilder
alias Pleroma.User
alias Pleroma.Web.OStatus
@httpoison Application.get_env(:pleroma, :httpoison)
def host_meta() do
base_url = Pleroma.Web.base_url
alias Pleroma.{Repo, User, XmlBuilder}
alias Pleroma.Web
alias Pleroma.Web.{XML, Salmon, OStatus}
require Logger
def host_meta do
base_url = Web.base_url
{
:XRD, %{ xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0" },
: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}" }
:Link, %{rel: "lrdd", type: "application/xrd+xml", template: "#{base_url}/.well-known/webfinger?resource={uri}"}
}
}
|> XmlBuilder.to_doc
end
def webfinger(resource) do
host = Pleroma.Web.host
regex = ~r/acct:(?<username>\w+)@#{host}/
case Regex.named_captures(regex, resource) do
%{"username" => username} ->
user = User.get_cached_by_nickname(username)
host = Pleroma.Web.Endpoint.host
regex = ~r/(acct:)?(?<username>\w+)@#{host}/
with %{"username" => username} <- Regex.named_captures(regex, resource) do
user = User.get_by_nickname(username)
{:ok, represent_user(user)}
else _e ->
with user when not is_nil(user) <- User.get_cached_by_ap_id(resource) do
{:ok, represent_user(user)}
_ -> nil
else _e ->
{:error, "Couldn't find user"}
end
end
end
def represent_user(user) do
{:ok, user} = ensure_keys_present(user)
{:ok, _private, public} = Salmon.keys_from_pem(user.info["keys"])
magic_key = Salmon.encode_key(public)
{
:XRD, %{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"},
[
{:Subject, "acct:#{user.nickname}@#{Pleroma.Web.host}"},
{:Subject, "acct:#{user.nickname}@#{Pleroma.Web.Endpoint.host}"},
{:Alias, user.ap_id},
{:Link, %{rel: "http://schemas.google.com/g/2010#updates-from", type: "application/atom+xml", href: OStatus.feed_path(user)}}
{:Link, %{rel: "http://schemas.google.com/g/2010#updates-from", type: "application/atom+xml", href: OStatus.feed_path(user)}},
{:Link, %{rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: user.ap_id}},
{:Link, %{rel: "salmon", href: OStatus.salmon_path(user)}},
{:Link, %{rel: "magic-public-key", href: "data:application/magic-public-key,#{magic_key}"}}
]
}
|> XmlBuilder.to_doc
end
# This seems a better fit in Salmon
def ensure_keys_present(user) do
info = user.info || %{}
if info["keys"] do
{:ok, user}
else
{:ok, pem} = Salmon.generate_rsa_pem
info = Map.put(info, "keys", pem)
Repo.update(Ecto.Changeset.change(user, info: info))
end
end
# FIXME: Make this call the host-meta to find the actual address.
defp webfinger_address(domain) do
"//#{domain}/.well-known/webfinger"
end
defp webfinger_from_xml(doc) do
magic_key = XML.string_from_xpath(~s{//Link[@rel="magic-public-key"]/@href}, doc)
"data:application/magic-public-key," <> magic_key = magic_key
topic = XML.string_from_xpath(~s{//Link[@rel="http://schemas.google.com/g/2010#updates-from"]/@href}, doc)
subject = XML.string_from_xpath("//Subject", doc)
salmon = XML.string_from_xpath(~s{//Link[@rel="salmon"]/@href}, doc)
data = %{
"magic_key" => magic_key,
"topic" => topic,
"subject" => subject,
"salmon" => salmon
}
{:ok, data}
end
def finger(account, getter \\ &@httpoison.get/3) do
domain = with [_name, domain] <- String.split(account, "@") do
domain
else _e ->
URI.parse(account).host
end
address = webfinger_address(domain)
# try https first
response = with {:ok, result} <- getter.("https:" <> address, ["Accept": "application/xrd+xml"], [params: [resource: account]]) do
{:ok, result}
else _ ->
getter.("http:" <> address, ["Accept": "application/xrd+xml"], [params: [resource: account], follow_redirect: true])
end
with {:ok, %{status_code: status_code, body: body}} when status_code in 200..299 <- response,
doc <- XML.parse_document(body),
{:ok, data} <- webfinger_from_xml(doc) do
{:ok, data}
else
e ->
Logger.debug(fn -> "Couldn't finger #{account}." end)
Logger.debug(fn -> inspect(e) end)
{:error, e}
end
end
end

View file

@ -12,7 +12,7 @@ defmodule Pleroma.Web.WebFinger.WebFingerController do
end
def webfinger(conn, %{"resource" => resource}) do
{:ok, response} = Pleroma.Web.WebFinger.webfinger(resource)
{:ok, response} = WebFinger.webfinger(resource)
conn
|> put_resp_content_type("application/xrd+xml")

View file

@ -1,16 +1,20 @@
defmodule Pleroma.Web.Websub do
alias Ecto.Changeset
alias Pleroma.Repo
alias Pleroma.Web.Websub.WebsubServerSubscription
alias Pleroma.Web.Websub.{WebsubServerSubscription, WebsubClientSubscription}
alias Pleroma.Web.OStatus.FeedRepresenter
alias Pleroma.Web.OStatus
alias Pleroma.Web.{XML, Endpoint, OStatus}
alias Pleroma.Web.Router.Helpers
require Logger
import Ecto.Query
@websub_verifier Application.get_env(:pleroma, :websub_verifier)
@httpoison Application.get_env(:pleroma, :httpoison)
def verify(subscription, getter \\ &HTTPoison.get/3 ) do
def verify(subscription, getter \\ &@httpoison.get/3) do
challenge = Base.encode16(:crypto.strong_rand_bytes(8))
lease_seconds = NaiveDateTime.diff(subscription.valid_until, subscription.updated_at) |> to_string
lease_seconds = NaiveDateTime.diff(subscription.valid_until, subscription.updated_at)
lease_seconds = lease_seconds |> to_string
params = %{
"hub.challenge": challenge,
@ -25,11 +29,11 @@ defmodule Pleroma.Web.Websub do
with {:ok, response} <- getter.(url, [], [params: params]),
^challenge <- response.body
do
changeset = Ecto.Changeset.change(subscription, %{state: "active"})
changeset = Changeset.change(subscription, %{state: "active"})
Repo.update(changeset)
else _e ->
changeset = Ecto.Changeset.change(subscription, %{state: "rejected"})
{:ok, subscription } = Repo.update(changeset)
changeset = Changeset.change(subscription, %{state: "rejected"})
{:ok, subscription} = Repo.update(changeset)
{:error, subscription}
end
end
@ -39,18 +43,27 @@ defmodule Pleroma.Web.Websub do
where: sub.topic == ^topic and sub.state == "active"
subscriptions = Repo.all(query)
Enum.each(subscriptions, fn(sub) ->
response = FeedRepresenter.to_simple_form(user, [activity], [user])
response = user
|> FeedRepresenter.to_simple_form([activity], [user])
|> :xmerl.export_simple(:xmerl_xml)
|> to_string
signature = :crypto.hmac(:sha, sub.secret, response) |> Base.encode16
signature = sign(sub.secret || "", response)
Logger.debug(fn -> "Pushing to #{sub.callback}" end)
HTTPoison.post(sub.callback, response, [
{"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"}
])
Task.start(fn ->
@httpoison.post(sub.callback, response, [
{"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"}
])
end)
end)
end
def sign(secret, doc) do
:crypto.hmac(:sha, secret, to_string(doc)) |> Base.encode16 |> String.downcase
end
def incoming_subscription_request(user, %{"hub.mode" => "subscribe"} = params) do
with {:ok, topic} <- valid_topic(params, user),
{:ok, lease_time} <- lease_time(params),
@ -65,23 +78,32 @@ defmodule Pleroma.Web.Websub do
callback: callback
}
change = Ecto.Changeset.change(subscription, data)
change = Changeset.change(subscription, data)
websub = Repo.insert_or_update!(change)
change = Ecto.Changeset.change(websub, %{valid_until: NaiveDateTime.add(websub.updated_at, lease_time)})
change = Changeset.change(websub, %{valid_until:
NaiveDateTime.add(websub.updated_at, lease_time)})
websub = Repo.update!(change)
# Just spawn that for now, maybe pool later.
spawn(fn -> @websub_verifier.verify(websub) end)
Pleroma.Web.Federator.enqueue(:verify_websub, websub)
{:ok, websub}
else {:error, reason} ->
Logger.debug("Couldn't create subscription.")
Logger.debug(inspect(reason))
{:error, reason}
end
end
defp get_subscription(topic, callback) do
Repo.get_by(WebsubServerSubscription, topic: topic, callback: callback) || %WebsubServerSubscription{}
Repo.get_by(WebsubServerSubscription, topic: topic, callback: callback) ||
%WebsubServerSubscription{}
end
# Temp hack for mastodon.
defp lease_time(%{"hub.lease_seconds" => ""}) do
{:ok, 60 * 60 * 24 * 3} # three days
end
defp lease_time(%{"hub.lease_seconds" => lease_seconds}) do
@ -94,9 +116,92 @@ defmodule Pleroma.Web.Websub do
defp valid_topic(%{"hub.topic" => topic}, user) do
if topic == OStatus.feed_path(user) do
{:ok, topic}
{:ok, OStatus.feed_path(user)}
else
{:error, "Wrong topic requested, expected #{OStatus.feed_path(user)}, got #{topic}"}
end
end
def subscribe(subscriber, subscribed, requester \\ &request_subscription/1) do
topic = subscribed.info["topic"]
# FIXME: Race condition, use transactions
{:ok, subscription} = with subscription when not is_nil(subscription) <- Repo.get_by(WebsubClientSubscription, topic: topic) do
subscribers = [subscriber.ap_id | subscription.subscribers] |> Enum.uniq
change = Ecto.Changeset.change(subscription, %{subscribers: subscribers})
Repo.update(change)
else _e ->
subscription = %WebsubClientSubscription{
topic: topic,
hub: subscribed.info["hub"],
subscribers: [subscriber.ap_id],
state: "requested",
secret: :crypto.strong_rand_bytes(8) |> Base.url_encode64,
user: subscribed
}
Repo.insert(subscription)
end
requester.(subscription)
end
def gather_feed_data(topic, getter \\ &@httpoison.get/1) do
with {:ok, response} <- getter.(topic),
status_code when status_code in 200..299 <- response.status_code,
body <- response.body,
doc <- XML.parse_document(body),
uri when not is_nil(uri) <- XML.string_from_xpath("/feed/author[1]/uri", doc),
hub when not is_nil(hub) <- XML.string_from_xpath(~S{/feed/link[@rel="hub"]/@href}, doc) do
name = XML.string_from_xpath("/feed/author[1]/name", doc)
preferredUsername = XML.string_from_xpath("/feed/author[1]/poco:preferredUsername", doc)
displayName = XML.string_from_xpath("/feed/author[1]/poco:displayName", doc)
avatar = OStatus.make_avatar_object(doc)
{:ok, %{
"uri" => uri,
"hub" => hub,
"nickname" => preferredUsername || name,
"name" => displayName || name,
"host" => URI.parse(uri).host,
"avatar" => avatar
}}
else e ->
{:error, e}
end
end
def request_subscription(websub, poster \\ &@httpoison.post/3, timeout \\ 10_000) do
data = [
"hub.mode": "subscribe",
"hub.topic": websub.topic,
"hub.secret": websub.secret,
"hub.callback": Helpers.websub_url(Endpoint, :websub_subscription_confirmation, websub.id)
]
# This checks once a second if we are confirmed yet
websub_checker = fn ->
helper = fn (helper) ->
:timer.sleep(1000)
websub = Repo.get_by(WebsubClientSubscription, id: websub.id, state: "accepted")
if websub, do: websub, else: helper.(helper)
end
helper.(helper)
end
task = Task.async(websub_checker)
with {:ok, %{status_code: 202}} <- poster.(websub.hub, {:form, data}, ["Content-type": "application/x-www-form-urlencoded"]),
{:ok, websub} <- Task.yield(task, timeout) do
{:ok, websub}
else e ->
Task.shutdown(task)
change = Ecto.Changeset.change(websub, %{state: "rejected"})
{:ok, websub} = Repo.update(change)
Logger.debug(fn -> "Couldn't confirm subscription: #{inspect(websub)}" end)
Logger.debug(fn -> "error: #{inspect(e)}" end)
{:error, websub}
end
end
end

View file

@ -0,0 +1,16 @@
defmodule Pleroma.Web.Websub.WebsubClientSubscription do
use Ecto.Schema
alias Pleroma.User
schema "websub_client_subscriptions" do
field :topic, :string
field :secret, :string
field :valid_until, :naive_datetime
field :state, :string
field :subscribers, {:array, :string}, default: []
field :hub, :string
belongs_to :user, User
timestamps()
end
end

View file

@ -1,7 +1,9 @@
defmodule Pleroma.Web.Websub.WebsubController do
use Pleroma.Web, :controller
alias Pleroma.User
alias Pleroma.Web.Websub
alias Pleroma.{Repo, User}
alias Pleroma.Web.{Websub, Federator}
alias Pleroma.Web.Websub.WebsubClientSubscription
require Logger
def websub_subscription_request(conn, %{"nickname" => nickname} = params) do
user = User.get_cached_by_nickname(nickname)
@ -15,4 +17,32 @@ defmodule Pleroma.Web.Websub.WebsubController do
|> send_resp(500, reason)
end
end
def websub_subscription_confirmation(conn, %{"id" => id, "hub.mode" => "subscribe", "hub.challenge" => challenge, "hub.topic" => topic}) do
with %WebsubClientSubscription{} = websub <- Repo.get_by(WebsubClientSubscription, id: id, topic: topic) do
change = Ecto.Changeset.change(websub, %{state: "accepted"})
{:ok, _websub} = Repo.update(change)
conn
|> send_resp(200, challenge)
else _e ->
conn
|> send_resp(500, "Error")
end
end
def websub_incoming(conn, %{"id" => id}) do
with "sha1=" <> signature <- hd(get_req_header(conn, "x-hub-signature")),
signature <- String.downcase(signature),
%WebsubClientSubscription{} = websub <- Repo.get(WebsubClientSubscription, id),
{:ok, body, _conn} = read_body(conn),
^signature <- Websub.sign(websub.secret, body) do
Federator.enqueue(:incoming_doc, body)
conn
|> send_resp(200, "OK")
else _e ->
Logger.debug("Can't handle incoming subscription post")
conn
|> send_resp(500, "Error")
end
end
end

View file

@ -0,0 +1,19 @@
defmodule Pleroma.Web.XML do
def string_from_xpath(xpath, doc) do
{:xmlObj, :string, res} = :xmerl_xpath.string('string(#{xpath})', doc)
res = res
|> to_string
|> String.trim
if res == "", do: nil, else: res
end
def parse_document(text) do
{doc, _rest} = text
|> :binary.bin_to_list
|> :xmerl_scan.string
doc
end
end

View file

@ -30,13 +30,13 @@ defmodule Pleroma.XmlBuilder do
NaiveDateTime.to_iso8601(time)
end
def to_doc(content), do: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" <> to_xml(content)
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(" ")
Enum.join([tag, attributes_string], " ") |> String.strip
[tag, attributes_string] |> Enum.join(" ") |> String.strip
end
end