defmodule Pleroma.User do
use Ecto.Schema
- import Ecto.Changeset
- import Ecto.Query
- alias Pleroma.{Repo, User, Activity, Object}
++
+ import Ecto.{Changeset, Query}
+ alias Pleroma.{Repo, User, Object, Web}
+ alias Comeonin.Pbkdf2
+ alias Pleroma.Web.OStatus
schema "users" do
field :bio, :string
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
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
|> 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
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
following = [ap_followers | follower.following]
|> Enum.uniq
|> follow_changeset(%{following: following})
|> Repo.update
else
- { :error, "Not subscribed!" }
+ {:error, "Not subscribed!"}
end
end
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
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)
map
end
- Repo.insert(%Activity{data: map})
+ Repo.insert(%Activity{data: map, local: local})
+ end
+
+ 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) do
+ def like(%User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object) do
cond do
# There's already a like here, so return the original activity.
ap_id in (object.data["likes"] || []) ->
"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)
|> 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
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
|> 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)
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
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
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
|> 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
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
{: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(%{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 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
- def to_simple_form(_,_,_), do: nil
++ def to_simple_form(_, _, _), do: nil
end
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)
[{
: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
}]
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
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,
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
|> 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)
+
+ Pleroma.Web.OStatus.handle_incoming(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 = ActivityRepresenter.to_simple_form(activity, 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
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})}
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
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/user_timeline", TwitterAPI.Controller, :user_timeline
get "/statuses/show/:id", TwitterAPI.Controller, :fetch_status
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
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
defmodule Pleroma.Web.Salmon do
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)
{: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)
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
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)
{: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 = :public_key.sign(signed_text, :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) ->
+ Logger.debug("sending salmon to #{remote_user.ap_id}")
+ send_to_user(remote_user, feed, poster)
+ end)
+ end
+ end
+
+ def publish(%{id: id}, _, _), do: Logger.debug("Keys missing for user #{id}")
end
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
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."
}
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,
}
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,
}
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] || []
|> Enum.filter(&(&1))
|> Enum.map(fn (user) -> UserRepresenter.to_map(user, opts) end)
+
+ conversation_id = with context when not is_nil(context) <- activity.data["context"] do
+ TwitterAPI.context_to_conversation_id(context)
+ else _e -> nil
+ end
+
%{
"id" => activity.id,
"user" => UserRepresenter.to_map(user, opts),
"is_local" => true,
"is_post_verb" => true,
"created_at" => created_at,
- "in_reply_to_status_id" => activity.data["object"]["inReplyToStatusId"],
+ "in_reply_to_status_id" => object["inReplyToStatusId"],
- "statusnet_conversation_id" => object["statusnetConversationId"],
+ "statusnet_conversation_id" => conversation_id,
- "attachments" => (activity.data["object"]["attachment"] || []) |> ObjectRepresenter.enum_to_list(opts),
+ "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
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
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})
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
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)
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)
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
}
}
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)
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
# 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
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
defmodule Pleroma.Web.WebFinger do
- alias Pleroma.{User, XmlBuilder}
- alias Pleroma.{Web, Web.OStatus}
- alias Pleroma.XmlBuilder
- alias Pleroma.{Repo, User}
++
++ alias Pleroma.{Repo, User, XmlBuilder}
++ alias Pleroma.Web
+ alias Pleroma.Web.{XML, Salmon, OStatus}
+ require Logger
- def host_meta() do
- base_url = Pleroma.Web.base_url
+ 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 = 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}@#{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("Couldn't finger #{account}.")
+ Logger.debug(inspect(e))
+ {:error, e}
+ end
+ end
end
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
- def verify(subscription, getter \\ &HTTPoison.get/3 ) do
+ @websub_verifier Application.get_env(:pleroma, :websub_verifier)
+
+ 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,
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
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 = Base.encode16(:crypto.hmac(:sha, sub.secret, response))
+ signature = sign(sub.secret || "", response)
+ Logger.debug("Pushing to #{sub.callback}")
+
HTTPoison.post(sub.callback, response, [
{"Content-Type", "application/atom+xml"},
{"X-Hub-Signature", "sha1=#{signature}"}
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),
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
{:ok, String.to_integer(lease_seconds)}
end
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("Couldn't confirm subscription: #{inspect(websub)}")
+ Logger.debug("error: #{inspect(e)}")
+
+ {:error, websub}
+ end
+ end
end