Merge branch 'develop' into issue/1276-2

This commit is contained in:
Maksim Pechnikov 2020-05-08 08:51:09 +03:00
commit b078e0567d
108 changed files with 4417 additions and 2063 deletions

View file

@ -238,7 +238,18 @@
account_field_value_length: 2048, account_field_value_length: 2048,
external_user_synchronization: true, external_user_synchronization: true,
extended_nickname_format: true, extended_nickname_format: true,
cleanup_attachments: false cleanup_attachments: false,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 5,
length: 16
]
]
config :pleroma, :extensions, output_relationships_in_statuses_by_default: true config :pleroma, :extensions, output_relationships_in_statuses_by_default: true

View file

@ -919,6 +919,62 @@
key: :external_user_synchronization, key: :external_user_synchronization,
type: :boolean, type: :boolean,
description: "Enabling following/followers counters synchronization for external users" description: "Enabling following/followers counters synchronization for external users"
},
%{
key: :multi_factor_authentication,
type: :keyword,
description: "Multi-factor authentication settings",
suggestions: [
[
totp: [digits: 6, period: 30],
backup_codes: [number: 5, length: 16]
]
],
children: [
%{
key: :totp,
type: :keyword,
description: "TOTP settings",
suggestions: [digits: 6, period: 30],
children: [
%{
key: :digits,
type: :integer,
suggestions: [6],
description:
"Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
},
%{
key: :period,
type: :integer,
suggestions: [30],
description:
"a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
}
]
},
%{
key: :backup_codes,
type: :keyword,
description: "MFA backup codes settings",
suggestions: [number: 5, length: 16],
children: [
%{
key: :number,
type: :integer,
suggestions: [5],
description: "number of backup codes to generate."
},
%{
key: :length,
type: :integer,
suggestions: [16],
description:
"Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
}
]
}
]
} }
] ]
}, },

View file

@ -56,6 +56,19 @@
ignore_hosts: [], ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"] ignore_tld: ["local", "localdomain", "lan"]
config :pleroma, :instance,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
config :web_push_encryption, :vapid_details, config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com", subject: "mailto:administrator@example.com",
public_key: public_key:

View file

@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
### Get a password reset token for a given nickname ### Get a password reset token for a given nickname
- Params: none - Params: none
- Response: - Response:
@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- `nicknames` - `nicknames`
- Response: none (code `204`) - Response: none (code `204`)
## PUT `/api/pleroma/admin/users/disable_mfa`
### Disable mfa for user's account.
- Params:
- `nickname`
- Response: Users nickname
## `GET /api/pleroma/admin/users/:nickname/credentials` ## `GET /api/pleroma/admin/users/:nickname/credentials`
### Get the user's email, password, display and settings-related fields ### Get the user's email, password, display and settings-related fields

View file

@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
* Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise * Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}` * Example response: `{"error": "Invalid password."}`
## `/api/pleroma/admin/` ## `/api/pleroma/accounts/mfa`
#### Gets current MFA settings
* method: `GET`
* Authentication: required
* OAuth scope: `read:security`
* Response: JSON. Returns `{"enabled": "false", "totp": false }`
## `/api/pleroma/accounts/mfa/setup/totp`
#### Pre-setup the MFA/TOTP method
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]" }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}`
## `/api/pleroma/accounts/mfa/confirm/totp`
#### Confirms & enables MFA/TOTP support for user account.
* method: `POST`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* `code`: token from TOTP App
* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
## `/api/pleroma/accounts/mfa/totp`
#### Disables MFA/TOTP method for user account.
* method: `DELETE`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}`
## `/api/pleroma/accounts/mfa/backup_codes`
#### Generstes backup codes MFA for user account.
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}`
## `/api/pleroma/admin/`
See [Admin-API](admin_api.md) See [Admin-API](admin_api.md)
## `/api/v1/pleroma/notifications/read` ## `/api/v1/pleroma/notifications/read`

View file

@ -8,6 +8,10 @@ For from source installations Pleroma configuration works by first importing the
To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted. To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted.
## :chat
* `enabled` - Enables the backend chat. Defaults to `true`.
## :instance ## :instance
* `name`: The instances name. * `name`: The instances name.
* `email`: Email used to reach an Administrator/Moderator of the instance. * `email`: Email used to reach an Administrator/Moderator of the instance.
@ -903,12 +907,18 @@ config :auto_linker,
* `runtime_dir`: A path to custom Elixir modules (such as MRF policies). * `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
## :configurable_from_database ## :configurable_from_database
Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information.
### Multi-factor authentication - :two_factor_authentication
* `totp` - a list containing TOTP configuration
- `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
- `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
* `backup_codes` - a list containing backup codes configuration
- `number` - number of backup codes to generate.
- `length` - backup code length. Defaults to 16 characters.
## Restrict entities access for unauthenticated users ## Restrict entities access for unauthenticated users
@ -926,6 +936,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us
* `local` * `local`
* `remote` * `remote`
## Pleroma.Web.ApiSpec.CastAndValidate ## Pleroma.Web.ApiSpec.CastAndValidate
* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`. * `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.

View file

@ -32,9 +32,8 @@ CustomLog ${APACHE_LOG_DIR}/access.log combined
<VirtualHost *:443> <VirtualHost *:443>
SSLEngine on SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/${servername}/cert.pem SSLCertificateFile /etc/letsencrypt/live/${servername}/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem
SSLCertificateChainFile /etc/letsencrypt/live/${servername}/fullchain.pem
# Mozilla modern configuration, tweak to your needs # Mozilla modern configuration, tweak to your needs
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1

View file

@ -8,6 +8,8 @@ defmodule Mix.Tasks.Pleroma.User do
alias Ecto.Changeset alias Ecto.Changeset
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
@shortdoc "Manages Pleroma users" @shortdoc "Manages Pleroma users"
@moduledoc File.read!("docs/administration/CLI_tasks/user.md") @moduledoc File.read!("docs/administration/CLI_tasks/user.md")
@ -96,8 +98,9 @@ def run(["new", nickname, email | rest]) do
def run(["rm", nickname]) do def run(["rm", nickname]) do
start_pleroma() start_pleroma()
with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do with %User{local: true} = user <- User.get_cached_by_nickname(nickname),
User.perform(:delete, user) {:ok, delete_data, _} <- Builder.delete(user, user.ap_id),
{:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
shell_info("User #{nickname} deleted.") shell_info("User #{nickname} deleted.")
else else
_ -> shell_error("No local user #{nickname}") _ -> shell_error("No local user #{nickname}")

View file

@ -173,7 +173,14 @@ defp chat_enabled?, do: Config.get([:chat, :enabled])
defp streamer_child(env) when env in [:test, :benchmark], do: [] defp streamer_child(env) when env in [:test, :benchmark], do: []
defp streamer_child(_) do defp streamer_child(_) do
[Pleroma.Web.Streamer.supervisor()] [
{Registry,
[
name: Pleroma.Web.Streamer.registry(),
keys: :duplicate,
partitions: System.schedulers_online()
]}
]
end end
defp chat_child(_env, true) do defp chat_child(_env, true) do

156
lib/pleroma/mfa.ex Normal file
View file

@ -0,0 +1,156 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA do
@moduledoc """
The MFA context.
"""
alias Comeonin.Pbkdf2
alias Pleroma.User
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.Changeset
alias Pleroma.MFA.Settings
alias Pleroma.MFA.TOTP
@doc """
Returns MFA methods the user has enabled.
## Examples
iex> Pleroma.MFA.supported_method(User)
"totp, u2f"
"""
@spec supported_methods(User.t()) :: String.t()
def supported_methods(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.reduce([], fn m, acc ->
if method_enabled?(m, settings) do
acc ++ [m]
else
acc
end
end)
|> Enum.join(",")
end
@doc "Checks that user enabled MFA"
def require?(user) do
fetch_settings(user).enabled
end
@doc """
Display MFA settings of user
"""
def mfa_settings(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.map(fn m -> [m, method_enabled?(m, settings)] end)
|> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end)
end
@doc false
def fetch_settings(%User{} = user) do
user.multi_factor_authentication_settings || %Settings{}
end
@doc "clears backup codes"
def invalidate_backup_code(%User{} = user, hash_code) do
%{backup_codes: codes} = fetch_settings(user)
user
|> Changeset.cast_backup_codes(codes -- [hash_code])
|> User.update_and_set_cache()
end
@doc "generates backup codes"
@spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()}
def generate_backup_codes(%User{} = user) do
with codes <- BackupCodes.generate(),
hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1),
changeset <- Changeset.cast_backup_codes(user, hashed_codes),
{:ok, _} <- User.update_and_set_cache(changeset) do
{:ok, codes}
else
{:error, msg} ->
%{error: msg}
end
end
@doc """
Generates secret key and set delivery_type to 'app' for TOTP method.
"""
@spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def setup_totp(user) do
user
|> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"})
|> User.update_and_set_cache()
end
@doc """
Confirms the TOTP method for user.
`attrs`:
`password` - current user password
`code` - TOTP token
"""
@spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
def confirm_totp(%User{} = user, attrs) do
with settings <- user.multi_factor_authentication_settings.totp,
{:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do
user
|> Changeset.confirm_totp()
|> User.update_and_set_cache()
end
end
@doc """
Disables the TOTP method for user.
`attrs`:
`password` - current user password
"""
@spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable_totp(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable()
|> User.update_and_set_cache()
end
@doc """
Force disables all MFA methods for user.
"""
@spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable(true)
|> User.update_and_set_cache()
end
@doc """
Checks if the user has MFA method enabled.
"""
def method_enabled?(method, settings) do
with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do
true
else
_ -> false
end
end
@doc """
Checks if the user has enabled at least one MFA method.
"""
def enabled?(settings) do
Settings.mfa_methods()
|> Enum.map(fn m -> method_enabled?(m, settings) end)
|> Enum.any?()
end
end

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.BackupCodes do
@moduledoc """
This module contains functions for generating backup codes.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :backup_codes]
@doc """
Generates backup codes.
"""
@spec generate(Keyword.t()) :: list(String.t())
def generate(opts \\ []) do
number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number())
code_length = Keyword.get(opts, :length, default_backup_codes_code_length())
Enum.map(1..number_of_codes, fn _ ->
:crypto.strong_rand_bytes(div(code_length, 2))
|> Base.encode16(case: :lower)
end)
end
defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5)
defp default_backup_codes_code_length,
do: Config.get(@config_ns ++ [:length], 16)
end

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Changeset do
alias Pleroma.MFA
alias Pleroma.MFA.Settings
alias Pleroma.User
def disable(%Ecto.Changeset{} = changeset, force \\ false) do
settings =
changeset
|> Ecto.Changeset.apply_changes()
|> MFA.fetch_settings()
if force || not MFA.enabled?(settings) do
put_change(changeset, %Settings{settings | enabled: false})
else
changeset
end
end
def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
user
|> put_change(%Settings{settings | totp: %Settings.TOTP{}})
end
def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
user
|> put_change(%Settings{settings | totp: totp_settings, enabled: true})
end
def setup_totp(%User{} = user, attrs) do
mfa_settings = MFA.fetch_settings(user)
totp_settings =
%Settings.TOTP{}
|> Ecto.Changeset.cast(attrs, [:secret, :delivery_type])
user
|> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)})
end
def cast_backup_codes(%User{} = user, codes) do
user
|> put_change(%Settings{
user.multi_factor_authentication_settings
| backup_codes: codes
})
end
defp put_change(%User{} = user, settings) do
user
|> Ecto.Changeset.change()
|> put_change(settings)
end
defp put_change(%Ecto.Changeset{} = changeset, settings) do
changeset
|> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings)
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Settings do
use Ecto.Schema
@primary_key false
@mfa_methods [:totp]
embedded_schema do
field(:enabled, :boolean, default: false)
field(:backup_codes, {:array, :string}, default: [])
embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do
field(:secret, :string)
# app | sms
field(:delivery_type, :string, default: "app")
field(:confirmed, :boolean, default: false)
end
end
def mfa_methods, do: @mfa_methods
end

106
lib/pleroma/mfa/token.ex Normal file
View file

@ -0,0 +1,106 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Token do
use Ecto.Schema
import Ecto.Query
import Ecto.Changeset
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token, as: OAuthToken
@expires 300
schema "mfa_tokens" do
field(:token, :string)
field(:valid_until, :naive_datetime_usec)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:authorization, Authorization)
timestamps()
end
def get_by_token(token) do
from(
t in __MODULE__,
where: t.token == ^token,
preload: [:user, :authorization]
)
|> Repo.find_resource()
end
def validate(token) do
with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)},
{:expired, false} <- {:expired, is_expired?(token)} do
{:ok, token}
else
{:expired, _} -> {:error, :expired_token}
{:fetch_token, _} -> {:error, :not_found}
error -> {:error, error}
end
end
def create_token(%User{} = user) do
%__MODULE__{}
|> change
|> assign_user(user)
|> put_token
|> put_valid_until
|> Repo.insert()
end
def create_token(user, authorization) do
%__MODULE__{}
|> change
|> assign_user(user)
|> assign_authorization(authorization)
|> put_token
|> put_valid_until
|> Repo.insert()
end
defp assign_user(changeset, user) do
changeset
|> put_assoc(:user, user)
|> validate_required([:user])
end
defp assign_authorization(changeset, authorization) do
changeset
|> put_assoc(:authorization, authorization)
|> validate_required([:authorization])
end
defp put_token(changeset) do
changeset
|> change(%{token: OAuthToken.Utils.generate_token()})
|> validate_required([:token])
|> unique_constraint(:token)
end
defp put_valid_until(changeset) do
expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires)
changeset
|> change(%{valid_until: expires_in})
|> validate_required([:valid_until])
end
def is_expired?(%__MODULE__{valid_until: valid_until}) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
end
def is_expired?(_), do: false
def delete_expired_tokens do
from(
q in __MODULE__,
where: fragment("?", q.valid_until) < ^Timex.now()
)
|> Repo.delete_all()
end
end

86
lib/pleroma/mfa/totp.ex Normal file
View file

@ -0,0 +1,86 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.TOTP do
@moduledoc """
This module represents functions to create secrets for
TOTP Application as well as validate them with a time based token.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :totp]
@doc """
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
"""
def provisioning_uri(secret, label, opts \\ []) do
query =
%{
secret: secret,
issuer: Keyword.get(opts, :issuer, default_issuer()),
digits: Keyword.get(opts, :digits, default_digits()),
period: Keyword.get(opts, :period, default_period())
}
|> Enum.filter(fn {_, v} -> not is_nil(v) end)
|> Enum.into(%{})
|> URI.encode_query()
%URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query}
|> URI.to_string()
end
defp default_period, do: Config.get(@config_ns ++ [:period])
defp default_digits, do: Config.get(@config_ns ++ [:digits])
defp default_issuer,
do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name]))
@doc "Creates a random Base 32 encoded string"
def generate_secret do
Base.encode32(:crypto.strong_rand_bytes(10))
end
@doc "Generates a valid token based on a secret"
def generate_token(secret) do
:pot.totp(secret)
end
@doc """
Validates a given token based on a secret.
optional parameters:
`token_length` default `6`
`interval_length` default `30`
`window` default 0
Returns {:ok, :pass} if the token is valid and
{:error, :invalid_token} if it is not.
"""
@spec validate_token(String.t(), String.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token)
when is_binary(secret) and is_binary(token) do
opts = [
token_length: default_digits(),
interval_length: default_period()
]
validate_token(secret, token, opts)
end
def validate_token(_, _), do: {:error, :invalid_secret_and_token}
@doc "See `validate_token/2`"
@spec validate_token(String.t(), String.t(), Keyword.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token, options)
when is_binary(secret) and is_binary(token) do
case :pot.valid_totp(token, secret, options) do
true -> {:ok, :pass}
false -> {:error, :invalid_token}
end
end
def validate_token(_, _, _), do: {:error, :invalid_secret_and_token}
end

View file

@ -15,6 +15,20 @@ def init(options) do
end end
@impl true @impl true
def perform(
%{
assigns: %{
auth_credentials: %{password: _},
user: %User{multi_factor_authentication_settings: %{enabled: true}}
}
} = conn,
_
) do
conn
|> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.")
|> halt()
end
def perform(%{assigns: %{user: %User{}}} = conn, _) do def perform(%{assigns: %{user: %User{}}} = conn, _) do
conn conn
end end

View file

@ -20,6 +20,7 @@ defmodule Pleroma.User do
alias Pleroma.Formatter alias Pleroma.Formatter
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.Keys alias Pleroma.Keys
alias Pleroma.MFA
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Registration alias Pleroma.Registration
@ -29,7 +30,9 @@ defmodule Pleroma.User do
alias Pleroma.UserRelationship alias Pleroma.UserRelationship
alias Pleroma.Web alias Pleroma.Web
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
@ -188,6 +191,12 @@ defmodule Pleroma.User do
# `:subscribers` is deprecated (replaced with `subscriber_users` relation) # `:subscribers` is deprecated (replaced with `subscriber_users` relation)
field(:subscribers, {:array, :string}, default: []) field(:subscribers, {:array, :string}, default: [])
embeds_one(
:multi_factor_authentication_settings,
MFA.Settings,
on_replace: :delete
)
timestamps() timestamps()
end end
@ -925,6 +934,7 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
end end
end end
@spec get_by_nickname(String.t()) :: User.t() | nil
def get_by_nickname(nickname) do def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname) || Repo.get_by(User, nickname: nickname) ||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
@ -1425,8 +1435,6 @@ def perform(:force_password_reset, user), do: force_password_reset(user)
@spec perform(atom(), User.t()) :: {:ok, User.t()} @spec perform(atom(), User.t()) :: {:ok, User.t()}
def perform(:delete, %User{} = user) do def perform(:delete, %User{} = user) do
{:ok, _user} = ActivityPub.delete(user)
# Remove all relationships # Remove all relationships
user user
|> get_followers() |> get_followers()
@ -1536,37 +1544,29 @@ def follow_import(%User{} = follower, followed_identifiers)
}) })
end end
def delete_user_activities(%User{ap_id: ap_id}) do def delete_user_activities(%User{ap_id: ap_id} = user) do
ap_id ap_id
|> Activity.Queries.by_actor() |> Activity.Queries.by_actor()
|> RepoStreamer.chunk_stream(50) |> RepoStreamer.chunk_stream(50)
|> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) |> Stream.each(fn activities ->
Enum.each(activities, fn activity -> delete_activity(activity, user) end)
end)
|> Stream.run() |> Stream.run()
end end
defp delete_activity(%{data: %{"type" => "Create"}} = activity) do defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do
activity {:ok, delete_data, _} = Builder.delete(user, object)
|> Object.normalize()
|> ActivityPub.delete() Pipeline.common_pipeline(delete_data, local: user.local)
end end
defp delete_activity(%{data: %{"type" => "Like"}} = activity) do defp delete_activity(%{data: %{"type" => type}} = activity, user)
object = Object.normalize(activity) when type in ["Like", "Announce"] do
{:ok, undo, _} = Builder.undo(user, activity)
activity.actor Pipeline.common_pipeline(undo, local: user.local)
|> get_cached_by_ap_id()
|> ActivityPub.unlike(object)
end end
defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do defp delete_activity(_activity, _user), do: "Doing nothing"
object = Object.normalize(activity)
activity.actor
|> get_cached_by_ap_id()
|> ActivityPub.unannounce(object)
end
defp delete_activity(_activity), do: "Doing nothing"
def html_filter_policy(%User{no_rich_text: true}) do def html_filter_policy(%User{no_rich_text: true}) do
Pleroma.HTML.Scrubber.TwitterText Pleroma.HTML.Scrubber.TwitterText

View file

@ -170,12 +170,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id})
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, map["actor"])
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
{:ok, activity} {:ok, activity}
else else
%Activity{} = activity -> %Activity{} = activity ->
@ -198,6 +192,15 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
end end
end end
def notify_and_stream(activity) do
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, activity.actor)
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
end
defp create_or_bump_conversation(activity, actor) do defp create_or_bump_conversation(activity, actor) do
with {:ok, conversation} <- Conversation.create_or_bump_for(activity), with {:ok, conversation} <- Conversation.create_or_bump_for(activity),
%User{} = user <- User.get_cached_by_ap_id(actor), %User{} = user <- User.get_cached_by_ap_id(actor),
@ -274,6 +277,7 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
_ <- increase_poll_votes_if_vote(create_data), _ <- increase_poll_votes_if_vote(create_data),
{:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
{:ok, _actor} <- increase_note_count_if_public(actor, activity), {:ok, _actor} <- increase_note_count_if_public(actor, activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -301,6 +305,7 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d
additional additional
), ),
{:ok, activity} <- insert(listen_data, local), {:ok, activity} <- insert(listen_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
@ -325,6 +330,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do
%{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object}
|> Utils.maybe_put("id", activity_id), |> Utils.maybe_put("id", activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
@ -344,6 +350,7 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do
}, },
data <- Utils.maybe_put(data, "id", activity_id), data <- Utils.maybe_put(data, "id", activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
@ -365,6 +372,7 @@ defp do_react_with_emoji(user, object, emoji, options) do
reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id), reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
{:ok, activity} <- insert(reaction_data, local), {:ok, activity} <- insert(reaction_data, local),
{:ok, object} <- add_emoji_reaction_to_object(activity, object), {:ok, object} <- add_emoji_reaction_to_object(activity, object),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, object} {:ok, activity, object}
else else
@ -373,54 +381,6 @@ defp do_react_with_emoji(user, object, emoji, options) do
end end
end end
@spec unreact_with_emoji(User.t(), String.t(), keyword()) ::
{:ok, Activity.t(), Object.t()} | {:error, any()}
def unreact_with_emoji(user, reaction_id, options \\ []) do
with {:ok, result} <-
Repo.transaction(fn -> do_unreact_with_emoji(user, reaction_id, options) end) do
result
end
end
defp do_unreact_with_emoji(user, reaction_id, options) do
with local <- Keyword.get(options, :local, true),
activity_id <- Keyword.get(options, :activity_id, nil),
user_ap_id <- user.ap_id,
%Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id),
object <- Object.normalize(reaction_activity),
unreact_data <- make_undo_data(user, reaction_activity, activity_id),
{:ok, activity} <- insert(unreact_data, local),
{:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
{:error, error} -> Repo.rollback(error)
end
end
@spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do
with {:ok, result} <-
Repo.transaction(fn -> do_unlike(actor, object, activity_id, local) end) do
result
end
end
defp do_unlike(actor, object, activity_id, local) do
with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object),
unlike_data <- make_unlike_data(actor, like_activity, activity_id),
{:ok, unlike_activity} <- insert(unlike_data, local),
{:ok, _activity} <- Repo.delete(like_activity),
{:ok, object} <- remove_like_from_object(like_activity, object),
:ok <- maybe_federate(unlike_activity) do
{:ok, unlike_activity, like_activity, object}
else
nil -> {:ok, object}
{:error, error} -> Repo.rollback(error)
end
end
@spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) :: @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) ::
{:ok, Activity.t(), Object.t()} | {:error, any()} {:ok, Activity.t(), Object.t()} | {:error, any()}
def announce( def announce(
@ -442,6 +402,7 @@ defp do_announce(user, object, activity_id, local, public) do
announce_data <- make_announce_data(user, object, activity_id, public), announce_data <- make_announce_data(user, object, activity_id, public),
{:ok, activity} <- insert(announce_data, local), {:ok, activity} <- insert(announce_data, local),
{:ok, object} <- add_announce_to_object(activity, object), {:ok, object} <- add_announce_to_object(activity, object),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, object} {:ok, activity, object}
else else
@ -450,34 +411,6 @@ defp do_announce(user, object, activity_id, local, public) do
end end
end end
@spec unannounce(User.t(), Object.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
def unannounce(
%User{} = actor,
%Object{} = object,
activity_id \\ nil,
local \\ true
) do
with {:ok, result} <-
Repo.transaction(fn -> do_unannounce(actor, object, activity_id, local) end) do
result
end
end
defp do_unannounce(actor, object, activity_id, local) do
with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- insert(unannounce_data, local),
:ok <- maybe_federate(unannounce_activity),
{:ok, _activity} <- Repo.delete(announce_activity),
{:ok, object} <- remove_announce_from_object(announce_activity, object) do
{:ok, unannounce_activity, object}
else
nil -> {:ok, object}
{:error, error} -> Repo.rollback(error)
end
end
@spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: @spec follow(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} {:ok, Activity.t()} | {:error, any()}
def follow(follower, followed, activity_id \\ nil, local \\ true) do def follow(follower, followed, activity_id \\ nil, local \\ true) do
@ -490,6 +423,7 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do
defp do_follow(follower, followed, activity_id, local) do defp do_follow(follower, followed, activity_id, local) do
with data <- make_follow_data(follower, followed, activity_id), with data <- make_follow_data(follower, followed, activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -511,6 +445,7 @@ defp do_unfollow(follower, followed, activity_id, local) do
{:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"), {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local), {:ok, activity} <- insert(unfollow_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -519,67 +454,6 @@ defp do_unfollow(follower, followed, activity_id, local) do
end end
end end
@spec delete(User.t() | Object.t(), keyword()) :: {:ok, User.t() | Object.t()} | {:error, any()}
def delete(entity, options \\ []) do
with {:ok, result} <- Repo.transaction(fn -> do_delete(entity, options) end) do
result
end
end
defp do_delete(%User{ap_id: ap_id, follower_address: follower_address} = user, _) do
with data <- %{
"to" => [follower_address],
"type" => "Delete",
"actor" => ap_id,
"object" => %{"type" => "Person", "id" => ap_id}
},
{:ok, activity} <- insert(data, true, true, true),
:ok <- maybe_federate(activity) do
{:ok, user}
end
end
defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) do
local = Keyword.get(options, :local, true)
activity_id = Keyword.get(options, :activity_id, nil)
actor = Keyword.get(options, :actor, actor)
user = User.get_cached_by_ap_id(actor)
to = (object.data["to"] || []) ++ (object.data["cc"] || [])
with create_activity <- Activity.get_create_by_object_ap_id(id),
data <-
%{
"type" => "Delete",
"actor" => actor,
"object" => id,
"to" => to,
"deleted_activity_id" => create_activity && create_activity.id
}
|> maybe_put("id", activity_id),
{:ok, activity} <- insert(data, local, false),
{:ok, object, _create_activity} <- Object.delete(object),
stream_out_participations(object, user),
_ <- decrease_replies_count_if_reply(object),
{:ok, _actor} <- decrease_note_count_if_public(user, object),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
{:error, error} ->
Repo.rollback(error)
end
end
defp do_delete(%Object{data: %{"type" => "Tombstone", "id" => ap_id}}, _) do
activity =
ap_id
|> Activity.Queries.by_object_id()
|> Activity.Queries.by_type("Delete")
|> Repo.one()
{:ok, activity}
end
@spec block(User.t(), User.t(), String.t() | nil, boolean()) :: @spec block(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} {:ok, Activity.t()} | {:error, any()}
def block(blocker, blocked, activity_id \\ nil, local \\ true) do def block(blocker, blocked, activity_id \\ nil, local \\ true) do
@ -601,6 +475,7 @@ defp do_block(blocker, blocked, activity_id, local) do
with true <- outgoing_blocks, with true <- outgoing_blocks,
block_data <- make_block_data(blocker, blocked, activity_id), block_data <- make_block_data(blocker, blocked, activity_id),
{:ok, activity} <- insert(block_data, local), {:ok, activity} <- insert(block_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -608,27 +483,6 @@ defp do_block(blocker, blocked, activity_id, local) do
end end
end end
@spec unblock(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} | nil
def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do
with {:ok, result} <-
Repo.transaction(fn -> do_unblock(blocker, blocked, activity_id, local) end) do
result
end
end
defp do_unblock(blocker, blocked, activity_id, local) do
with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
{:ok, activity} <- insert(unblock_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
nil -> nil
{:error, error} -> Repo.rollback(error)
end
end
@spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()}
def flag( def flag(
%{ %{
@ -655,6 +509,7 @@ def flag(
with flag_data <- make_flag_data(params, additional), with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local), {:ok, activity} <- insert(flag_data, local),
{:ok, stripped_activity} <- strip_report_status_data(activity), {:ok, stripped_activity} <- strip_report_status_data(activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(stripped_activity) do :ok <- maybe_federate(stripped_activity) do
User.all_superusers() User.all_superusers()
|> Enum.filter(fn user -> not is_nil(user.email) end) |> Enum.filter(fn user -> not is_nil(user.email) end)
@ -678,7 +533,8 @@ def move(%User{} = origin, %User{} = target, local \\ true) do
} }
with true <- origin.ap_id in target.also_known_as, with true <- origin.ap_id in target.also_known_as,
{:ok, activity} <- insert(params, local) do {:ok, activity} <- insert(params, local),
_ <- notify_and_stream(activity) do
maybe_federate(activity) maybe_federate(activity)
BackgroundWorker.enqueue("move_following", %{ BackgroundWorker.enqueue("move_following", %{

View file

@ -396,7 +396,10 @@ def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{
|> json(err) |> json(err)
end end
defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do defp handle_user_activity(
%User{} = user,
%{"type" => "Create", "object" => %{"type" => "Note"}} = params
) do
object = object =
params["object"] params["object"]
|> Map.merge(Map.take(params, ["to", "cc"])) |> Map.merge(Map.take(params, ["to", "cc"]))
@ -415,7 +418,8 @@ defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do
defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do
with %Object{} = object <- Object.normalize(params["object"]), with %Object{} = object <- Object.normalize(params["object"]),
true <- user.is_moderator || user.ap_id == object.data["actor"], true <- user.is_moderator || user.ap_id == object.data["actor"],
{:ok, delete} <- ActivityPub.delete(object) do {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
{:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
{:ok, delete} {:ok, delete}
else else
_ -> {:error, dgettext("errors", "Can't delete object")} _ -> {:error, dgettext("errors", "Can't delete object")}

View file

@ -10,6 +10,46 @@ defmodule Pleroma.Web.ActivityPub.Builder do
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
@spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()}
def undo(actor, object) do
{:ok,
%{
"id" => Utils.generate_activity_id(),
"actor" => actor.ap_id,
"type" => "Undo",
"object" => object.data["id"],
"to" => object.data["to"] || [],
"cc" => object.data["cc"] || []
}, []}
end
@spec delete(User.t(), String.t()) :: {:ok, map(), keyword()}
def delete(actor, object_id) do
object = Object.normalize(object_id, false)
user = !object && User.get_cached_by_ap_id(object_id)
to =
case {object, user} do
{%Object{}, _} ->
# We are deleting an object, address everyone who was originally mentioned
(object.data["to"] || []) ++ (object.data["cc"] || [])
{_, %User{follower_address: follower_address}} ->
# We are deleting a user, address the followers of that user
[follower_address]
end
{:ok,
%{
"id" => Utils.generate_activity_id(),
"actor" => actor.ap_id,
"object" => object_id,
"to" => to,
"type" => "Delete"
}, []}
end
@spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()}
def like(actor, object) do def like(actor, object) do
object_actor = User.get_cached_by_ap_id(object.data["actor"]) object_actor = User.get_cached_by_ap_id(object.data["actor"])

View file

@ -11,11 +11,34 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator
@spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
def validate(object, meta) def validate(object, meta)
def validate(%{"type" => "Undo"} = object, meta) do
with {:ok, object} <-
object
|> UndoValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert) do
object = stringify_keys(object)
{:ok, object, meta}
end
end
def validate(%{"type" => "Delete"} = object, meta) do
with cng <- DeleteValidator.cast_and_validate(object),
do_not_federate <- DeleteValidator.do_not_federate?(cng),
{:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do
object = stringify_keys(object)
meta = Keyword.put(meta, :do_not_federate, do_not_federate)
{:ok, object, meta}
end
end
def validate(%{"type" => "Like"} = object, meta) do def validate(%{"type" => "Like"} = object, meta) do
with {:ok, object} <- with {:ok, object} <-
object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do
@ -24,13 +47,25 @@ def validate(%{"type" => "Like"} = object, meta) do
end end
end end
def stringify_keys(%{__struct__: _} = object) do
object
|> Map.from_struct()
|> stringify_keys
end
def stringify_keys(object) do def stringify_keys(object) do
object object
|> Map.new(fn {key, val} -> {to_string(key), val} end) |> Map.new(fn {key, val} -> {to_string(key), val} end)
end end
def fetch_actor(object) do
with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do
User.get_or_fetch_by_ap_id(actor)
end
end
def fetch_actor_and_object(object) do def fetch_actor_and_object(object) do
User.get_or_fetch_by_ap_id(object["actor"]) fetch_actor(object)
Object.normalize(object["object"]) Object.normalize(object["object"])
:ok :ok
end end

View file

@ -5,10 +5,33 @@
defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
import Ecto.Changeset import Ecto.Changeset
alias Pleroma.Activity
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
def validate_actor_presence(cng, field_name \\ :actor) do def validate_recipients_presence(cng, fields \\ [:to, :cc]) do
non_empty =
fields
|> Enum.map(fn field -> get_field(cng, field) end)
|> Enum.any?(fn
[] -> false
_ -> true
end)
if non_empty do
cng
else
fields
|> Enum.reduce(cng, fn field, cng ->
cng
|> add_error(field, "no recipients in any field")
end)
end
end
def validate_actor_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :actor)
cng cng
|> validate_change(field_name, fn field_name, actor -> |> validate_change(field_name, fn field_name, actor ->
if User.get_cached_by_ap_id(actor) do if User.get_cached_by_ap_id(actor) do
@ -19,14 +42,39 @@ def validate_actor_presence(cng, field_name \\ :actor) do
end) end)
end end
def validate_object_presence(cng, field_name \\ :object) do def validate_object_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :object)
allowed_types = Keyword.get(options, :allowed_types, false)
cng cng
|> validate_change(field_name, fn field_name, object -> |> validate_change(field_name, fn field_name, object_id ->
if Object.get_cached_by_ap_id(object) do object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id)
[]
else cond do
[{field_name, "can't find object"}] !object ->
[{field_name, "can't find object"}]
object && allowed_types && object.data["type"] not in allowed_types ->
[{field_name, "object not in allowed types"}]
true ->
[]
end end
end) end)
end end
def validate_object_or_user_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :object)
options = Keyword.put(options, :field_name, field_name)
actor_cng =
cng
|> validate_actor_presence(options)
object_cng =
cng
|> validate_object_presence(options)
if actor_cng.valid?, do: actor_cng, else: object_cng
end
end end

View file

@ -0,0 +1,99 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:actor, Types.ObjectID)
field(:to, Types.Recipients, default: [])
field(:cc, Types.Recipients, default: [])
field(:deleted_activity_id, Types.ObjectID)
field(:object, Types.ObjectID)
end
def cast_data(data) do
%__MODULE__{}
|> cast(data, __schema__(:fields))
end
def add_deleted_activity_id(cng) do
object =
cng
|> get_field(:object)
with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do
cng
|> put_change(:deleted_activity_id, id)
else
_ -> cng
end
end
@deletable_types ~w{
Answer
Article
Audio
Event
Note
Page
Question
Video
}
def validate_data(cng) do
cng
|> validate_required([:id, :type, :actor, :to, :cc, :object])
|> validate_inclusion(:type, ["Delete"])
|> validate_actor_presence()
|> validate_deletion_rights()
|> validate_object_or_user_presence(allowed_types: @deletable_types)
|> add_deleted_activity_id()
end
def do_not_federate?(cng) do
!same_domain?(cng)
end
defp same_domain?(cng) do
actor_uri =
cng
|> get_field(:actor)
|> URI.parse()
object_uri =
cng
|> get_field(:object)
|> URI.parse()
object_uri.host == actor_uri.host
end
def validate_deletion_rights(cng) do
actor = User.get_cached_by_ap_id(get_field(cng, :actor))
if User.superuser?(actor) || same_domain?(cng) do
cng
else
cng
|> add_error(:actor, "is not allowed to delete object")
end
end
def cast_and_validate(data) do
data
|> cast_data
|> validate_data
end
end

View file

@ -20,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do
field(:object, Types.ObjectID) field(:object, Types.ObjectID)
field(:actor, Types.ObjectID) field(:actor, Types.ObjectID)
field(:context, :string) field(:context, :string)
field(:to, {:array, :string}, default: []) field(:to, Types.Recipients, default: [])
field(:cc, {:array, :string}, default: []) field(:cc, Types.Recipients, default: [])
end end
def cast_and_validate(data) do def cast_and_validate(data) do

View file

@ -0,0 +1,34 @@
defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do
use Ecto.Type
alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID
def type, do: {:array, ObjectID}
def cast(object) when is_binary(object) do
cast([object])
end
def cast(data) when is_list(data) do
data
|> Enum.reduce({:ok, []}, fn element, acc ->
case {acc, ObjectID.cast(element)} do
{:error, _} -> :error
{_, :error} -> :error
{{:ok, list}, {:ok, id}} -> {:ok, [id | list]}
end
end)
end
def cast(_) do
:error
end
def dump(data) do
{:ok, data}
end
def load(data) do
{:ok, data}
end
end

View file

@ -0,0 +1,62 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:object, Types.ObjectID)
field(:actor, Types.ObjectID)
field(:to, {:array, :string}, default: [])
field(:cc, {:array, :string}, default: [])
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
def cast_data(data) do
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
struct
|> cast(data, __schema__(:fields))
end
def validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["Undo"])
|> validate_required([:id, :type, :object, :actor, :to, :cc])
|> validate_actor_presence()
|> validate_object_presence()
|> validate_undo_rights()
end
def validate_undo_rights(cng) do
actor = get_field(cng, :actor)
object = get_field(cng, :object)
with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object),
true <- object_actor != actor do
cng
|> add_error(:actor, "not the same as object actor")
else
_ -> cng
end
end
end

View file

@ -44,7 +44,9 @@ defp maybe_federate(%Object{}, _), do: {:ok, :not_federated}
defp maybe_federate(%Activity{} = activity, meta) do defp maybe_federate(%Activity{} = activity, meta) do
with {:ok, local} <- Keyword.fetch(meta, :local) do with {:ok, local} <- Keyword.fetch(meta, :local) do
if local do do_not_federate = meta[:do_not_federate]
if !do_not_federate && local do
Federator.publish(activity) Federator.publish(activity)
{:ok, :federated} {:ok, :federated}
else else

View file

@ -5,8 +5,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
liked object, a `Follow` activity will add the user to the follower liked object, a `Follow` activity will add the user to the follower
collection, and so on. collection, and so on.
""" """
alias Pleroma.Activity
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
def handle(object, meta \\ []) def handle(object, meta \\ [])
@ -23,8 +27,95 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do
{:ok, object, meta} {:ok, object, meta}
end end
def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do
with undone_object <- Activity.get_by_ap_id(undone_object),
:ok <- handle_undoing(undone_object) do
{:ok, object, meta}
end
end
# Tasks this handles:
# - Delete and unpins the create activity
# - Replace object with Tombstone
# - Set up notification
# - Reduce the user note count
# - Reduce the reply count
# - Stream out the activity
def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
deleted_object =
Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
result =
case deleted_object do
%Object{} ->
with {:ok, deleted_object, activity} <- Object.delete(deleted_object),
%User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do
User.remove_pinnned_activity(user, activity)
{:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object)
if in_reply_to = deleted_object.data["inReplyTo"] do
Object.decrease_replies_count(in_reply_to)
end
ActivityPub.stream_out(object)
ActivityPub.stream_out_participations(deleted_object, user)
:ok
end
%User{} ->
with {:ok, _} <- User.delete(deleted_object) do
:ok
end
end
if result == :ok do
Notification.create_notifications(object)
{:ok, object, meta}
else
{:error, result}
end
end
# Nothing to do # Nothing to do
def handle(object, meta) do def handle(object, meta) do
{:ok, object, meta} {:ok, object, meta}
end end
def handle_undoing(%{data: %{"type" => "Like"}} = object) do
with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_like_from_object(object, liked_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do
with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(%{data: %{"type" => "Announce"}} = object) do
with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_announce_from_object(object, liked_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(
%{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object
) do
with %User{} = blocker <- User.get_cached_by_ap_id(blocker),
%User{} = blocked <- User.get_cached_by_ap_id(blocked),
{:ok, _} <- User.unblock(blocker, blocked),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(object), do: {:error, ["don't know how to handle", object]}
end end

View file

@ -735,55 +735,12 @@ def handle_incoming(
end end
end end
# TODO: We presently assume that any actor on the same origin domain as the object being
# deleted has the rights to delete that object. A better way to validate whether or not
# the object should be deleted is to refetch the object URI, which should return either
# an error or a tombstone. This would allow us to verify that a deletion actually took
# place.
def handle_incoming( def handle_incoming(
%{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data, %{"type" => "Delete"} = data,
_options _options
) do ) do
object_id = Utils.get_ap_id(object_id) with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
:ok <- Containment.contain_origin(actor.ap_id, object.data),
{:ok, activity} <-
ActivityPub.delete(object, local: false, activity_id: id, actor: actor.ap_id) do
{:ok, activity} {:ok, activity}
else
nil ->
case User.get_cached_by_ap_id(object_id) do
%User{ap_id: ^actor} = user ->
User.delete(user)
nil ->
:error
end
_e ->
:error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Announce", "object" => object_id},
"actor" => _actor,
"id" => id
} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
{:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
{:ok, activity}
else
_e -> :error
end end
end end
@ -809,75 +766,13 @@ def handle_incoming(
def handle_incoming( def handle_incoming(
%{ %{
"type" => "Undo", "type" => "Undo",
"object" => %{"type" => "EmojiReact", "id" => reaction_activity_id}, "object" => %{"type" => type}
"actor" => _actor,
"id" => id
} = data, } = data,
_options _options
) do )
with actor <- Containment.get_actor(data), when type in ["Like", "EmojiReact", "Announce", "Block"] do
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
{:ok, activity, _} <-
ActivityPub.unreact_with_emoji(actor, reaction_activity_id,
activity_id: id,
local: false
) do
{:ok, activity} {:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Block", "object" => blocked},
"actor" => blocker,
"id" => id
} = _data,
_options
) do
with %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
User.unblock(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data,
_options
) do
with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
User.unfollow(blocker, blocked)
User.block(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Like", "object" => object_id},
"actor" => _actor,
"id" => id
} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
{:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
{:ok, activity}
else
_e -> :error
end end
end end
@ -899,6 +794,21 @@ def handle_incoming(
end end
end end
def handle_incoming(
%{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data,
_options
) do
with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
User.unfollow(blocker, blocked)
User.block(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming( def handle_incoming(
%{ %{
"type" => "Move", "type" => "Move",

View file

@ -512,7 +512,7 @@ def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do
#### Announce-related helpers #### Announce-related helpers
@doc """ @doc """
Retruns an existing announce activity if the notice has already been announced Returns an existing announce activity if the notice has already been announced
""" """
@spec get_existing_announce(String.t(), map()) :: Activity.t() | nil @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do
@ -562,45 +562,6 @@ def make_announce_data(
|> maybe_put("id", activity_id) |> maybe_put("id", activity_id)
end end
@doc """
Make unannounce activity data for the given actor and object
"""
def make_unannounce_data(
%User{ap_id: ap_id} = user,
%Activity{data: %{"context" => context, "object" => object}} = activity,
activity_id
) do
object = Object.normalize(object)
%{
"type" => "Undo",
"actor" => ap_id,
"object" => activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => context
}
|> maybe_put("id", activity_id)
end
def make_unlike_data(
%User{ap_id: ap_id} = user,
%Activity{data: %{"context" => context, "object" => object}} = activity,
activity_id
) do
object = Object.normalize(object)
%{
"type" => "Undo",
"actor" => ap_id,
"object" => activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => context
}
|> maybe_put("id", activity_id)
end
def make_undo_data( def make_undo_data(
%User{ap_id: actor, follower_address: follower_address}, %User{ap_id: actor, follower_address: follower_address},
%Activity{ %Activity{
@ -688,16 +649,6 @@ def make_block_data(blocker, blocked, activity_id) do
|> maybe_put("id", activity_id) |> maybe_put("id", activity_id)
end end
def make_unblock_data(blocker, blocked, block_activity, activity_id) do
%{
"type" => "Undo",
"actor" => blocker.ap_id,
"to" => [blocked.ap_id],
"object" => block_activity.data
}
|> maybe_put("id", activity_id)
end
#### Create-related helpers #### Create-related helpers
def make_create_data(params, additional) do def make_create_data(params, additional) do

View file

@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.ConfigDB alias Pleroma.ConfigDB
alias Pleroma.MFA
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.ReportNote alias Pleroma.ReportNote
@ -17,6 +18,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
@ -59,6 +62,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
:right_add, :right_add,
:right_add_multiple, :right_add_multiple,
:right_delete, :right_delete,
:disable_mfa,
:right_delete_multiple, :right_delete_multiple,
:update_user_credentials :update_user_credentials
] ]
@ -133,23 +137,20 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
action_fallback(:errors) action_fallback(:errors)
def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do def user_delete(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname) user_delete(conn, %{"nicknames" => [nickname]})
User.delete(user)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "delete"
})
conn
|> json(nickname)
end end
def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) users =
User.delete(users) nicknames
|> Enum.map(&User.get_cached_by_nickname/1)
users
|> Enum.each(fn user ->
{:ok, delete_data, _} = Builder.delete(admin, user.ap_id)
Pipeline.common_pipeline(delete_data, local: true)
end)
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
actor: admin, actor: admin,
@ -675,6 +676,18 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic
json_response(conn, :no_content, "") json_response(conn, :no_content, "")
end end
@doc "Disable mfa for user's account."
def disable_mfa(conn, %{"nickname" => nickname}) do
case User.get_by_nickname(nickname) do
%User{} = user ->
MFA.disable(user)
json(conn, nickname)
_ ->
{:error, :not_found}
end
end
@doc "Show a given user's credentials" @doc "Show a given user's credentials"
def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do

View file

@ -556,11 +556,12 @@ defp update_creadentials_request do
} }
end end
defp array_of_accounts do def array_of_accounts do
%Schema{ %Schema{
title: "ArrayOfAccounts", title: "ArrayOfAccounts",
type: :array, type: :array,
items: Account items: Account,
example: [Account.schema().example]
} }
end end

View file

@ -0,0 +1,76 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.PollOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def show_operation do
%Operation{
tags: ["Polls"],
summary: "View a poll",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [id_param()],
operationId: "PollController.show",
responses: %{
200 => Operation.response("Poll", "application/json", Poll),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def vote_operation do
%Operation{
tags: ["Polls"],
summary: "Vote on a poll",
parameters: [id_param()],
operationId: "PollController.vote",
requestBody: vote_request(),
security: [%{"oAuth" => ["write:statuses"]}],
responses: %{
200 => Operation.response("Poll", "application/json", Poll),
422 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
defp id_param do
Operation.parameter(:id, :path, FlakeID, "Poll ID",
example: "123",
required: true
)
end
defp vote_request do
request_body(
"Parameters",
%Schema{
type: :object,
properties: %{
choices: %Schema{
type: :array,
items: %Schema{type: :integer},
description: "Array of own votes containing index for each option (starting from 0)"
}
},
required: [:choices]
},
required: true,
example: %{
"choices" => [0, 1, 2]
}
)
end
end

View file

@ -0,0 +1,207 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.SearchOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.Tag
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def account_search_operation do
%Operation{
tags: ["Search"],
summary: "Search for matching accounts by username or display name",
operationId: "SearchController.account_search",
parameters: [
Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for",
required: true
),
Operation.parameter(
:limit,
:query,
%Schema{type: :integer, default: 40},
"Maximum number of results"
),
Operation.parameter(
:resolve,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Attempt WebFinger lookup. Use this when `q` is an exact address."
),
Operation.parameter(
:following,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Only include accounts that the user is following"
)
],
responses: %{
200 =>
Operation.response(
"Array of Account",
"application/json",
AccountOperation.array_of_accounts()
)
}
}
end
def search_operation do
%Operation{
tags: ["Search"],
summary: "Search results",
security: [%{"oAuth" => ["read:search"]}],
operationId: "SearchController.search",
deprecated: true,
parameters: [
Operation.parameter(
:account_id,
:query,
FlakeID,
"If provided, statuses returned will be authored only by this account"
),
Operation.parameter(
:type,
:query,
%Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]},
"Search type"
),
Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true),
Operation.parameter(
:resolve,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Attempt WebFinger lookup"
),
Operation.parameter(
:following,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Only include accounts that the user is following"
),
Operation.parameter(
:offset,
:query,
%Schema{type: :integer},
"Offset"
)
| pagination_params()
],
responses: %{
200 => Operation.response("Results", "application/json", results())
}
}
end
def search2_operation do
%Operation{
tags: ["Search"],
summary: "Search results",
security: [%{"oAuth" => ["read:search"]}],
operationId: "SearchController.search2",
parameters: [
Operation.parameter(
:account_id,
:query,
FlakeID,
"If provided, statuses returned will be authored only by this account"
),
Operation.parameter(
:type,
:query,
%Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]},
"Search type"
),
Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for",
required: true
),
Operation.parameter(
:resolve,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Attempt WebFinger lookup"
),
Operation.parameter(
:following,
:query,
%Schema{allOf: [BooleanLike], default: false},
"Only include accounts that the user is following"
)
| pagination_params()
],
responses: %{
200 => Operation.response("Results", "application/json", results2())
}
}
end
defp results2 do
%Schema{
title: "SearchResults",
type: :object,
properties: %{
accounts: %Schema{
type: :array,
items: Account,
description: "Accounts which match the given query"
},
statuses: %Schema{
type: :array,
items: Status,
description: "Statuses which match the given query"
},
hashtags: %Schema{
type: :array,
items: Tag,
description: "Hashtags which match the given query"
}
},
example: %{
"accounts" => [Account.schema().example],
"statuses" => [Status.schema().example],
"hashtags" => [Tag.schema().example]
}
}
end
defp results do
%Schema{
title: "SearchResults",
type: :object,
properties: %{
accounts: %Schema{
type: :array,
items: Account,
description: "Accounts which match the given query"
},
statuses: %Schema{
type: :array,
items: Status,
description: "Statuses which match the given query"
},
hashtags: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Hashtags which match the given query"
}
},
example: %{
"accounts" => [Account.schema().example],
"statuses" => [Status.schema().example],
"hashtags" => ["cofe"]
}
}
end
end

View file

@ -11,26 +11,72 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
OpenApiSpex.schema(%{ OpenApiSpex.schema(%{
title: "Poll", title: "Poll",
description: "Response schema for account custom fields", description: "Represents a poll attached to a status",
type: :object, type: :object,
properties: %{ properties: %{
id: FlakeID, id: FlakeID,
expires_at: %Schema{type: :string, format: "date-time"}, expires_at: %Schema{
expired: %Schema{type: :boolean}, type: :string,
multiple: %Schema{type: :boolean}, format: :"date-time",
votes_count: %Schema{type: :integer}, nullable: true,
voted: %Schema{type: :boolean}, description: "When the poll ends"
emojis: %Schema{type: :array, items: Emoji}, },
expired: %Schema{type: :boolean, description: "Is the poll currently expired?"},
multiple: %Schema{
type: :boolean,
description: "Does the poll allow multiple-choice answers?"
},
votes_count: %Schema{
type: :integer,
nullable: true,
description: "How many votes have been received. Number, or null if `multiple` is false."
},
voted: %Schema{
type: :boolean,
nullable: true,
description:
"When called with a user token, has the authorized user voted? Boolean, or null if no current user."
},
emojis: %Schema{
type: :array,
items: Emoji,
description: "Custom emoji to be used for rendering poll options."
},
options: %Schema{ options: %Schema{
type: :array, type: :array,
items: %Schema{ items: %Schema{
title: "PollOption",
type: :object, type: :object,
properties: %{ properties: %{
title: %Schema{type: :string}, title: %Schema{type: :string},
votes_count: %Schema{type: :integer} votes_count: %Schema{type: :integer}
} }
} },
description: "Possible answers for the poll."
} }
},
example: %{
id: "34830",
expires_at: "2019-12-05T04:05:08.302Z",
expired: true,
multiple: false,
votes_count: 10,
voters_count: nil,
voted: true,
own_votes: [
1
],
options: [
%{
title: "accept",
votes_count: 6
},
%{
title: "deny",
votes_count: 4
}
],
emojis: []
} }
}) })
end end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll alias Pleroma.Web.ApiSpec.Schemas.Poll
alias Pleroma.Web.ApiSpec.Schemas.Tag
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
require OpenApiSpex require OpenApiSpex
@ -106,16 +107,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
replies_count: %Schema{type: :integer}, replies_count: %Schema{type: :integer},
sensitive: %Schema{type: :boolean}, sensitive: %Schema{type: :boolean},
spoiler_text: %Schema{type: :string}, spoiler_text: %Schema{type: :string},
tags: %Schema{ tags: %Schema{type: :array, items: Tag},
type: :array,
items: %Schema{
type: :object,
properties: %{
name: %Schema{type: :string},
url: %Schema{type: :string, format: :uri}
}
}
},
uri: %Schema{type: :string, format: :uri}, uri: %Schema{type: :string, format: :uri},
url: %Schema{type: :string, nullable: true, format: :uri}, url: %Schema{type: :string, nullable: true, format: :uri},
visibility: VisibilityScope visibility: VisibilityScope

View file

@ -0,0 +1,27 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
alias OpenApiSpex.Schema
require OpenApiSpex
OpenApiSpex.schema(%{
title: "Tag",
description: "Represents a hashtag used within the content of a status",
type: :object,
properties: %{
name: %Schema{type: :string, description: "The value of the hashtag after the # sign"},
url: %Schema{
type: :string,
format: :uri,
description: "A link to the hashtag on the instance"
}
},
example: %{
name: "cofe",
url: "https://lain.com/tag/cofe"
}
})
end

View file

@ -19,8 +19,8 @@ def get_user(%Plug.Conn{} = conn) do
{_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do
{:ok, user} {:ok, user}
else else
error -> {:error, _reason} = error -> error
{:error, error} error -> {:error, error}
end end
end end

View file

@ -0,0 +1,45 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.TOTPAuthenticator do
alias Comeonin.Pbkdf2
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.User
@doc "Verify code or check backup code."
@spec verify(String.t(), User.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def verify(
token,
%User{
multi_factor_authentication_settings:
%{enabled: true, totp: %{secret: secret, confirmed: true}} = _
} = _user
)
when is_binary(token) and byte_size(token) > 0 do
TOTP.validate_token(secret, token)
end
def verify(_, _), do: {:error, :invalid_token}
@spec verify_recovery_code(User.t(), String.t()) ::
{:ok, :pass} | {:error, :invalid_token}
def verify_recovery_code(
%User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user,
code
)
when is_list(codes) and is_binary(code) do
hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end)
if hash_code do
MFA.invalidate_backup_code(user, hash_code)
{:ok, :pass}
else
{:error, :invalid_token}
end
end
def verify_recovery_code(_, _), do: {:error, :invalid_token}
end

View file

@ -24,6 +24,14 @@ defmodule Pleroma.Web.CommonAPI do
require Pleroma.Constants require Pleroma.Constants
require Logger require Logger
def unblock(blocker, blocked) do
with %Activity{} = block <- Utils.fetch_latest_block(blocker, blocked),
{:ok, unblock_data, _} <- Builder.undo(blocker, block),
{:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do
{:ok, unblock}
end
end
def follow(follower, followed) do def follow(follower, followed) do
timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout])
@ -79,8 +87,8 @@ def delete(activity_id, user) do
{:find_activity, Activity.get_by_id_with_object(activity_id)}, {:find_activity, Activity.get_by_id_with_object(activity_id)},
%Object{} = object <- Object.normalize(activity), %Object{} = object <- Object.normalize(activity),
true <- User.superuser?(user) || user.ap_id == object.data["actor"], true <- User.superuser?(user) || user.ap_id == object.data["actor"],
{:ok, _} <- unpin(activity_id, user), {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
{:ok, delete} <- ActivityPub.delete(object) do {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
{:ok, delete} {:ok, delete}
else else
{:find_activity, _} -> {:error, :not_found} {:find_activity, _} -> {:error, :not_found}
@ -107,9 +115,12 @@ def repeat(id, user, params \\ %{}) do
def unrepeat(id, user) do def unrepeat(id, user) do
with {_, %Activity{data: %{"type" => "Create"}} = activity} <- with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
{:find_activity, Activity.get_by_id(id)} do {:find_activity, Activity.get_by_id(id)},
object = Object.normalize(activity) %Object{} = note <- Object.normalize(activity, false),
ActivityPub.unannounce(user, object) %Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note),
{:ok, undo, _} <- Builder.undo(user, announce),
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
{:ok, activity}
else else
{:find_activity, _} -> {:error, :not_found} {:find_activity, _} -> {:error, :not_found}
_ -> {:error, dgettext("errors", "Could not unrepeat")} _ -> {:error, dgettext("errors", "Could not unrepeat")}
@ -166,9 +177,12 @@ def favorite_helper(user, id) do
def unfavorite(id, user) do def unfavorite(id, user) do
with {_, %Activity{data: %{"type" => "Create"}} = activity} <- with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
{:find_activity, Activity.get_by_id(id)} do {:find_activity, Activity.get_by_id(id)},
object = Object.normalize(activity) %Object{} = note <- Object.normalize(activity, false),
ActivityPub.unlike(user, object) %Activity{} = like <- Utils.get_existing_like(user.ap_id, note),
{:ok, undo, _} <- Builder.undo(user, like),
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
{:ok, activity}
else else
{:find_activity, _} -> {:error, :not_found} {:find_activity, _} -> {:error, :not_found}
_ -> {:error, dgettext("errors", "Could not unfavorite")} _ -> {:error, dgettext("errors", "Could not unfavorite")}
@ -186,8 +200,10 @@ def react_with_emoji(id, user, emoji) do
end end
def unreact_with_emoji(id, user, emoji) do def unreact_with_emoji(id, user, emoji) do
with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji) do with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji),
ActivityPub.unreact_with_emoji(user, reaction_activity.data["id"]) {:ok, undo, _} <- Builder.undo(user, reaction_activity),
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
{:ok, activity}
else else
_ -> _ ->
{:error, dgettext("errors", "Could not remove reaction emoji")} {:error, dgettext("errors", "Could not remove reaction emoji")}

View file

@ -402,6 +402,7 @@ defp shortname(name) do
end end
end end
@spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
def confirm_current_password(user, password) do def confirm_current_password(user, password) do
with %User{local: true} = db_user <- User.get_cached_by_id(user.id), with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do

View file

@ -356,8 +356,7 @@ def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
@doc "POST /api/v1/accounts/:id/unblock" @doc "POST /api/v1/accounts/:id/unblock"
def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
with {:ok, _user_block} <- User.unblock(blocker, blocked), with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
{:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do
render(conn, "relationship.json", user: blocker, target: blocked) render(conn, "relationship.json", user: blocker, target: blocked)
else else
{:error, message} -> json_response(conn, :forbidden, %{error: message}) {:error, message} -> json_response(conn, :forbidden, %{error: message})

View file

@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
@ -22,8 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
@doc "GET /api/v1/polls/:id" @doc "GET /api/v1/polls/:id"
def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do def show(%{assigns: %{user: user}} = conn, %{id: id}) do
with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user) do true <- Visibility.visible_for_user?(activity, user) do
@ -35,7 +39,7 @@ def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do
end end
@doc "POST /api/v1/polls/:id/votes" @doc "POST /api/v1/polls/:id/votes"
def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do
with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user), true <- Visibility.visible_for_user?(activity, user),

View file

@ -5,7 +5,7 @@
defmodule Pleroma.Web.MastodonAPI.SearchController do defmodule Pleroma.Web.MastodonAPI.SearchController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 2, skip_relationships?: 1] import Pleroma.Web.ControllerHelper, only: [skip_relationships?: 1]
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
@ -18,6 +18,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
require Logger require Logger
plug(Pleroma.Web.ApiSpec.CastAndValidate)
# Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
@ -25,7 +27,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
accounts = User.search(query, search_options(params, user)) accounts = User.search(query, search_options(params, user))
conn conn
@ -36,7 +40,7 @@ def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) d
def search2(conn, params), do: do_search(:v2, conn, params) def search2(conn, params), do: do_search(:v2, conn, params)
def search(conn, params), do: do_search(:v1, conn, params) def search(conn, params), do: do_search(:v1, conn, params)
defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = params) do defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
options = search_options(params, user) options = search_options(params, user)
timeout = Keyword.get(Repo.config(), :timeout, 15_000) timeout = Keyword.get(Repo.config(), :timeout, 15_000)
default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []} default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
@ -44,7 +48,7 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = para
result = result =
default_values default_values
|> Enum.map(fn {resource, default_value} -> |> Enum.map(fn {resource, default_value} ->
if params["type"] in [nil, resource] do if params[:type] in [nil, resource] do
{resource, fn -> resource_search(version, resource, query, options) end} {resource, fn -> resource_search(version, resource, query, options) end}
else else
{resource, fn -> default_value end} {resource, fn -> default_value end}
@ -68,11 +72,11 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = para
defp search_options(params, user) do defp search_options(params, user) do
[ [
skip_relationships: skip_relationships?(params), skip_relationships: skip_relationships?(params),
resolve: params["resolve"] == "true", resolve: params[:resolve],
following: params["following"] == "true", following: params[:following],
limit: fetch_integer_param(params, "limit"), limit: params[:limit],
offset: fetch_integer_param(params, "offset"), offset: params[:offset],
type: params["type"], type: params[:type],
author: get_author(params), author: get_author(params),
for_user: user for_user: user
] ]
@ -135,7 +139,7 @@ defp with_fallback(f, fallback \\ []) do
end end
end end
defp get_author(%{"account_id" => account_id}) when is_binary(account_id), defp get_author(%{account_id: account_id}) when is_binary(account_id),
do: User.get_cached_by_id(account_id) do: User.get_cached_by_id(account_id)
defp get_author(_params), do: nil defp get_author(_params), do: nil

View file

@ -206,9 +206,9 @@ def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do
end end
@doc "POST /api/v1/statuses/:id/unreblog" @doc "POST /api/v1/statuses/:id/unreblog"
def unreblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do def unreblog(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user),
%Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do %Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) try_render(conn, "show.json", %{activity: activity, for: user, as: :activity})
end end
end end
@ -222,9 +222,9 @@ def favourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
end end
@doc "POST /api/v1/statuses/:id/unfavourite" @doc "POST /api/v1/statuses/:id/unfavourite"
def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do %Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity) try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end end
end end

View file

@ -12,6 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
@behaviour :cowboy_websocket @behaviour :cowboy_websocket
# Cowboy timeout period.
@timeout :timer.seconds(30)
# Hibernate every X messages
@hibernate_every 100
@streams [ @streams [
"public", "public",
"public:local", "public:local",
@ -25,9 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
] ]
@anonymous_streams ["public", "public:local", "hashtag"] @anonymous_streams ["public", "public:local", "hashtag"]
# Handled by periodic keepalive in Pleroma.Web.Streamer.Ping.
@timeout :infinity
def init(%{qs: qs} = req, state) do def init(%{qs: qs} = req, state) do
with params <- :cow_qs.parse_qs(qs), with params <- :cow_qs.parse_qs(qs),
sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil), sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil),
@ -42,7 +44,7 @@ def init(%{qs: qs} = req, state) do
req req
end end
{:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}} {:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}}
else else
{:error, code} -> {:error, code} ->
Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}") Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}")
@ -57,7 +59,13 @@ def init(%{qs: qs} = req, state) do
end end
def websocket_init(state) do def websocket_init(state) do
send(self(), :subscribe) Logger.debug(
"#{__MODULE__} accepted websocket connection for user #{
(state.user || %{id: "anonymous"}).id
}, topic #{state.topic}"
)
Streamer.add_socket(state.topic, state.user)
{:ok, state} {:ok, state}
end end
@ -66,19 +74,24 @@ def websocket_handle(_frame, state) do
{:ok, state} {:ok, state}
end end
def websocket_info(:subscribe, state) do def websocket_info({:render_with_user, view, template, item}, state) do
Logger.debug( user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
"#{__MODULE__} accepted websocket connection for user #{
(state.user || %{id: "anonymous"}).id
}, topic #{state.topic}"
)
Streamer.add_socket(state.topic, streamer_socket(state)) unless Streamer.filtered_by_user?(user, item) do
{:ok, state} websocket_info({:text, view.render(template, item, user)}, %{state | user: user})
else
{:ok, state}
end
end end
def websocket_info({:text, message}, state) do def websocket_info({:text, message}, state) do
{:reply, {:text, message}, state} # If the websocket processed X messages, force an hibernate/GC.
# We don't hibernate at every message to balance CPU usage/latency with RAM usage.
if state.count > @hibernate_every do
{:reply, {:text, message}, %{state | count: 0}, :hibernate}
else
{:reply, {:text, message}, %{state | count: state.count + 1}}
end
end end
def terminate(reason, _req, state) do def terminate(reason, _req, state) do
@ -88,7 +101,7 @@ def terminate(reason, _req, state) do
}, topic #{state.topic || "?"}: #{inspect(reason)}" }, topic #{state.topic || "?"}: #{inspect(reason)}"
) )
Streamer.remove_socket(state.topic, streamer_socket(state)) Streamer.remove_socket(state.topic)
:ok :ok
end end
@ -136,8 +149,4 @@ defp expand_topic("list", params) do
end end
defp expand_topic(topic, _), do: topic defp expand_topic(topic, _), do: topic
defp streamer_socket(state) do
%{transport_pid: self(), assigns: state}
end
end end

View file

@ -0,0 +1,97 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.MFAController do
@moduledoc """
The model represents api to use Multi Factor authentications.
"""
use Pleroma.Web, :controller
alias Pleroma.MFA
alias Pleroma.Web.Auth.TOTPAuthenticator
alias Pleroma.Web.OAuth.MFAView, as: View
alias Pleroma.Web.OAuth.OAuthController
alias Pleroma.Web.OAuth.Token
plug(:fetch_session when action in [:show, :verify])
plug(:fetch_flash when action in [:show, :verify])
@doc """
Display form to input mfa code or recovery code.
"""
def show(conn, %{"mfa_token" => mfa_token} = params) do
template = Map.get(params, "challenge_type", "totp")
conn
|> put_view(View)
|> render("#{template}.html", %{
mfa_token: mfa_token,
redirect_uri: params["redirect_uri"],
state: params["state"]
})
end
@doc """
Verification code and continue authorization.
"""
def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do
with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
{:ok, _} <- validates_challenge(user, mfa_params) do
conn
|> OAuthController.after_create_authorization(auth, %{
"authorization" => %{
"redirect_uri" => mfa_params["redirect_uri"],
"state" => mfa_params["state"]
}
})
else
_ ->
conn
|> put_flash(:error, "Two-factor authentication failed.")
|> put_status(:unauthorized)
|> show(mfa_params)
end
end
@doc """
Verification second step of MFA (or recovery) and returns access token.
## Endpoint
POST /oauth/mfa/challenge
params:
`client_id`
`client_secret`
`mfa_token` - access token to check second step of mfa
`challenge_type` - 'totp' or 'recovery'
`code`
"""
def challenge(conn, %{"mfa_token" => mfa_token} = params) do
with {:ok, app} <- Token.Utils.fetch_app(conn),
{:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
{:ok, _} <- validates_challenge(user, params),
{:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build(user, token))
else
_error ->
conn
|> put_status(400)
|> json(%{error: "Invalid code"})
end
end
# Verify TOTP Code
defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do
TOTPAuthenticator.verify(code, user)
end
# Verify Recovery Code
defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do
TOTPAuthenticator.verify_recovery_code(user, code)
end
defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type}
end

View file

@ -0,0 +1,8 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.MFAView do
use Pleroma.Web, :view
import Phoenix.HTML.Form
end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Helpers.UriHelper alias Pleroma.Helpers.UriHelper
alias Pleroma.MFA
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.Registration alias Pleroma.Registration
alias Pleroma.Repo alias Pleroma.Repo
@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
alias Pleroma.Web.ControllerHelper alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.App
alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.MFAController
alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Scopes
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
@ -121,7 +123,8 @@ def create_authorization(
%{"authorization" => _} = params, %{"authorization" => _} = params,
opts \\ [] opts \\ []
) do ) do
with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
after_create_authorization(conn, auth, params) after_create_authorization(conn, auth, params)
else else
error -> error ->
@ -179,6 +182,22 @@ defp handle_create_authorization_error(
|> authorize(params) |> authorize(params)
end end
defp handle_create_authorization_error(
%Plug.Conn{} = conn,
{:mfa_required, user, auth, _},
params
) do
{:ok, token} = MFA.Token.create_token(user, auth)
data = %{
"mfa_token" => token.token,
"redirect_uri" => params["authorization"]["redirect_uri"],
"state" => params["authorization"]["state"]
}
MFAController.show(conn, data)
end
defp handle_create_authorization_error( defp handle_create_authorization_error(
%Plug.Conn{} = conn, %Plug.Conn{} = conn,
{:account_status, :password_reset_pending}, {:account_status, :password_reset_pending},
@ -231,7 +250,8 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"}
json(conn, Token.Response.build(user, token, response_attrs)) json(conn, Token.Response.build(user, token, response_attrs))
else else
_error -> render_invalid_credentials_error(conn) error ->
handle_token_exchange_error(conn, error)
end end
end end
@ -244,6 +264,7 @@ def token_exchange(
{:account_status, :active} <- {:account_status, User.account_status(user)}, {:account_status, :active} <- {:account_status, User.account_status(user)},
{:ok, scopes} <- validate_scopes(app, params), {:ok, scopes} <- validate_scopes(app, params),
{:ok, auth} <- Authorization.create_authorization(app, user, scopes), {:ok, auth} <- Authorization.create_authorization(app, user, scopes),
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
{:ok, token} <- Token.exchange_token(app, auth) do {:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build(user, token)) json(conn, Token.Response.build(user, token))
else else
@ -270,13 +291,20 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"}
{:ok, token} <- Token.exchange_token(app, auth) do {:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build_for_client_credentials(token)) json(conn, Token.Response.build_for_client_credentials(token))
else else
_error -> render_invalid_credentials_error(conn) _error ->
handle_token_exchange_error(conn, :invalid_credentails)
end end
end end
# Bad request # Bad request
def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
conn
|> put_status(:forbidden)
|> json(build_and_response_mfa_token(user, auth))
end
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
render_error( render_error(
conn, conn,
@ -434,7 +462,8 @@ def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs})
def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id), %Registration{} = registration <- Repo.get(Registration, registration_id),
{_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)}, {_, {:ok, auth, _user}} <-
{:create_authorization, do_create_authorization(conn, params)},
%User{} = user <- Repo.preload(auth, :user).user, %User{} = user <- Repo.preload(auth, :user).user,
{:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
conn conn
@ -500,8 +529,9 @@ defp do_create_authorization(
%App{} = app <- Repo.get_by(App, client_id: client_id), %App{} = app <- Repo.get_by(App, client_id: client_id),
true <- redirect_uri in String.split(app.redirect_uris), true <- redirect_uri in String.split(app.redirect_uris),
{:ok, scopes} <- validate_scopes(app, auth_attrs), {:ok, scopes} <- validate_scopes(app, auth_attrs),
{:account_status, :active} <- {:account_status, User.account_status(user)} do {:account_status, :active} <- {:account_status, User.account_status(user)},
Authorization.create_authorization(app, user, scopes) {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
{:ok, auth, user}
end end
end end
@ -515,6 +545,12 @@ defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :re
defp put_session_registration_id(%Plug.Conn{} = conn, registration_id), defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
do: put_session(conn, :registration_id, registration_id) do: put_session(conn, :registration_id, registration_id)
defp build_and_response_mfa_token(user, auth) do
with {:ok, token} <- MFA.Token.create_token(user, auth) do
Token.Response.build_for_mfa_token(user, token)
end
end
@spec validate_scopes(App.t(), map()) :: @spec validate_scopes(App.t(), map()) ::
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
defp validate_scopes(%App{} = app, params) do defp validate_scopes(%App{} = app, params) do

View file

@ -0,0 +1,38 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.Token.CleanWorker do
@moduledoc """
The module represents functions to clean an expired OAuth and MFA tokens.
"""
use GenServer
@ten_seconds 10_000
@one_day 86_400_000
alias Pleroma.MFA
alias Pleroma.Web.OAuth
alias Pleroma.Workers.BackgroundWorker
def start_link(_), do: GenServer.start_link(__MODULE__, %{})
def init(_) do
Process.send_after(self(), :perform, @ten_seconds)
{:ok, nil}
end
@doc false
def handle_info(:perform, state) do
BackgroundWorker.enqueue("clean_expired_tokens", %{})
interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day)
Process.send_after(self(), :perform, interval)
{:noreply, state}
end
def perform(:clean) do
OAuth.Token.delete_expired_tokens()
MFA.Token.delete_expired_tokens()
end
end

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.OAuth.Token.Response do defmodule Pleroma.Web.OAuth.Token.Response do
@moduledoc false @moduledoc false
alias Pleroma.MFA
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.OAuth.Token.Utils alias Pleroma.Web.OAuth.Token.Utils
@ -32,5 +33,13 @@ def build_for_client_credentials(token) do
} }
end end
def build_for_mfa_token(user, mfa_token) do
%{
error: "mfa_required",
mfa_token: mfa_token.token,
supported_challenge_types: MFA.supported_methods(user)
}
end
defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
end end

View file

@ -98,7 +98,8 @@ def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{
"id" => activity_id, "id" => activity_id,
"emoji" => emoji "emoji" => emoji
}) do }) do
with {:ok, _activity, _object} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji), with {:ok, _activity} <-
CommonAPI.unreact_with_emoji(activity_id, user, emoji),
activity <- Activity.get_by_id(activity_id) do activity <- Activity.get_by_id(activity_id) do
conn conn
|> put_view(StatusView) |> put_view(StatusView)

View file

@ -0,0 +1,133 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do
@moduledoc "The module represents actions to manage MFA"
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [json_response: 3]
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.CommonAPI.Utils
plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings])
plug(
OAuthScopesPlug,
%{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes]
)
@doc """
Gets user multi factor authentication settings
## Endpoint
GET /api/pleroma/accounts/mfa
"""
def settings(%{assigns: %{user: user}} = conn, _params) do
json(conn, %{settings: MFA.mfa_settings(user)})
end
@doc """
Prepare setup mfa method
## Endpoint
GET /api/pleroma/accounts/mfa/setup/[:method]
"""
def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do
with {:ok, user} <- MFA.setup_totp(user),
%{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do
provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}")
json(conn, %{provisioning_uri: provisioning_uri, key: secret})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def setup(conn, _params) do
json_response(conn, :bad_request, %{error: "undefined method"})
end
@doc """
Confirms setup and enable mfa method
## Endpoint
POST /api/pleroma/accounts/mfa/confirm/:method
- params:
`code` - confirmation code
`password` - current password
"""
def confirm(
%{assigns: %{user: user}} = conn,
%{"method" => "totp", "password" => _, "code" => _} = params
) do
with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.confirm_totp(user, params) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def confirm(conn, _) do
json_response(conn, :bad_request, %{error: "undefined mfa method"})
end
@doc """
Disable mfa method and disable mfa if need.
"""
def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do
with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.disable_totp(user) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do
with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.disable(user) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def disable(conn, _) do
json_response(conn, :bad_request, %{error: "undefined mfa method"})
end
@doc """
Generates backup codes.
## Endpoint
GET /api/pleroma/accounts/mfa/backup_codes
## Response
### Success
`{codes: [codes]}`
### Error
`{error: [error_message]}`
"""
def backup_codes(%{assigns: %{user: user}} = conn, _params) do
with {:ok, codes} <- MFA.generate_backup_codes(user) do
json(conn, %{codes: codes})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
end

View file

@ -132,6 +132,7 @@ defmodule Pleroma.Web.Router do
post("/users/follow", AdminAPIController, :user_follow) post("/users/follow", AdminAPIController, :user_follow)
post("/users/unfollow", AdminAPIController, :user_unfollow) post("/users/unfollow", AdminAPIController, :user_unfollow)
put("/users/disable_mfa", AdminAPIController, :disable_mfa)
delete("/users", AdminAPIController, :user_delete) delete("/users", AdminAPIController, :user_delete)
post("/users", AdminAPIController, :users_create) post("/users", AdminAPIController, :users_create)
patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
@ -258,6 +259,16 @@ defmodule Pleroma.Web.Router do
post("/follow_import", UtilController, :follow_import) post("/follow_import", UtilController, :follow_import)
end end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
end
scope "/oauth", Pleroma.Web.OAuth do scope "/oauth", Pleroma.Web.OAuth do
scope [] do scope [] do
pipe_through(:oauth) pipe_through(:oauth)
@ -269,6 +280,10 @@ defmodule Pleroma.Web.Router do
post("/revoke", OAuthController, :token_revoke) post("/revoke", OAuthController, :token_revoke)
get("/registration_details", OAuthController, :registration_details) get("/registration_details", OAuthController, :registration_details)
post("/mfa/challenge", MFAController, :challenge)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show)
scope [] do scope [] do
pipe_through(:browser) pipe_through(:browser)

View file

@ -1,37 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Ping do
use GenServer
require Logger
alias Pleroma.Web.Streamer.State
alias Pleroma.Web.Streamer.StreamerSocket
@keepalive_interval :timer.seconds(30)
def start_link(opts) do
ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval)
GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__)
end
def init(%{ping_interval: ping_interval} = args) do
Process.send_after(self(), :ping, ping_interval)
{:ok, args}
end
def handle_info(:ping, %{ping_interval: ping_interval} = state) do
State.get_sockets()
|> Map.values()
|> List.flatten()
|> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} ->
Logger.debug("Sending keepalive ping")
send(transport_pid, {:text, ""})
end)
Process.send_after(self(), :ping, ping_interval)
{:noreply, state}
end
end

View file

@ -1,82 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.State do
use GenServer
require Logger
alias Pleroma.Web.Streamer.StreamerSocket
@env Mix.env()
def start_link(_) do
GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__)
end
def add_socket(topic, socket) do
GenServer.call(__MODULE__, {:add, topic, socket})
end
def remove_socket(topic, socket) do
do_remove_socket(@env, topic, socket)
end
def get_sockets do
%{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state)
stream_sockets
end
def init(init_arg) do
{:ok, init_arg}
end
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do
internal_topic = internal_topic(topic, socket)
stream_socket = StreamerSocket.from_socket(socket)
sockets_for_topic =
sockets
|> Map.get(internal_topic, [])
|> List.insert_at(0, stream_socket)
|> Enum.uniq()
state = put_in(state, [:sockets, internal_topic], sockets_for_topic)
Logger.debug("Got new conn for #{topic}")
{:reply, state, state}
end
def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do
internal_topic = internal_topic(topic, socket)
stream_socket = StreamerSocket.from_socket(socket)
sockets_for_topic =
sockets
|> Map.get(internal_topic, [])
|> List.delete(stream_socket)
state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic)
{:reply, state, state}
end
defp do_remove_socket(:test, _, _) do
:ok
end
defp do_remove_socket(_env, topic, socket) do
GenServer.call(__MODULE__, {:remove, topic, socket})
end
defp internal_topic(topic, socket)
when topic in ~w[user user:notification direct] do
"#{topic}:#{socket.assigns[:user].id}"
end
defp internal_topic(topic, _) do
topic
end
end

View file

@ -3,53 +3,241 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer do defmodule Pleroma.Web.Streamer do
alias Pleroma.Web.Streamer.State require Logger
alias Pleroma.Web.Streamer.Worker
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.StreamerView
@timeout 60_000
@mix_env Mix.env() @mix_env Mix.env()
@registry Pleroma.Web.StreamerRegistry
def add_socket(topic, socket) do def registry, do: @registry
State.add_socket(topic, socket)
def add_socket(topic, %User{} = user) do
if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true)
end end
def remove_socket(topic, socket) do def add_socket(topic, _) do
State.remove_socket(topic, socket) if should_env_send?(), do: Registry.register(@registry, topic, false)
end end
def get_sockets do def remove_socket(topic) do
State.get_sockets() if should_env_send?(), do: Registry.unregister(@registry, topic)
end end
def stream(topics, items) do def stream(topics, item) when is_list(topics) do
if should_send?() do if should_env_send?() do
Task.async(fn -> Enum.each(topics, fn t ->
:poolboy.transaction( spawn(fn -> do_stream(t, item) end)
:streamer_worker,
&Worker.stream(&1, topics, items),
@timeout
)
end) end)
end end
:ok
end end
def supervisor, do: Pleroma.Web.Streamer.Supervisor def stream(topic, items) when is_list(items) do
if should_env_send?() do
Enum.each(items, fn i ->
spawn(fn -> do_stream(topic, i) end)
end)
defp should_send? do :ok
handle_should_send(@mix_env)
end
defp handle_should_send(:test) do
case Process.whereis(:streamer_worker) do
nil ->
false
pid ->
Process.alive?(pid)
end end
end end
defp handle_should_send(:benchmark), do: false def stream(topic, item) do
if should_env_send?() do
spawn(fn -> do_stream(topic, item) end)
end
defp handle_should_send(_), do: true :ok
end
def filtered_by_user?(%User{} = user, %Activity{} = item) do
%{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
with parent <- Object.normalize(item) || item,
true <-
Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(user, item) do
false
else
_ -> true
end
end
def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do
filtered_by_user?(user, activity)
end
defp do_stream("direct", item) do
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "direct:#{id}" end)
Enum.each(recipient_topics, fn user_topic ->
Logger.debug("Trying to push direct message to #{user_topic}\n\n")
push_to_socket(user_topic, item)
end)
end
defp do_stream("participation", participation) do
user_topic = "direct:#{participation.user_id}"
Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
push_to_socket(user_topic, participation)
end
defp do_stream("list", item) do
# filter the recipient list if the activity is not public, see #270.
recipient_lists =
case Visibility.is_public?(item) do
true ->
Pleroma.List.get_lists_from_activity(item)
_ ->
Pleroma.List.get_lists_from_activity(item)
|> Enum.filter(fn list ->
owner = User.get_cached_by_id(list.user_id)
Visibility.visible_for_user?(item, owner)
end)
end
recipient_topics =
recipient_lists
|> Enum.map(fn %{id: id} -> "list:#{id}" end)
Enum.each(recipient_topics, fn list_topic ->
Logger.debug("Trying to push message to #{list_topic}\n\n")
push_to_socket(list_topic, item)
end)
end
defp do_stream(topic, %Notification{} = item)
when topic in ["user", "user:notification"] do
Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
Enum.each(list, fn {pid, _auth} ->
send(pid, {:render_with_user, StreamerView, "notification.json", item})
end)
end)
end
defp do_stream("user", item) do
Logger.debug("Trying to push to users")
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
Enum.each(recipient_topics, fn topic ->
push_to_socket(topic, item)
end)
end
defp do_stream(topic, item) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")
push_to_socket(topic, item)
end
defp push_to_socket(topic, %Participation{} = participation) do
rendered = StreamerView.render("conversation.json", participation)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(topic, %Activity{
data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
}) do
rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)})
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
defp push_to_socket(topic, item) do
anon_render = StreamerView.render("update.json", item)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
send(pid, {:render_with_user, StreamerView, "update.json", item})
else
send(pid, {:text, anon_render})
end
end)
end)
end
defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
defp thread_containment(activity, user) do
if Config.get([:instance, :skip_thread_containment]) do
true
else
ActivityPub.contain_activity(activity, user)
end
end
# In test environement, only return true if the registry is started.
# In benchmark environment, returns false.
# In any other environment, always returns true.
cond do
@mix_env == :test ->
def should_env_send? do
case Process.whereis(@registry) do
nil ->
false
pid ->
Process.alive?(pid)
end
end
@mix_env == :benchmark ->
def should_env_send?, do: false
true ->
def should_env_send?, do: true
end
defp user_topic(topic, user)
when topic in ~w[user user:notification direct] do
"#{topic}:#{user.id}"
end
defp user_topic(topic, _) do
topic
end
end end

View file

@ -1,35 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.StreamerSocket do
defstruct transport_pid: nil, user: nil
alias Pleroma.User
alias Pleroma.Web.Streamer.StreamerSocket
def from_socket(%{
transport_pid: transport_pid,
assigns: %{user: nil}
}) do
%StreamerSocket{
transport_pid: transport_pid
}
end
def from_socket(%{
transport_pid: transport_pid,
assigns: %{user: %User{} = user}
}) do
%StreamerSocket{
transport_pid: transport_pid,
user: user
}
end
def from_socket(%{transport_pid: transport_pid}) do
%StreamerSocket{
transport_pid: transport_pid
}
end
end

View file

@ -1,37 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(args) do
children = [
{Pleroma.Web.Streamer.State, args},
{Pleroma.Web.Streamer.Ping, args},
:poolboy.child_spec(:streamer_worker, poolboy_config())
]
opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor]
Supervisor.init(children, opts)
end
defp poolboy_config do
opts =
Pleroma.Config.get(:streamer,
workers: 3,
overflow_workers: 2
)
[
{:name, {:local, :streamer_worker}},
{:worker_module, Pleroma.Web.Streamer.Worker},
{:size, opts[:workers]},
{:max_overflow, opts[:overflow_workers]}
]
end
end

View file

@ -1,208 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Worker do
use GenServer
require Logger
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Streamer.State
alias Pleroma.Web.Streamer.StreamerSocket
alias Pleroma.Web.StreamerView
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, [])
end
def init(init_arg) do
{:ok, init_arg}
end
def stream(pid, topics, items) do
GenServer.call(pid, {:stream, topics, items})
end
def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do
Enum.each(topics, fn t ->
do_stream(%{topic: t, item: item})
end)
{:reply, state, state}
end
def handle_call({:stream, topic, items}, _from, state) when is_list(items) do
Enum.each(items, fn i ->
do_stream(%{topic: topic, item: i})
end)
{:reply, state, state}
end
def handle_call({:stream, topic, item}, _from, state) do
do_stream(%{topic: topic, item: item})
{:reply, state, state}
end
defp do_stream(%{topic: "direct", item: item}) do
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "direct:#{id}" end)
Enum.each(recipient_topics, fn user_topic ->
Logger.debug("Trying to push direct message to #{user_topic}\n\n")
push_to_socket(State.get_sockets(), user_topic, item)
end)
end
defp do_stream(%{topic: "participation", item: participation}) do
user_topic = "direct:#{participation.user_id}"
Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
push_to_socket(State.get_sockets(), user_topic, participation)
end
defp do_stream(%{topic: "list", item: item}) do
# filter the recipient list if the activity is not public, see #270.
recipient_lists =
case Visibility.is_public?(item) do
true ->
Pleroma.List.get_lists_from_activity(item)
_ ->
Pleroma.List.get_lists_from_activity(item)
|> Enum.filter(fn list ->
owner = User.get_cached_by_id(list.user_id)
Visibility.visible_for_user?(item, owner)
end)
end
recipient_topics =
recipient_lists
|> Enum.map(fn %{id: id} -> "list:#{id}" end)
Enum.each(recipient_topics, fn list_topic ->
Logger.debug("Trying to push message to #{list_topic}\n\n")
push_to_socket(State.get_sockets(), list_topic, item)
end)
end
defp do_stream(%{topic: topic, item: %Notification{} = item})
when topic in ["user", "user:notification"] do
State.get_sockets()
|> Map.get("#{topic}:#{item.user_id}", [])
|> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} ->
with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id),
true <- should_send?(user, item) do
send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)})
end
end)
end
defp do_stream(%{topic: "user", item: item}) do
Logger.debug("Trying to push to users")
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
Enum.each(recipient_topics, fn topic ->
push_to_socket(State.get_sockets(), topic, item)
end)
end
defp do_stream(%{topic: topic, item: item}) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")
push_to_socket(State.get_sockets(), topic, item)
end
defp should_send?(%User{} = user, %Activity{} = item) do
%{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
with parent <- Object.normalize(item) || item,
true <-
Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(user, item) do
true
else
_ -> false
end
end
defp should_send?(%User{} = user, %Notification{activity: activity}) do
should_send?(user, activity)
end
def push_to_socket(topics, topic, %Participation{} = participation) do
Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
send(transport_pid, {:text, StreamerView.render("conversation.json", participation)})
end)
end
def push_to_socket(topics, topic, %Activity{
data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
}) do
Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
send(
transport_pid,
{:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()}
)
end)
end
def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
def push_to_socket(topics, topic, item) do
Enum.each(topics[topic] || [], fn %StreamerSocket{
transport_pid: transport_pid,
user: socket_user
} ->
# Get the current user so we have up-to-date blocks etc.
if socket_user do
user = User.get_cached_by_ap_id(socket_user.ap_id)
if should_send?(user, item) do
send(transport_pid, {:text, StreamerView.render("update.json", item, user)})
end
else
send(transport_pid, {:text, StreamerView.render("update.json", item)})
end
end)
end
@spec thread_containment(Activity.t(), User.t()) :: boolean()
defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
defp thread_containment(activity, user) do
if Config.get([:instance, :skip_thread_containment]) do
true
else
ActivityPub.contain_activity(activity, user)
end
end
end

View file

@ -0,0 +1,24 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>Two-factor recovery</h2>
<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, "Recovery code" %>
<%= text_input f, :code %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "recovery" %>
</div>
<%= submit "Verify" %>
<% end %>
<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
Enter a two-factor code
</a>

View file

@ -0,0 +1,24 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>Two-factor authentication</h2>
<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, "Authentication code" %>
<%= text_input f, :code %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "totp" %>
</div>
<%= submit "Verify" %>
<% end %>
<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
Enter a two-factor recovery code
</a>

View file

@ -0,0 +1,13 @@
<%= if @error do %>
<h2><%= @error %></h2>
<% end %>
<h2>Two-factor authentication</h2>
<p><%= @followee.nickname %></p>
<img height="128" width="128" src="<%= avatar_url(@followee) %>">
<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %>
<%= text_input f, :code, placeholder: "Authentication code", required: true %>
<br>
<%= hidden_input f, :id, value: @followee.id %>
<%= hidden_input f, :token, value: @mfa_token %>
<%= submit "Authorize" %>
<% end %>

View file

@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
require Logger require Logger
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.MFA
alias Pleroma.Object.Fetcher alias Pleroma.Object.Fetcher
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Auth.Authenticator alias Pleroma.Web.Auth.Authenticator
alias Pleroma.Web.Auth.TOTPAuthenticator
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
@status_types ["Article", "Event", "Note", "Video", "Page", "Question"] @status_types ["Article", "Event", "Note", "Video", "Page", "Question"]
@ -68,6 +70,8 @@ defp is_status?(acct) do
# POST /ostatus_subscribe # POST /ostatus_subscribe
# #
# adds a remote account in followers if user already is signed in.
#
def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do
with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
@ -78,9 +82,33 @@ def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" =>
end end
end end
# POST /ostatus_subscribe
#
# step 1.
# checks login\password and displays step 2 form of MFA if need.
#
def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do
with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee}, {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee},
{_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do
redirect(conn, to: "/users/#{followee.id}")
else
error ->
handle_follow_error(conn, error)
end
end
# POST /ostatus_subscribe
#
# step 2
# checks TOTP code. otherwise displays form with errors
#
def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do
with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)},
{_, _, _, {:ok, _}} <-
{:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do {:ok, _, _, _} <- CommonAPI.follow(user, followee) do
redirect(conn, to: "/users/#{followee.id}") redirect(conn, to: "/users/#{followee.id}")
else else
@ -94,6 +122,23 @@ def do_follow(%{assigns: %{user: nil}} = conn, _) do
render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."}) render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."})
end end
defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do
render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
end
defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do
render(conn, "follow_mfa.html", %{
error: "Wrong authentication code",
followee: followee,
mfa_token: token
})
end
defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do
{:ok, %{token: token}} = MFA.Token.create_token(user)
render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false})
end
defp handle_follow_error(conn, {:auth, _, followee} = _) do defp handle_follow_error(conn, {:auth, _, followee} = _) do
render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee}) render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
end end

View file

@ -25,7 +25,7 @@ def render("update.json", %Activity{} = activity, %User{} = user) do
|> Jason.encode!() |> Jason.encode!()
end end
def render("notification.json", %User{} = user, %Notification{} = notify) do def render("notification.json", %Notification{} = notify, %User{} = user) do
%{ %{
event: "notification", event: "notification",
payload: payload:

View file

@ -176,6 +176,7 @@ defp deps do
{:quack, "~> 0.1.1"}, {:quack, "~> 0.1.1"},
{:joken, "~> 2.0"}, {:joken, "~> 2.0"},
{:benchee, "~> 1.0"}, {:benchee, "~> 1.0"},
{:pot, "~> 0.10.2"},
{:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
{:ex_const, "~> 0.2"}, {:ex_const, "~> 0.2"},
{:plug_static_index_html, "~> 1.0.0"}, {:plug_static_index_html, "~> 1.0.0"},

View file

@ -89,6 +89,7 @@
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
"pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"},
"prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"},
"prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"},
"prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"},

View file

@ -0,0 +1,9 @@
defmodule Pleroma.Repo.Migrations.AddMultiFactorAuthenticationSettingsToUser do
use Ecto.Migration
def change do
alter table(:users) do
add(:multi_factor_authentication_settings, :map, default: %{})
end
end
end

View file

@ -0,0 +1,16 @@
defmodule Pleroma.Repo.Migrations.CreateMfaTokens do
use Ecto.Migration
def change do
create table(:mfa_tokens) do
add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
add(:authorization_id, references(:oauth_authorizations, on_delete: :delete_all))
add(:token, :string)
add(:valid_until, :naive_datetime_usec)
timestamps()
end
create(unique_index(:mfa_tokens, :token))
end
end

View file

@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth alias Pleroma.Web.OAuth
@moduletag needs_streamer: true, capture_log: true
@path Pleroma.Web.Endpoint.url() @path Pleroma.Web.Endpoint.url()
|> URI.parse() |> URI.parse()
|> Map.put(:scheme, "ws") |> Map.put(:scheme, "ws")
|> Map.put(:path, "/api/v1/streaming") |> Map.put(:path, "/api/v1/streaming")
|> URI.to_string() |> URI.to_string()
setup_all do
start_supervised(Pleroma.Web.Streamer.supervisor())
:ok
end
def start_socket(qs \\ nil, headers \\ []) do def start_socket(qs \\ nil, headers \\ []) do
path = path =
case qs do case qs do

View file

@ -0,0 +1,11 @@
defmodule Pleroma.MFA.BackupCodesTest do
use Pleroma.DataCase
alias Pleroma.MFA.BackupCodes
test "generate backup codes" do
codes = BackupCodes.generate(number_of_codes: 2, length: 4)
assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes
end
end

17
test/mfa/totp_test.exs Normal file
View file

@ -0,0 +1,17 @@
defmodule Pleroma.MFA.TOTPTest do
use Pleroma.DataCase
alias Pleroma.MFA.TOTP
test "create provisioning_uri to generate qrcode" do
uri =
TOTP.provisioning_uri("test-secrcet", "test@example.com",
issuer: "Plerome-42",
digits: 8,
period: 60
)
assert uri ==
"otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet"
end
end

53
test/mfa_test.exs Normal file
View file

@ -0,0 +1,53 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFATest do
use Pleroma.DataCase
import Pleroma.Factory
alias Comeonin.Pbkdf2
alias Pleroma.MFA
describe "mfa_settings" do
test "returns settings user's" do
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true}
}
)
settings = MFA.mfa_settings(user)
assert match?(^settings, %{enabled: true, totp: true})
end
end
describe "generate backup codes" do
test "returns backup codes" do
user = insert(:user)
{:ok, [code1, code2]} = MFA.generate_backup_codes(user)
updated_user = refresh_record(user)
[hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes
assert Pbkdf2.checkpw(code1, hash1)
assert Pbkdf2.checkpw(code2, hash2)
end
end
describe "invalidate_backup_code" do
test "invalid used code" do
user = insert(:user)
{:ok, _} = MFA.generate_backup_codes(user)
user = refresh_record(user)
assert length(user.multi_factor_authentication_settings.backup_codes) == 2
[hash_code | _] = user.multi_factor_authentication_settings.backup_codes
{:ok, user} = MFA.invalidate_backup_code(user, hash_code)
assert length(user.multi_factor_authentication_settings.backup_codes) == 1
end
end
end

View file

@ -165,14 +165,18 @@ test "does not create a notification for subscribed users if status is a reply"
@tag needs_streamer: true @tag needs_streamer: true
test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do
user = insert(:user) user = insert(:user)
task = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}})
Streamer.add_socket( task =
"user:notification", Task.async(fn ->
%{transport_pid: task_user_notification.pid, assigns: %{user: user}} Streamer.add_socket("user", user)
) assert_receive {:render_with_user, _, _, _}, 4_000
end)
task_user_notification =
Task.async(fn ->
Streamer.add_socket("user:notification", user)
assert_receive {:render_with_user, _, _, _}, 4_000
end)
activity = insert(:note_activity) activity = insert(:note_activity)
@ -737,7 +741,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is un
assert length(Notification.for_user(user)) == 1 assert length(Notification.for_user(user)) == 1
{:ok, _, _, _} = CommonAPI.unfavorite(activity.id, other_user) {:ok, _} = CommonAPI.unfavorite(activity.id, other_user)
assert Enum.empty?(Notification.for_user(user)) assert Enum.empty?(Notification.for_user(user))
end end
@ -771,7 +775,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is
assert length(Notification.for_user(user)) == 1 assert length(Notification.for_user(user)) == 1
{:ok, _, _} = CommonAPI.unrepeat(activity.id, other_user) {:ok, _} = CommonAPI.unrepeat(activity.id, other_user)
assert Enum.empty?(Notification.for_user(user)) assert Enum.empty?(Notification.for_user(user))
end end

View file

@ -24,6 +24,31 @@ test "it continues if a user is assigned", %{conn: conn} do
end end
end end
test "it halts if user is assigned and MFA enabled", %{conn: conn} do
conn =
conn
|> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}})
|> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
assert conn.status == 403
assert conn.halted == true
assert conn.resp_body ==
"{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}"
end
test "it continues if user is assigned and MFA disabled", %{conn: conn} do
conn =
conn
|> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}})
|> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
refute conn.status == 403
refute conn.halted
end
describe "with :if_func / :unless_func options" do describe "with :if_func / :unless_func options" do
setup do setup do
%{ %{

View file

@ -21,7 +21,15 @@ def build(data \\ %{}, opts \\ %{}) do
def insert(data \\ %{}, opts \\ %{}) do def insert(data \\ %{}, opts \\ %{}) do
activity = build(data, opts) activity = build(data, opts)
ActivityPub.insert(activity)
case ActivityPub.insert(activity) do
ok = {:ok, activity} ->
ActivityPub.notify_and_stream(activity)
ok
error ->
error
end
end end
def insert_list(times, data \\ %{}, opts \\ %{}) do def insert_list(times, data \\ %{}, opts \\ %{}) do

View file

@ -11,6 +11,7 @@ def build(data \\ %{}) do
bio: "A tester.", bio: "A tester.",
ap_id: "some id", ap_id: "some id",
last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
notification_settings: %Pleroma.User.NotificationSetting{} notification_settings: %Pleroma.User.NotificationSetting{}
} }

View file

@ -139,7 +139,11 @@ defp ensure_federating_or_authenticated(conn, url, user) do
end end
if tags[:needs_streamer] do if tags[:needs_streamer] do
start_supervised(Pleroma.Web.Streamer.supervisor()) start_supervised(%{
id: Pleroma.Web.Streamer.registry(),
start:
{Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
})
end end
{:ok, conn: Phoenix.ConnTest.build_conn()} {:ok, conn: Phoenix.ConnTest.build_conn()}

View file

@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do
end end
if tags[:needs_streamer] do if tags[:needs_streamer] do
start_supervised(Pleroma.Web.Streamer.supervisor()) start_supervised(%{
id: Pleroma.Web.Streamer.registry(),
start:
{Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
})
end end
:ok :ok

View file

@ -33,7 +33,8 @@ def user_factory do
bio: sequence(:bio, &"Tester Number #{&1}"), bio: sequence(:bio, &"Tester Number #{&1}"),
last_digest_emailed_at: NaiveDateTime.utc_now(), last_digest_emailed_at: NaiveDateTime.utc_now(),
last_refreshed_at: NaiveDateTime.utc_now(), last_refreshed_at: NaiveDateTime.utc_now(),
notification_settings: %Pleroma.User.NotificationSetting{} notification_settings: %Pleroma.User.NotificationSetting{},
multi_factor_authentication_settings: %Pleroma.MFA.Settings{}
} }
%{ %{
@ -422,4 +423,13 @@ def marker_factory do
last_read_id: "1" last_read_id: "1"
} }
end end
def mfa_token_factory do
%Pleroma.MFA.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
authorization: build(:oauth_authorization),
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
user: build(:user)
}
end
end end

View file

@ -4,14 +4,17 @@
defmodule Mix.Tasks.Pleroma.UserTest do defmodule Mix.Tasks.Pleroma.UserTest do
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
use Pleroma.DataCase use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
import Pleroma.Factory
import ExUnit.CaptureIO import ExUnit.CaptureIO
import Mock
import Pleroma.Factory
setup_all do setup_all do
Mix.shell(Mix.Shell.Process) Mix.shell(Mix.Shell.Process)
@ -87,12 +90,17 @@ test "user is not created" do
test "user is deleted" do test "user is deleted" do
user = insert(:user) user = insert(:user)
Mix.Tasks.Pleroma.User.run(["rm", user.nickname]) with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
Mix.Tasks.Pleroma.User.run(["rm", user.nickname])
ObanHelpers.perform_all()
assert_received {:mix_shell, :info, [message]} assert_received {:mix_shell, :info, [message]}
assert message =~ " deleted" assert message =~ " deleted"
assert %{deactivated: true} = User.get_by_nickname(user.nickname)
assert %{deactivated: true} = User.get_by_nickname(user.nickname) assert called(Pleroma.Web.Federator.publish(:_))
end
end end
test "no user to delete" do test "no user to delete" do

View file

@ -172,6 +172,7 @@ test "works with URIs" do
|> Map.put(:search_rank, nil) |> Map.put(:search_rank, nil)
|> Map.put(:search_type, nil) |> Map.put(:search_type, nil)
|> Map.put(:last_digest_emailed_at, nil) |> Map.put(:last_digest_emailed_at, nil)
|> Map.put(:multi_factor_authentication_settings, nil)
|> Map.put(:notification_settings, nil) |> Map.put(:notification_settings, nil)
assert user == expected assert user == expected

View file

@ -15,7 +15,6 @@ defmodule Pleroma.UserTest do
use Pleroma.DataCase use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo use Oban.Testing, repo: Pleroma.Repo
import Mock
import Pleroma.Factory import Pleroma.Factory
import ExUnit.CaptureLog import ExUnit.CaptureLog
@ -1131,7 +1130,7 @@ test ".delete_user_activities deletes all create activities", %{user: user} do
User.delete_user_activities(user) User.delete_user_activities(user)
# TODO: Remove favorites, repeats, delete activities. # TODO: Test removal favorites, repeats, delete activities.
refute Activity.get_by_id(activity.id) refute Activity.get_by_id(activity.id)
end end
@ -1170,31 +1169,6 @@ test "it deactivates a user, all follow relationships and all activities", %{use
refute Activity.get_by_id(like_two.id) refute Activity.get_by_id(like_two.id)
refute Activity.get_by_id(repeat.id) refute Activity.get_by_id(repeat.id)
end end
test_with_mock "it sends out User Delete activity",
%{user: user},
Pleroma.Web.ActivityPub.Publisher,
[:passthrough],
[] do
Pleroma.Config.put([:instance, :federating], true)
{:ok, follower} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
{:ok, _} = User.follow(follower, user)
{:ok, job} = User.delete(user)
{:ok, _user} = ObanHelpers.perform(job)
assert ObanHelpers.member?(
%{
"op" => "publish_one",
"params" => %{
"inbox" => "http://mastodon.example.org/inbox",
"id" => "pleroma:fakeid"
}
},
all_enqueued(worker: Pleroma.Workers.PublisherWorker)
)
end
end end
test "get_public_key_for_ap_id fetches a user that's not in the db" do test "get_public_key_for_ap_id fetches a user that's not in the db" do

View file

@ -815,6 +815,21 @@ test "it inserts an incoming create activity into the database", %{
assert object["content"] == activity["object"]["content"] assert object["content"] == activity["object"]["content"]
end end
test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do
user = insert(:user)
activity =
activity
|> put_in(["object", "type"], "Benis")
_result =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/outbox", activity)
|> json_response(400)
end
test "it inserts an incoming sensitive activity into the database", %{ test "it inserts an incoming sensitive activity into the database", %{
conn: conn, conn: conn,
activity: activity activity: activity

View file

@ -939,122 +939,6 @@ test "reverts emoji reaction on error" do
end end
end end
describe "unreacting to an object" do
test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do
Config.put([:instance, :federating], true)
user = insert(:user)
reactor = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
assert object = Object.normalize(activity)
{:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
assert called(Federator.publish(reaction_activity))
{:ok, unreaction_activity, _object} =
ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
assert called(Federator.publish(unreaction_activity))
end
test "adds an undo activity to the db" do
user = insert(:user)
reactor = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
assert object = Object.normalize(activity)
{:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
{:ok, unreaction_activity, _object} =
ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
assert unreaction_activity.actor == reactor.ap_id
assert unreaction_activity.data["object"] == reaction_activity.data["id"]
object = Object.get_by_ap_id(object.data["id"])
assert object.data["reaction_count"] == 0
assert object.data["reactions"] == []
end
test "reverts emoji unreact on error" do
[user, reactor] = insert_list(2, :user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "Status"})
object = Object.normalize(activity)
{:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "😀")
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} =
ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
end
object = Object.get_by_ap_id(object.data["id"])
assert object.data["reaction_count"] == 1
assert object.data["reactions"] == [["😀", [reactor.ap_id]]]
end
end
describe "unliking" do
test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do
Config.put([:instance, :federating], true)
note_activity = insert(:note_activity)
object = Object.normalize(note_activity)
user = insert(:user)
{:ok, object} = ActivityPub.unlike(user, object)
refute called(Federator.publish())
{:ok, _like_activity} = CommonAPI.favorite(user, note_activity.id)
object = Object.get_by_id(object.id)
assert object.data["like_count"] == 1
{:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
assert object.data["like_count"] == 0
assert called(Federator.publish(unlike_activity))
end
test "reverts unliking on error" do
note_activity = insert(:note_activity)
user = insert(:user)
{:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
object = Object.normalize(note_activity)
assert object.data["like_count"] == 1
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.unlike(user, object)
end
assert Object.get_by_ap_id(object.data["id"]) == object
assert object.data["like_count"] == 1
assert Activity.get_by_id(like_activity.id)
end
test "unliking a previously liked object" do
note_activity = insert(:note_activity)
object = Object.normalize(note_activity)
user = insert(:user)
# Unliking something that hasn't been liked does nothing
{:ok, object} = ActivityPub.unlike(user, object)
assert object.data["like_count"] == 0
{:ok, like_activity} = CommonAPI.favorite(user, note_activity.id)
object = Object.get_by_id(object.id)
assert object.data["like_count"] == 1
{:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object)
assert object.data["like_count"] == 0
assert Activity.get_by_id(like_activity.id) == nil
assert note_activity.actor in unlike_activity.recipients
end
end
describe "announcing an object" do describe "announcing an object" do
test "adds an announce activity to the db" do test "adds an announce activity to the db" do
note_activity = insert(:note_activity) note_activity = insert(:note_activity)
@ -1124,52 +1008,6 @@ test "does not add an announce activity to the db if the announcer is not the au
end end
end end
describe "unannouncing an object" do
test "unannouncing a previously announced object" do
note_activity = insert(:note_activity)
object = Object.normalize(note_activity)
user = insert(:user)
# Unannouncing an object that is not announced does nothing
{:ok, object} = ActivityPub.unannounce(user, object)
refute object.data["announcement_count"]
{:ok, announce_activity, object} = ActivityPub.announce(user, object)
assert object.data["announcement_count"] == 1
{:ok, unannounce_activity, object} = ActivityPub.unannounce(user, object)
assert object.data["announcement_count"] == 0
assert unannounce_activity.data["to"] == [
User.ap_followers(user),
object.data["actor"]
]
assert unannounce_activity.data["type"] == "Undo"
assert unannounce_activity.data["object"] == announce_activity.data
assert unannounce_activity.data["actor"] == user.ap_id
assert unannounce_activity.data["context"] == announce_activity.data["context"]
assert Activity.get_by_id(announce_activity.id) == nil
end
test "reverts unannouncing on error" do
note_activity = insert(:note_activity)
object = Object.normalize(note_activity)
user = insert(:user)
{:ok, _announce_activity, object} = ActivityPub.announce(user, object)
assert object.data["announcement_count"] == 1
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.unannounce(user, object)
end
object = Object.get_by_ap_id(object.data["id"])
assert object.data["announcement_count"] == 1
end
end
describe "uploading files" do describe "uploading files" do
test "copies the file to the configured folder" do test "copies the file to the configured folder" do
file = %Plug.Upload{ file = %Plug.Upload{
@ -1276,7 +1114,7 @@ test "creates an undo activity for a pending follow request" do
end end
end end
describe "blocking / unblocking" do describe "blocking" do
test "reverts block activity on error" do test "reverts block activity on error" do
[blocker, blocked] = insert_list(2, :user) [blocker, blocked] = insert_list(2, :user)
@ -1298,175 +1136,6 @@ test "creates a block activity" do
assert activity.data["actor"] == blocker.ap_id assert activity.data["actor"] == blocker.ap_id
assert activity.data["object"] == blocked.ap_id assert activity.data["object"] == blocked.ap_id
end end
test "reverts unblock activity on error" do
[blocker, blocked] = insert_list(2, :user)
{:ok, block_activity} = ActivityPub.block(blocker, blocked)
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.unblock(blocker, blocked)
end
assert block_activity.data["type"] == "Block"
assert block_activity.data["actor"] == blocker.ap_id
assert Repo.aggregate(Activity, :count, :id) == 1
assert Repo.aggregate(Object, :count, :id) == 1
end
test "creates an undo activity for the last block" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, block_activity} = ActivityPub.block(blocker, blocked)
{:ok, activity} = ActivityPub.unblock(blocker, blocked)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == blocker.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
assert embedded_object["type"] == "Block"
assert embedded_object["object"] == blocked.ap_id
assert embedded_object["id"] == block_activity.data["id"]
end
end
describe "deletion" do
setup do: clear_config([:instance, :rewrite_policy])
test "it reverts deletion on error" do
note = insert(:note_activity)
object = Object.normalize(note)
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.delete(object)
end
assert Repo.aggregate(Activity, :count, :id) == 1
assert Repo.get(Object, object.id) == object
assert Activity.get_by_id(note.id) == note
end
test "it creates a delete activity and deletes the original object" do
note = insert(:note_activity)
object = Object.normalize(note)
{:ok, delete} = ActivityPub.delete(object)
assert delete.data["type"] == "Delete"
assert delete.data["actor"] == note.data["actor"]
assert delete.data["object"] == object.data["id"]
assert Activity.get_by_id(delete.id) != nil
assert Repo.get(Object, object.id).data["type"] == "Tombstone"
end
test "it doesn't fail when an activity was already deleted" do
{:ok, delete} = insert(:note_activity) |> Object.normalize() |> ActivityPub.delete()
assert {:ok, ^delete} = delete |> Object.normalize() |> ActivityPub.delete()
end
test "decrements user note count only for public activities" do
user = insert(:user, note_count: 10)
{:ok, a1} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
"status" => "yeah",
"visibility" => "public"
})
{:ok, a2} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
"status" => "yeah",
"visibility" => "unlisted"
})
{:ok, a3} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
"status" => "yeah",
"visibility" => "private"
})
{:ok, a4} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
"status" => "yeah",
"visibility" => "direct"
})
{:ok, _} = Object.normalize(a1) |> ActivityPub.delete()
{:ok, _} = Object.normalize(a2) |> ActivityPub.delete()
{:ok, _} = Object.normalize(a3) |> ActivityPub.delete()
{:ok, _} = Object.normalize(a4) |> ActivityPub.delete()
user = User.get_cached_by_id(user.id)
assert user.note_count == 10
end
test "it creates a delete activity and checks that it is also sent to users mentioned by the deleted object" do
user = insert(:user)
note = insert(:note_activity)
object = Object.normalize(note)
{:ok, object} =
object
|> Object.change(%{
data: %{
"actor" => object.data["actor"],
"id" => object.data["id"],
"to" => [user.ap_id],
"type" => "Note"
}
})
|> Object.update_and_set_cache()
{:ok, delete} = ActivityPub.delete(object)
assert user.ap_id in delete.data["to"]
end
test "decreases reply count" do
user = insert(:user)
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"})
reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id}
ap_id = activity.data["id"]
{:ok, public_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public"))
{:ok, unlisted_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted"))
{:ok, private_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private"))
{:ok, direct_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct"))
_ = CommonAPI.delete(direct_reply.id, user2)
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
_ = CommonAPI.delete(private_reply.id, user2)
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
_ = CommonAPI.delete(public_reply.id, user2)
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 1
_ = CommonAPI.delete(unlisted_reply.id, user2)
assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 0
end
test "it passes delete activity through MRF before deleting the object" do
Pleroma.Config.put([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.DropPolicy)
note = insert(:note_activity)
object = Object.normalize(note)
{:error, {:reject, _}} = ActivityPub.delete(object)
assert Activity.get_by_id(note.id)
assert Repo.get(Object, object.id).data["type"] == object.data["type"]
end
end end
describe "timeline post-processing" do describe "timeline post-processing" do

View file

@ -1,6 +1,8 @@
defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
use Pleroma.DataCase use Pleroma.DataCase
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
@ -8,6 +10,138 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do
import Pleroma.Factory import Pleroma.Factory
describe "Undos" do
setup do
user = insert(:user)
{:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"})
{:ok, like} = CommonAPI.favorite(user, post_activity.id)
{:ok, valid_like_undo, []} = Builder.undo(user, like)
%{user: user, like: like, valid_like_undo: valid_like_undo}
end
test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do
assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, [])
end
test "it does not validate if the actor of the undo is not the actor of the object", %{
valid_like_undo: valid_like_undo
} do
other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
bad_actor =
valid_like_undo
|> Map.put("actor", other_user.ap_id)
{:error, cng} = ObjectValidator.validate(bad_actor, [])
assert {:actor, {"not the same as object actor", []}} in cng.errors
end
test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do
missing_object =
valid_like_undo
|> Map.put("object", "https://gensokyo.2hu/objects/1")
{:error, cng} = ObjectValidator.validate(missing_object, [])
assert {:object, {"can't find object", []}} in cng.errors
assert length(cng.errors) == 1
end
end
describe "deletes" do
setup do
user = insert(:user)
{:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"})
{:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"])
{:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id)
%{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete}
end
test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do
{:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, [])
assert valid_post_delete["deleted_activity_id"]
end
test "it is invalid if the object isn't in a list of certain types", %{
valid_post_delete: valid_post_delete
} do
object = Object.get_by_ap_id(valid_post_delete["object"])
data =
object.data
|> Map.put("type", "Like")
{:ok, _object} =
object
|> Ecto.Changeset.change(%{data: data})
|> Object.update_and_set_cache()
{:error, cng} = ObjectValidator.validate(valid_post_delete, [])
assert {:object, {"object not in allowed types", []}} in cng.errors
end
test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do
assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, []))
end
test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do
no_id =
valid_post_delete
|> Map.delete("id")
{:error, cng} = ObjectValidator.validate(no_id, [])
assert {:id, {"can't be blank", [validation: :required]}} in cng.errors
end
test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do
missing_object =
valid_post_delete
|> Map.put("object", "http://does.not/exist")
{:error, cng} = ObjectValidator.validate(missing_object, [])
assert {:object, {"can't find object", []}} in cng.errors
end
test "it's invalid if the actor of the object and the actor of delete are from different domains",
%{valid_post_delete: valid_post_delete} do
valid_user = insert(:user)
valid_other_actor =
valid_post_delete
|> Map.put("actor", valid_user.ap_id)
assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, []))
invalid_other_actor =
valid_post_delete
|> Map.put("actor", "https://gensokyo.2hu/users/raymoo")
{:error, cng} = ObjectValidator.validate(invalid_other_actor, [])
assert {:actor, {"is not allowed to delete object", []}} in cng.errors
end
test "it's valid if the actor of the object is a local superuser",
%{valid_post_delete: valid_post_delete} do
user =
insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo")
valid_other_actor =
valid_post_delete
|> Map.put("actor", user.ap_id)
{:ok, _, meta} = ObjectValidator.validate(valid_other_actor, [])
assert meta[:do_not_federate]
end
end
describe "likes" do describe "likes" do
setup do setup do
user = insert(:user) user = insert(:user)

View file

@ -0,0 +1,27 @@
defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do
alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients
use Pleroma.DataCase
test "it asserts that all elements of the list are object ids" do
list = ["https://lain.com/users/lain", "invalid"]
assert :error == Recipients.cast(list)
end
test "it works with a list" do
list = ["https://lain.com/users/lain"]
assert {:ok, list} == Recipients.cast(list)
end
test "it works with a list with whole objects" do
list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}]
resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"]
assert {:ok, resulting_list} == Recipients.cast(list)
end
test "it turns a single string into a list" do
recipient = "https://lain.com/users/lain"
assert {:ok, [recipient]} == Recipients.cast(recipient)
end
end

View file

@ -3,17 +3,174 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.SideEffectsTest do defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.ActivityPub.SideEffects
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
import Pleroma.Factory import Pleroma.Factory
import Mock
describe "delete objects" do
setup do
user = insert(:user)
other_user = insert(:user)
{:ok, op} = CommonAPI.post(other_user, %{"status" => "big oof"})
{:ok, post} = CommonAPI.post(user, %{"status" => "hey", "in_reply_to_id" => op})
object = Object.normalize(post)
{:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
{:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id)
{:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
{:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true)
%{user: user, delete: delete, post: post, object: object, delete_user: delete_user, op: op}
end
test "it handles object deletions", %{
delete: delete,
post: post,
object: object,
user: user,
op: op
} do
with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough],
stream_out: fn _ -> nil end,
stream_out_participations: fn _, _ -> nil end do
{:ok, delete, _} = SideEffects.handle(delete)
user = User.get_cached_by_ap_id(object.data["actor"])
assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete))
assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user))
end
object = Object.get_by_id(object.id)
assert object.data["type"] == "Tombstone"
refute Activity.get_by_id(post.id)
user = User.get_by_id(user.id)
assert user.note_count == 0
object = Object.normalize(op.data["object"], false)
assert object.data["repliesCount"] == 0
end
test "it handles user deletions", %{delete_user: delete, user: user} do
{:ok, _delete, _} = SideEffects.handle(delete)
ObanHelpers.perform_all()
assert User.get_cached_by_ap_id(user.ap_id).deactivated
end
end
describe "Undo objects" do
setup do
poster = insert(:user)
user = insert(:user)
{:ok, post} = CommonAPI.post(poster, %{"status" => "hey"})
{:ok, like} = CommonAPI.favorite(user, post.id)
{:ok, reaction, _} = CommonAPI.react_with_emoji(post.id, user, "👍")
{:ok, announce, _} = CommonAPI.repeat(post.id, user)
{:ok, block} = ActivityPub.block(user, poster)
User.block(user, poster)
{:ok, undo_data, _meta} = Builder.undo(user, like)
{:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true)
{:ok, undo_data, _meta} = Builder.undo(user, reaction)
{:ok, reaction_undo, _meta} = ActivityPub.persist(undo_data, local: true)
{:ok, undo_data, _meta} = Builder.undo(user, announce)
{:ok, announce_undo, _meta} = ActivityPub.persist(undo_data, local: true)
{:ok, undo_data, _meta} = Builder.undo(user, block)
{:ok, block_undo, _meta} = ActivityPub.persist(undo_data, local: true)
%{
like_undo: like_undo,
post: post,
like: like,
reaction_undo: reaction_undo,
reaction: reaction,
announce_undo: announce_undo,
announce: announce,
block_undo: block_undo,
block: block,
poster: poster,
user: user
}
end
test "deletes the original block", %{block_undo: block_undo, block: block} do
{:ok, _block_undo, _} = SideEffects.handle(block_undo)
refute Activity.get_by_id(block.id)
end
test "unblocks the blocked user", %{block_undo: block_undo, block: block} do
blocker = User.get_by_ap_id(block.data["actor"])
blocked = User.get_by_ap_id(block.data["object"])
{:ok, _block_undo, _} = SideEffects.handle(block_undo)
refute User.blocks?(blocker, blocked)
end
test "an announce undo removes the announce from the object", %{
announce_undo: announce_undo,
post: post
} do
{:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
object = Object.get_by_ap_id(post.data["object"])
assert object.data["announcement_count"] == 0
assert object.data["announcements"] == []
end
test "deletes the original announce", %{announce_undo: announce_undo, announce: announce} do
{:ok, _announce_undo, _} = SideEffects.handle(announce_undo)
refute Activity.get_by_id(announce.id)
end
test "a reaction undo removes the reaction from the object", %{
reaction_undo: reaction_undo,
post: post
} do
{:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
object = Object.get_by_ap_id(post.data["object"])
assert object.data["reaction_count"] == 0
assert object.data["reactions"] == []
end
test "deletes the original reaction", %{reaction_undo: reaction_undo, reaction: reaction} do
{:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo)
refute Activity.get_by_id(reaction.id)
end
test "a like undo removes the like from the object", %{like_undo: like_undo, post: post} do
{:ok, _like_undo, _} = SideEffects.handle(like_undo)
object = Object.get_by_ap_id(post.data["object"])
assert object.data["like_count"] == 0
assert object.data["likes"] == []
end
test "deletes the original like", %{like_undo: like_undo, like: like} do
{:ok, _like_undo, _} = SideEffects.handle(like_undo)
refute Activity.get_by_id(like.id)
end
end
describe "like objects" do describe "like objects" do
setup do setup do

View file

@ -0,0 +1,86 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Transmogrifier
import Pleroma.Factory
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
test "it works for incoming deletes" do
activity = insert(:note_activity)
deleting_user = insert(:user)
data =
File.read!("test/fixtures/mastodon-delete.json")
|> Poison.decode!()
|> Map.put("actor", deleting_user.ap_id)
|> put_in(["object", "id"], activity.data["object"])
{:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
Transmogrifier.handle_incoming(data)
assert id == data["id"]
# We delete the Create activity because we base our timelines on it.
# This should be changed after we unify objects and activities
refute Activity.get_by_id(activity.id)
assert actor == deleting_user.ap_id
# Objects are replaced by a tombstone object.
object = Object.normalize(activity.data["object"])
assert object.data["type"] == "Tombstone"
end
test "it fails for incoming deletes with spoofed origin" do
activity = insert(:note_activity)
%{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo")
data =
File.read!("test/fixtures/mastodon-delete.json")
|> Poison.decode!()
|> Map.put("actor", ap_id)
|> put_in(["object", "id"], activity.data["object"])
assert match?({:error, _}, Transmogrifier.handle_incoming(data))
end
@tag capture_log: true
test "it works for incoming user deletes" do
%{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin")
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Poison.decode!()
{:ok, _} = Transmogrifier.handle_incoming(data)
ObanHelpers.perform_all()
assert User.get_cached_by_ap_id(ap_id).deactivated
end
test "it fails for incoming user deletes with spoofed origin" do
%{ap_id: ap_id} = insert(:user)
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Poison.decode!()
|> Map.put("actor", ap_id)
assert match?({:error, _}, Transmogrifier.handle_incoming(data))
assert User.get_cached_by_ap_id(ap_id)
end
end

View file

@ -0,0 +1,185 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
test "it works for incoming emoji reaction undos" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
{:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌")
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", reaction_activity.data["id"])
|> Map.put("actor", user.ap_id)
{:ok, activity} = Transmogrifier.handle_incoming(data)
assert activity.actor == user.ap_id
assert activity.data["id"] == data["id"]
assert activity.data["type"] == "Undo"
end
test "it returns an error for incoming unlikes wihout a like activity" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
assert Transmogrifier.handle_incoming(data) == :error
end
test "it works for incoming unlikes with an existing like activity" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
like_data =
File.read!("test/fixtures/mastodon-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
_liker = insert(:user, ap_id: like_data["actor"], local: false)
{:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", like_data)
|> Map.put("actor", like_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["actor"] == "http://mastodon.example.org/users/admin"
assert data["type"] == "Undo"
assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
note = Object.get_by_ap_id(like_data["object"])
assert note.data["like_count"] == 0
assert note.data["likes"] == []
end
test "it works for incoming unlikes with an existing like activity and a compact object" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
like_data =
File.read!("test/fixtures/mastodon-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
_liker = insert(:user, ap_id: like_data["actor"], local: false)
{:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", like_data["id"])
|> Map.put("actor", like_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["actor"] == "http://mastodon.example.org/users/admin"
assert data["type"] == "Undo"
assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
assert data["object"] == "http://mastodon.example.org/users/admin#likes/2"
end
test "it works for incoming unannounces with an existing notice" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
announce_data =
File.read!("test/fixtures/mastodon-announce.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
_announcer = insert(:user, ap_id: announce_data["actor"], local: false)
{:ok, %Activity{data: announce_data, local: false}} =
Transmogrifier.handle_incoming(announce_data)
data =
File.read!("test/fixtures/mastodon-undo-announce.json")
|> Poison.decode!()
|> Map.put("object", announce_data)
|> Map.put("actor", announce_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert data["object"] ==
"http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
end
test "it works for incomming unfollows with an existing follow" do
user = insert(:user)
follow_data =
File.read!("test/fixtures/mastodon-follow-activity.json")
|> Poison.decode!()
|> Map.put("object", user.ap_id)
_follower = insert(:user, ap_id: follow_data["actor"], local: false)
{:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data)
data =
File.read!("test/fixtures/mastodon-unfollow-activity.json")
|> Poison.decode!()
|> Map.put("object", follow_data)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert data["object"]["type"] == "Follow"
assert data["object"]["object"] == user.ap_id
assert data["actor"] == "http://mastodon.example.org/users/admin"
refute User.following?(User.get_cached_by_ap_id(data["actor"]), user)
end
test "it works for incoming unblocks with an existing block" do
user = insert(:user)
block_data =
File.read!("test/fixtures/mastodon-block-activity.json")
|> Poison.decode!()
|> Map.put("object", user.ap_id)
_blocker = insert(:user, ap_id: block_data["actor"], local: false)
{:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
data =
File.read!("test/fixtures/mastodon-unblock-activity.json")
|> Poison.decode!()
|> Map.put("object", block_data)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert data["object"] == block_data["id"]
blocker = User.get_cached_by_ap_id(data["actor"])
refute User.blocks?(blocker, user)
end
end

View file

@ -362,87 +362,6 @@ test "it reject invalid emoji reactions" do
assert :error = Transmogrifier.handle_incoming(data) assert :error = Transmogrifier.handle_incoming(data)
end end
test "it works for incoming emoji reaction undos" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
{:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌")
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", reaction_activity.data["id"])
|> Map.put("actor", user.ap_id)
{:ok, activity} = Transmogrifier.handle_incoming(data)
assert activity.actor == user.ap_id
assert activity.data["id"] == data["id"]
assert activity.data["type"] == "Undo"
end
test "it returns an error for incoming unlikes wihout a like activity" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
assert Transmogrifier.handle_incoming(data) == :error
end
test "it works for incoming unlikes with an existing like activity" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
like_data =
File.read!("test/fixtures/mastodon-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
{:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", like_data)
|> Map.put("actor", like_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["actor"] == "http://mastodon.example.org/users/admin"
assert data["type"] == "Undo"
assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
end
test "it works for incoming unlikes with an existing like activity and a compact object" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
like_data =
File.read!("test/fixtures/mastodon-like.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
{:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data)
data =
File.read!("test/fixtures/mastodon-undo-like.json")
|> Poison.decode!()
|> Map.put("object", like_data["id"])
|> Map.put("actor", like_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["actor"] == "http://mastodon.example.org/users/admin"
assert data["type"] == "Undo"
assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo"
assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2"
end
test "it works for incoming announces" do test "it works for incoming announces" do
data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!()
@ -766,113 +685,6 @@ test "it works for incoming update activities which lock the account" do
assert user.locked == true assert user.locked == true
end end
test "it works for incoming deletes" do
activity = insert(:note_activity)
deleting_user = insert(:user)
data =
File.read!("test/fixtures/mastodon-delete.json")
|> Poison.decode!()
object =
data["object"]
|> Map.put("id", activity.data["object"])
data =
data
|> Map.put("object", object)
|> Map.put("actor", deleting_user.ap_id)
{:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} =
Transmogrifier.handle_incoming(data)
assert id == data["id"]
refute Activity.get_by_id(activity.id)
assert actor == deleting_user.ap_id
end
test "it fails for incoming deletes with spoofed origin" do
activity = insert(:note_activity)
data =
File.read!("test/fixtures/mastodon-delete.json")
|> Poison.decode!()
object =
data["object"]
|> Map.put("id", activity.data["object"])
data =
data
|> Map.put("object", object)
assert capture_log(fn ->
:error = Transmogrifier.handle_incoming(data)
end) =~
"[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}"
assert Activity.get_by_id(activity.id)
end
@tag capture_log: true
test "it works for incoming user deletes" do
%{ap_id: ap_id} =
insert(:user, ap_id: "http://mastodon.example.org/users/admin", local: false)
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Poison.decode!()
{:ok, _} = Transmogrifier.handle_incoming(data)
ObanHelpers.perform_all()
refute User.get_cached_by_ap_id(ap_id)
end
test "it fails for incoming user deletes with spoofed origin" do
%{ap_id: ap_id} = insert(:user)
data =
File.read!("test/fixtures/mastodon-delete-user.json")
|> Poison.decode!()
|> Map.put("actor", ap_id)
assert capture_log(fn ->
assert :error == Transmogrifier.handle_incoming(data)
end) =~ "Object containment failed"
assert User.get_cached_by_ap_id(ap_id)
end
test "it works for incoming unannounces with an existing notice" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
announce_data =
File.read!("test/fixtures/mastodon-announce.json")
|> Poison.decode!()
|> Map.put("object", activity.data["object"])
{:ok, %Activity{data: announce_data, local: false}} =
Transmogrifier.handle_incoming(announce_data)
data =
File.read!("test/fixtures/mastodon-undo-announce.json")
|> Poison.decode!()
|> Map.put("object", announce_data)
|> Map.put("actor", announce_data["actor"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert object_data = data["object"]
assert object_data["type"] == "Announce"
assert object_data["object"] == activity.data["object"]
assert object_data["id"] ==
"http://mastodon.example.org/users/admin/statuses/99542391527669785/activity"
end
test "it works for incomming unfollows with an existing follow" do test "it works for incomming unfollows with an existing follow" do
user = insert(:user) user = insert(:user)
@ -967,32 +779,6 @@ test "incoming blocks successfully tear down any follow relationship" do
refute User.following?(blocked, blocker) refute User.following?(blocked, blocker)
end end
test "it works for incoming unblocks with an existing block" do
user = insert(:user)
block_data =
File.read!("test/fixtures/mastodon-block-activity.json")
|> Poison.decode!()
|> Map.put("object", user.ap_id)
{:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data)
data =
File.read!("test/fixtures/mastodon-unblock-activity.json")
|> Poison.decode!()
|> Map.put("object", block_data)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "Undo"
assert data["object"]["type"] == "Block"
assert data["object"]["object"] == user.ap_id
assert data["actor"] == "http://mastodon.example.org/users/admin"
blocker = User.get_cached_by_ap_id(data["actor"])
refute User.blocks?(blocker, user)
end
test "it works for incoming accepts which were pre-accepted" do test "it works for incoming accepts which were pre-accepted" do
follower = insert(:user) follower = insert(:user)
followed = insert(:user) followed = insert(:user)

View file

@ -102,34 +102,6 @@ test "works with an object has tags as map" do
end end
end end
describe "make_unlike_data/3" do
test "returns data for unlike activity" do
user = insert(:user)
like_activity = insert(:like_activity, data_attrs: %{"context" => "test context"})
object = Object.normalize(like_activity.data["object"])
assert Utils.make_unlike_data(user, like_activity, nil) == %{
"type" => "Undo",
"actor" => user.ap_id,
"object" => like_activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => like_activity.data["context"]
}
assert Utils.make_unlike_data(user, like_activity, "9mJEZK0tky1w2xD2vY") == %{
"type" => "Undo",
"actor" => user.ap_id,
"object" => like_activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => like_activity.data["context"],
"id" => "9mJEZK0tky1w2xD2vY"
}
end
end
describe "make_like_data" do describe "make_like_data" do
setup do setup do
user = insert(:user) user = insert(:user)

View file

@ -6,13 +6,15 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
use Pleroma.Web.ConnCase use Pleroma.Web.ConnCase
use Oban.Testing, repo: Pleroma.Repo use Oban.Testing, repo: Pleroma.Repo
import Pleroma.Factory
import ExUnit.CaptureLog import ExUnit.CaptureLog
import Mock
import Pleroma.Factory
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.ConfigDB alias Pleroma.ConfigDB
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.MFA
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.ReportNote alias Pleroma.ReportNote
@ -147,17 +149,26 @@ test "GET /api/pleroma/admin/users/:nickname requires " <>
test "single user", %{admin: admin, conn: conn} do test "single user", %{admin: admin, conn: conn} do
user = insert(:user) user = insert(:user)
conn = with_mock Pleroma.Web.Federator,
conn publish: fn _ -> nil end do
|> put_req_header("accept", "application/json") conn =
|> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") conn
|> put_req_header("accept", "application/json")
|> delete("/api/pleroma/admin/users?nickname=#{user.nickname}")
log_entry = Repo.one(ModerationLog) ObanHelpers.perform_all()
assert ModerationLog.get_log_entry_message(log_entry) == assert User.get_by_nickname(user.nickname).deactivated
"@#{admin.nickname} deleted users: @#{user.nickname}"
assert json_response(conn, 200) == user.nickname log_entry = Repo.one(ModerationLog)
assert ModerationLog.get_log_entry_message(log_entry) ==
"@#{admin.nickname} deleted users: @#{user.nickname}"
assert json_response(conn, 200) == [user.nickname]
assert called(Pleroma.Web.Federator.publish(:_))
end
end end
test "multiple users", %{admin: admin, conn: conn} do test "multiple users", %{admin: admin, conn: conn} do
@ -1268,6 +1279,38 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi
"@#{admin.nickname} deactivated users: @#{user.nickname}" "@#{admin.nickname} deactivated users: @#{user.nickname}"
end end
describe "PUT disable_mfa" do
test "returns 200 and disable 2fa", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
}
)
response =
conn
|> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname})
|> json_response(200)
assert response == user.nickname
mfa_settings = refresh_record(user).multi_factor_authentication_settings
refute mfa_settings.enabled
refute mfa_settings.totp.confirmed
end
test "returns 404 if user not found", %{conn: conn} do
response =
conn
|> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"})
|> json_response(404)
assert response == "Not found"
end
end
describe "POST /api/pleroma/admin/users/invite_token" do describe "POST /api/pleroma/admin/users/invite_token" do
test "without options", %{conn: conn} do test "without options", %{conn: conn} do
conn = post(conn, "/api/pleroma/admin/users/invite_token") conn = post(conn, "/api/pleroma/admin/users/invite_token")

View file

@ -0,0 +1,43 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do
use Pleroma.Web.ConnCase
alias Pleroma.Web.Auth.PleromaAuthenticator
import Pleroma.Factory
setup do
password = "testpassword"
name = "AgentSmith"
user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
{:ok, [user: user, name: name, password: password]}
end
test "get_user/authorization", %{user: user, name: name, password: password} do
params = %{"authorization" => %{"name" => name, "password" => password}}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:ok, user} == res
end
test "get_user/authorization with invalid password", %{name: name} do
params = %{"authorization" => %{"name" => name, "password" => "password"}}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:error, {:checkpw, false}} == res
end
test "get_user/grant_type_password", %{user: user, name: name, password: password} do
params = %{"grant_type" => "password", "username" => name, "password" => password}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:ok, user} == res
end
test "error credintails" do
res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}})
assert {:error, :invalid_credentials} == res
end
end

View file

@ -0,0 +1,51 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do
use Pleroma.Web.ConnCase
alias Pleroma.MFA
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.TOTP
alias Pleroma.Web.Auth.TOTPAuthenticator
import Pleroma.Factory
test "verify token" do
otp_secret = TOTP.generate_secret()
otp_token = TOTP.generate_token(otp_secret)
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass}
assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token}
assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token}
end
test "checks backup codes" do
[code | _] = backup_codes = BackupCodes.generate()
hashed_codes =
backup_codes
|> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
backup_codes: hashed_codes,
totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
}
)
assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass}
refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass}
end
end

View file

@ -9,11 +9,13 @@ defmodule Pleroma.Web.CommonAPITest do
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
import Pleroma.Factory import Pleroma.Factory
import Mock
require Pleroma.Constants require Pleroma.Constants
@ -21,6 +23,84 @@ defmodule Pleroma.Web.CommonAPITest do
setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :limit])
setup do: clear_config([:instance, :max_pinned_statuses]) setup do: clear_config([:instance, :max_pinned_statuses])
describe "deletion" do
test "it allows users to delete their posts" do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, delete} = CommonAPI.delete(post.id, user)
assert delete.local
assert called(Pleroma.Web.Federator.publish(delete))
end
refute Activity.get_by_id(post.id)
end
test "it does not allow a user to delete their posts" do
user = insert(:user)
other_user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user)
assert Activity.get_by_id(post.id)
end
test "it allows moderators to delete other user's posts" do
user = insert(:user)
moderator = insert(:user, is_moderator: true)
{:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
assert delete.local
refute Activity.get_by_id(post.id)
end
test "it allows admins to delete other user's posts" do
user = insert(:user)
moderator = insert(:user, is_admin: true)
{:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"})
assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
assert delete.local
refute Activity.get_by_id(post.id)
end
test "superusers deleting non-local posts won't federate the delete" do
# This is the user of the ingested activity
_user =
insert(:user,
local: false,
ap_id: "http://mastodon.example.org/users/admin",
last_refreshed_at: NaiveDateTime.utc_now()
)
moderator = insert(:user, is_admin: true)
data =
File.read!("test/fixtures/mastodon-post-activity.json")
|> Jason.decode!()
{:ok, post} = Transmogrifier.handle_incoming(data)
with_mock Pleroma.Web.Federator,
publish: fn _ -> nil end do
assert {:ok, delete} = CommonAPI.delete(post.id, moderator)
assert delete.local
refute called(Pleroma.Web.Federator.publish(:_))
end
refute Activity.get_by_id(post.id)
end
end
test "favoriting race condition" do test "favoriting race condition" do
user = insert(:user) user = insert(:user)
users_serial = insert_list(10, :user) users_serial = insert_list(10, :user)
@ -295,10 +375,11 @@ test "unreacting to a status with an emoji" do
{:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
{:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍") {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍")
{:ok, unreaction, _} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
assert unreaction.data["type"] == "Undo" assert unreaction.data["type"] == "Undo"
assert unreaction.data["object"] == reaction.data["id"] assert unreaction.data["object"] == reaction.data["id"]
assert unreaction.local
end end
test "repeating a status" do test "repeating a status" do

View file

@ -24,7 +24,7 @@ test "returns poll entity for object id", %{user: user, conn: conn} do
conn = get(conn, "/api/v1/polls/#{object.id}") conn = get(conn, "/api/v1/polls/#{object.id}")
response = json_response(conn, 200) response = json_response_and_validate_schema(conn, 200)
id = to_string(object.id) id = to_string(object.id)
assert %{"id" => ^id, "expired" => false, "multiple" => false} = response assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
end end
@ -43,7 +43,7 @@ test "does not expose polls for private statuses", %{conn: conn} do
conn = get(conn, "/api/v1/polls/#{object.id}") conn = get(conn, "/api/v1/polls/#{object.id}")
assert json_response(conn, 404) assert json_response_and_validate_schema(conn, 404)
end end
end end
@ -65,9 +65,12 @@ test "votes are added to the poll", %{conn: conn} do
object = Object.normalize(activity) object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
assert json_response(conn, 200) assert json_response_and_validate_schema(conn, 200)
object = Object.get_by_id(object.id) object = Object.get_by_id(object.id)
assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} -> assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
@ -85,8 +88,9 @@ test "author can't vote", %{user: user, conn: conn} do
object = Object.normalize(activity) object = Object.normalize(activity)
assert conn assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
|> json_response(422) == %{"error" => "Poll's author can't vote"} |> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
object = Object.get_by_id(object.id) object = Object.get_by_id(object.id)
@ -105,8 +109,9 @@ test "does not allow multiple choices on a single-choice question", %{conn: conn
object = Object.normalize(activity) object = Object.normalize(activity)
assert conn assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
|> json_response(422) == %{"error" => "Too many choices"} |> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
object = Object.get_by_id(object.id) object = Object.get_by_id(object.id)
@ -126,15 +131,21 @@ test "does not allow choice index to be greater than options count", %{conn: con
object = Object.normalize(activity) object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [2]}) conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
assert json_response(conn, 422) == %{"error" => "Invalid indices"} assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
end end
test "returns 404 error when object is not exist", %{conn: conn} do test "returns 404 error when object is not exist", %{conn: conn} do
conn = post(conn, "/api/v1/polls/1/votes", %{"choices" => [0]}) conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/1/votes", %{"choices" => [0]})
assert json_response(conn, 404) == %{"error" => "Record not found"} assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end end
test "returns 404 when poll is private and not available for user", %{conn: conn} do test "returns 404 when poll is private and not available for user", %{conn: conn} do
@ -149,9 +160,12 @@ test "returns 404 when poll is private and not available for user", %{conn: conn
object = Object.normalize(activity) object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0]}) conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
assert json_response(conn, 404) == %{"error" => "Record not found"} assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end end
end end
end end

View file

@ -27,8 +27,8 @@ test "it returns empty result if user or status search return undefined error",
capture_log(fn -> capture_log(fn ->
results = results =
conn conn
|> get("/api/v2/search", %{"q" => "2hu"}) |> get("/api/v2/search?q=2hu")
|> json_response(200) |> json_response_and_validate_schema(200)
assert results["accounts"] == [] assert results["accounts"] == []
assert results["statuses"] == [] assert results["statuses"] == []
@ -54,8 +54,8 @@ test "search", %{conn: conn} do
results = results =
conn conn
|> get("/api/v2/search", %{"q" => "2hu #private"}) |> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
|> json_response(200) |> json_response_and_validate_schema(200)
[account | _] = results["accounts"] [account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id) assert account["id"] == to_string(user_three.id)
@ -68,8 +68,8 @@ test "search", %{conn: conn} do
assert status["id"] == to_string(activity.id) assert status["id"] == to_string(activity.id)
results = results =
get(conn, "/api/v2/search", %{"q" => "天子"}) get(conn, "/api/v2/search?q=天子")
|> json_response(200) |> json_response_and_validate_schema(200)
[status] = results["statuses"] [status] = results["statuses"]
assert status["id"] == to_string(activity.id) assert status["id"] == to_string(activity.id)
@ -89,8 +89,8 @@ test "excludes a blocked users from search results", %{conn: conn} do
conn conn
|> assign(:user, user) |> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"])) |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v2/search", %{"q" => "Agent"}) |> get("/api/v2/search?q=Agent")
|> json_response(200) |> json_response_and_validate_schema(200)
status_ids = Enum.map(results["statuses"], fn g -> g["id"] end) status_ids = Enum.map(results["statuses"], fn g -> g["id"] end)
@ -107,8 +107,8 @@ test "account search", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/accounts/search", %{"q" => "shp"}) |> get("/api/v1/accounts/search?q=shp")
|> json_response(200) |> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"] result_ids = for result <- results, do: result["acct"]
@ -117,8 +117,8 @@ test "account search", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/accounts/search", %{"q" => "2hu"}) |> get("/api/v1/accounts/search?q=2hu")
|> json_response(200) |> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"] result_ids = for result <- results, do: result["acct"]
@ -130,8 +130,8 @@ test "returns account if query contains a space", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/accounts/search", %{"q" => "shp@shitposter.club xxx "}) |> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
|> json_response(200) |> json_response_and_validate_schema(200)
assert length(results) == 1 assert length(results) == 1
end end
@ -146,8 +146,8 @@ test "it returns empty result if user or status search return undefined error",
capture_log(fn -> capture_log(fn ->
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "2hu"}) |> get("/api/v1/search?q=2hu")
|> json_response(200) |> json_response_and_validate_schema(200)
assert results["accounts"] == [] assert results["accounts"] == []
assert results["statuses"] == [] assert results["statuses"] == []
@ -173,8 +173,8 @@ test "search", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "2hu"}) |> get("/api/v1/search?q=2hu")
|> json_response(200) |> json_response_and_validate_schema(200)
[account | _] = results["accounts"] [account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id) assert account["id"] == to_string(user_three.id)
@ -194,8 +194,8 @@ test "search fetches remote statuses and prefers them over other results", %{con
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "https://shitposter.club/notice/2827873"}) |> get("/api/v1/search?q=https://shitposter.club/notice/2827873")
|> json_response(200) |> json_response_and_validate_schema(200)
[status, %{"id" => ^activity_id}] = results["statuses"] [status, %{"id" => ^activity_id}] = results["statuses"]
@ -212,10 +212,12 @@ test "search doesn't show statuses that it shouldn't", %{conn: conn} do
}) })
capture_log(fn -> capture_log(fn ->
q = Object.normalize(activity).data["id"]
results = results =
conn conn
|> get("/api/v1/search", %{"q" => Object.normalize(activity).data["id"]}) |> get("/api/v1/search?q=#{q}")
|> json_response(200) |> json_response_and_validate_schema(200)
[] = results["statuses"] [] = results["statuses"]
end) end)
@ -228,8 +230,8 @@ test "search fetches remote accounts", %{conn: conn} do
conn conn
|> assign(:user, user) |> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"])) |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "true"}) |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=true")
|> json_response(200) |> json_response_and_validate_schema(200)
[account] = results["accounts"] [account] = results["accounts"]
assert account["acct"] == "mike@osada.macgirvin.com" assert account["acct"] == "mike@osada.macgirvin.com"
@ -238,8 +240,8 @@ test "search fetches remote accounts", %{conn: conn} do
test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "false"}) |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false")
|> json_response(200) |> json_response_and_validate_schema(200)
assert [] == results["accounts"] assert [] == results["accounts"]
end end
@ -254,16 +256,16 @@ test "search with limit and offset", %{conn: conn} do
result = result =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "limit" => 1}) |> get("/api/v1/search?q=2hu&limit=1")
assert results = json_response(result, 200) assert results = json_response_and_validate_schema(result, 200)
assert [%{"id" => activity_id1}] = results["statuses"] assert [%{"id" => activity_id1}] = results["statuses"]
assert [_] = results["accounts"] assert [_] = results["accounts"]
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "limit" => 1, "offset" => 1}) |> get("/api/v1/search?q=2hu&limit=1&offset=1")
|> json_response(200) |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"] assert [%{"id" => activity_id2}] = results["statuses"]
assert [] = results["accounts"] assert [] = results["accounts"]
@ -279,13 +281,13 @@ test "search returns results only for the given type", %{conn: conn} do
assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} = assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "type" => "statuses"}) |> get("/api/v1/search?q=2hu&type=statuses")
|> json_response(200) |> json_response_and_validate_schema(200)
assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} = assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "type" => "accounts"}) |> get("/api/v1/search?q=2hu&type=accounts")
|> json_response(200) |> json_response_and_validate_schema(200)
end end
test "search uses account_id to filter statuses by the author", %{conn: conn} do test "search uses account_id to filter statuses by the author", %{conn: conn} do
@ -297,8 +299,8 @@ test "search uses account_id to filter statuses by the author", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "account_id" => user.id}) |> get("/api/v1/search?q=2hu&account_id=#{user.id}")
|> json_response(200) |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id1}] = results["statuses"] assert [%{"id" => activity_id1}] = results["statuses"]
assert activity_id1 == activity1.id assert activity_id1 == activity1.id
@ -306,8 +308,8 @@ test "search uses account_id to filter statuses by the author", %{conn: conn} do
results = results =
conn conn
|> get("/api/v1/search", %{"q" => "2hu", "account_id" => user_two.id}) |> get("/api/v1/search?q=2hu&account_id=#{user_two.id}")
|> json_response(200) |> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"] assert [%{"id" => activity_id2}] = results["statuses"]
assert activity_id2 == activity2.id assert activity_id2 == activity2.id

Some files were not shown because too many files have changed in this diff Show more