The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-## [unreleased]
+## [Unreleased]
+### Security
+- OStatus: eliminate the possibility of a protocol downgrade attack.
+- OStatus: prevent following locked accounts, bypassing the approval process.
+
+### Changed
+- **Breaking:** Configuration: A setting to explicitly disable the mailer was added, defaulting to true, if you are using a mailer add `config :pleroma, Pleroma.Emails.Mailer, enabled: true` to your config
+- **Breaking:** Configuration: `/media/` is now removed when `base_url` is configured, append `/media/` to your `base_url` config to keep the old behaviour if desired
+- Configuration: OpenGraph and TwitterCard providers enabled by default
+- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
+- Federation: Return 403 errors when trying to request pages from a user's follower/following collections if they have `hide_followers`/`hide_follows` set
+- NodeInfo: Return `skipThreadContainment` in `metadata` for the `skip_thread_containment` option
+- NodeInfo: Return `mailerEnabled` in `metadata`
+- Mastodon API: Unsubscribe followers when they unfollow a user
+- AdminAPI: Add "godmode" while fetching user statuses (i.e. admin can see private statuses)
+- Improve digest email template
+
+### Fixed
+- Not being able to pin unlisted posts
+- Objects being re-embedded to activities after being updated (e.g faved/reposted). Running 'mix pleroma.database prune_objects' again is advised.
+- Favorites timeline doing database-intensive queries
+- Metadata rendering errors resulting in the entire page being inaccessible
+- `federation_incoming_replies_max_depth` option being ignored in certain cases
+- Federation/MediaProxy not working with instances that have wrong certificate order
+- Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`)
+- Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity
+- Mastodon API: follower/following counters not being nullified, when `hide_follows`/`hide_followers` is set
+- Mastodon API: `muted` in the Status entity, using author's account to determine if the tread was muted
+- Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`)
+- Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes
+- ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set
+- Existing user id not being preserved on insert conflict
+- Rich Media: Parser failing when no TTL can be found by image TTL setters
+- Rich Media: The crawled URL is now spliced into the rich media data.
+- ActivityPub S2S: sharedInbox usage has been mostly aligned with the rules in the AP specification.
+- ActivityPub S2S: remote user deletions now work the same as local user deletions.
+- ActivityPub S2S: POST requests are now signed with `(request-target)` pseudo-header.
+- Not being able to access the Mastodon FE login page on private instances
+- Invalid SemVer version generation, when the current branch does not have commits ahead of tag/checked out on a tag
+- Pleroma.Upload base_url was not automatically whitelisted by MediaProxy. Now your custom CDN or file hosting will be accessed directly as expected.
+- Report email not being sent to admins when the reporter is a remote user
+- MRF: ensure that subdomain_match calls are case-insensitive
+- Reverse Proxy limiting `max_body_length` was incorrectly defined and only checked `Content-Length` headers which may not be sufficient in some circumstances
+- MRF: fix use of unserializable keyword lists in describe() implementations
+- ActivityPub: Deactivated user deletion
+
+### Added
+- Expiring/ephemeral activites. All activities can have expires_at value set, which controls when they should be deleted automatically.
+- Mastodon API: in post_status, the expires_in parameter lets you set the number of seconds until an activity expires. It must be at least one hour.
+- Mastodon API: all status JSON responses contain a `pleroma.expires_at` item which states when an activity will expire. The value is only shown to the user who created the activity. To everyone else it's empty.
+- Configuration: `ActivityExpiration.enabled` controls whether expired activites will get deleted at the appropriate time. Enabled by default.
+- Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data.
+- **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo.
+ Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules.
+- MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`)
+- MRF: Support for excluding specific domains from Transparency.
+- MRF: Support for filtering posts based on who they mention (`Pleroma.Web.ActivityPub.MRF.MentionPolicy`)
+- MRF: Support for filtering posts based on ActivityStreams vocabulary (`Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`)
+- MRF (Simple Policy): Support for wildcard domains.
+- Support for wildcard domains in user domain blocks setting.
+- Configuration: `quarantined_instances` support wildcard domains.
+- Configuration: `federation_incoming_replies_max_depth` option
+- Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses)
+- Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header
+- Mastodon API, extension: Ability to reset avatar, profile banner, and background
+- Mastodon API: Add support for categories for custom emojis by reusing the group feature. <https://github.com/tootsuite/mastodon/pull/11196>
+- Mastodon API: Add support for muting/unmuting notifications
+- Mastodon API: Add support for the `blocked_by` attribute in the relationship API (`GET /api/v1/accounts/relationships`). <https://github.com/tootsuite/mastodon/pull/10373>
+- Mastodon API: Add support for the `domain_blocking` attribute in the relationship API (`GET /api/v1/accounts/relationships`).
+- Mastodon API: Add `pleroma.deactivated` to the Account entity
+- Mastodon API: added `/auth/password` endpoint for password reset with rate limit.
+- Mastodon API: /api/v1/accounts/:id/statuses now supports nicknames or user id
+- Mastodon API: Improve support for the user profile custom fields
+- Admin API: Return users' tags when querying reports
+- Admin API: Return avatar and display name when querying users
+- Admin API: Allow querying user by ID
+- Admin API: Added support for `tuples`.
+- Admin API: Added endpoints to run mix tasks pleroma.config migrate_to_db & pleroma.config migrate_from_db
+- Added synchronization of following/followers counters for external users
+- Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`.
+- Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options.
+- Configuration: `user_bio_length` and `user_name_length` options.
+- Addressable lists
+- Twitter API: added rate limit for `/api/account/password_reset` endpoint.
+- ActivityPub: Add an internal service actor for fetching ActivityPub objects.
+- ActivityPub: Optional signing of ActivityPub object fetches.
+- Admin API: Endpoint for fetching latest user's statuses
+- Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=<email>` for resending account confirmation.
+- Relays: Added a task to list relay subscriptions.
+- Mix Tasks: `mix pleroma.database fix_likes_collections`
+- Federation: Remove `likes` from objects.
+
+### Changed
+- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
+- Admin API: changed json structure for saving config settings.
+- RichMedia: parsers and their order are configured in `rich_media` config.
+- RichMedia: add the rich media ttl based on image expiration time.
+
+### Removed
+- Emoji: Remove longfox emojis.
+- Remove `Reply-To` header from report emails for admins.
+- ActivityPub: The `accept_blocks` configuration setting.
+
+## [1.0.1] - 2019-07-14
+### Security
+- OStatus: fix an object spoofing vulnerability.
+
+## [1.0.0] - 2019-06-29
+### Security
+- Mastodon API: Fix display names not being sanitized
+- Rich media: Do not crawl private IP ranges
+
### Added
+- Digest email for inactive users
+- Add a generic settings store for frontends / clients to use.
+- Explicit addressing option for posting.
- Optional SSH access mode. (Needs `erlang-ssh` package on some distributions).
- [MongooseIM](https://github.com/esl/MongooseIM) http authentication support.
- LDAP authentication
- External OAuth provider authentication
+- Support for building a release using [`mix release`](https://hexdocs.pm/mix/master/Mix.Tasks.Release.html)
- A [job queue](https://git.pleroma.social/pleroma/pleroma_job_queue) for federation, emails, web push, etc.
- [Prometheus](https://prometheus.io/) metrics
- Support for Mastodon's remote interaction
- Mix Tasks: `mix pleroma.database remove_embedded_objects`
- Mix Tasks: `mix pleroma.database update_users_following_followers_counts`
- Mix Tasks: `mix pleroma.user toggle_confirmed`
+- Mix Tasks: `mix pleroma.config migrate_to_db`
+- Mix Tasks: `mix pleroma.config migrate_from_db`
+- Federation: Support for `Question` and `Answer` objects
- Federation: Support for reports
+- Configuration: `poll_limits` option
+- Configuration: `pack_extensions` option
- Configuration: `safe_dm_mentions` option
- Configuration: `link_name` option
- Configuration: `fetch_initial_posts` option
- Configuration: `notify_email` option
- Configuration: Media proxy `whitelist` option
- Configuration: `report_uri` option
+- Configuration: `email_notifications` option
+- Configuration: `limit_to_local_content` option
- Pleroma API: User subscriptions
- Pleroma API: Healthcheck endpoint
- Pleroma API: `/api/v1/pleroma/mascot` per-user frontend mascot configuration endpoints
- Admin API: added filters (role, tags, email, name) for users endpoint
- Admin API: Endpoints for managing reports
- Admin API: Endpoints for deleting and changing the scope of individual reported statuses
+- Admin API: Endpoints to view and change config settings.
- AdminFE: initial release with basic user management accessible at /pleroma/admin/
+- Mastodon API: Add chat token to `verify_credentials` response
+- Mastodon API: Add background image setting to `update_credentials`
- Mastodon API: [Scheduled statuses](https://docs.joinmastodon.org/api/rest/scheduled-statuses/)
- Mastodon API: `/api/v1/notifications/destroy_multiple` (glitch-soc extension)
- Mastodon API: `/api/v1/pleroma/accounts/:id/favourites` (API extension)
- Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/)
- Mastodon API: `POST /api/v1/accounts` (account creation API)
+- Mastodon API: [Polls](https://docs.joinmastodon.org/api/rest/polls/)
- ActivityPub C2S: OAuth endpoints
- Metadata: RelMe provider
- OAuth: added support for refresh tokens
- OAuth: added job to clean expired access tokens
- MRF: Support for rejecting reports from specific instances (`mrf_simple`)
- MRF: Support for stripping avatars and banner images from specific instances (`mrf_simple`)
+- MRF: Support for running subchains.
+- Configuration: `skip_thread_containment` option
+- Configuration: `rate_limit` option. See `Pleroma.Plugs.RateLimiter` documentation for details.
+- MRF: Support for filtering out likely spam messages by rejecting posts from new users that contain links.
+- Configuration: `ignore_hosts` option
+- Configuration: `ignore_tld` option
+- Configuration: default syslog tag "Pleroma" is now lowercased to "pleroma"
### Changed
+- **Breaking:** bind to 127.0.0.1 instead of 0.0.0.0 by default
- **Breaking:** Configuration: move from Pleroma.Mailer to Pleroma.Emails.Mailer
+- Thread containment / test for complete visibility will be skipped by default.
- Enforcement of OAuth scopes
- Add multiple use/time expiring invite token
- Restyled OAuth pages to fit with Pleroma's default theme
- Federation: Expand the audience of delete activities to all recipients of the deleted object
- Federation: Removed `inReplyToStatusId` from objects
- Configuration: Dedupe enabled by default
+- Configuration: Default log level in `prod` environment is now set to `warn`
- Configuration: Added `extra_cookie_attrs` for setting non-standard cookie attributes. Defaults to ["SameSite=Lax"] so that remote follows work.
- Timelines: Messages involving people you have blocked will be excluded from the timeline in all cases instead of just repeats.
- Admin API: Move the user related API to `api/pleroma/admin/users`
+ - Admin API: `POST /api/pleroma/admin/users` will take list of users
- Pleroma API: Support for emoji tags in `/api/pleroma/emoji` resulting in a breaking API change
- Mastodon API: Support for `exclude_types`, `limit` and `min_id` in `/api/v1/notifications`
- Mastodon API: Add `languages` and `registrations` to `/api/v1/instance`
- Posts which are marked sensitive or tagged nsfw no longer have link previews.
- HTTP connection timeout is now set to 10 seconds.
- Respond with a 404 Not implemented JSON error message when requested API is not implemented
+- Rich Media: crawl only https URLs.
### Fixed
+- Follow requests don't get 'stuck' anymore.
- Added an FTS index on objects. Running `vacuum analyze` and setting a larger `work_mem` is recommended.
- Followers counter not being updated when a follower is blocked
- Deactivated users being able to request an access token
import Ecto.Query
alias Comeonin.Pbkdf2
+ alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.Keys
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Registration
alias Pleroma.Repo
+ alias Pleroma.RepoStreamer
alias Pleroma.User
alias Pleroma.Web
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
alias Pleroma.Web.OAuth
alias Pleroma.Web.OStatus
field(:avatar, :map)
field(:local, :boolean, default: true)
field(:follower_address, :string)
+ field(:following_address, :string)
field(:search_rank, :float, virtual: true)
field(:search_type, :integer, virtual: true)
field(:tags, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec)
+ field(:last_digest_emailed_at, :naive_datetime)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, User.Info)
def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa
def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers"
- def user_info(%User{} = user) do
+ @spec ap_following(User.t()) :: Sring.t()
+ def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa
+ def ap_following(%User{} = user), do: "#{ap_id(user)}/following"
+
+ def user_info(%User{} = user, args \\ %{}) do
+ following_count =
+ if args[:following_count],
+ do: args[:following_count],
+ else: user.info.following_count || following_count(user)
+
+ follower_count =
+ if args[:follower_count], do: args[:follower_count], else: user.info.follower_count
+
%{
- following_count: following_count(user),
note_count: user.info.note_count,
- follower_count: user.info.follower_count,
locked: user.info.locked,
confirmation_pending: user.info.confirmation_pending,
default_scope: user.info.default_scope
}
+ |> Map.put(:following_count, following_count)
+ |> Map.put(:follower_count, follower_count)
+ end
+
+ def follow_state(%User{} = user, %User{} = target) do
+ follow_activity = Utils.fetch_latest_follow(user, target)
+
+ if follow_activity,
+ do: follow_activity.data["state"],
+ # Ideally this would be nil, but then Cachex does not commit the value
+ else: false
end
+ def get_cached_follow_state(user, target) do
+ key = "follow_state:#{user.ap_id}|#{target.ap_id}"
+ Cachex.fetch!(:user_cache, key, fn _ -> {:commit, follow_state(user, target)} end)
+ end
+
+ def set_follow_state_cache(user_ap_id, target_ap_id, state) do
+ Cachex.put(
+ :user_cache,
+ "follow_state:#{user_ap_id}|#{target_ap_id}",
+ state
+ )
+ end
+
+ def set_info_cache(user, args) do
+ Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user, args))
+ end
+
+ @spec restrict_deactivated(Ecto.Query.t()) :: Ecto.Query.t()
def restrict_deactivated(query) do
from(u in query,
where: not fragment("? \\? 'deactivated' AND ?->'deactivated' @> 'true'", u.info, u.info)
end
def remote_user_creation(params) do
- params =
- params
- |> Map.put(:info, params[:info] || %{})
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+ params = Map.put(params, :info, params[:info] || %{})
info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info])
changes =
|> validate_required([:name, :ap_id])
|> unique_constraint(:nickname)
|> validate_format(:nickname, @email_regex)
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, max: name_limit)
|> put_change(:local, false)
|> put_embed(:info, info_cng)
if changes.valid? do
case info_cng.changes[:source_data] do
- %{"followers" => followers} ->
+ %{"followers" => followers, "following" => following} ->
changes
|> put_change(:follower_address, followers)
+ |> put_change(:following_address, following)
_ ->
followers = User.ap_followers(%User{nickname: changes.changes[:nickname]})
end
def update_changeset(struct, params \\ %{}) do
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+
struct
|> cast(params, [:bio, :name, :avatar, :following])
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, min: 1, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
end
- def upgrade_changeset(struct, params \\ %{}) do
- params =
- params
- |> Map.put(:last_refreshed_at, NaiveDateTime.utc_now())
+ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
- info_cng =
- struct.info
- |> User.Info.user_upgrade(params[:info])
+ params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now())
+ info_cng = User.Info.user_upgrade(struct.info, params[:info], remote?)
struct
- |> cast(params, [:bio, :name, :follower_address, :avatar, :last_refreshed_at])
+ |> cast(params, [
+ :bio,
+ :name,
+ :follower_address,
+ :following_address,
+ :avatar,
+ :last_refreshed_at
+ ])
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, max: name_limit)
|> put_embed(:info, info_cng)
end
def password_update_changeset(struct, params) do
- changeset =
- struct
- |> cast(params, [:password, :password_confirmation])
- |> validate_required([:password, :password_confirmation])
- |> validate_confirmation(:password)
-
- OAuth.Token.delete_user_tokens(struct)
- OAuth.Authorization.delete_user_authorizations(struct)
-
- if changeset.valid? do
- hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
-
- changeset
- |> put_change(:password_hash, hashed)
- else
- changeset
+ struct
+ |> cast(params, [:password, :password_confirmation])
+ |> validate_required([:password, :password_confirmation])
+ |> validate_confirmation(:password)
+ |> put_password_hash
+ end
+
+ @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+ def reset_password(%User{id: user_id} = user, data) do
+ multi =
+ Multi.new()
+ |> Multi.update(:user, password_update_changeset(user, data))
+ |> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id))
+ |> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user))
+
+ case Repo.transaction(multi) do
+ {:ok, %{user: user} = _} -> set_cache(user)
+ {:error, _, changeset, _} -> {:error, changeset}
end
end
- def reset_password(user, data) do
- update_and_set_cache(password_update_changeset(user, data))
- end
-
def register_changeset(struct, params \\ %{}, opts \\ []) do
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+
need_confirmation? =
if is_nil(opts[:need_confirmation]) do
Pleroma.Config.get([:instance, :account_activation_required])
|> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames]))
|> validate_format(:nickname, local_nickname_regex())
|> validate_format(:email, @email_regex)
- |> validate_length(:bio, max: 1000)
- |> validate_length(:name, min: 1, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
|> put_change(:info, info_change)
changeset =
end
if changeset.valid? do
- hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]})
changeset
- |> put_change(:password_hash, hashed)
+ |> put_password_hash
|> put_change(:ap_id, ap_id)
|> unique_constraint(:ap_id)
|> put_change(:following, [followers])
@doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
def register(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Repo.insert(changeset),
- {:ok, user} <- autofollow_users(user),
+ {:ok, user} <- post_register_action(user) do
+ {:ok, user}
+ end
+ end
+
+ def post_register_action(%User{} = user) do
+ with {:ok, user} <- autofollow_users(user),
{:ok, user} <- set_cache(user),
{:ok, _} <- User.WelcomeMessage.post_welcome_message_to_user(user),
{:ok, _} <- try_send_confirmation_email(user) do
def needs_update?(_), do: true
+ @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()}
def maybe_direct_follow(%User{} = follower, %User{local: true, info: %{locked: true}}) do
{:ok, follower}
end
end
end
- def maybe_follow(%User{} = follower, %User{info: _info} = followed) do
- if not following?(follower, followed) do
- follow(follower, followed)
- else
- {:ok, follower}
- end
- end
-
@doc "A mass follow for local users. Respects blocks in both directions but does not create activities."
@spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()}
def follow_all(follower, followeds) do
ap_followers = followed.follower_address
cond do
- following?(follower, followed) or info.deactivated ->
- {:error, "Could not follow user: #{followed.nickname} is already on your list."}
+ info.deactivated ->
+ {:error, "Could not follow user: You are deactivated."}
deny_follow_blocked and blocks?(followed, follower) ->
{:error, "Could not follow user: #{followed.nickname} blocked you."}
{1, [follower]} = Repo.update_all(q, [])
+ follower = maybe_update_following_count(follower)
+
{:ok, _} = update_follower_count(followed)
set_cache(follower)
{1, [follower]} = Repo.update_all(q, [])
+ follower = maybe_update_following_count(follower)
+
{:ok, followed} = update_follower_count(followed)
set_cache(follower)
Repo.get_by(User, ap_id: ap_id)
end
+ def get_all_by_ap_id(ap_ids) do
+ from(u in __MODULE__,
+ where: u.ap_id in ^ap_ids
+ )
+ |> Repo.all()
+ end
+
# This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
# of the ap_id and the domain and tries to get that user
def get_by_guessed_nickname(ap_id) do
end
def update_and_set_cache(changeset) do
- with {:ok, user} <- Repo.update(changeset) do
+ with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
set_cache(user)
else
e -> e
@spec get_followers_query(User.t()) :: Ecto.Query.t()
def get_followers_query(user), do: get_followers_query(user, nil)
+ @spec get_followers(User.t(), pos_integer()) :: {:ok, list(User.t())}
def get_followers(user, page \\ nil) do
q = get_followers_query(user, page)
{:ok, Repo.all(q)}
end
+ @spec get_external_followers(User.t(), pos_integer()) :: {:ok, list(User.t())}
+ def get_external_followers(user, page \\ nil) do
+ q =
+ user
+ |> get_followers_query(page)
+ |> User.Query.build(%{external: true})
+
+ {:ok, Repo.all(q)}
+ end
+
def get_followers_ids(user, page \\ nil) do
q = get_followers_query(user, page)
|> update_and_set_cache()
end
+ @spec maybe_fetch_follow_information(User.t()) :: User.t()
+ def maybe_fetch_follow_information(user) do
+ with {:ok, user} <- fetch_follow_information(user) do
+ user
+ else
+ e ->
+ Logger.error("Follower/Following counter update for #{user.ap_id} failed.\n#{inspect(e)}")
+
+ user
+ end
+ end
+
+ def fetch_follow_information(user) do
+ with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do
+ info_cng = User.Info.follow_information_update(user.info, info)
+
+ changeset =
+ user
+ |> change()
+ |> put_embed(:info, info_cng)
+
+ update_and_set_cache(changeset)
+ else
+ {:error, _} = e -> e
+ e -> {:error, e}
+ end
+ end
+
def update_follower_count(%User{} = user) do
- follower_count_query =
- User.Query.build(%{followers: user, deactivated: false})
- |> select([u], %{count: count(u.id)})
+ if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do
+ follower_count_query =
+ User.Query.build(%{followers: user, deactivated: false})
+ |> select([u], %{count: count(u.id)})
+
+ User
+ |> where(id: ^user.id)
+ |> join(:inner, [u], s in subquery(follower_count_query))
+ |> update([u, s],
+ set: [
+ info:
+ fragment(
+ "jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)",
+ u.info,
+ s.count
+ )
+ ]
+ )
+ |> select([u], u)
+ |> Repo.update_all([])
+ |> case do
+ {1, [user]} -> set_cache(user)
+ _ -> {:error, user}
+ end
+ else
+ {:ok, maybe_fetch_follow_information(user)}
+ end
+ end
- User
- |> where(id: ^user.id)
- |> join(:inner, [u], s in subquery(follower_count_query))
- |> update([u, s],
- set: [
- info:
- fragment(
- "jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)",
- u.info,
- s.count
- )
- ]
- )
- |> select([u], u)
- |> Repo.update_all([])
- |> case do
- {1, [user]} -> set_cache(user)
- _ -> {:error, user}
+ @spec maybe_update_following_count(User.t()) :: User.t()
+ def maybe_update_following_count(%User{local: false} = user) do
+ if Pleroma.Config.get([:instance, :external_user_synchronization]) do
+ maybe_fetch_follow_information(user)
+ else
+ user
end
end
+ def maybe_update_following_count(user), do: user
+
def remove_duplicated_following(%User{following: following} = user) do
uniq_following = Enum.uniq(following)
|> Repo.all()
end
- def search(query, resolve \\ false, for_user \\ nil) do
- # Strip the beginning @ off if there is a query
- query = String.trim_leading(query, "@")
-
- if resolve, do: get_or_fetch(query)
-
- {:ok, results} =
- Repo.transaction(fn ->
- Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", [])
- Repo.all(search_query(query, for_user))
- end)
-
- results
- end
-
- def search_query(query, for_user) do
- fts_subquery = fts_search_subquery(query)
- trigram_subquery = trigram_search_subquery(query)
- union_query = from(s in trigram_subquery, union_all: ^fts_subquery)
- distinct_query = from(s in subquery(union_query), order_by: s.search_type, distinct: s.id)
+ @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()}
+ def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do
+ info = muter.info
- from(s in subquery(boost_search_rank_query(distinct_query, for_user)),
- order_by: [desc: s.search_rank],
- limit: 40
- )
- end
-
- defp boost_search_rank_query(query, nil), do: query
-
- defp boost_search_rank_query(query, for_user) do
- friends_ids = get_friends_ids(for_user)
- followers_ids = get_followers_ids(for_user)
-
- from(u in subquery(query),
- select_merge: %{
- search_rank:
- fragment(
- """
- CASE WHEN (?) THEN (?) * 1.3
- WHEN (?) THEN (?) * 1.2
- WHEN (?) THEN (?) * 1.1
- ELSE (?) END
- """,
- u.id in ^friends_ids and u.id in ^followers_ids,
- u.search_rank,
- u.id in ^friends_ids,
- u.search_rank,
- u.id in ^followers_ids,
- u.search_rank,
- u.search_rank
- )
- }
- )
- end
-
- defp fts_search_subquery(term, query \\ User) do
- processed_query =
- term
- |> String.replace(~r/\W+/, " ")
- |> String.trim()
- |> String.split()
- |> Enum.map(&(&1 <> ":*"))
- |> Enum.join(" | ")
-
- from(
- u in query,
- select_merge: %{
- search_type: ^0,
- search_rank:
- fragment(
- """
- ts_rank_cd(
- setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
- setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'),
- to_tsquery('simple', ?),
- 32
- )
- """,
- u.nickname,
- u.name,
- ^processed_query
- )
- },
- where:
- fragment(
- """
- (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
- setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?)
- """,
- u.nickname,
- u.name,
- ^processed_query
- )
- )
- |> restrict_deactivated()
- end
-
- defp trigram_search_subquery(term) do
- from(
- u in User,
- select_merge: %{
- # ^1 gives 'Postgrex expected a binary, got 1' for some weird reason
- search_type: fragment("?", 1),
- search_rank:
- fragment(
- "similarity(?, trim(? || ' ' || coalesce(?, '')))",
- ^term,
- u.nickname,
- u.name
- )
- },
- where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term)
- )
- |> restrict_deactivated()
- end
-
- def mute(muter, %User{ap_id: ap_id}) do
info_cng =
- muter.info
- |> User.Info.add_to_mutes(ap_id)
+ User.Info.add_to_mutes(info, ap_id)
+ |> User.Info.add_to_muted_notifications(info, ap_id, notifications?)
cng =
change(muter)
end
def unmute(muter, %{ap_id: ap_id}) do
+ info = muter.info
+
info_cng =
- muter.info
- |> User.Info.remove_from_mutes(ap_id)
+ User.Info.remove_from_mutes(info, ap_id)
+ |> User.Info.remove_from_muted_notifications(info, ap_id)
cng =
change(muter)
blocker
end
+ # clear any requested follows as well
+ blocked =
+ case CommonAPI.reject_follow_request(blocked, blocker) do
+ {:ok, %User{} = updated_blocked} -> updated_blocked
+ nil -> blocked
+ end
+
blocker =
if subscribed_to?(blocked, blocker) do
{:ok, blocker} = unsubscribe(blocked, blocker)
def mutes?(nil, _), do: false
def mutes?(user, %{ap_id: ap_id}), do: Enum.member?(user.info.mutes, ap_id)
- def blocks?(user, %{ap_id: ap_id}) do
- blocks = user.info.blocks
- domain_blocks = user.info.domain_blocks
- %{host: host} = URI.parse(ap_id)
+ @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean()
+ def muted_notifications?(nil, _), do: false
- Enum.member?(blocks, ap_id) ||
- Enum.any?(domain_blocks, fn domain ->
- host == domain
- end)
+ def muted_notifications?(user, %{ap_id: ap_id}),
+ do: Enum.member?(user.info.muted_notifications, ap_id)
+
+ def blocks?(%User{} = user, %User{} = target) do
+ blocks_ap_id?(user, target) || blocks_domain?(user, target)
+ end
+
+ def blocks?(nil, _), do: false
+
+ def blocks_ap_id?(%User{} = user, %User{} = target) do
+ Enum.member?(user.info.blocks, target.ap_id)
+ end
+
+ def blocks_ap_id?(_, _), do: false
+
+ def blocks_domain?(%User{} = user, %User{} = target) do
+ domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.domain_blocks)
+ %{host: host} = URI.parse(target.ap_id)
+ Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host)
end
+ def blocks_domain?(_, _), do: false
+
def subscribed_to?(user, %{ap_id: ap_id}) do
with %User{} = target <- get_cached_by_ap_id(ap_id) do
Enum.member?(target.info.subscribers, user.ap_id)
@spec perform(atom(), User.t()) :: {:ok, User.t()}
def perform(:delete, %User{} = user) do
- {:ok, user} = User.deactivate(user)
+ {:ok, _user} = ActivityPub.delete(user)
# Remove all relationships
{:ok, followers} = User.get_followers(user)
- Enum.each(followers, fn follower -> User.unfollow(follower, user) end)
+ Enum.each(followers, fn follower ->
+ ActivityPub.unfollow(follower, user)
+ User.unfollow(follower, user)
+ end)
{:ok, friends} = User.get_friends(user)
- Enum.each(friends, fn followed -> User.unfollow(user, followed) end)
+ Enum.each(friends, fn followed ->
+ ActivityPub.unfollow(user, followed)
+ User.unfollow(user, followed)
+ end)
delete_user_activities(user)
+ invalidate_cache(user)
+ Repo.delete(user)
end
@spec perform(atom(), User.t()) :: {:ok, User.t()}
)
end
+ @spec external_users_query() :: Ecto.Query.t()
+ def external_users_query do
+ User.Query.build(%{
+ external: true,
+ active: true,
+ order_by: :id
+ })
+ end
+
+ @spec external_users(keyword()) :: [User.t()]
+ def external_users(opts \\ []) do
+ query =
+ external_users_query()
+ |> select([u], struct(u, [:id, :ap_id, :info]))
+
+ query =
+ if opts[:max_id],
+ do: where(query, [u], u.id > ^opts[:max_id]),
+ else: query
+
+ query =
+ if opts[:limit],
+ do: limit(query, ^opts[:limit]),
+ else: query
+
+ Repo.all(query)
+ end
+
def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers),
do:
PleromaJobQueue.enqueue(:background, __MODULE__, [
])
def delete_user_activities(%User{ap_id: ap_id} = user) do
- stream =
- ap_id
- |> Activity.query_by_actor()
- |> Repo.stream()
-
- Repo.transaction(fn -> Enum.each(stream, &delete_activity(&1)) end, timeout: :infinity)
+ ap_id
+ |> Activity.query_by_actor()
+ |> RepoStreamer.chunk_stream(50)
+ |> Stream.each(fn activities ->
+ Enum.each(activities, &delete_activity(&1))
+ end)
+ |> Stream.run()
{:ok, user}
end
defp delete_activity(%{data: %{"type" => "Create"}} = activity) do
- Object.normalize(activity) |> ActivityPub.delete()
+ activity
+ |> Object.normalize()
+ |> ActivityPub.delete()
+ end
+
+ defp delete_activity(%{data: %{"type" => "Like"}} = activity) do
+ user = get_cached_by_ap_id(activity.actor)
+ object = Object.normalize(activity)
+
+ ActivityPub.unlike(user, object)
+ end
+
+ defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do
+ user = get_cached_by_ap_id(activity.actor)
+ object = Object.normalize(activity)
+
+ ActivityPub.unannounce(user, object)
end
defp delete_activity(_activity), do: "Doing nothing"
Pleroma.HTML.Scrubber.TwitterText
end
- @default_scrubbers Pleroma.Config.get([:markup, :scrub_policy])
-
- def html_filter_policy(_), do: @default_scrubbers
+ def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy])
def fetch_by_ap_id(ap_id) do
ap_try = ActivityPub.make_user_from_ap_id(ap_id)
end
end
- def get_or_create_instance_user do
- relay_uri = "#{Pleroma.Web.Endpoint.url()}/relay"
-
- if user = get_cached_by_ap_id(relay_uri) do
+ @doc "Creates an internal service actor by URI if missing. Optionally takes nickname for addressing."
+ def get_or_create_service_actor_by_ap_id(uri, nickname \\ nil) do
+ if user = get_cached_by_ap_id(uri) do
user
else
changes =
%User{info: %User.Info{}}
|> cast(%{}, [:ap_id, :nickname, :local])
- |> put_change(:ap_id, relay_uri)
- |> put_change(:nickname, nil)
+ |> put_change(:ap_id, uri)
+ |> put_change(:nickname, nickname)
|> put_change(:local, true)
- |> put_change(:follower_address, relay_uri <> "/followers")
+ |> put_change(:follower_address, uri <> "/followers")
{:ok, user} = Repo.insert(changes)
user
end
# OStatus Magic Key
- def public_key_from_info(%{magic_key: magic_key}) do
+ def public_key_from_info(%{magic_key: magic_key}) when not is_nil(magic_key) do
{:ok, Pleroma.Web.Salmon.decode_key(magic_key)}
end
+ def public_key_from_info(_), do: {:error, "not found key"}
+
def get_public_key_for_ap_id(ap_id) do
with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id),
{:ok, public_key} <- public_key_from_info(user.info) do
data
|> Map.put(:name, blank?(data[:name]) || data[:nickname])
|> remote_user_creation()
- |> Repo.insert(on_conflict: :replace_all, conflict_target: :nickname)
+ |> Repo.insert(on_conflict: :replace_all_except_primary_key, conflict_target: :nickname)
|> set_cache()
end
target.ap_id not in user.info.muted_reblogs
end
+ @doc """
+ The function returns a query to get users with no activity for given interval of days.
+ Inactive users are those who didn't read any notification, or had any activity where
+ the user is the activity's actor, during `inactivity_threshold` days.
+ Deactivated users will not appear in this list.
+
+ ## Examples
+
+ iex> Pleroma.User.list_inactive_users()
+ %Ecto.Query{}
+ """
+ @spec list_inactive_users_query(integer()) :: Ecto.Query.t()
+ def list_inactive_users_query(inactivity_threshold \\ 7) do
+ negative_inactivity_threshold = -inactivity_threshold
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+ # Subqueries are not supported in `where` clauses, join gets too complicated.
+ has_read_notifications =
+ from(n in Pleroma.Notification,
+ where: n.seen == true,
+ group_by: n.id,
+ having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
+ select: n.user_id
+ )
+ |> Pleroma.Repo.all()
+
+ from(u in Pleroma.User,
+ left_join: a in Pleroma.Activity,
+ on: u.ap_id == a.actor,
+ where: not is_nil(u.nickname),
+ where: fragment("not (?->'deactivated' @> 'true')", u.info),
+ where: u.id not in ^has_read_notifications,
+ group_by: u.id,
+ having:
+ max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
+ is_nil(max(a.inserted_at))
+ )
+ end
+
+ @doc """
+ Enable or disable email notifications for user
+
+ ## Examples
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true)
+ Pleroma.User{info: %{email_notifications: %{"digest" => true}}}
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false)
+ Pleroma.User{info: %{email_notifications: %{"digest" => false}}}
+ """
+ @spec switch_email_notifications(t(), String.t(), boolean()) ::
+ {:ok, t()} | {:error, Ecto.Changeset.t()}
+ def switch_email_notifications(user, type, status) do
+ info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status})
+
+ change(user)
+ |> put_embed(:info, info)
+ |> update_and_set_cache()
+ end
+
+ @doc """
+ Set `last_digest_emailed_at` value for the user to current time
+ """
+ @spec touch_last_digest_emailed_at(t()) :: t()
+ def touch_last_digest_emailed_at(user) do
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+
+ {:ok, updated_user} =
+ user
+ |> change(%{last_digest_emailed_at: now})
+ |> update_and_set_cache()
+
+ updated_user
+ end
+
@spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
def toggle_confirmation(%User{} = user) do
need_confirmation? = !user.info.confirmation_pending
}
end
- def ensure_keys_present(user) do
- info = user.info
-
+ def ensure_keys_present(%User{info: info} = user) do
if info.keys do
{:ok, user}
else
{:ok, pem} = Keys.generate_rsa_pem()
- info_cng =
- info
- |> User.Info.set_keys(pem)
+ user
+ |> Ecto.Changeset.change()
+ |> Ecto.Changeset.put_embed(:info, User.Info.set_keys(info, pem))
+ |> update_and_set_cache()
+ end
+ end
- cng =
- Ecto.Changeset.change(user)
- |> Ecto.Changeset.put_embed(:info, info_cng)
+ def get_ap_ids_by_nicknames(nicknames) do
+ from(u in User,
+ where: u.nickname in ^nicknames,
+ select: u.ap_id
+ )
+ |> Repo.all()
+ end
- update_and_set_cache(cng)
- end
+ defdelegate search(query, opts \\ []), to: User.Search
+
+ defp put_password_hash(
+ %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
+ ) do
+ change(changeset, password_hash: Pbkdf2.hashpwsalt(password))
end
+
+ defp put_password_hash(changeset), do: changeset
+
+ def is_internal_user?(%User{nickname: nil}), do: true
+ def is_internal_user?(%User{local: true, nickname: "internal." <> _}), do: true
+ def is_internal_user?(_), do: false
end
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.AdminAPI.AccountView
+ alias Pleroma.Web.AdminAPI.Config
+ alias Pleroma.Web.AdminAPI.ConfigView
alias Pleroma.Web.AdminAPI.ReportView
alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.CommonAPI
|> json("ok")
end
- def user_create(
- conn,
- %{"nickname" => nickname, "email" => email, "password" => password}
- ) do
- user_data = %{
- nickname: nickname,
- name: nickname,
- email: email,
- password: password,
- password_confirmation: password,
- bio: "."
- }
-
- changeset = User.register_changeset(%User{}, user_data, need_confirmation: false)
- {:ok, user} = User.register(changeset)
-
- conn
- |> json(user.nickname)
+ def users_create(conn, %{"users" => users}) do
+ changesets =
+ Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} ->
+ user_data = %{
+ nickname: nickname,
+ name: nickname,
+ email: email,
+ password: password,
+ password_confirmation: password,
+ bio: "."
+ }
+
+ User.register_changeset(%User{}, user_data, need_confirmation: false)
+ end)
+ |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi ->
+ Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset)
+ end)
+
+ case Pleroma.Repo.transaction(changesets) do
+ {:ok, users} ->
+ res =
+ users
+ |> Map.values()
+ |> Enum.map(fn user ->
+ {:ok, user} = User.post_register_action(user)
+ user
+ end)
+ |> Enum.map(&AccountView.render("created.json", %{user: &1}))
+
+ conn
+ |> json(res)
+
+ {:error, id, changeset, _} ->
+ res =
+ Enum.map(changesets.operations, fn
+ {current_id, {:changeset, _current_changeset, _}} when current_id == id ->
+ AccountView.render("create-error.json", %{changeset: changeset})
+
+ {_, {:changeset, current_changeset, _}} ->
+ AccountView.render("create-error.json", %{changeset: current_changeset})
+ end)
+
+ conn
+ |> put_status(:conflict)
+ |> json(res)
+ end
end
def user_show(conn, %{"nickname" => nickname}) do
- with %User{} = user <- User.get_cached_by_nickname(nickname) do
+ with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do
conn
|> json(AccountView.render("show.json", %{user: user}))
else
end
end
+ def list_user_statuses(conn, %{"nickname" => nickname} = params) do
+ godmode = params["godmode"] == "true" || params["godmode"] == true
+
+ with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do
+ {_, page_size} = page_params(params)
+
+ activities =
+ ActivityPub.fetch_user_activities(user, nil, %{
+ "limit" => page_size,
+ "godmode" => godmode
+ })
+
+ conn
+ |> json(StatusView.render("index.json", %{activities: activities, as: :activity}))
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
def user_toggle_activation(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname)
end
def right_add(conn, _) do
- conn
- |> put_status(404)
- |> json(%{error: "No such permission_group"})
+ render_error(conn, :not_found, "No such permission_group")
end
def right_get(conn, %{"nickname" => nickname}) do
)
when permission_group in ["moderator", "admin"] do
if admin_nickname == nickname do
- conn
- |> put_status(403)
- |> json(%{error: "You can't revoke your own admin status."})
+ render_error(conn, :forbidden, "You can't revoke your own admin status.")
else
user = User.get_cached_by_nickname(nickname)
end
def right_delete(conn, _) do
- conn
- |> put_status(404)
- |> json(%{error: "No such permission_group"})
+ render_error(conn, :not_found, "No such permission_group")
end
def set_activation_status(conn, %{"nickname" => nickname, "status" => status}) do
@doc "Revokes invite by token"
def revoke_invite(conn, %{"token" => token}) do
- invite = UserInviteToken.find_by_token!(token)
- {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true})
-
- conn
- |> json(AccountView.render("invite.json", %{invite: updated_invite}))
+ with {:ok, invite} <- UserInviteToken.find_by_token(token),
+ {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do
+ conn
+ |> json(AccountView.render("invite.json", %{invite: updated_invite}))
+ else
+ nil -> {:error, :not_found}
+ end
end
@doc "Get a password reset token (base64 string) for given nickname"
end
end
+ def migrate_to_db(conn, _params) do
+ Mix.Tasks.Pleroma.Config.run(["migrate_to_db"])
+ json(conn, %{})
+ end
+
+ def migrate_from_db(conn, _params) do
+ Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "true"])
+ json(conn, %{})
+ end
+
+ def config_show(conn, _params) do
+ configs = Pleroma.Repo.all(Config)
+
+ conn
+ |> put_view(ConfigView)
+ |> render("index.json", %{configs: configs})
+ end
+
+ def config_update(conn, %{"configs" => configs}) do
+ updated =
+ if Pleroma.Config.get([:instance, :dynamic_configuration]) do
+ updated =
+ Enum.map(configs, fn
+ %{"group" => group, "key" => key, "delete" => "true"} = params ->
+ {:ok, config} = Config.delete(%{group: group, key: key, subkeys: params["subkeys"]})
+ config
+
+ %{"group" => group, "key" => key, "value" => value} ->
+ {:ok, config} = Config.update_or_create(%{group: group, key: key, value: value})
+ config
+ end)
+ |> Enum.reject(&is_nil(&1))
+
+ Pleroma.Config.TransferTask.load_and_update_env()
+ Mix.Tasks.Pleroma.Config.run(["migrate_from_db", Pleroma.Config.get(:env), "false"])
+ updated
+ else
+ []
+ end
+
+ conn
+ |> put_view(ConfigView)
+ |> render("index.json", %{configs: updated})
+ end
+
def errors(conn, {:error, :not_found}) do
conn
- |> put_status(404)
- |> json("Not found")
+ |> put_status(:not_found)
+ |> json(dgettext("errors", "Not found"))
end
def errors(conn, {:error, reason}) do
conn
- |> put_status(400)
+ |> put_status(:bad_request)
|> json(reason)
end
def errors(conn, {:param_cast, _}) do
conn
- |> put_status(400)
- |> json("Invalid parameters")
+ |> put_status(:bad_request)
+ |> json(dgettext("errors", "Invalid parameters"))
end
def errors(conn, _) do
conn
- |> put_status(500)
- |> json("Something went wrong")
+ |> put_status(:internal_server_error)
+ |> json(dgettext("errors", "Something went wrong"))
end
defp page_params(params) do
defmodule Pleroma.Web.AdminAPI.AccountView do
use Pleroma.Web, :view
+ alias Pleroma.HTML
+ alias Pleroma.User
alias Pleroma.User.Info
alias Pleroma.Web.AdminAPI.AccountView
+ alias Pleroma.Web.MediaProxy
def render("index.json", %{users: users, count: count, page_size: page_size}) do
%{
end
def render("show.json", %{user: user}) do
+ avatar = User.avatar_url(user) |> MediaProxy.url()
+ display_name = HTML.strip_tags(user.name || user.nickname)
+
%{
"id" => user.id,
+ "avatar" => avatar,
"nickname" => user.nickname,
+ "display_name" => display_name,
"deactivated" => user.info.deactivated,
"local" => user.local,
"roles" => Info.roles(user.info),
invites: render_many(invites, AccountView, "invite.json", as: :invite)
}
end
+
+ def render("created.json", %{user: user}) do
+ %{
+ type: "success",
+ code: 200,
+ data: %{
+ nickname: user.nickname,
+ email: user.email
+ }
+ }
+ end
+
+ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do
+ %{
+ type: "error",
+ code: 409,
+ error: parse_error(errors),
+ data: %{
+ nickname: Map.get(changes, :nickname),
+ email: Map.get(changes, :email)
+ }
+ }
+ end
+
+ defp parse_error([]), do: ""
+
+ defp parse_error(errors) do
+ ## when nickname is duplicate ap_id constraint error is raised
+ nickname_error = Keyword.get(errors, :nickname) || Keyword.get(errors, :ap_id)
+ email_error = Keyword.get(errors, :email)
+ password_error = Keyword.get(errors, :password)
+
+ cond do
+ nickname_error ->
+ "nickname #{elem(nickname_error, 0)}"
+
+ email_error ->
+ "email #{elem(email_error, 0)}"
+
+ password_error ->
+ "password #{elem(password_error, 0)}"
+
+ true ->
+ ""
+ end
+ end
end
plug(Pleroma.Plugs.UserEnabledPlug)
plug(Pleroma.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Plugs.EnsureUserKeyPlug)
+ plug(Pleroma.Plugs.IdempotencyPlug)
end
pipeline :authenticated_api do
plug(Pleroma.Plugs.UserEnabledPlug)
plug(Pleroma.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Plugs.EnsureAuthenticatedPlug)
+ plug(Pleroma.Plugs.IdempotencyPlug)
end
pipeline :admin_api do
plug(Pleroma.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Plugs.UserIsAdminPlug)
+ plug(Pleroma.Plugs.IdempotencyPlug)
end
pipeline :mastodon_html do
scope "/api/pleroma", Pleroma.Web.TwitterAPI do
pipe_through(:pleroma_api)
- get("/password_reset/:token", UtilController, :show_password_reset)
- post("/password_reset", UtilController, :password_reset)
+ get("/password_reset/:token", PasswordController, :reset, as: :reset_password)
+ post("/password_reset", PasswordController, :do_reset, as: :reset_password)
get("/emoji", UtilController, :emoji)
get("/captcha", UtilController, :captcha)
get("/healthcheck", UtilController, :healthcheck)
post("/users/unfollow", AdminAPIController, :user_unfollow)
delete("/users", AdminAPIController, :user_delete)
- post("/users", AdminAPIController, :user_create)
+ post("/users", AdminAPIController, :users_create)
patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
put("/users/tag", AdminAPIController, :tag_users)
delete("/users/tag", AdminAPIController, :untag_users)
- # TODO: to be removed at version 1.0
- get("/permission_group/:nickname", AdminAPIController, :right_get)
- get("/permission_group/:nickname/:permission_group", AdminAPIController, :right_get)
- post("/permission_group/:nickname/:permission_group", AdminAPIController, :right_add)
- delete("/permission_group/:nickname/:permission_group", AdminAPIController, :right_delete)
-
get("/users/:nickname/permission_group", AdminAPIController, :right_get)
get("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_get)
post("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_add)
post("/users/revoke_invite", AdminAPIController, :revoke_invite)
post("/users/email_invite", AdminAPIController, :email_invite)
- # TODO: to be removed at version 1.0
- get("/password_reset", AdminAPIController, :get_password_reset)
-
get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset)
get("/users", AdminAPIController, :list_users)
get("/users/:nickname", AdminAPIController, :user_show)
+ get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
get("/reports", AdminAPIController, :list_reports)
get("/reports/:id", AdminAPIController, :report_show)
put("/statuses/:id", AdminAPIController, :status_update)
delete("/statuses/:id", AdminAPIController, :status_delete)
+
+ get("/config", AdminAPIController, :config_show)
+ post("/config", AdminAPIController, :config_update)
+ get("/config/migrate_to_db", AdminAPIController, :migrate_to_db)
+ get("/config/migrate_from_db", AdminAPIController, :migrate_from_db)
end
scope "/", Pleroma.Web.TwitterAPI do
end
end
+ scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
+ pipe_through(:authenticated_api)
+
+ scope [] do
+ pipe_through(:oauth_read)
+ get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
+ get("/conversations/:id", PleromaAPIController, :conversation)
+ end
+
+ scope [] do
+ pipe_through(:oauth_write)
+ patch("/conversations/:id", PleromaAPIController, :update_conversation)
+ end
+ end
+
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
post("/conversations/:id/read", MastodonAPIController, :conversation_read)
get("/endorsements", MastodonAPIController, :empty_array)
-
- get("/pleroma/flavour", MastodonAPIController, :get_flavour)
end
scope [] do
put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status)
delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status)
+ post("/polls/:id/votes", MastodonAPIController, :poll_vote)
+
post("/media", MastodonAPIController, :upload)
put("/media/:id", MastodonAPIController, :update_media)
put("/filters/:id", MastodonAPIController, :update_filter)
delete("/filters/:id", MastodonAPIController, :delete_filter)
- post("/pleroma/flavour/:flavour", MastodonAPIController, :set_flavour)
+ patch("/pleroma/accounts/update_avatar", MastodonAPIController, :update_avatar)
+ patch("/pleroma/accounts/update_banner", MastodonAPIController, :update_banner)
+ patch("/pleroma/accounts/update_background", MastodonAPIController, :update_background)
get("/pleroma/mascot", MastodonAPIController, :get_mascot)
put("/pleroma/mascot", MastodonAPIController, :set_mascot)
get("/trends", MastodonAPIController, :empty_array)
- get("/accounts/search", MastodonAPIController, :account_search)
+ get("/accounts/search", SearchController, :account_search)
+
+ post(
+ "/pleroma/accounts/confirmation_resend",
+ MastodonAPIController,
+ :account_confirmation_resend
+ )
scope [] do
pipe_through(:oauth_read_or_public)
get("/statuses/:id", MastodonAPIController, :get_status)
get("/statuses/:id/context", MastodonAPIController, :get_context)
+ get("/polls/:id", MastodonAPIController, :get_poll)
+
get("/accounts/:id/statuses", MastodonAPIController, :user_statuses)
get("/accounts/:id/followers", MastodonAPIController, :followers)
get("/accounts/:id/following", MastodonAPIController, :following)
get("/accounts/:id", MastodonAPIController, :user)
- get("/search", MastodonAPIController, :search)
+ get("/search", SearchController, :search)
get("/pleroma/accounts/:id/favourites", MastodonAPIController, :user_favourites)
end
scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through([:api, :oauth_read_or_public])
- get("/search", MastodonAPIController, :search2)
+ get("/search", SearchController, :search2)
end
scope "/api", Pleroma.Web do
end
end
- pipeline :ap_relay do
+ pipeline :ap_service_actor do
plug(:accepts, ["activity+json", "json"])
end
post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request)
get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation)
post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming)
- end
-
- scope "/", Pleroma.Web do
- pipe_through(:oembed)
- get("/oembed", OEmbed.OEmbedController, :url)
+ get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
end
pipeline :activitypub do
plug(:accepts, ["activity+json", "json"])
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
+ plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
end
scope "/", Pleroma.Web.ActivityPub do
# XXX: not really ostatus
pipe_through(:ostatus)
- get("/users/:nickname/followers", ActivityPubController, :followers)
- get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/outbox", ActivityPubController, :outbox)
get("/objects/:uuid/likes", ActivityPubController, :object_likes)
end
pipe_through(:oauth_write)
post("/users/:nickname/outbox", ActivityPubController, :update_outbox)
end
- end
- scope "/relay", Pleroma.Web.ActivityPub do
- pipe_through(:ap_relay)
- get("/", ActivityPubController, :relay)
+ scope [] do
+ pipe_through(:oauth_read_or_public)
+ get("/users/:nickname/followers", ActivityPubController, :followers)
+ get("/users/:nickname/following", ActivityPubController, :following)
+ end
end
scope "/", Pleroma.Web.ActivityPub do
post("/users/:nickname/inbox", ActivityPubController, :inbox)
end
+ scope "/relay", Pleroma.Web.ActivityPub do
+ pipe_through(:ap_service_actor)
+
+ get("/", ActivityPubController, :relay)
+ post("/inbox", ActivityPubController, :inbox)
+ end
+
+ scope "/internal/fetch", Pleroma.Web.ActivityPub do
+ pipe_through(:ap_service_actor)
+
+ get("/", ActivityPubController, :internal_fetch)
+ post("/inbox", ActivityPubController, :inbox)
+ end
+
scope "/.well-known", Pleroma.Web do
pipe_through(:well_known)
get("/web/login", MastodonAPIController, :login)
delete("/auth/sign_out", MastodonAPIController, :logout)
+ post("/auth/password", MastodonAPIController, :password_reset)
+
scope [] do
- pipe_through(:oauth_read_or_public)
+ pipe_through(:oauth_read)
get("/web/*path", MastodonAPIController, :index)
end
end
get("/:sig/:url/:filename", MediaProxyController, :remote)
end
- if Mix.env() == :dev do
+ if Pleroma.Config.get(:env) == :dev do
scope "/dev" do
pipe_through([:mailbox_preview])
options("/*path", RedirectController, :empty)
end
end
-
-defmodule Fallback.RedirectController do
- use Pleroma.Web, :controller
- alias Pleroma.User
- alias Pleroma.Web.Metadata
-
- def api_not_implemented(conn, _params) do
- conn
- |> put_status(404)
- |> json(%{error: "Not implemented"})
- end
-
- def redirector(conn, _params, code \\ 200) do
- conn
- |> put_resp_content_type("text/html")
- |> send_file(code, index_file_path())
- end
-
- def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} = params) do
- with %User{} = user <- User.get_cached_by_nickname_or_id(maybe_nickname_or_id) do
- redirector_with_meta(conn, %{user: user})
- else
- nil ->
- redirector(conn, params)
- end
- end
-
- def redirector_with_meta(conn, params) do
- {:ok, index_content} = File.read(index_file_path())
- tags = Metadata.build_tags(params)
- response = String.replace(index_content, "<!--server-generated-meta-->", tags)
-
- conn
- |> put_resp_content_type("text/html")
- |> send_resp(200, response)
- end
-
- def index_file_path do
- Pleroma.Plugs.InstanceStatic.file_path("index.html")
- end
-
- def registration_page(conn, params) do
- redirector(conn, params)
- end
-
- def empty(conn, _params) do
- conn
- |> put_status(204)
- |> text("")
- end
-end
use Pleroma.Web.ConnCase
alias Pleroma.Activity
+ alias Pleroma.HTML
alias Pleroma.User
alias Pleroma.UserInviteToken
alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MediaProxy
import Pleroma.Factory
describe "/api/pleroma/admin/users" do
|> assign(:user, admin)
|> put_req_header("accept", "application/json")
|> post("/api/pleroma/admin/users", %{
- "nickname" => "lain",
- "email" => "lain@example.org",
- "password" => "test"
+ "users" => [
+ %{
+ "nickname" => "lain",
+ "email" => "lain@example.org",
+ "password" => "test"
+ },
+ %{
+ "nickname" => "lain2",
+ "email" => "lain2@example.org",
+ "password" => "test"
+ }
+ ]
})
- assert json_response(conn, 200) == "lain"
+ response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type"))
+ assert response == ["success", "success"]
+ end
+
+ test "Cannot create user with exisiting email" do
+ admin = insert(:user, info: %{is_admin: true})
+ user = insert(:user)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> put_req_header("accept", "application/json")
+ |> post("/api/pleroma/admin/users", %{
+ "users" => [
+ %{
+ "nickname" => "lain",
+ "email" => user.email,
+ "password" => "test"
+ }
+ ]
+ })
+
+ assert json_response(conn, 409) == [
+ %{
+ "code" => 409,
+ "data" => %{
+ "email" => user.email,
+ "nickname" => "lain"
+ },
+ "error" => "email has already been taken",
+ "type" => "error"
+ }
+ ]
+ end
+
+ test "Cannot create user with exisiting nickname" do
+ admin = insert(:user, info: %{is_admin: true})
+ user = insert(:user)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> put_req_header("accept", "application/json")
+ |> post("/api/pleroma/admin/users", %{
+ "users" => [
+ %{
+ "nickname" => user.nickname,
+ "email" => "someuser@plerama.social",
+ "password" => "test"
+ }
+ ]
+ })
+
+ assert json_response(conn, 409) == [
+ %{
+ "code" => 409,
+ "data" => %{
+ "email" => "someuser@plerama.social",
+ "nickname" => user.nickname
+ },
+ "error" => "nickname has already been taken",
+ "type" => "error"
+ }
+ ]
+ end
+
+ test "Multiple user creation works in transaction" do
+ admin = insert(:user, info: %{is_admin: true})
+ user = insert(:user)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> put_req_header("accept", "application/json")
+ |> post("/api/pleroma/admin/users", %{
+ "users" => [
+ %{
+ "nickname" => "newuser",
+ "email" => "newuser@pleroma.social",
+ "password" => "test"
+ },
+ %{
+ "nickname" => "lain",
+ "email" => user.email,
+ "password" => "test"
+ }
+ ]
+ })
+
+ assert json_response(conn, 409) == [
+ %{
+ "code" => 409,
+ "data" => %{
+ "email" => user.email,
+ "nickname" => "lain"
+ },
+ "error" => "email has already been taken",
+ "type" => "error"
+ },
+ %{
+ "code" => 409,
+ "data" => %{
+ "email" => "newuser@pleroma.social",
+ "nickname" => "newuser"
+ },
+ "error" => "",
+ "type" => "error"
+ }
+ ]
+
+ assert User.get_by_nickname("newuser") === nil
end
end
"local" => true,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
assert expected == json_response(conn, 200)
describe "POST /api/pleroma/admin/email_invite, with valid config" do
setup do
- registrations_open = Pleroma.Config.get([:instance, :registrations_open])
- invites_enabled = Pleroma.Config.get([:instance, :invites_enabled])
- Pleroma.Config.put([:instance, :registrations_open], false)
- Pleroma.Config.put([:instance, :invites_enabled], true)
+ [user: insert(:user, info: %{is_admin: true})]
+ end
- on_exit(fn ->
- Pleroma.Config.put([:instance, :registrations_open], registrations_open)
- Pleroma.Config.put([:instance, :invites_enabled], invites_enabled)
- :ok
- end)
+ clear_config([:instance, :registrations_open]) do
+ Pleroma.Config.put([:instance, :registrations_open], false)
+ end
- [user: insert(:user, info: %{is_admin: true})]
+ clear_config([:instance, :invites_enabled]) do
+ Pleroma.Config.put([:instance, :invites_enabled], true)
end
test "sends invitation and returns 204", %{conn: conn, user: user} do
[user: insert(:user, info: %{is_admin: true})]
end
+ clear_config([:instance, :registrations_open])
+ clear_config([:instance, :invites_enabled])
+
test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn, user: user} do
- registrations_open = Pleroma.Config.get([:instance, :registrations_open])
- invites_enabled = Pleroma.Config.get([:instance, :invites_enabled])
Pleroma.Config.put([:instance, :registrations_open], false)
Pleroma.Config.put([:instance, :invites_enabled], false)
- on_exit(fn ->
- Pleroma.Config.put([:instance, :registrations_open], registrations_open)
- Pleroma.Config.put([:instance, :invites_enabled], invites_enabled)
- :ok
- end)
-
conn =
conn
|> assign(:user, user)
end
test "it returns 500 if `registrations_open` is enabled", %{conn: conn, user: user} do
- registrations_open = Pleroma.Config.get([:instance, :registrations_open])
- invites_enabled = Pleroma.Config.get([:instance, :invites_enabled])
Pleroma.Config.put([:instance, :registrations_open], true)
Pleroma.Config.put([:instance, :invites_enabled], true)
- on_exit(fn ->
- Pleroma.Config.put([:instance, :registrations_open], registrations_open)
- Pleroma.Config.put([:instance, :invites_enabled], invites_enabled)
- :ok
- end)
-
conn =
conn
|> assign(:user, user)
"nickname" => admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname)
},
%{
"deactivated" => user.info.deactivated,
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => false,
- "tags" => ["foo", "bar"]
+ "tags" => ["foo", "bar"],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
|> Enum.sort_by(& &1["nickname"])
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user2.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user2) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user2.name || user2.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
},
%{
"deactivated" => admin.info.deactivated,
"nickname" => admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname)
},
%{
"deactivated" => false,
"local" => true,
"nickname" => old_admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname)
}
]
|> Enum.sort_by(& &1["nickname"])
"nickname" => admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => admin.local,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(admin.name || admin.nickname)
},
%{
"deactivated" => false,
"nickname" => second_admin.nickname,
"roles" => %{"admin" => true, "moderator" => false},
"local" => second_admin.local,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname)
}
]
|> Enum.sort_by(& &1["nickname"])
"nickname" => moderator.nickname,
"roles" => %{"admin" => false, "moderator" => true},
"local" => moderator.local,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(moderator) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(moderator.name || moderator.nickname)
}
]
}
"nickname" => user1.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => user1.local,
- "tags" => ["first"]
+ "tags" => ["first"],
+ "avatar" => User.avatar_url(user1) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user1.name || user1.nickname)
},
%{
"deactivated" => false,
"nickname" => user2.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => user2.local,
- "tags" => ["second"]
+ "tags" => ["second"],
+ "avatar" => User.avatar_url(user2) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user2.name || user2.nickname)
}
]
|> Enum.sort_by(& &1["nickname"])
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => user.local,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
]
}
"nickname" => user.nickname,
"roles" => %{"admin" => false, "moderator" => false},
"local" => true,
- "tags" => []
+ "tags" => [],
+ "avatar" => User.avatar_url(user) |> MediaProxy.url(),
+ "display_name" => HTML.strip_tags(user.name || user.nickname)
}
end
"uses" => 0
}
end
+
+ test "with invalid token" do
+ admin = insert(:user, info: %{is_admin: true})
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"})
+
+ assert json_response(conn, :not_found) == "Not found"
+ end
end
describe "GET /api/pleroma/admin/reports/:id" do
recipients = Enum.map(response["mentions"], & &1["username"])
- assert conn.assigns[:user].nickname in recipients
assert reporter.nickname in recipients
assert response["content"] == "I will check it out"
assert response["visibility"] == "direct"
assert json_response(conn, :bad_request) == "Could not delete"
end
end
+
+ describe "GET /api/pleroma/admin/config" do
+ setup %{conn: conn} do
+ admin = insert(:user, info: %{is_admin: true})
+
+ %{conn: assign(conn, :user, admin)}
+ end
+
+ test "without any settings in db", %{conn: conn} do
+ conn = get(conn, "/api/pleroma/admin/config")
+
+ assert json_response(conn, 200) == %{"configs" => []}
+ end
+
+ test "with settings in db", %{conn: conn} do
+ config1 = insert(:config)
+ config2 = insert(:config)
+
+ conn = get(conn, "/api/pleroma/admin/config")
+
+ %{
+ "configs" => [
+ %{
+ "key" => key1,
+ "value" => _
+ },
+ %{
+ "key" => key2,
+ "value" => _
+ }
+ ]
+ } = json_response(conn, 200)
+
+ assert key1 == config1.key
+ assert key2 == config2.key
+ end
+ end
+
+ describe "POST /api/pleroma/admin/config" do
+ setup %{conn: conn} do
+ admin = insert(:user, info: %{is_admin: true})
+
+ temp_file = "config/test.exported_from_db.secret.exs"
+
+ on_exit(fn ->
+ Application.delete_env(:pleroma, :key1)
+ Application.delete_env(:pleroma, :key2)
+ Application.delete_env(:pleroma, :key3)
+ Application.delete_env(:pleroma, :key4)
+ Application.delete_env(:pleroma, :keyaa1)
+ Application.delete_env(:pleroma, :keyaa2)
+ Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal)
+ Application.delete_env(:pleroma, Pleroma.Captcha.NotReal)
+ :ok = File.rm(temp_file)
+ end)
+
+ %{conn: assign(conn, :user, admin)}
+ end
+
+ clear_config([:instance, :dynamic_configuration]) do
+ Pleroma.Config.put([:instance, :dynamic_configuration], true)
+ end
+
+ test "create new config setting in db", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: "pleroma", key: "key1", value: "value1"},
+ %{
+ group: "ueberauth",
+ key: "Ueberauth.Strategy.Twitter.OAuth",
+ value: [%{"tuple" => [":consumer_secret", "aaaa"]}]
+ },
+ %{
+ group: "pleroma",
+ key: "key2",
+ value: %{
+ ":nested_1" => "nested_value1",
+ ":nested_2" => [
+ %{":nested_22" => "nested_value222"},
+ %{":nested_33" => %{":nested_44" => "nested_444"}}
+ ]
+ }
+ },
+ %{
+ group: "pleroma",
+ key: "key3",
+ value: [
+ %{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
+ %{"nested_4" => true}
+ ]
+ },
+ %{
+ group: "pleroma",
+ key: "key4",
+ value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"}
+ },
+ %{
+ group: "idna",
+ key: "key5",
+ value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => "key1",
+ "value" => "value1"
+ },
+ %{
+ "group" => "ueberauth",
+ "key" => "Ueberauth.Strategy.Twitter.OAuth",
+ "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}]
+ },
+ %{
+ "group" => "pleroma",
+ "key" => "key2",
+ "value" => %{
+ ":nested_1" => "nested_value1",
+ ":nested_2" => [
+ %{":nested_22" => "nested_value222"},
+ %{":nested_33" => %{":nested_44" => "nested_444"}}
+ ]
+ }
+ },
+ %{
+ "group" => "pleroma",
+ "key" => "key3",
+ "value" => [
+ %{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
+ %{"nested_4" => true}
+ ]
+ },
+ %{
+ "group" => "pleroma",
+ "key" => "key4",
+ "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}
+ },
+ %{
+ "group" => "idna",
+ "key" => "key5",
+ "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}
+ }
+ ]
+ }
+
+ assert Application.get_env(:pleroma, :key1) == "value1"
+
+ assert Application.get_env(:pleroma, :key2) == %{
+ nested_1: "nested_value1",
+ nested_2: [
+ %{nested_22: "nested_value222"},
+ %{nested_33: %{nested_44: "nested_444"}}
+ ]
+ }
+
+ assert Application.get_env(:pleroma, :key3) == [
+ %{"nested_3" => :nested_3, "nested_33" => "nested_33"},
+ %{"nested_4" => true}
+ ]
+
+ assert Application.get_env(:pleroma, :key4) == %{
+ "endpoint" => "https://example.com",
+ nested_5: :upload
+ }
+
+ assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []}
+ end
+
+ test "update config setting & delete", %{conn: conn} do
+ config1 = insert(:config, key: "keyaa1")
+ config2 = insert(:config, key: "keyaa2")
+
+ insert(:config,
+ group: "ueberauth",
+ key: "Ueberauth.Strategy.Microsoft.OAuth",
+ value: :erlang.term_to_binary([])
+ )
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{group: config1.group, key: config1.key, value: "another_value"},
+ %{group: config2.group, key: config2.key, delete: "true"},
+ %{
+ group: "ueberauth",
+ key: "Ueberauth.Strategy.Microsoft.OAuth",
+ delete: "true"
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => config1.key,
+ "value" => "another_value"
+ }
+ ]
+ }
+
+ assert Application.get_env(:pleroma, :keyaa1) == "another_value"
+ refute Application.get_env(:pleroma, :keyaa2)
+ end
+
+ test "common config example", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Captcha.NotReal",
+ "value" => [
+ %{"tuple" => [":enabled", false]},
+ %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]},
+ %{"tuple" => [":seconds_valid", 60]},
+ %{"tuple" => [":path", ""]},
+ %{"tuple" => [":key1", nil]},
+ %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Captcha.NotReal",
+ "value" => [
+ %{"tuple" => [":enabled", false]},
+ %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]},
+ %{"tuple" => [":seconds_valid", 60]},
+ %{"tuple" => [":path", ""]},
+ %{"tuple" => [":key1", nil]},
+ %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]}
+ ]
+ }
+ ]
+ }
+ end
+
+ test "tuples with more than two values", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Web.Endpoint.NotReal",
+ "value" => [
+ %{
+ "tuple" => [
+ ":http",
+ [
+ %{
+ "tuple" => [
+ ":key2",
+ [
+ %{
+ "tuple" => [
+ ":_",
+ [
+ %{
+ "tuple" => [
+ "/api/v1/streaming",
+ "Pleroma.Web.MastodonAPI.WebsocketHandler",
+ []
+ ]
+ },
+ %{
+ "tuple" => [
+ "/websocket",
+ "Phoenix.Endpoint.CowboyWebSocket",
+ %{
+ "tuple" => [
+ "Phoenix.Transports.WebSocket",
+ %{
+ "tuple" => [
+ "Pleroma.Web.Endpoint",
+ "Pleroma.Web.UserSocket",
+ []
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ %{
+ "tuple" => [
+ ":_",
+ "Phoenix.Endpoint.Cowboy2Handler",
+ %{"tuple" => ["Pleroma.Web.Endpoint", []]}
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Web.Endpoint.NotReal",
+ "value" => [
+ %{
+ "tuple" => [
+ ":http",
+ [
+ %{
+ "tuple" => [
+ ":key2",
+ [
+ %{
+ "tuple" => [
+ ":_",
+ [
+ %{
+ "tuple" => [
+ "/api/v1/streaming",
+ "Pleroma.Web.MastodonAPI.WebsocketHandler",
+ []
+ ]
+ },
+ %{
+ "tuple" => [
+ "/websocket",
+ "Phoenix.Endpoint.CowboyWebSocket",
+ %{
+ "tuple" => [
+ "Phoenix.Transports.WebSocket",
+ %{
+ "tuple" => [
+ "Pleroma.Web.Endpoint",
+ "Pleroma.Web.UserSocket",
+ []
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ %{
+ "tuple" => [
+ ":_",
+ "Phoenix.Endpoint.Cowboy2Handler",
+ %{"tuple" => ["Pleroma.Web.Endpoint", []]}
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ end
+
+ test "settings with nesting map", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{"tuple" => [":key2", "some_val"]},
+ %{
+ "tuple" => [
+ ":key3",
+ %{
+ ":max_options" => 20,
+ ":max_option_chars" => 200,
+ ":min_expiration" => 0,
+ ":max_expiration" => 31_536_000,
+ "nested" => %{
+ ":max_options" => 20,
+ ":max_option_chars" => 200,
+ ":min_expiration" => 0,
+ ":max_expiration" => 31_536_000
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) ==
+ %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => ":key1",
+ "value" => [
+ %{"tuple" => [":key2", "some_val"]},
+ %{
+ "tuple" => [
+ ":key3",
+ %{
+ ":max_expiration" => 31_536_000,
+ ":max_option_chars" => 200,
+ ":max_options" => 20,
+ ":min_expiration" => 0,
+ "nested" => %{
+ ":max_expiration" => 31_536_000,
+ ":max_option_chars" => 200,
+ ":max_options" => 20,
+ ":min_expiration" => 0
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ end
+
+ test "value as map", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma",
+ "key" => ":key1",
+ "value" => %{"key" => "some_val"}
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) ==
+ %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => ":key1",
+ "value" => %{"key" => "some_val"}
+ }
+ ]
+ }
+ end
+
+ test "dispatch setting", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Web.Endpoint.NotReal",
+ "value" => [
+ %{
+ "tuple" => [
+ ":http",
+ [
+ %{"tuple" => [":ip", %{"tuple" => [127, 0, 0, 1]}]},
+ %{"tuple" => [":dispatch", ["{:_,
+ [
+ {\"/api/v1/streaming\", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
+ {\"/websocket\", Phoenix.Endpoint.CowboyWebSocket,
+ {Phoenix.Transports.WebSocket,
+ {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: \"/websocket\"]}}},
+ {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
+ ]}"]]}
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ })
+
+ dispatch_string =
+ "{:_, [{\"/api/v1/streaming\", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, " <>
+ "{\"/websocket\", Phoenix.Endpoint.CowboyWebSocket, {Phoenix.Transports.WebSocket, " <>
+ "{Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, [path: \"/websocket\"]}}}, " <>
+ "{:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}]}"
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => "Pleroma.Web.Endpoint.NotReal",
+ "value" => [
+ %{
+ "tuple" => [
+ ":http",
+ [
+ %{"tuple" => [":ip", %{"tuple" => [127, 0, 0, 1]}]},
+ %{
+ "tuple" => [
+ ":dispatch",
+ [
+ dispatch_string
+ ]
+ ]
+ }
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ end
+
+ test "queues key as atom", %{conn: conn} do
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ "group" => "pleroma_job_queue",
+ "key" => ":queues",
+ "value" => [
+ %{"tuple" => [":federator_incoming", 50]},
+ %{"tuple" => [":federator_outgoing", 50]},
+ %{"tuple" => [":web_push", 50]},
+ %{"tuple" => [":mailer", 10]},
+ %{"tuple" => [":transmogrifier", 20]},
+ %{"tuple" => [":scheduled_activities", 10]},
+ %{"tuple" => [":background", 5]}
+ ]
+ }
+ ]
+ })
+
+ assert json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma_job_queue",
+ "key" => ":queues",
+ "value" => [
+ %{"tuple" => [":federator_incoming", 50]},
+ %{"tuple" => [":federator_outgoing", 50]},
+ %{"tuple" => [":web_push", 50]},
+ %{"tuple" => [":mailer", 10]},
+ %{"tuple" => [":transmogrifier", 20]},
+ %{"tuple" => [":scheduled_activities", 10]},
+ %{"tuple" => [":background", 5]}
+ ]
+ }
+ ]
+ }
+ end
+
+ test "delete part of settings by atom subkeys", %{conn: conn} do
+ config =
+ insert(:config,
+ key: "keyaa1",
+ value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3")
+ )
+
+ conn =
+ post(conn, "/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: config.group,
+ key: config.key,
+ subkeys: [":subkey1", ":subkey3"],
+ delete: "true"
+ }
+ ]
+ })
+
+ assert(
+ json_response(conn, 200) == %{
+ "configs" => [
+ %{
+ "group" => "pleroma",
+ "key" => "keyaa1",
+ "value" => [%{"tuple" => [":subkey2", "val2"]}]
+ }
+ ]
+ }
+ )
+ end
+ end
+
+ describe "config mix tasks run" do
+ setup %{conn: conn} do
+ admin = insert(:user, info: %{is_admin: true})
+
+ temp_file = "config/test.exported_from_db.secret.exs"
+
+ Mix.shell(Mix.Shell.Quiet)
+
+ on_exit(fn ->
+ Mix.shell(Mix.Shell.IO)
+ :ok = File.rm(temp_file)
+ end)
+
+ %{conn: assign(conn, :user, admin), admin: admin}
+ end
+
+ clear_config([:instance, :dynamic_configuration]) do
+ Pleroma.Config.put([:instance, :dynamic_configuration], true)
+ end
+
+ test "transfer settings to DB and to file", %{conn: conn, admin: admin} do
+ assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == []
+ conn = get(conn, "/api/pleroma/admin/config/migrate_to_db")
+ assert json_response(conn, 200) == %{}
+ assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) > 0
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+ |> get("/api/pleroma/admin/config/migrate_from_db")
+
+ assert json_response(conn, 200) == %{}
+ assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == []
+ end
+ end
+
+ describe "GET /api/pleroma/admin/users/:nickname/statuses" do
+ setup do
+ admin = insert(:user, info: %{is_admin: true})
+ user = insert(:user)
+
+ date1 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!()
+ date2 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!()
+ date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!()
+
+ insert(:note_activity, user: user, published: date1)
+ insert(:note_activity, user: user, published: date2)
+ insert(:note_activity, user: user, published: date3)
+
+ conn =
+ build_conn()
+ |> assign(:user, admin)
+
+ {:ok, conn: conn, user: user}
+ end
+
+ test "renders user's statuses", %{conn: conn, user: user} do
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses")
+
+ assert json_response(conn, 200) |> length() == 3
+ end
+
+ test "renders user's statuses with a limit", %{conn: conn, user: user} do
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?page_size=2")
+
+ assert json_response(conn, 200) |> length() == 2
+ end
+
+ test "doesn't return private statuses by default", %{conn: conn, user: user} do
+ {:ok, _private_status} =
+ CommonAPI.post(user, %{"status" => "private", "visibility" => "private"})
+
+ {:ok, _public_status} =
+ CommonAPI.post(user, %{"status" => "public", "visibility" => "public"})
+
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses")
+
+ assert json_response(conn, 200) |> length() == 4
+ end
+
+ test "returns private statuses with godmode on", %{conn: conn, user: user} do
+ {:ok, _private_status} =
+ CommonAPI.post(user, %{"status" => "private", "visibility" => "private"})
+
+ {:ok, _public_status} =
+ CommonAPI.post(user, %{"status" => "public", "visibility" => "public"})
+
+ conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?godmode=true")
+
+ assert json_response(conn, 200) |> length() == 5
+ end
+ end
+end
+
+# Needed for testing
+defmodule Pleroma.Web.Endpoint.NotReal do
+end
+
+defmodule Pleroma.Captcha.NotReal do
end