diff options
Diffstat (limited to 'lib')
22 files changed, 795 insertions, 126 deletions
diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 9e2523b18..6a83a8c0d 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -163,7 +163,7 @@ defmodule Mix.Tasks.Pleroma.User do Common.start_pleroma() with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - User.delete(user) + User.perform(:delete, user) Mix.shell().info("User #{nickname} deleted.") else _ -> @@ -380,7 +380,7 @@ defmodule Mix.Tasks.Pleroma.User do Common.start_pleroma() with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - User.delete_user_activities(user) + {:ok, _} = User.delete_user_activities(user) Mix.shell().info("User #{nickname} statuses deleted.") else _ -> diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 4a2ded518..73e63bb14 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -14,6 +14,8 @@ defmodule Pleroma.Activity do import Ecto.Query @type t :: %__MODULE__{} + @type actor :: String.t() + @primary_key {:id, Pleroma.FlakeId, autogenerate: true} # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @@ -260,4 +262,9 @@ defmodule Pleroma.Activity do |> where([s], s.actor == ^actor) |> Repo.all() end + + @spec query_by_actor(actor()) :: Ecto.Query.t() + def query_by_actor(actor) do + from(a in Activity, where: a.actor == ^actor) + end end diff --git a/lib/pleroma/bbs/authenticator.ex b/lib/pleroma/bbs/authenticator.ex new file mode 100644 index 000000000..a2c153720 --- /dev/null +++ b/lib/pleroma/bbs/authenticator.ex @@ -0,0 +1,16 @@ +defmodule Pleroma.BBS.Authenticator do + use Sshd.PasswordAuthenticator + alias Comeonin.Pbkdf2 + alias Pleroma.User + + def authenticate(username, password) do + username = to_string(username) + password = to_string(password) + + with %User{} = user <- User.get_by_nickname(username) do + Pbkdf2.checkpw(password, user.password_hash) + else + _e -> false + end + end +end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex new file mode 100644 index 000000000..106fe5d18 --- /dev/null +++ b/lib/pleroma/bbs/handler.ex @@ -0,0 +1,147 @@ +defmodule Pleroma.BBS.Handler do + use Sshd.ShellHandler + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + + def on_shell(username, _pubkey, _ip, _port) do + :ok = IO.puts("Welcome to #{Pleroma.Config.get([:instance, :name])}!") + user = Pleroma.User.get_cached_by_nickname(to_string(username)) + Logger.debug("#{inspect(user)}") + loop(run_state(user: user)) + end + + def on_connect(username, ip, port, method) do + Logger.debug(fn -> + """ + Incoming SSH shell #{inspect(self())} requested for #{username} from #{inspect(ip)}:#{ + inspect(port) + } using #{inspect(method)} + """ + end) + end + + def on_disconnect(username, ip, port) do + Logger.debug(fn -> + "Disconnecting SSH shell for #{username} from #{inspect(ip)}:#{inspect(port)}" + end) + end + + defp loop(state) do + self_pid = self() + counter = state.counter + prefix = state.prefix + user = state.user + + input = spawn(fn -> io_get(self_pid, prefix, counter, user.nickname) end) + wait_input(state, input) + end + + def puts_activity(activity) do + status = Pleroma.Web.MastodonAPI.StatusView.render("status.json", %{activity: activity}) + IO.puts("-- #{status.id} by #{status.account.display_name} (#{status.account.acct})") + IO.puts(HtmlSanitizeEx.strip_tags(status.content)) + IO.puts("") + end + + def handle_command(state, "help") do + IO.puts("Available commands:") + IO.puts("help - This help") + IO.puts("home - Show the home timeline") + IO.puts("p <text> - Post the given text") + IO.puts("r <id> <text> - Reply to the post with the given id") + IO.puts("quit - Quit") + + state + end + + def handle_command(%{user: user} = state, "r " <> text) do + text = String.trim(text) + [activity_id, rest] = String.split(text, " ", parts: 2) + + with %Activity{} <- Activity.get_by_id(activity_id), + {:ok, _activity} <- + CommonAPI.post(user, %{"status" => rest, "in_reply_to_status_id" => activity_id}) do + IO.puts("Replied!") + else + _e -> IO.puts("Could not reply...") + end + + state + end + + def handle_command(%{user: user} = state, "p " <> text) do + text = String.trim(text) + + with {:ok, _activity} <- CommonAPI.post(user, %{"status" => text}) do + IO.puts("Posted!") + else + _e -> IO.puts("Could not post...") + end + + state + end + + def handle_command(state, "home") do + user = state.user + + params = + %{} + |> Map.put("type", ["Create"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + activities = + [user.ap_id | user.following] + |> ActivityPub.fetch_activities(params) + |> ActivityPub.contain_timeline(user) + + Enum.each(activities, fn activity -> + puts_activity(activity) + end) + + state + end + + def handle_command(state, command) do + IO.puts("Unknown command '#{command}'") + state + end + + defp wait_input(state, input) do + receive do + {:input, ^input, "quit\n"} -> + IO.puts("Exiting...") + + {:input, ^input, code} when is_binary(code) -> + code = String.trim(code) + + state = handle_command(state, code) + + loop(%{state | counter: state.counter + 1}) + + {:error, :interrupted} -> + IO.puts("Caught Ctrl+C...") + loop(%{state | counter: state.counter + 1}) + + {:input, ^input, msg} -> + :ok = Logger.warn("received unknown message: #{inspect(msg)}") + loop(%{state | counter: state.counter + 1}) + end + end + + defp run_state(opts) do + %{prefix: "pleroma", counter: 1, user: opts[:user]} + end + + defp io_get(pid, prefix, counter, username) do + prompt = prompt(prefix, counter, username) + send(pid, {:input, self(), IO.gets(:stdio, prompt)}) + end + + defp prompt(prefix, counter, username) do + prompt = "#{username}@#{prefix}:#{counter}>" + prompt <> " " + end +end diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex new file mode 100644 index 000000000..6e26c5fd4 --- /dev/null +++ b/lib/pleroma/conversation.ex @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Conversation do + alias Pleroma.Conversation.Participation + alias Pleroma.Repo + alias Pleroma.User + use Ecto.Schema + import Ecto.Changeset + + schema "conversations" do + # This is the context ap id. + field(:ap_id, :string) + has_many(:participations, Participation) + has_many(:users, through: [:participations, :user]) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:ap_id]) + |> validate_required([:ap_id]) + |> unique_constraint(:ap_id) + end + + def create_for_ap_id(ap_id) do + %__MODULE__{} + |> creation_cng(%{ap_id: ap_id}) + |> Repo.insert( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + returning: true, + conflict_target: :ap_id + ) + end + + def get_for_ap_id(ap_id) do + Repo.get_by(__MODULE__, ap_id: ap_id) + end + + @doc """ + This will + 1. Create a conversation if there isn't one already + 2. Create a participation for all the people involved who don't have one already + 3. Bump all relevant participations to 'unread' + """ + def create_or_bump_for(activity) do + with true <- Pleroma.Web.ActivityPub.Visibility.is_direct?(activity), + object <- Pleroma.Object.normalize(activity), + "Create" <- activity.data["type"], + "Note" <- object.data["type"], + ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do + {:ok, conversation} = create_for_ap_id(ap_id) + + users = User.get_users_from_set(activity.recipients, false) + + participations = + Enum.map(users, fn user -> + {:ok, participation} = + Participation.create_for_user_and_conversation(user, conversation) + + participation + end) + + {:ok, + %{ + conversation + | participations: participations + }} + else + e -> {:error, e} + end + end +end diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex new file mode 100644 index 000000000..61021fb18 --- /dev/null +++ b/lib/pleroma/conversation/participation.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Conversation.Participation do + use Ecto.Schema + alias Pleroma.Conversation + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + import Ecto.Changeset + import Ecto.Query + + schema "conversation_participations" do + belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:conversation, Conversation) + field(:read, :boolean, default: false) + field(:last_activity_id, Pleroma.FlakeId, virtual: true) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:user_id, :conversation_id]) + |> validate_required([:user_id, :conversation_id]) + end + + def create_for_user_and_conversation(user, conversation) do + %__MODULE__{} + |> creation_cng(%{user_id: user.id, conversation_id: conversation.id}) + |> Repo.insert( + on_conflict: [set: [read: false, updated_at: NaiveDateTime.utc_now()]], + returning: true, + conflict_target: [:user_id, :conversation_id] + ) + end + + def read_cng(struct, params) do + struct + |> cast(params, [:read]) + |> validate_required([:read]) + end + + def mark_as_read(participation) do + participation + |> read_cng(%{read: true}) + |> Repo.update() + end + + def mark_as_unread(participation) do + participation + |> read_cng(%{read: false}) + |> Repo.update() + end + + def for_user(user, params \\ %{}) do + from(p in __MODULE__, + where: p.user_id == ^user.id, + order_by: [desc: p.updated_at] + ) + |> Pleroma.Pagination.fetch_paginated(params) + |> Repo.preload(conversation: [:users]) + end + + def for_user_with_last_activity_id(user, params \\ %{}) do + for_user(user, params) + |> Enum.map(fn participation -> + activity_id = + ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ + "user" => user, + "blocking_user" => user + }) + + %{ + participation + | last_activity_id: activity_id + } + end) + end +end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index aa5d427ae..f57e088bc 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -19,4 +19,32 @@ defmodule Pleroma.Repo do def init(_, opts) do {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} end + + @doc "find resource based on prepared query" + @spec find_resource(Ecto.Query.t()) :: {:ok, struct()} | {:error, :not_found} + def find_resource(%Ecto.Query{} = query) do + case __MODULE__.one(query) do + nil -> {:error, :not_found} + resource -> {:ok, resource} + end + end + + def find_resource(_query), do: {:error, :not_found} + + @doc """ + Gets association from cache or loads if need + + ## Examples + + iex> Repo.get_assoc(token, :user) + %User{} + + """ + @spec get_assoc(struct(), atom()) :: {:ok, struct()} | {:error, :not_found} + def get_assoc(resource, association) do + case __MODULE__.preload(resource, association) do + %{^association => assoc} when not is_nil(assoc) -> {:ok, assoc} + _ -> {:error, :not_found} + end + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1741ce684..fd2ce81ad 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1164,7 +1164,12 @@ defmodule Pleroma.User do |> update_and_set_cache() end - def delete(%User{} = user) do + @spec delete(User.t()) :: :ok + def delete(%User{} = user), + do: PleromaJobQueue.enqueue(:background, __MODULE__, [:delete, user]) + + @spec perform(atom(), User.t()) :: {:ok, User.t()} + def perform(:delete, %User{} = user) do {:ok, user} = User.deactivate(user) # Remove all relationships @@ -1180,22 +1185,23 @@ defmodule Pleroma.User do end def delete_user_activities(%User{ap_id: ap_id} = user) do - Activity - |> where(actor: ^ap_id) - |> Activity.with_preloaded_object() - |> Repo.all() - |> Enum.each(fn - %{data: %{"type" => "Create"}} = activity -> - activity |> Object.normalize() |> ActivityPub.delete() + stream = + ap_id + |> Activity.query_by_actor() + |> Activity.with_preloaded_object() + |> Repo.stream() - # TODO: Do something with likes, follows, repeats. - _ -> - "Doing nothing" - end) + Repo.transaction(fn -> Enum.each(stream, &delete_activity(&1)) end, timeout: :infinity) {:ok, user} end + defp delete_activity(%{data: %{"type" => "Create"}} = activity) do + Object.normalize(activity) |> ActivityPub.delete() + end + + defp delete_activity(_activity), do: "Doing nothing" + def html_filter_policy(%User{info: %{no_rich_text: true}}) do Pleroma.HTML.Scrubber.TwitterText end diff --git a/lib/pleroma/user_invite_token.ex b/lib/pleroma/user_invite_token.ex index 86f0a5486..fadc89891 100644 --- a/lib/pleroma/user_invite_token.ex +++ b/lib/pleroma/user_invite_token.ex @@ -24,7 +24,7 @@ defmodule Pleroma.UserInviteToken do timestamps() end - @spec create_invite(map()) :: UserInviteToken.t() + @spec create_invite(map()) :: {:ok, UserInviteToken.t()} def create_invite(params \\ %{}) do %UserInviteToken{} |> cast(params, [:max_use, :expires_at]) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 483a2153f..6c737d0a4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Activity + alias Pleroma.Conversation alias Pleroma.Instances alias Pleroma.Notification alias Pleroma.Object @@ -141,7 +142,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end) Notification.create_notifications(activity) + + participations = + activity + |> Conversation.create_or_bump_for() + |> get_participations() + stream_out(activity) + stream_out_participations(participations) {:ok, activity} else %Activity{} = activity -> @@ -164,6 +172,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + defp get_participations({:ok, %{participations: participations}}), do: participations + defp get_participations(_), do: [] + + def stream_out_participations(participations) do + participations = + participations + |> Repo.preload(:user) + + Enum.each(participations, fn participation -> + Pleroma.Web.Streamer.stream("participation", participation) + end) + end + def stream_out(activity) do public = "https://www.w3.org/ns/activitystreams#Public" @@ -195,6 +216,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end else + # TODO: Write test, replace with visibility test if !Enum.member?(activity.data["cc"] || [], public) && !Enum.member?( activity.data["to"], @@ -457,35 +479,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def fetch_activities_for_context(context, opts \\ %{}) do + defp fetch_activities_for_context_query(context, opts) do public = ["https://www.w3.org/ns/activitystreams#Public"] recipients = if opts["user"], do: [opts["user"].ap_id | opts["user"].following] ++ public, else: public - query = from(activity in Activity) - - query = - query - |> restrict_blocked(opts) - |> restrict_recipients(recipients, opts["user"]) - - query = - from( - activity in query, - where: - fragment( - "?->>'type' = ? and ?->>'context' = ?", - activity.data, - "Create", - activity.data, - ^context - ), - order_by: [desc: :id] + from(activity in Activity) + |> restrict_blocked(opts) + |> restrict_recipients(recipients, opts["user"]) + |> where( + [activity], + fragment( + "?->>'type' = ? and ?->>'context' = ?", + activity.data, + "Create", + activity.data, + ^context ) - |> Activity.with_preloaded_object() + ) + |> order_by([activity], desc: activity.id) + end + + @spec fetch_activities_for_context(String.t(), keyword() | map()) :: [Activity.t()] + def fetch_activities_for_context(context, opts \\ %{}) do + context + |> fetch_activities_for_context_query(opts) + |> Activity.with_preloaded_object() + |> Repo.all() + end - Repo.all(query) + @spec fetch_latest_activity_id_for_context(String.t(), keyword() | map()) :: + Pleroma.FlakeId.t() | nil + def fetch_latest_activity_id_for_context(context, opts \\ %{}) do + context + |> fetch_activities_for_context_query(opts) + |> limit(1) + |> select([a], a.id) + |> Repo.one() end def fetch_public_activities(opts \\ %{}) do diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index b099199af..be60e5e3c 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config + alias Pleroma.Conversation.Participation alias Pleroma.Filter alias Pleroma.Formatter alias Pleroma.Notification @@ -24,6 +25,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AppView + alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.FilterView alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI @@ -165,7 +167,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end end - @mastodon_api_level "2.5.0" + @mastodon_api_level "2.6.5" def masto_instance(conn, _params) do instance = Config.get(:instance) @@ -1712,6 +1714,31 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end end + def conversations(%{assigns: %{user: user}} = conn, params) do + participations = Participation.for_user_with_last_activity_id(user, params) + + conversations = + Enum.map(participations, fn participation -> + ConversationView.render("participation.json", %{participation: participation, user: user}) + end) + + conn + |> add_link_headers(:conversations, participations) + |> json(conversations) + end + + def conversation_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + with %Participation{} = participation <- + Repo.get_by(Participation, id: participation_id, user_id: user.id), + {:ok, participation} <- Participation.mark_as_read(participation) do + participation_view = + ConversationView.render("participation.json", %{participation: participation, user: user}) + + conn + |> json(participation_view) + end + end + def try_render(conn, target, params) when is_binary(target) do res = render(conn, target, params) diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex new file mode 100644 index 000000000..8e8f7cf31 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -0,0 +1,38 @@ +defmodule Pleroma.Web.MastodonAPI.ConversationView do + use Pleroma.Web, :view + + alias Pleroma.Activity + alias Pleroma.Repo + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("participation.json", %{participation: participation, user: user}) do + participation = Repo.preload(participation, conversation: :users) + + last_activity_id = + with nil <- participation.last_activity_id do + ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ + "user" => user, + "blocking_user" => user + }) + end + + activity = Activity.get_by_id_with_object(last_activity_id) + + last_status = StatusView.render("status.json", %{activity: activity, for: user}) + + accounts = + AccountView.render("accounts.json", %{ + users: participation.conversation.users, + as: :user + }) + + %{ + id: participation.id |> to_string(), + accounts: accounts, + unread: !participation.read, + last_status: last_status + } + end +end diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index 3476da484..bccc2ac96 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.App do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} schema "apps" do field(:client_name, :string) field(:redirect_uris, :string) diff --git a/lib/pleroma/web/oauth/authorization.ex b/lib/pleroma/web/oauth/authorization.ex index 3461f9983..ca3901cc4 100644 --- a/lib/pleroma/web/oauth/authorization.ex +++ b/lib/pleroma/web/oauth/authorization.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.OAuth.Authorization do import Ecto.Changeset import Ecto.Query + @type t :: %__MODULE__{} schema "oauth_authorizations" do field(:token, :string) field(:scopes, {:array, :string}, default: []) @@ -63,4 +64,11 @@ defmodule Pleroma.Web.OAuth.Authorization do ) |> Repo.delete_all() end + + @doc "gets auth for app by token" + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id} = _app, token) do + from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token) + |> Repo.find_resource() + end end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 688eaca11..e3c01217d 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -13,11 +13,15 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken + alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] if Pleroma.Config.oauth_consumer_enabled?(), do: plug(Ueberauth) + @expires_in Pleroma.Config.get([:oauth2, :token_expires_in], 600) + plug(:fetch_session) plug(:fetch_flash) @@ -138,25 +142,33 @@ defmodule Pleroma.Web.OAuth.OAuthController do Authenticator.handle_error(conn, error) end + @doc "Renew access_token with refresh_token" + def token_exchange( + conn, + %{"grant_type" => "refresh_token", "refresh_token" => token} = params + ) do + with %App{} = app <- get_app_from_request(conn, params), + {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token), + {:ok, token} <- RefreshToken.grant(token) do + response_attrs = %{created_at: Token.Utils.format_created_at(token)} + + json(conn, response_token(user, token, response_attrs)) + else + _error -> + put_status(conn, 400) + |> json(%{error: "Invalid credentials"}) + end + end + def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) do with %App{} = app <- get_app_from_request(conn, params), - fixed_token = fix_padding(params["code"]), - %Authorization{} = auth <- - Repo.get_by(Authorization, token: fixed_token, app_id: app.id), + fixed_token = Token.Utils.fix_padding(params["code"]), + {:ok, auth} <- Authorization.get_by_token(app, fixed_token), %User{} = user <- User.get_cached_by_id(auth.user_id), - {:ok, token} <- Token.exchange_token(app, auth), - {:ok, inserted_at} <- DateTime.from_naive(token.inserted_at, "Etc/UTC") do - response = %{ - token_type: "Bearer", - access_token: token.token, - refresh_token: token.refresh_token, - created_at: DateTime.to_unix(inserted_at), - expires_in: 60 * 10, - scope: Enum.join(token.scopes, " "), - me: user.ap_id - } - - json(conn, response) + {:ok, token} <- Token.exchange_token(app, auth) do + response_attrs = %{created_at: Token.Utils.format_created_at(token)} + + json(conn, response_token(user, token, response_attrs)) else _error -> put_status(conn, 400) @@ -177,16 +189,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do true <- Enum.any?(scopes), {:ok, auth} <- Authorization.create_authorization(app, user, scopes), {:ok, token} <- Token.exchange_token(app, auth) do - response = %{ - token_type: "Bearer", - access_token: token.token, - refresh_token: token.refresh_token, - expires_in: 60 * 10, - scope: Enum.join(token.scopes, " "), - me: user.ap_id - } - - json(conn, response) + json(conn, response_token(user, token)) else {:auth_active, false} -> # Per https://github.com/tootsuite/mastodon/blob/ @@ -218,10 +221,12 @@ defmodule Pleroma.Web.OAuth.OAuthController do token_exchange(conn, params) end - def token_revoke(conn, %{"token" => token} = params) do + # Bad request + def token_exchange(conn, params), do: bad_request(conn, params) + + def token_revoke(conn, %{"token" => _token} = params) do with %App{} = app <- get_app_from_request(conn, params), - %Token{} = token <- Repo.get_by(Token, token: token, app_id: app.id), - {:ok, %Token{}} <- Repo.delete(token) do + {:ok, _token} <- RevokeToken.revoke(app, params) do json(conn, %{}) else _error -> @@ -230,6 +235,15 @@ defmodule Pleroma.Web.OAuth.OAuthController do end end + def token_revoke(conn, params), do: bad_request(conn, params) + + # Response for bad request + defp bad_request(conn, _) do + conn + |> put_status(500) + |> json(%{error: "Bad request"}) + end + @doc "Prepares OAuth request to provider for Ueberauth" def prepare_request(conn, %{"provider" => provider, "authorization" => auth_attrs}) do scope = @@ -278,25 +292,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do params = callback_params(params) with {:ok, registration} <- Authenticator.get_registration(conn) do - user = Repo.preload(registration, :user).user auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state)) - if user do - create_authorization( - conn, - %{"authorization" => auth_attrs}, - user: user - ) - else - registration_params = - Map.merge(auth_attrs, %{ - "nickname" => Registration.nickname(registration), - "email" => Registration.email(registration) - }) + case Repo.get_assoc(registration, :user) do + {:ok, user} -> + create_authorization(conn, %{"authorization" => auth_attrs}, user: user) - conn - |> put_session(:registration_id, registration.id) - |> registration_details(%{"authorization" => registration_params}) + _ -> + registration_params = + Map.merge(auth_attrs, %{ + "nickname" => Registration.nickname(registration), + "email" => Registration.email(registration) + }) + + conn + |> put_session(:registration_id, registration.id) + |> registration_details(%{"authorization" => registration_params}) end else _ -> @@ -399,36 +410,30 @@ defmodule Pleroma.Web.OAuth.OAuthController do end end - # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be - # decoding it. Investigate sometime. - defp fix_padding(token) do - token - |> URI.decode() - |> Base.url_decode64!(padding: false) - |> Base.url_encode64(padding: false) + defp get_app_from_request(conn, params) do + conn + |> fetch_client_credentials(params) + |> fetch_client end - defp get_app_from_request(conn, params) do - # Per RFC 6749, HTTP Basic is preferred to body params - {client_id, client_secret} = - with ["Basic " <> encoded] <- get_req_header(conn, "authorization"), - {:ok, decoded} <- Base.decode64(encoded), - [id, secret] <- - String.split(decoded, ":") - |> Enum.map(fn s -> URI.decode_www_form(s) end) do - {id, secret} - else - _ -> {params["client_id"], params["client_secret"]} - end + defp fetch_client({id, secret}) when is_binary(id) and is_binary(secret) do + Repo.get_by(App, client_id: id, client_secret: secret) + end - if client_id && client_secret do - Repo.get_by( - App, - client_id: client_id, - client_secret: client_secret - ) + defp fetch_client({_id, _secret}), do: nil + + defp fetch_client_credentials(conn, params) do + # Per RFC 6749, HTTP Basic is preferred to body params + with ["Basic " <> encoded] <- get_req_header(conn, "authorization"), + {:ok, decoded} <- Base.decode64(encoded), + [id, secret] <- + Enum.map( + String.split(decoded, ":"), + fn s -> URI.decode_www_form(s) end + ) do + {id, secret} else - nil + _ -> {params["client_id"], params["client_secret"]} end end @@ -441,4 +446,16 @@ defmodule Pleroma.Web.OAuth.OAuthController do defp put_session_registration_id(conn, registration_id), do: put_session(conn, :registration_id, registration_id) + + defp response_token(%User{} = user, token, opts \\ %{}) do + %{ + token_type: "Bearer", + access_token: token.token, + refresh_token: token.refresh_token, + expires_in: @expires_in, + scope: Enum.join(token.scopes, " "), + me: user.ap_id + } + |> Map.merge(opts) + end end diff --git a/lib/pleroma/web/oauth/token.ex b/lib/pleroma/web/oauth/token.ex index 399140003..4e5d1d118 100644 --- a/lib/pleroma/web/oauth/token.ex +++ b/lib/pleroma/web/oauth/token.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.Token do use Ecto.Schema import Ecto.Query + import Ecto.Changeset alias Pleroma.Repo alias Pleroma.User @@ -13,6 +14,9 @@ defmodule Pleroma.Web.OAuth.Token do alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token + @expires_in Pleroma.Config.get([:oauth2, :token_expires_in], 600) + @type t :: %__MODULE__{} + schema "oauth_tokens" do field(:token, :string) field(:refresh_token, :string) @@ -24,28 +28,67 @@ defmodule Pleroma.Web.OAuth.Token do timestamps() end + @doc "Gets token for app by access token" + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id} = _app, token) do + from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token) + |> Repo.find_resource() + end + + @doc "Gets token for app by refresh token" + @spec get_by_refresh_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_refresh_token(%App{id: app_id} = _app, token) do + from(t in __MODULE__, + where: t.app_id == ^app_id and t.refresh_token == ^token, + preload: [:user] + ) + |> Repo.find_resource() + end + def exchange_token(app, auth) do with {:ok, auth} <- Authorization.use_token(auth), true <- auth.app_id == app.id do - create_token(app, User.get_cached_by_id(auth.user_id), auth.scopes) + create_token( + app, + User.get_cached_by_id(auth.user_id), + %{scopes: auth.scopes} + ) end end - def create_token(%App{} = app, %User{} = user, scopes \\ nil) do - scopes = scopes || app.scopes - token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - refresh_token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) - - token = %Token{ - token: token, - refresh_token: refresh_token, - scopes: scopes, - user_id: user.id, - app_id: app.id, - valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10) - } - - Repo.insert(token) + defp put_token(changeset) do + changeset + |> change(%{token: Token.Utils.generate_token()}) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_refresh_token(changeset, attrs) do + refresh_token = Map.get(attrs, :refresh_token, Token.Utils.generate_token()) + + changeset + |> change(%{refresh_token: refresh_token}) + |> validate_required([:refresh_token]) + |> unique_constraint(:refresh_token) + end + + defp put_valid_until(changeset, attrs) do + expires_in = + Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), @expires_in)) + + changeset + |> change(%{valid_until: expires_in}) + |> validate_required([:valid_until]) + end + + def create_token(%App{} = app, %User{} = user, attrs \\ %{}) do + %__MODULE__{user_id: user.id, app_id: app.id} + |> cast(%{scopes: attrs[:scopes] || app.scopes}, [:scopes]) + |> validate_required([:scopes, :user_id, :app_id]) + |> put_valid_until(attrs) + |> put_token + |> put_refresh_token(attrs) + |> Repo.insert() end def delete_user_tokens(%User{id: user_id}) do @@ -73,4 +116,10 @@ defmodule Pleroma.Web.OAuth.Token do |> Repo.all() |> Repo.preload(:app) end + + def is_expired?(%__MODULE__{valid_until: valid_until}) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 + end + + def is_expired?(_), do: false end diff --git a/lib/pleroma/web/oauth/token/strategy/refresh_token.ex b/lib/pleroma/web/oauth/token/strategy/refresh_token.ex new file mode 100644 index 000000000..7df0be14e --- /dev/null +++ b/lib/pleroma/web/oauth/token/strategy/refresh_token.ex @@ -0,0 +1,54 @@ +defmodule Pleroma.Web.OAuth.Token.Strategy.RefreshToken do + @moduledoc """ + Functions for dealing with refresh token strategy. + """ + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.Revoke + + @doc """ + Will grant access token by refresh token. + """ + @spec grant(Token.t()) :: {:ok, Token.t()} | {:error, any()} + def grant(token) do + access_token = Repo.preload(token, [:user, :app]) + + result = + Repo.transaction(fn -> + token_params = %{ + app: access_token.app, + user: access_token.user, + scopes: access_token.scopes + } + + access_token + |> revoke_access_token() + |> create_access_token(token_params) + end) + + case result do + {:ok, {:error, reason}} -> {:error, reason} + {:ok, {:ok, token}} -> {:ok, token} + {:error, reason} -> {:error, reason} + end + end + + defp revoke_access_token(token) do + Revoke.revoke(token) + end + + defp create_access_token({:error, error}, _), do: {:error, error} + + defp create_access_token({:ok, token}, %{app: app, user: user} = token_params) do + Token.create_token(app, user, add_refresh_token(token_params, token.refresh_token)) + end + + defp add_refresh_token(params, token) do + case Config.get([:oauth2, :issue_new_refresh_token], false) do + true -> Map.put(params, :refresh_token, token) + false -> params + end + end +end diff --git a/lib/pleroma/web/oauth/token/strategy/revoke.ex b/lib/pleroma/web/oauth/token/strategy/revoke.ex new file mode 100644 index 000000000..dea63ca54 --- /dev/null +++ b/lib/pleroma/web/oauth/token/strategy/revoke.ex @@ -0,0 +1,22 @@ +defmodule Pleroma.Web.OAuth.Token.Strategy.Revoke do + @moduledoc """ + Functions for dealing with revocation. + """ + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Token + + @doc "Finds and revokes access token for app and by token" + @spec revoke(App.t(), map()) :: {:ok, Token.t()} | {:error, :not_found | Ecto.Changeset.t()} + def revoke(%App{} = app, %{"token" => token} = _attrs) do + with {:ok, token} <- Token.get_by_token(app, token), + do: revoke(token) + end + + @doc "Revokes access token" + @spec revoke(Token.t()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()} + def revoke(%Token{} = token) do + Repo.delete(token) + end +end diff --git a/lib/pleroma/web/oauth/token/utils.ex b/lib/pleroma/web/oauth/token/utils.ex new file mode 100644 index 000000000..a81560a1c --- /dev/null +++ b/lib/pleroma/web/oauth/token/utils.ex @@ -0,0 +1,30 @@ +defmodule Pleroma.Web.OAuth.Token.Utils do + @moduledoc """ + Auxiliary functions for dealing with tokens. + """ + + @doc "convert token inserted_at to unix timestamp" + def format_created_at(%{inserted_at: inserted_at} = _token) do + inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + end + + @doc false + @spec generate_token(keyword()) :: binary() + def generate_token(opts \\ []) do + opts + |> Keyword.get(:size, 32) + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + # XXX - for whatever reason our token arrives urlencoded, but Plug.Conn should be + # decoding it. Investigate sometime. + def fix_padding(token) do + token + |> URI.decode() + |> Base.url_decode64!(padding: false) + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index ff4f08af5..6d9c77c1a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -276,6 +276,9 @@ defmodule Pleroma.Web.Router do get("/suggestions", MastodonAPIController, :suggestions) + get("/conversations", MastodonAPIController, :conversations) + post("/conversations/:id/read", MastodonAPIController, :conversation_read) + get("/endorsements", MastodonAPIController, :empty_array) get("/pleroma/flavour", MastodonAPIController, :get_flavour) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 72eaf2084..133decfc4 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.Streamer do use GenServer require Logger alias Pleroma.Activity + alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -71,6 +72,15 @@ defmodule Pleroma.Web.Streamer do {:noreply, topics} end + def handle_cast(%{action: :stream, topic: "participation", item: participation}, topics) do + user_topic = "direct:#{participation.user_id}" + Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") + + push_to_socket(topics, user_topic, participation) + + {:noreply, topics} + end + def handle_cast(%{action: :stream, topic: "list", item: item}, topics) do # filter the recipient list if the activity is not public, see #270. recipient_lists = @@ -192,6 +202,19 @@ defmodule Pleroma.Web.Streamer do |> Jason.encode!() end + def represent_conversation(%Participation{} = participation) do + %{ + event: "conversation", + payload: + Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{ + participation: participation, + user: participation.user + }) + |> Jason.encode!() + } + |> Jason.encode!() + end + def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do Enum.each(topics[topic] || [], fn socket -> # Get the current user so we have up-to-date blocks etc. @@ -214,6 +237,12 @@ defmodule Pleroma.Web.Streamer do end) end + def push_to_socket(topics, topic, %Participation{} = participation) do + Enum.each(topics[topic] || [], fn socket -> + send(socket.transport_pid, {:text, represent_conversation(participation)}) + end) + end + def push_to_socket(topics, topic, %Activity{ data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} }) do diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 1122e6c5d..c03f8ab3a 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -352,7 +352,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do def delete_account(%{assigns: %{user: user}} = conn, params) do case CommonAPI.Utils.confirm_current_password(user, params["password"]) do {:ok, user} -> - Task.start(fn -> User.delete(user) end) + User.delete(user) json(conn, %{status: "success"}) {:error, msg} -> |