forked from AkkomaGang/akkoma
Merge branch 'develop' into issue/1276-2
This commit is contained in:
commit
b078e0567d
108 changed files with 4417 additions and 2063 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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: User’s 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
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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 instance’s name.
|
* `name`: The instance’s 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`.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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
156
lib/pleroma/mfa.ex
Normal 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
|
31
lib/pleroma/mfa/backup_codes.ex
Normal file
31
lib/pleroma/mfa/backup_codes.ex
Normal 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
|
64
lib/pleroma/mfa/changeset.ex
Normal file
64
lib/pleroma/mfa/changeset.ex
Normal 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
|
24
lib/pleroma/mfa/settings.ex
Normal file
24
lib/pleroma/mfa/settings.ex
Normal 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
106
lib/pleroma/mfa/token.ex
Normal 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
86
lib/pleroma/mfa/totp.ex
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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", %{
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
76
lib/pleroma/web/api_spec/operations/poll_operation.ex
Normal file
76
lib/pleroma/web/api_spec/operations/poll_operation.ex
Normal 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
|
207
lib/pleroma/web/api_spec/operations/search_operation.ex
Normal file
207
lib/pleroma/web/api_spec/operations/search_operation.ex
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
27
lib/pleroma/web/api_spec/schemas/tag.ex
Normal file
27
lib/pleroma/web/api_spec/schemas/tag.ex
Normal 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
|
|
@ -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
|
||||||
|
|
||||||
|
|
45
lib/pleroma/web/auth/totp_authenticator.ex
Normal file
45
lib/pleroma/web/auth/totp_authenticator.ex
Normal 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
|
|
@ -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")}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
97
lib/pleroma/web/oauth/mfa_controller.ex
Normal file
97
lib/pleroma/web/oauth/mfa_controller.ex
Normal 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
|
8
lib/pleroma/web/oauth/mfa_view.ex
Normal file
8
lib/pleroma/web/oauth/mfa_view.ex
Normal 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
|
|
@ -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
|
||||||
|
|
38
lib/pleroma/web/oauth/token/clean_worker.ex
Normal file
38
lib/pleroma/web/oauth/token/clean_worker.ex
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
24
lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
Normal file
24
lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex
Normal 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>
|
24
lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
Normal file
24
lib/pleroma/web/templates/o_auth/mfa/totp.html.eex
Normal 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>
|
|
@ -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 %>
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
1
mix.exs
1
mix.exs
|
@ -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"},
|
||||||
|
|
1
mix.lock
1
mix.lock
|
@ -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"},
|
||||||
|
|
|
@ -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
|
16
priv/repo/migrations/20190508193213_create_mfa_tokens.exs
Normal file
16
priv/repo/migrations/20190508193213_create_mfa_tokens.exs
Normal 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
|
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||||
|
|
11
test/mfa/backup_codes_test.exs
Normal file
11
test/mfa/backup_codes_test.exs
Normal 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
17
test/mfa/totp_test.exs
Normal 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
53
test/mfa_test.exs
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
%{
|
%{
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
185
test/web/activity_pub/transmogrifier/undo_handling_test.exs
Normal file
185
test/web/activity_pub/transmogrifier/undo_handling_test.exs
Normal 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
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
43
test/web/auth/pleroma_authenticator_test.exs
Normal file
43
test/web/auth/pleroma_authenticator_test.exs
Normal 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
|
51
test/web/auth/totp_authenticator_test.exs
Normal file
51
test/web/auth/totp_authenticator_test.exs
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue