Merge branch 'develop' into feature/reports-groups-and-multiple-state-update
authorMaxim Filippov <colixer@gmail.com>
Mon, 4 Nov 2019 23:12:48 +0000 (02:12 +0300)
committerMaxim Filippov <colixer@gmail.com>
Mon, 4 Nov 2019 23:12:48 +0000 (02:12 +0300)
1  2 
CHANGELOG.md
docs/API/admin_api.md
lib/pleroma/web/activity_pub/utils.ex
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/common_api/common_api.ex
lib/pleroma/web/router.ex
test/web/admin_api/admin_api_controller_test.exs
test/web/common_api/common_api_test.exs

diff --combined CHANGELOG.md
index c51807c8069662b548b1453ba6201956e6c1e196,77edf7bf0d2897e780013109b3e7e061e853131b..64a28218d20fce42cb2595e2a06f9e029194d441
@@@ -4,8 -4,40 +4,40 @@@ All notable changes to this project wil
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
  
  ## [Unreleased]
+ ### Removed
+ - **Breaking**: Removed 1.0+ deprecated configurations `Pleroma.Upload, :strip_exif` and `:instance, :dedupe_media`
+ - **Breaking**: OStatus protocol support
+ ### Changed
+ - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
+ - Replaced [pleroma_job_queue](https://git.pleroma.social/pleroma/pleroma_job_queue) and `Pleroma.Web.Federator.RetryQueue` with [Oban](https://github.com/sorentwo/oban) (see [`docs/config.md`](docs/config.md) on migrating customized worker / retry settings)
+ - Introduced [quantum](https://github.com/quantum-elixir/quantum-core) job scheduler
+ - Enabled `:instance, extended_nickname_format` in the default config
+ - Add `rel="ugc"` to all links in statuses, to prevent SEO spam
+ - Extract RSS functionality from OStatus
+ - MRF (Simple Policy): Also use `:accept`/`:reject` on the actors rather than only their activities
+ <details>
+   <summary>API Changes</summary>
+ - **Breaking:** Admin API: Return link alongside with token on password reset
+ - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string.
+ - Admin API: Return `total` when querying for reports
+ - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`)
+ - Admin API: Return link alongside with token on password reset
+ - Mastodon API: Add `pleroma.direct_conversation_id` to the status endpoint (`GET /api/v1/statuses/:id`)
+ - Mastodon API: `pleroma.thread_muted` to the Status entity
+ - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message
+ - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload.
+ </details>
  ### Added
  - Refreshing poll results for remote polls
+ - Authentication: Added rate limit for password-authorized actions / login existence checks
+ - Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
+ - Support for `X-Forwarded-For` and similar HTTP headers which used by reverse proxies to pass a real user IP address to the backend. Must not be enabled unless your instance is behind at least one reverse proxy (such as Nginx, Apache HTTPD or Varnish Cache).
+ <details>
+   <summary>API Changes</summary>
  - Job queue stats to the healthcheck page
  - Admin API: Add ability to require password reset
  - Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition)
  - Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance`
  - Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity
  - OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/)
- - Authentication: Added rate limit for password-authorized actions / login existence checks
  - Metadata Link: Atom syndication Feed
- - Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
+ - Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints
+ - Admin API: `/users/:nickname/toggle_activation` endpoint is now deprecated in favor of: `/users/activate`, `/users/deactivate`, both accept `nicknames` array
+ - Admin API: `POST/DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group` are deprecated in favor of: `POST/DELETE /api/pleroma/admin/users/permission_group/:permission_group` (both accept `nicknames` array), `DELETE /api/pleroma/admin/users` (`nickname` query param or `nickname` sent in JSON body) is deprecated in favor of: `DELETE /api/pleroma/admin/users` (`nicknames` query array param or `nicknames` sent in JSON body).
+ - Admin API: Add `GET /api/pleroma/admin/relay` endpoint - lists all followed relays
+ - Pleroma API: `POST /api/v1/pleroma/conversations/read` to mark all conversations as read
+ - Mastodon API: Add `/api/v1/markers` for managing timeline read markers
 +- Admin API: Add ability to fetch reports, grouped by status `GET /api/pleroma/admin/grouped_reports`
  
  ### Changed
  - **Breaking:** Elixir >=1.8 is now required (was >= 1.7)
  - **Breaking:** Admin API: Return link alongside with token on password reset
 +- **Breaking:** Admin API: Changing report state now uses `PATCH` (it was `PUT` before) and allows updating multiple reports at once (API changed)
  - Replaced [pleroma_job_queue](https://git.pleroma.social/pleroma/pleroma_job_queue) and `Pleroma.Web.Federator.RetryQueue` with [Oban](https://github.com/sorentwo/oban) (see [`docs/config.md`](docs/config.md) on migrating customized worker / retry settings)
  - Introduced [quantum](https://github.com/quantum-elixir/quantum-core) job scheduler
  - Admin API: Return `total` when querying for reports
  - MRF (Simple Policy): Also use `:accept`/`:reject` on the actors rather than only their activities
  - OStatus: Extract RSS functionality
  - Mastodon API: Add `pleroma.direct_conversation_id` to the status endpoint (`GET /api/v1/statuses/:id`)
+ - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message
+ - Deprecated `User.Info` embedded schema (fields moved to `User`)
+ - Store status data inside Flag activity
+ </details>
  
  ### Fixed
+ - Report emails now include functional links to profiles of remote user accounts
+ <details>
+   <summary>API Changes</summary>
  - Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`)
  - Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname`
- - Added `:instance, extended_nickname_format` setting to the default config
- - Report emails now include functional links to profiles of remote user accounts
+ </details>
+ ## [1.1.2] - 2019-10-18
+ ### Fixed
+ - `pleroma_ctl` trying to connect to a running instance when generating the config, which of course doesn't exist.
  
- ## [1.1.0] - 2019-??-??
+ ## [1.1.1] - 2019-10-18
+ ### Fixed
+ - One of the migrations between 1.0.0 and 1.1.0 wiping user info of the relay user because of unexpected behavior of postgresql's `jsonb_set`, resulting in inability to post in the default configuration. If you were affected, please run the following query in postgres console, the relay user will be recreated automatically:
+ ```
+ delete from users where ap_id = 'https://your.instance.hostname/relay';
+ ```
+ - Bad user search matches
+ ## [1.1.0] - 2019-10-14
+ **Breaking:** The stable branch has been changed from `master` to `stable`. If you want to keep using 1.0, the `release/1.0` branch will receive security updates for 6 months after 1.1 release.
+ **OTP Note:** `pleroma_ctl` in 1.0 defaults to `master` and doesn't support specifying arbitrary branches, making `./pleroma_ctl update` fail. To fix this, fetch a version of `pleroma_ctl` from 1.1 using the command below and proceed with the update normally:
+ ```
+ curl -Lo ./bin/pleroma_ctl 'https://git.pleroma.social/pleroma/pleroma/raw/develop/rel/files/bin/pleroma_ctl'
+ ```
  ### Security
  - Mastodon API: respect post privacy in `/api/v1/statuses/:id/{favourited,reblogged}_by`
  
  - **Breaking:** GNU Social API with Qvitter extensions support
  - Emoji: Remove longfox emojis.
  - Remove `Reply-To` header from report emails for admins.
+ - ActivityPub: The `/objects/:uuid/likes` endpoint.
  
  ### 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
  - **Breaking:** `/api/pleroma/notifications/read` is moved to `/api/v1/pleroma/notifications/read` and now supports `max_id` and responds with Mastodon API entities.
- - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string.
  - Configuration: added `config/description.exs`, from which `docs/config.md` is generated
  - Configuration: OpenGraph and TwitterCard providers enabled by default
  - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
- - Mastodon API: `pleroma.thread_muted` key in the Status entity
  - 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`
  - AdminAPI: Add "godmode" while fetching user statuses (i.e. admin can see private statuses)
  - Improve digest email template
  – Pagination: (optional) return `total` alongside with `items` when paginating
- - Add `rel="ugc"` to all links in statuses, to prevent SEO spam
+ - The `Pleroma.FlakeId` module has been replaced with the `flake_id` library.
  
  ### Fixed
  - Following from Osada
  - Mastodon API: Misskey's endless polls being unable to render
  - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity
  - Mastodon API: Notifications endpoint crashing if one notification failed to render
+ - Mastodon API: `exclude_replies` is correctly handled again.
  - 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
+ - Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`)
+ - Mastodon API: Ensure the `account` field is not empty when rendering Notification entities.
+ - Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname`
+ - Mastodon API: Blocks are now treated consistently between the Streaming API and the Timeline APIs
  - 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.
- - 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
- - Reverse Proxy limiting `max_body_length` was incorrectly defined and only checked `Content-Length` headers which may not be sufficient in some circumstances
+ - ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set
  - ActivityPub: Deactivated user deletion
  - ActivityPub: Fix `/users/:nickname/inbox` crashing without an authenticated user
  - MRF: fix ability to follow a relay when AntiFollowbotPolicy was enabled
- - Mastodon API: Blocks are now treated consistently between the Streaming API and the Timeline APIs
- - Mastodon API: `exclude_replies` is correctly handled again.
+ - ActivityPub: Correct addressing of Undo.
+ - ActivityPub: Correct addressing of profile update activities.
+ - ActivityPub: Polls are now refreshed when necessary.
+ - Report emails now include functional links to profiles of remote user accounts
+ - Existing user id not being preserved on insert conflict
+ - 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
+ - Reverse Proxy limiting `max_body_length` was incorrectly defined and only checked `Content-Length` headers which may not be sufficient in some circumstances
  
  ### Added
  - Expiring/ephemeral activites. All activities can have expires_at value set, which controls when they should be deleted automatically.
  - 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 `fields_attributes` API parameter (setting custom fields)
  - 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: 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
- - Mastodon API: follower/following counters are nullified when `hide_follows`/`hide_followers` and `hide_follows_count`/`hide_followers_count` are set
+ - Mastodon API: Add support for `fields_attributes` API parameter (setting custom fields)
+ - Mastodon API: Added an endpoint to get multiple statuses by IDs (`GET /api/v1/statuses/?ids[]=1&ids[]=2`)
  - 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
  - Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=<email>` for resending account confirmation.
  - Pleroma API: Email change endpoint.
  - Admin API: Added moderation log
- - Support for `X-Forwarded-For` and similar HTTP headers which used by reverse proxies to pass a real user IP address to the backend. Must not be enabled unless your instance is behind at least one reverse proxy (such as Nginx, Apache HTTPD or Varnish Cache).
  - Web response cache (currently, enabled for ActivityPub)
- - Mastodon API: Added an endpoint to get multiple statuses by IDs (`GET /api/v1/statuses/?ids[]=1&ids[]=2`)
- - ActivityPub: Add ActivityPub actor's `discoverable` parameter.
- - Admin API: Added moderation log filters (user/start date/end date/search/pagination)
  - Reverse Proxy: Do not retry failed requests to limit pressure on the peer
  
  ### Changed
diff --combined docs/API/admin_api.md
index e8232225c6f969428f35567eb267c4523f17a447,e64ae6429496266b4dd4043a22f3922bc563f80c..9499d0140b3a0612001d861c73c21c682eb2cebd
@@@ -2,10 -2,11 +2,10 @@@
  
  Authentication is required and the user must be an admin.
  
 -## `/api/pleroma/admin/users`
 +## `GET /api/pleroma/admin/users`
  
  ### List users
  
 -- Method `GET`
  - Query Params:
    - *optional* `query`: **string** search term (e.g. nickname, domain, nickname@domain)
    - *optional* `filters`: **string** comma-separated string of filters:
  }
  ```
  
- ## `DELETE /api/pleroma/admin/users`
+ ## DEPRECATED `DELETE /api/pleroma/admin/users`
  
  ### Remove a user
  
 -- Method `DELETE`
  - Params:
    - `nickname`
  - Response: User’s nickname
  
+ ## `DELETE /api/pleroma/admin/users`
+ ### Remove a user
+ - Method `DELETE`
+ - Params:
+   - `nicknames`
+ - Response: Array of user nicknames
  ### Create a user
  
  - Method: `POST`
    ]
  - Response: User’s nickname
  
 -## `/api/pleroma/admin/users/follow`
 +## `POST /api/pleroma/admin/users/follow`
 +
  ### Make a user follow another user
  
 -- Methods: `POST`
  - Params:
 - - `follower`: The nickname of the follower
 - - `followed`: The nickname of the followed
 +  - `follower`: The nickname of the follower
 +  - `followed`: The nickname of the followed
  - Response:
 - - "ok"
 +  - "ok"
 +
 +## `POST /api/pleroma/admin/users/unfollow`
  
 -## `/api/pleroma/admin/users/unfollow`
  ### Make a user unfollow another user
  
 -- Methods: `POST`
  - Params:
 - - `follower`: The nickname of the follower
 - - `followed`: The nickname of the followed
 +  - `follower`: The nickname of the follower
 +  - `followed`: The nickname of the followed
  - Response:
 - - "ok"
 +  - "ok"
  
 -## `/api/pleroma/admin/users/:nickname/toggle_activation`
 +## `PATCH /api/pleroma/admin/users/:nickname/toggle_activation`
  
  ### Toggle user activation
  
 -- Method: `PATCH`
  - Params:
    - `nickname`
  - Response: User’s object
  }
  ```
  
 -## `/api/pleroma/admin/users/tag`
 +## `PUT /api/pleroma/admin/users/tag`
  
  ### Tag a list of users
  
 -- Method: `PUT`
  - Params:
    - `nicknames` (array)
    - `tags` (array)
  
 +## `DELETE /api/pleroma/admin/users/tag`
 +
  ### Untag a list of users
  
 -- Method: `DELETE`
  - Params:
    - `nicknames` (array)
    - `tags` (array)
  
 -## `/api/pleroma/admin/users/:nickname/permission_group`
 +## `GET /api/pleroma/admin/users/:nickname/permission_group`
  
  ### Get user user permission groups membership
  
 -- Method: `GET`
  - Params: none
  - Response:
  
  }
  ```
  
 -## `/api/pleroma/admin/users/:nickname/permission_group/:permission_group`
 +## `GET /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
  
  Note: Available `:permission_group` is currently moderator and admin. 404 is returned when the permission group doesn’t exist.
  
  ### Get user user permission groups membership per permission group
  
 -- Method: `GET`
  - Params: none
  - Response:
  
  }
  ```
  
- ## `POST /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
+ ## DEPRECATED `POST /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
  
- ### Add user in permission group
+ ### Add user to permission group
  
  - Params: none
  - Response:
    - On failure: `{"error": "…"}`
-   - On success: JSON of the `user.info`
+   - On success: JSON of the user
+ ## `POST /api/pleroma/admin/users/permission_group/:permission_group`
+ ### Add users to permission group
+ - Params:
+   - `nicknames`: nicknames array
+ - Response:
+   - On failure: `{"error": "…"}`
+   - On success: JSON of the user
+ ## DEPRECATED `DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
  
 +## `DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
 +
  ### Remove user from permission group
  
  - Params: none
  - Response:
    - On failure: `{"error": "…"}`
-   - On success: JSON of the `user.info`
+   - On success: JSON of the user
+ - Note: An admin cannot revoke their own admin status.
+ ## `DELETE /api/pleroma/admin/users/permission_group/:permission_group`
+ ### Remove users from permission group
+ - Params:
+   - `nicknames`: nicknames array
+ - Response:
+   - On failure: `{"error": "…"}`
+   - On success: JSON of the user
  - Note: An admin cannot revoke their own admin status.
  
- ## `PUT /api/pleroma/admin/users/:nickname/activation_status`
+ ## `PATCH /api/pleroma/admin/users/activate`
+ ### Activate user
+ - Params:
+   - `nicknames`: nicknames array
+ - Response:
+ ```json
+ {
+   users: [
+     {
+       // user object
+     }
+   ]
+ }
+ ```
+ ## `PATCH /api/pleroma/admin/users/deactivate`
+ ### Deactivate user
+ - Params:
+   - `nicknames`: nicknames array
+ - Response:
+ ```json
+ {
+   users: [
+     {
+       // user object
+     }
+   ]
+ }
+ ```
+ ## DEPRECATED `PATCH /api/pleroma/admin/users/:nickname/activation_status`
  
  ### Active or deactivate a user
  
    - `nickname`
    - `status` BOOLEAN field, false value means deactivation.
  
 -## `/api/pleroma/admin/users/:nickname_or_id`
 +## `GET /api/pleroma/admin/users/:nickname_or_id`
  
  ### Retrive the details of a user
  
 -- Method: `GET`
  - Params:
    - `nickname` or `id`
  - Response:
    - On failure: `Not found`
    - On success: JSON of the user
  
 -## `/api/pleroma/admin/users/:nickname_or_id/statuses`
 +## `GET /api/pleroma/admin/users/:nickname_or_id/statuses`
  
  ### Retrive user's latest statuses
  
 -- Method: `GET`
  - Params:
    - `nickname` or `id`
    - *optional* `page_size`: number of statuses to return (default is `20`)
    - On failure: `Not found`
    - On success: JSON array of user's latest statuses
  
 -## `/api/pleroma/admin/relay`
 +## `POST /api/pleroma/admin/relay`
  
  ### Follow a Relay
  
 -- Methods: `POST`
  - Params:
    - `relay_url`
  - Response:
    - On success: URL of the followed relay
  
 +## `DELETE /api/pleroma/admin/relay`
 +
  ### Unfollow a Relay
  
 -- Methods: `DELETE`
  - Params:
    - `relay_url`
  - Response:
    - On success: URL of the unfollowed relay
  
 -## `/api/pleroma/admin/users/invite_token`
+ ## `GET /api/pleroma/admin/relay`
+ ### List Relays
+ - Params: none
+ - Response:
+   - On success: JSON array of relays
 +## `POST /api/pleroma/admin/users/invite_token`
  
  ### Create an account registration invite token
  
 -- Methods: `POST`
  - Params:
    - *optional* `max_use` (integer)
    - *optional* `expires_at` (date string e.g. "2019-04-07")
  }
  ```
  
 -## `/api/pleroma/admin/users/invites`
 +## `GET /api/pleroma/admin/users/invites`
  
  ### Get a list of generated invites
  
 -- Methods: `GET`
  - Params: none
  - Response:
  
  }
  ```
  
 -## `/api/pleroma/admin/users/revoke_invite`
 +## `POST /api/pleroma/admin/users/revoke_invite`
  
  ### Revoke invite by token
  
 -- Methods: `POST`
  - Params:
    - `token`
  - Response:
  }
  ```
  
 -
 -## `/api/pleroma/admin/users/email_invite`
 +## `POST /api/pleroma/admin/users/email_invite`
  
  ### Sends registration invite via email
  
 -- Methods: `POST`
  - Params:
    - `email`
    - `name`, optional
  
 -## `/api/pleroma/admin/users/:nickname/password_reset`
 +## `GET /api/pleroma/admin/users/:nickname/password_reset`
  
  ### Get a password reset token for a given nickname
  
 -- Methods: `GET`
  - Params: none
  - Response:
  
  }
  ```
  
 -
 -## `/api/pleroma/admin/users/:nickname/force_password_reset`
 +## `PATCH /api/pleroma/admin/users/:nickname/force_password_reset`
  
  ### Force passord reset for a user with a given nickname
  
 -- Methods: `PATCH`
  - Params: none
  - Response: none (code `204`)
  
 -## `/api/pleroma/admin/reports`
 +## `GET /api/pleroma/admin/reports`
 +
  ### Get a list of reports
 -- Method `GET`
 +
  - Params:
    - *optional* `state`: **string** the state of reports. Valid values are `open`, `closed` and `resolved`
    - *optional* `limit`: **integer** the number of records to retrieve
  
  ```json
  {
 -  "total" : 1,
 +  "totalReports" : 1,
    "reports": [
      {
        "account": {
  }
  ```
  
 -## `/api/pleroma/admin/reports/:id`
 +## `GET /api/pleroma/admin/grouped_reports`
 +
 +### Get a list of reports, grouped by status
 +
 +- Params: none
 +- On success: JSON, returns a list of reports, where:
 +  - `date`: date of the latest report
 +  - `account`: the user who has been reported (see `/api/pleroma/admin/reports` for reference)
 +  - `status`: reported status (see `/api/pleroma/admin/reports` for reference)
 +  - `actors`: users who had reported this status (see `/api/pleroma/admin/reports` for reference)
 +  - `reports`: reports (see `/api/pleroma/admin/reports` for reference)
 +
 +```json
 +  "reports": [
 +    {
 +      "date": "2019-10-07T12:31:39.615149Z",
 +      "account": { ... },
 +      "status": { ... },
 +      "actors": [{ ... }, { ... }],
 +      "reports": [{ ... }]
 +    }
 +  ]
 +```
 +
 +## `GET /api/pleroma/admin/reports/:id`
 +
  ### Get an individual report
 -- Method `GET`
 +
  - Params:
    - `id`
  - Response:
      - 404 Not Found `"Not found"`
    - On success: JSON, Report object (see above)
  
 -## `/api/pleroma/admin/reports/:id`
 -### Change the state of the report
 -- Method `PUT`
 +## `PATCH /api/pleroma/admin/reports`
 +
 +### Change the state of one or multiple reports
 +
  - Params:
 -  - `id`
 -  - `state`: required, the new state. Valid values are `open`, `closed` and `resolved`
 +
 +```json
 +  `reports`: [
 +    {
 +      `id`, // required, report id
 +      `state` // required, the new state. Valid values are `open`, `closed` and `resolved`
 +    },
 +    ...
 +  ]
 +```
 +
  - Response:
    - On failure:
 -    - 400 Bad Request `"Unsupported state"`
 -    - 403 Forbidden `{"error": "error_msg"}`
 -    - 404 Not Found `"Not found"`
 -  - On success: JSON, Report object (see above)
 +    - 400 Bad Request, JSON:
 +
 +    ```json
 +      [
 +        {
 +          `id`, // report id
 +          `error` // error message
 +        }
 +      ]
 +    ```
 +
 +  - On success: `204`, empty response
 +
 +## `POST /api/pleroma/admin/reports/:id/respond`
  
 -## `/api/pleroma/admin/reports/:id/respond`
  ### Respond to a report
 -- Method `POST`
 +
  - Params:
    - `id`
    - `status`: required, the message
  }
  ```
  
 -## `/api/pleroma/admin/statuses/:id`
 +## `PUT /api/pleroma/admin/statuses/:id`
 +
  ### Change the scope of an individual reported status
 -- Method `PUT`
 +
  - Params:
    - `id`
    - `sensitive`: optional, valid values are `true` or `false`
      - 404 Not Found `"Not found"`
    - On success: JSON, Mastodon Status entity
  
 -## `/api/pleroma/admin/statuses/:id`
 +## `DELETE /api/pleroma/admin/statuses/:id`
 +
  ### Delete an individual reported status
 -- Method `DELETE`
 +
  - Params:
    - `id`
  - Response:
      - 404 Not Found `"Not found"`
    - On success: 200 OK `{}`
  
 +## `GET /api/pleroma/admin/config/migrate_to_db`
  
 -## `/api/pleroma/admin/config/migrate_to_db`
  ### Run mix task pleroma.config migrate_to_db
 +
  Copy settings on key `:pleroma` to DB.
 -- Method `GET`
 +
  - Params: none
  - Response:
  
  {}
  ```
  
 -## `/api/pleroma/admin/config/migrate_from_db`
 +## `GET /api/pleroma/admin/config/migrate_from_db`
 +
  ### Run mix task pleroma.config migrate_from_db
 +
  Copy all settings from DB to `config/prod.exported_from_db.secret.exs` with deletion from DB.
 +
  - Method `GET`
  - Params: none
  - Response:
  {}
  ```
  
 -## `/api/pleroma/admin/config`
 +## `GET /api/pleroma/admin/config`
 +
  ### List config settings
 +
  List config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`.
 -- Method `GET`
 +
  - Params: none
  - Response:
  
  }
  ```
  
 -## `/api/pleroma/admin/config`
 +## `POST /api/pleroma/admin/config`
 +
  ### Update config settings
 +
  Updating config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`.
  Module name can be passed as string, which starts with `Pleroma`, e.g. `"Pleroma.Upload"`.
  Atom keys and values can be passed with `:` in the beginning, e.g. `":upload"`.
@@@ -713,6 -747,7 +789,6 @@@ Compile time settings (need instance re
    - `Pleroma.Upload` -> `:proxy_remote`
    - `:instance` -> `:upload_limit`
  
 -- Method `POST`
  - Params:
    - `configs` => [
      - `group` (string)
  }
  ```
  
 -## `/api/pleroma/admin/moderation_log`
 +## `GET /api/pleroma/admin/moderation_log`
 +
  ### Get moderation log
 -- Method `GET`
 +
  - Params:
    - *optional* `page`: **integer** page number
    - *optional* `page_size`: **integer** number of log entries per page (default is `50`)
  ```
  
  ## `POST /api/pleroma/admin/reload_emoji`
 +
  ### Reload the instance's custom emoji
 -* Method `POST`
 -* Authentication: required
 -* Params: None
 -* Response: JSON, "ok" and 200 status
 +
 +- Authentication: required
 +- Params: None
 +- Response: JSON, "ok" and 200 status
index f2beb0809710edc28f5c5fd7b4e224bd6e1bb738,d812fd734292cf9d3b481cbfe2dc520517653a3f..57349e30463cb7d8a190d426bd89acea91f99b5c
@@@ -6,14 -6,13 +6,15 @@@ defmodule Pleroma.Web.ActivityPub.Util
    alias Ecto.Changeset
    alias Ecto.UUID
    alias Pleroma.Activity
 +  alias Pleroma.Activity.Queries
    alias Pleroma.Notification
    alias Pleroma.Object
    alias Pleroma.Repo
    alias Pleroma.User
    alias Pleroma.Web
 +  alias Pleroma.Web.ActivityPub.ActivityPub
    alias Pleroma.Web.ActivityPub.Visibility
+   alias Pleroma.Web.AdminAPI.AccountView
    alias Pleroma.Web.Endpoint
    alias Pleroma.Web.Router.Helpers
  
@@@ -23,6 -22,7 +24,7 @@@
    require Pleroma.Constants
  
    @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"]
+   @strip_status_report_states ~w(closed resolved)
    @supported_report_states ~w(open closed resolved)
    @valid_visibilities ~w(public unlisted private direct)
  
  
    def determine_explicit_mentions(_), do: []
  
-   @spec recipient_in_collection(any(), any()) :: boolean()
-   defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
-   defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
-   defp recipient_in_collection(_, _), do: false
+   @spec label_in_collection?(any(), any()) :: boolean()
+   defp label_in_collection?(ap_id, coll) when is_binary(coll), do: ap_id == coll
+   defp label_in_collection?(ap_id, coll) when is_list(coll), do: ap_id in coll
+   defp label_in_collection?(_, _), do: false
+   @spec label_in_message?(String.t(), map()) :: boolean()
+   def label_in_message?(label, params),
+     do:
+       [params["to"], params["cc"], params["bto"], params["bcc"]]
+       |> Enum.any?(&label_in_collection?(label, &1))
+   @spec unaddressed_message?(map()) :: boolean()
+   def unaddressed_message?(params),
+     do:
+       [params["to"], params["cc"], params["bto"], params["bcc"]]
+       |> Enum.all?(&is_nil(&1))
  
    @spec recipient_in_message(User.t(), User.t(), map()) :: boolean()
-   def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
-     addresses = [params["to"], params["cc"], params["bto"], params["bcc"]]
-     cond do
-       Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true
-       # if the message is unaddressed at all, then assume it is directly addressed
-       # to the recipient
-       Enum.all?(addresses, &is_nil(&1)) -> true
-       # if the message is sent from somebody the user is following, then assume it
-       # is addressed to the recipient
-       User.following?(recipient, actor) -> true
-       true -> false
-     end
-   end
+   def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params),
+     do:
+       label_in_message?(ap_id, params) || unaddressed_message?(params) ||
+         User.following?(recipient, actor)
  
    defp extract_list(target) when is_binary(target), do: [target]
    defp extract_list(lst) when is_list(lst), do: lst
@@@ -78,8 -80,8 +82,8 @@@
  
    def maybe_splice_recipient(ap_id, params) do
      need_splice? =
-       !recipient_in_collection(ap_id, params["to"]) &&
-         !recipient_in_collection(ap_id, params["cc"])
+       !label_in_collection?(ap_id, params["to"]) &&
+         !label_in_collection?(ap_id, params["cc"])
  
      if need_splice? do
        cc_list = extract_list(params["cc"])
          %Activity{data: %{"actor" => actor}},
          object
        ) do
-     announcements = take_announcements(object)
+     unless actor |> User.get_cached_by_ap_id() |> User.invisible?() do
+       announcements = take_announcements(object)
  
-     with announcements <- Enum.uniq([actor | announcements]) do
-       update_element_in_object("announcement", announcements, object)
+       with announcements <- Enum.uniq([actor | announcements]) do
+         update_element_in_object("announcement", announcements, object)
+       end
+     else
+       {:ok, object}
      end
    end
  
  
    defp build_flag_object(%{account: account, statuses: statuses} = _) do
      [account.ap_id] ++
-       Enum.map(statuses || [], fn
-         %Activity{} = act -> act.data["id"]
-         act when is_map(act) -> act["id"]
-         act when is_binary(act) -> act
+       Enum.map(statuses || [], fn act ->
+         id =
+           case act do
+             %Activity{} = act -> act.data["id"]
+             act when is_map(act) -> act["id"]
+             act when is_binary(act) -> act
+           end
+         activity = Activity.get_by_ap_id_with_object(id)
+         actor = User.get_by_ap_id(activity.object.data["actor"])
+         %{
+           "type" => "Note",
+           "id" => activity.data["id"],
+           "content" => activity.object.data["content"],
+           "published" => activity.object.data["published"],
+           "actor" => AccountView.render("show.json", %{user: actor})
+         }
        end)
    end
  
    end
  
    #### Report-related helpers
 +  def get_reports(params, page, page_size) do
 +    params =
 +      params
 +      |> Map.put("type", "Flag")
 +      |> Map.put("skip_preload", true)
 +      |> Map.put("total", true)
 +      |> Map.put("limit", page_size)
 +      |> Map.put("offset", (page - 1) * page_size)
 +
 +    ActivityPub.fetch_activities([], params, :offset)
 +  end
 +
 +  @spec get_reports_grouped_by_status() :: %{
 +          required(:groups) => [
 +            %{
 +              required(:date) => String.t(),
 +              required(:account) => %User{},
 +              required(:status) => %Activity{},
 +              required(:actors) => [%User{}],
 +              required(:reports) => [%Activity{}]
 +            }
 +          ],
 +          required(:total) => integer
 +        }
 +  def get_reports_grouped_by_status do
 +    paginated_activities = get_reported_status_ids()
 +
 +    groups =
 +      paginated_activities
 +      |> Enum.map(fn entry ->
 +        status =
 +          Activity
 +          |> Queries.by_ap_id(entry[:activity_id])
 +          |> Activity.with_preloaded_object(:left)
 +          |> Activity.with_preloaded_user_actor()
 +          |> Repo.one()
 +
 +        reports = get_reports_by_status_id(status.data["id"])
 +
 +        max_date =
 +          Enum.max_by(reports, &Pleroma.Web.CommonAPI.Utils.to_masto_date(&1.data["published"])).data[
 +            "published"
 +          ]
 +
 +        actors = Enum.map(reports, & &1.user_actor)
 +
 +        %{
 +          date: max_date,
 +          account: status.user_actor,
 +          status: status,
 +          actors: actors,
 +          reports: reports
 +        }
 +      end)
 +
 +    %{
 +      groups: groups
 +    }
 +  end
 +
 +  def get_reports_by_status_id(status_id) do
 +    from(a in Activity,
 +      where: fragment("(?)->>'type' = 'Flag'", a.data),
 +      where: fragment("(?)->'object' \\? (?)", a.data, ^status_id)
 +    )
 +    |> Activity.with_preloaded_user_actor()
 +    |> Repo.all()
 +  end
 +
 +  @spec get_reported_status_ids() :: %{
 +          required(:items) => [%Activity{}],
 +          required(:total) => integer
 +        }
 +  def get_reported_status_ids do
 +    from(a in Activity,
 +      where: fragment("(?)->>'type' = 'Flag'", a.data),
 +      select: %{
 +        date: fragment("max(?->>'published') date", a.data),
 +        activity_id:
 +          fragment("jsonb_array_elements_text((? #- '{object,0}')->'object') activity_id", a.data)
 +      },
 +      group_by: fragment("activity_id"),
 +      order_by: fragment("date DESC")
 +    )
 +    |> Repo.all()
 +  end
  
+   def update_report_state(%Activity{} = activity, state)
+       when state in @strip_status_report_states do
+     {:ok, stripped_activity} = strip_report_status_data(activity)
+     new_data =
+       activity.data
+       |> Map.put("state", state)
+       |> Map.put("object", stripped_activity.data["object"])
+     activity
+     |> Changeset.change(data: new_data)
+     |> Repo.update()
+   end
    def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
      new_data = Map.put(activity.data, "state", state)
  
      |> Repo.update()
    end
  
 +  def update_report_state(activity_ids, state) when state in @supported_report_states do
 +    activities_num = length(activity_ids)
 +
 +    from(a in Activity, where: a.id in ^activity_ids)
 +    |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)])
 +    |> Repo.update_all([])
 +    |> case do
 +      {^activities_num, _} -> :ok
 +      _ -> {:error, activity_ids}
 +    end
 +  end
 +
    def update_report_state(_, _), do: {:error, "Unsupported state"}
  
+   def strip_report_status_data(activity) do
+     [actor | reported_activities] = activity.data["object"]
+     stripped_activities = Enum.map(reported_activities, & &1["id"])
+     new_data = put_in(activity.data, ["object"], [actor | stripped_activities])
+     {:ok, %{activity | data: new_data}}
+   end
    def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
      [to, cc, recipients] =
        activity
index a556ab050d93184c23577863cd6dedd6b7584d74,b47618bde4b668238f1c9d6b9ec0f23eebdf459d..1f48ce8c17efd3cf120874b40e108b3675b00f6c
@@@ -11,7 -11,6 +11,7 @@@ defmodule Pleroma.Web.AdminAPI.AdminAPI
    alias Pleroma.UserInviteToken
    alias Pleroma.Web.ActivityPub.ActivityPub
    alias Pleroma.Web.ActivityPub.Relay
 +  alias Pleroma.Web.ActivityPub.Utils
    alias Pleroma.Web.AdminAPI.AccountView
    alias Pleroma.Web.AdminAPI.Config
    alias Pleroma.Web.AdminAPI.ConfigView
             :user_delete,
             :users_create,
             :user_toggle_activation,
+            :user_activate,
+            :user_deactivate,
             :tag_users,
             :untag_users,
             :right_add,
-            :right_delete,
-            :set_activation_status
+            :right_delete
           ]
    )
  
  
      ModerationLog.insert_log(%{
        actor: admin,
-       subject: user,
+       subject: [user],
        action: "delete"
      })
  
      |> json(nickname)
    end
  
+   def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+     users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
+     User.delete(users)
+     ModerationLog.insert_log(%{
+       actor: admin,
+       subject: users,
+       action: "delete"
+     })
+     conn
+     |> json(nicknames)
+   end
    def user_follow(%{assigns: %{user: admin}} = conn, %{
          "follower" => follower_nick,
          "followed" => followed_nick
    def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
      user = User.get_cached_by_nickname(nickname)
  
-     {:ok, updated_user} = User.deactivate(user, !user.info.deactivated)
+     {:ok, updated_user} = User.deactivate(user, !user.deactivated)
  
-     action = if user.info.deactivated, do: "activate", else: "deactivate"
+     action = if user.deactivated, do: "activate", else: "deactivate"
  
      ModerationLog.insert_log(%{
        actor: admin,
-       subject: user,
+       subject: [user],
        action: action
      })
  
      |> render("show.json", %{user: updated_user})
    end
  
+   def user_activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
+     {:ok, updated_users} = User.deactivate(users, false)
+     ModerationLog.insert_log(%{
+       actor: admin,
+       subject: users,
+       action: "activate"
+     })
+     conn
+     |> put_view(AccountView)
+     |> render("index.json", %{users: Keyword.values(updated_users)})
+   end
+   def user_deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
+     users = Enum.map(nicknames, &User.get_cached_by_nickname/1)
+     {:ok, updated_users} = User.deactivate(users, true)
+     ModerationLog.insert_log(%{
+       actor: admin,
+       subject: users,
+       action: "deactivate"
+     })
+     conn
+     |> put_view(AccountView)
+     |> render("index.json", %{users: Keyword.values(updated_users)})
+   end
    def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do
      with {:ok, _} <- User.tag(nicknames, tags) do
        ModerationLog.insert_log(%{
      }
  
      with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)),
+          {:ok, users, count} <- filter_relay_user(users, count),
           do:
             conn
             |> json(
             )
    end
  
+   defp filter_relay_user(users, count) do
+     filtered_users = Enum.reject(users, &relay_user?/1)
+     count = if Enum.any?(users, &relay_user?/1), do: length(filtered_users), else: count
+     {:ok, filtered_users, count}
+   end
+   defp relay_user?(user) do
+     user.ap_id == Relay.relay_ap_id()
+   end
    @filters ~w(local external active deactivated is_admin is_moderator)
  
    @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{}
      |> Enum.into(%{}, &{&1, true})
    end
  
+   def right_add_multiple(%{assigns: %{user: admin}} = conn, %{
+         "permission_group" => permission_group,
+         "nicknames" => nicknames
+       })
+       when permission_group in ["moderator", "admin"] do
+     update = %{:"is_#{permission_group}" => true}
+     users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
+     for u <- users, do: User.admin_api_update(u, update)
+     ModerationLog.insert_log(%{
+       action: "grant",
+       actor: admin,
+       subject: users,
+       permission: permission_group
+     })
+     json(conn, update)
+   end
+   def right_add_multiple(conn, _) do
+     render_error(conn, :not_found, "No such permission_group")
+   end
    def right_add(%{assigns: %{user: admin}} = conn, %{
          "permission_group" => permission_group,
          "nickname" => nickname
        })
        when permission_group in ["moderator", "admin"] do
-     info = Map.put(%{}, "is_" <> permission_group, true)
+     fields = %{:"is_#{permission_group}" => true}
  
      {:ok, user} =
        nickname
        |> User.get_cached_by_nickname()
-       |> User.update_info(&User.Info.admin_api_update(&1, info))
+       |> User.admin_api_update(fields)
  
      ModerationLog.insert_log(%{
        action: "grant",
        actor: admin,
-       subject: user,
+       subject: [user],
        permission: permission_group
      })
  
-     json(conn, info)
+     json(conn, fields)
    end
  
    def right_add(conn, _) do
  
      conn
      |> json(%{
-       is_moderator: user.info.is_moderator,
-       is_admin: user.info.is_admin
+       is_moderator: user.is_moderator,
+       is_admin: user.is_admin
      })
    end
  
-   def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do
-     render_error(conn, :forbidden, "You can't revoke your own admin status.")
+   def right_delete_multiple(
+         %{assigns: %{user: %{nickname: admin_nickname} = admin}} = conn,
+         %{
+           "permission_group" => permission_group,
+           "nicknames" => nicknames
+         }
+       )
+       when permission_group in ["moderator", "admin"] do
+     with false <- Enum.member?(nicknames, admin_nickname) do
+       update = %{:"is_#{permission_group}" => false}
+       users = nicknames |> Enum.map(&User.get_cached_by_nickname/1)
+       for u <- users, do: User.admin_api_update(u, update)
+       ModerationLog.insert_log(%{
+         action: "revoke",
+         actor: admin,
+         subject: users,
+         permission: permission_group
+       })
+       json(conn, update)
+     else
+       _ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.")
+     end
+   end
+   def right_delete_multiple(conn, _) do
+     render_error(conn, :not_found, "No such permission_group")
    end
  
    def right_delete(
          }
        )
        when permission_group in ["moderator", "admin"] do
-     info = Map.put(%{}, "is_" <> permission_group, false)
+     fields = %{:"is_#{permission_group}" => false}
  
      {:ok, user} =
        nickname
        |> User.get_cached_by_nickname()
-       |> User.update_info(&User.Info.admin_api_update(&1, info))
+       |> User.admin_api_update(fields)
  
      ModerationLog.insert_log(%{
        action: "revoke",
        actor: admin,
-       subject: user,
+       subject: [user],
        permission: permission_group
      })
  
-     json(conn, info)
+     json(conn, fields)
    end
  
-   def right_delete(conn, _) do
-     render_error(conn, :not_found, "No such permission_group")
+   def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do
+     render_error(conn, :forbidden, "You can't revoke your own admin status.")
    end
  
-   def set_activation_status(%{assigns: %{user: admin}} = conn, %{
-         "nickname" => nickname,
-         "status" => status
-       }) do
-     with {:ok, status} <- Ecto.Type.cast(:boolean, status),
-          %User{} = user <- User.get_cached_by_nickname(nickname),
-          {:ok, _} <- User.deactivate(user, !status) do
-       action = if(user.info.deactivated, do: "activate", else: "deactivate")
-       ModerationLog.insert_log(%{
-         actor: admin,
-         subject: user,
-         action: action
-       })
-       json_response(conn, :no_content, "")
+   def relay_list(conn, _params) do
+     with {:ok, list} <- Relay.list() do
+       json(conn, %{relays: list})
+     else
+       _ ->
+         conn
+         |> put_status(500)
      end
    end
  
    def list_reports(conn, params) do
      {page, page_size} = page_params(params)
  
 -    params =
 -      params
 -      |> Map.put("type", "Flag")
 -      |> Map.put("skip_preload", true)
 -      |> Map.put("total", true)
 -      |> Map.put("limit", page_size)
 -      |> Map.put("offset", (page - 1) * page_size)
 -
 -    reports = ActivityPub.fetch_activities([], params, :offset)
 +    conn
 +    |> put_view(ReportView)
 +    |> render("index.json", %{reports: Utils.get_reports(params, page, page_size)})
 +  end
  
 +  def list_grouped_reports(conn, _params) do
      conn
      |> put_view(ReportView)
 -    |> render("index.json", %{reports: reports})
 +    |> render("index_grouped.json", Utils.get_reports_grouped_by_status())
    end
  
    def report_show(conn, %{"id" => id}) do
      end
    end
  
 -  def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" => state}) do
 -    with {:ok, report} <- CommonAPI.update_report_state(id, state) do
 -      ModerationLog.insert_log(%{
 -        action: "report_update",
 -        actor: admin,
 -        subject: report
 -      })
 +  def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do
 +    result =
 +      reports
 +      |> Enum.map(fn report ->
 +        with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do
 +          ModerationLog.insert_log(%{
 +            action: "report_update",
 +            actor: admin,
 +            subject: activity
 +          })
 +
 +          activity
 +        else
 +          {:error, message} -> %{id: report["id"], error: message}
 +        end
 +      end)
  
 -      conn
 -      |> put_view(ReportView)
 -      |> render("show.json", Report.extract_report_info(report))
 +    case Enum.any?(result, &Map.has_key?(&1, :error)) do
 +      true -> json_response(conn, :bad_request, result)
 +      false -> json_response(conn, :no_content, "")
      end
    end
  
index e5d399d02777e7e6574bc4280ab2e8ef6ae502bc,e5734562145769ab205f3ecd3249b291ea8caf54..4bf39baa94e6d2fddaf03b5e40c557b9184c9a59
@@@ -6,6 -6,7 +6,7 @@@ defmodule Pleroma.Web.CommonAPI d
    alias Pleroma.Activity
    alias Pleroma.ActivityExpiration
    alias Pleroma.Conversation.Participation
+   alias Pleroma.FollowingRelationship
    alias Pleroma.Object
    alias Pleroma.ThreadMute
    alias Pleroma.User
@@@ -40,6 -41,7 +41,7 @@@
      with {:ok, follower} <- User.follow(follower, followed),
           %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
           {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
+          {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept"),
           {:ok, _activity} <-
             ActivityPub.accept(%{
               to: [follower.ap_id],
@@@ -54,6 -56,7 +56,7 @@@
    def reject_follow_request(follower, followed) do
      with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
           {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"),
+          {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "reject"),
           {:ok, _activity} <-
             ActivityPub.reject(%{
               to: [follower.ap_id],
    # Updates the emojis for a user based on their profile
    def update(user) do
      emoji = emoji_from_profile(user)
-     source_data = user.info |> Map.get(:source_data, %{}) |> Map.put("tag", emoji)
+     source_data = Map.put(user.source_data, "tag", emoji)
  
      user =
-       case User.update_info(user, &User.Info.set_source_data(&1, source_data)) do
+       case User.update_source_data(user, source_data) do
          {:ok, user} -> user
          _ -> user
        end
             object: %Object{data: %{"type" => "Note"}}
           } = activity <- get_by_id_or_ap_id(id_or_ap_id),
           true <- Visibility.is_public?(activity),
-          {:ok, _user} <- User.update_info(user, &User.Info.add_pinnned_activity(&1, activity)) do
+          {:ok, _user} <- User.add_pinnned_activity(user, activity) do
        {:ok, activity}
      else
-       {:error, %{changes: %{info: %{errors: [pinned_activities: {err, _}]}}}} -> {:error, err}
+       {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err}
        _ -> {:error, dgettext("errors", "Could not pin")}
      end
    end
  
    def unpin(id_or_ap_id, user) do
      with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id),
-          {:ok, _user} <- User.update_info(user, &User.Info.remove_pinnned_activity(&1, activity)) do
+          {:ok, _user} <- User.remove_pinnned_activity(user, activity) do
        {:ok, activity}
      else
-       %{errors: [pinned_activities: {err, _}]} -> {:error, err}
+       {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err}
        _ -> {:error, dgettext("errors", "Could not unpin")}
      end
    end
      end
    end
  
 +  def update_report_state(activity_ids, state) when is_list(activity_ids) do
 +    case Utils.update_report_state(activity_ids, state) do
 +      :ok -> {:ok, activity_ids}
 +      _ -> {:error, dgettext("errors", "Could not update state")}
 +    end
 +  end
 +
    def update_report_state(activity_id, state) do
      with %Activity{} = activity <- Activity.get_by_id(activity_id) do
        Utils.update_report_state(activity, state)
    defp set_visibility(activity, _), do: {:ok, activity}
  
    def hide_reblogs(user, %{ap_id: ap_id} = _muted) do
-     if ap_id not in user.info.muted_reblogs do
-       User.update_info(user, &User.Info.add_reblog_mute(&1, ap_id))
+     if ap_id not in user.muted_reblogs do
+       User.add_reblog_mute(user, ap_id)
      end
    end
  
    def show_reblogs(user, %{ap_id: ap_id} = _muted) do
-     if ap_id in user.info.muted_reblogs do
-       User.update_info(user, &User.Info.remove_reblog_mute(&1, ap_id))
+     if ap_id in user.muted_reblogs do
+       User.remove_reblog_mute(user, ap_id)
      end
    end
  end
index b974579fb7d2468046e93ab9375658e5656ec357,f69c5c2bc610799d8725efcbb63852a83de3105c..7b9e9b1b73a3dbc50c25d7686ab286174d0742b8
@@@ -137,11 -137,14 +137,14 @@@ defmodule Pleroma.Web.Router d
      delete("/users", AdminAPIController, :user_delete)
      post("/users", AdminAPIController, :users_create)
      patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
+     patch("/users/activate", AdminAPIController, :user_activate)
+     patch("/users/deactivate", AdminAPIController, :user_deactivate)
      put("/users/tag", AdminAPIController, :tag_users)
      delete("/users/tag", AdminAPIController, :untag_users)
  
      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)
  
      delete(
        :right_delete
      )
  
-     put("/users/:nickname/activation_status", AdminAPIController, :set_activation_status)
+     post("/users/permission_group/:permission_group", AdminAPIController, :right_add_multiple)
+     delete(
+       "/users/permission_group/:permission_group",
+       AdminAPIController,
+       :right_delete_multiple
+     )
  
+     get("/relay", AdminAPIController, :relay_list)
      post("/relay", AdminAPIController, :relay_follow)
      delete("/relay", AdminAPIController, :relay_unfollow)
  
      get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
  
      get("/reports", AdminAPIController, :list_reports)
 +    get("/grouped_reports", AdminAPIController, :list_grouped_reports)
      get("/reports/:id", AdminAPIController, :report_show)
 -    put("/reports/:id", AdminAPIController, :report_update_state)
 +    patch("/reports", AdminAPIController, :reports_update)
      post("/reports/:id/respond", AdminAPIController, :report_respond)
  
      put("/statuses/:id", AdminAPIController, :status_update)
  
        get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
        get("/conversations/:id", PleromaAPIController, :conversation)
+       post("/conversations/read", PleromaAPIController, :read_conversations)
      end
  
      scope [] do
      get("/push/subscription", SubscriptionController, :get)
      put("/push/subscription", SubscriptionController, :update)
      delete("/push/subscription", SubscriptionController, :delete)
+     get("/markers", MarkerController, :index)
+     post("/markers", MarkerController, :upsert)
    end
  
    scope "/api/web", Pleroma.Web do
      get("/users/:nickname/feed", Feed.FeedController, :feed)
      get("/users/:nickname", Feed.FeedController, :feed_redirect)
  
-     post("/users/:nickname/salmon", OStatus.OStatusController, :salmon_incoming)
-     post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request)
-     get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation)
-     post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming)
      get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
    end
  
      get("/:version", Nodeinfo.NodeinfoController, :nodeinfo)
    end
  
+   scope "/", Pleroma.Web do
+     pipe_through(:api)
+     get("/web/manifest.json", MastoFEController, :manifest)
+   end
    scope "/", Pleroma.Web do
      pipe_through(:mastodon_html)
  
index daa0631db518ce3e80e2976675c23459b89fe6dc,045c87e952bef70158c0ddd5aca0c68607042f80..35367bed3469cfef76e0dee9be5019524de2a6ee
@@@ -13,13 -13,20 +13,20 @@@ defmodule Pleroma.Web.AdminAPI.AdminAPI
    alias Pleroma.Tests.ObanHelpers
    alias Pleroma.User
    alias Pleroma.UserInviteToken
+   alias Pleroma.Web.ActivityPub.Relay
    alias Pleroma.Web.CommonAPI
    alias Pleroma.Web.MediaProxy
    import Pleroma.Factory
  
-   describe "/api/pleroma/admin/users" do
-     test "Delete" do
-       admin = insert(:user, info: %{is_admin: true})
+   setup_all do
+     Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+     :ok
+   end
+   describe "DELETE /api/pleroma/admin/users" do
+     test "single user" do
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
  
        log_entry = Repo.one(ModerationLog)
  
-       assert log_entry.data["subject"]["nickname"] == user.nickname
-       assert log_entry.data["action"] == "delete"
        assert ModerationLog.get_log_entry_message(log_entry) ==
-                "@#{admin.nickname} deleted user @#{user.nickname}"
+                "@#{admin.nickname} deleted users: @#{user.nickname}"
  
        assert json_response(conn, 200) == user.nickname
      end
  
+     test "multiple users" do
+       admin = insert(:user, is_admin: true)
+       user_one = insert(:user)
+       user_two = insert(:user)
+       conn =
+         build_conn()
+         |> assign(:user, admin)
+         |> put_req_header("accept", "application/json")
+         |> delete("/api/pleroma/admin/users", %{
+           nicknames: [user_one.nickname, user_two.nickname]
+         })
+       log_entry = Repo.one(ModerationLog)
+       assert ModerationLog.get_log_entry_message(log_entry) ==
+                "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}"
+       response = json_response(conn, 200)
+       assert response -- [user_one.nickname, user_two.nickname] == []
+     end
+   end
+   describe "/api/pleroma/admin/users" do
      test "Create" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
@@@ -70,7 -98,7 +98,7 @@@
      end
  
      test "Cannot create user with exisiting email" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
      end
  
      test "Cannot create user with exisiting nickname" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
      end
  
      test "Multiple user creation works in transaction" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
  
    describe "/api/pleroma/admin/users/:nickname" do
      test "Show", %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
      end
  
      test "when the user doesn't exist", %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = build(:user)
  
        conn =
  
    describe "/api/pleroma/admin/users/follow" do
      test "allows to force-follow another user" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
        follower = insert(:user)
  
  
    describe "/api/pleroma/admin/users/unfollow" do
      test "allows to force-unfollow another user" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
        follower = insert(:user)
  
  
    describe "PUT /api/pleroma/admin/users/tag" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user1 = insert(:user, %{tags: ["x"]})
        user2 = insert(:user, %{tags: ["y"]})
        user3 = insert(:user, %{tags: ["unchanged"]})
  
    describe "DELETE /api/pleroma/admin/users/tag" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user1 = insert(:user, %{tags: ["x"]})
        user2 = insert(:user, %{tags: ["y", "z"]})
        user3 = insert(:user, %{tags: ["unchanged"]})
  
    describe "/api/pleroma/admin/users/:nickname/permission_group" do
      test "GET is giving user_info" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
      end
  
      test "/:right POST, can add to a permission group" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        conn =
                 "@#{admin.nickname} made @#{user.nickname} admin"
      end
  
-     test "/:right DELETE, can remove from a permission group" do
-       admin = insert(:user, info: %{is_admin: true})
-       user = insert(:user, info: %{is_admin: true})
+     test "/:right POST, can add to a permission group (multiple)" do
+       admin = insert(:user, is_admin: true)
+       user_one = insert(:user)
+       user_two = insert(:user)
  
        conn =
          build_conn()
          |> assign(:user, admin)
          |> put_req_header("accept", "application/json")
-         |> delete("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin")
+         |> post("/api/pleroma/admin/users/permission_group/admin", %{
+           nicknames: [user_one.nickname, user_two.nickname]
+         })
  
        assert json_response(conn, 200) == %{
-                "is_admin" => false
+                "is_admin" => true
               }
  
        log_entry = Repo.one(ModerationLog)
  
        assert ModerationLog.get_log_entry_message(log_entry) ==
-                "@#{admin.nickname} revoked admin role from @#{user.nickname}"
+                "@#{admin.nickname} made @#{user_one.nickname}, @#{user_two.nickname} admin"
      end
-   end
  
-   describe "PUT /api/pleroma/admin/users/:nickname/activation_status" do
-     setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+     test "/:right DELETE, can remove from a permission group" do
+       admin = insert(:user, is_admin: true)
+       user = insert(:user, is_admin: true)
  
        conn =
-         conn
+         build_conn()
          |> assign(:user, admin)
          |> put_req_header("accept", "application/json")
+         |> delete("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin")
  
-       %{conn: conn, admin: admin}
-     end
-     test "deactivates the user", %{conn: conn, admin: admin} do
-       user = insert(:user)
-       conn =
-         conn
-         |> put("/api/pleroma/admin/users/#{user.nickname}/activation_status", %{status: false})
-       user = User.get_cached_by_id(user.id)
-       assert user.info.deactivated == true
-       assert json_response(conn, :no_content)
+       assert json_response(conn, 200) == %{
+                "is_admin" => false
+              }
  
        log_entry = Repo.one(ModerationLog)
  
        assert ModerationLog.get_log_entry_message(log_entry) ==
-                "@#{admin.nickname} deactivated user @#{user.nickname}"
+                "@#{admin.nickname} revoked admin role from @#{user.nickname}"
      end
  
-     test "activates the user", %{conn: conn, admin: admin} do
-       user = insert(:user, info: %{deactivated: true})
+     test "/:right DELETE, can remove from a permission group (multiple)" do
+       admin = insert(:user, is_admin: true)
+       user_one = insert(:user, is_admin: true)
+       user_two = insert(:user, is_admin: true)
  
        conn =
-         conn
-         |> put("/api/pleroma/admin/users/#{user.nickname}/activation_status", %{status: true})
+         build_conn()
+         |> assign(:user, admin)
+         |> put_req_header("accept", "application/json")
+         |> delete("/api/pleroma/admin/users/permission_group/admin", %{
+           nicknames: [user_one.nickname, user_two.nickname]
+         })
  
-       user = User.get_cached_by_id(user.id)
-       assert user.info.deactivated == false
-       assert json_response(conn, :no_content)
+       assert json_response(conn, 200) == %{
+                "is_admin" => false
+              }
  
        log_entry = Repo.one(ModerationLog)
  
        assert ModerationLog.get_log_entry_message(log_entry) ==
-                "@#{admin.nickname} activated user @#{user.nickname}"
-     end
-     test "returns 403 when requested by a non-admin", %{conn: conn} do
-       user = insert(:user)
-       conn =
-         conn
-         |> assign(:user, user)
-         |> put("/api/pleroma/admin/users/#{user.nickname}/activation_status", %{status: false})
-       assert json_response(conn, :forbidden)
+                "@#{admin.nickname} revoked admin role from @#{user_one.nickname}, @#{
+                  user_two.nickname
+                }"
      end
    end
  
    describe "POST /api/pleroma/admin/email_invite, with valid config" do
      setup do
-       [user: insert(:user, info: %{is_admin: true})]
+       [user: insert(:user, is_admin: true)]
      end
  
      clear_config([:instance, :registrations_open]) do
  
    describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do
      setup do
-       [user: insert(:user, info: %{is_admin: true})]
+       [user: insert(:user, is_admin: true)]
      end
  
      clear_config([:instance, :registrations_open])
    end
  
    test "/api/pleroma/admin/users/:nickname/password_reset" do
-     admin = insert(:user, info: %{is_admin: true})
+     admin = insert(:user, is_admin: true)
      user = insert(:user)
  
      conn =
  
    describe "GET /api/pleroma/admin/users" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
        users =
          [
            %{
-             "deactivated" => admin.info.deactivated,
+             "deactivated" => admin.deactivated,
              "id" => admin.id,
              "nickname" => admin.nickname,
              "roles" => %{"admin" => true, "moderator" => false},
              "display_name" => HTML.strip_tags(admin.name || admin.nickname)
            },
            %{
-             "deactivated" => user.info.deactivated,
+             "deactivated" => user.deactivated,
              "id" => user.id,
              "nickname" => user.nickname,
              "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 1,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 "page_size" => 1,
                 "users" => [
                   %{
-                    "deactivated" => user2.info.deactivated,
+                    "deactivated" => user2.deactivated,
                     "id" => user2.id,
                     "nickname" => user2.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
      end
  
      test "only local users" do
-       admin = insert(:user, info: %{is_admin: true}, nickname: "john")
+       admin = insert(:user, is_admin: true, nickname: "john")
        user = insert(:user, nickname: "bob")
  
        insert(:user, nickname: "bobb", local: false)
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
      end
  
      test "only local users with no query", %{admin: old_admin} do
-       admin = insert(:user, info: %{is_admin: true}, nickname: "john")
+       admin = insert(:user, is_admin: true, nickname: "john")
        user = insert(:user, nickname: "bob")
  
        insert(:user, nickname: "bobb", local: false)
        users =
          [
            %{
-             "deactivated" => user.info.deactivated,
+             "deactivated" => user.deactivated,
              "id" => user.id,
              "nickname" => user.nickname,
              "roles" => %{"admin" => false, "moderator" => false},
              "display_name" => HTML.strip_tags(user.name || user.nickname)
            },
            %{
-             "deactivated" => admin.info.deactivated,
+             "deactivated" => admin.deactivated,
              "id" => admin.id,
              "nickname" => admin.nickname,
              "roles" => %{"admin" => true, "moderator" => false},
      end
  
      test "load only admins", %{conn: conn, admin: admin} do
-       second_admin = insert(:user, info: %{is_admin: true})
+       second_admin = insert(:user, is_admin: true)
        insert(:user)
        insert(:user)
  
      end
  
      test "load only moderators", %{conn: conn} do
-       moderator = insert(:user, info: %{is_moderator: true})
+       moderator = insert(:user, is_moderator: true)
        insert(:user)
        insert(:user)
  
      end
  
      test "it works with multiple filters" do
-       admin = insert(:user, nickname: "john", info: %{is_admin: true})
-       user = insert(:user, nickname: "bob", local: false, info: %{deactivated: true})
+       admin = insert(:user, nickname: "john", is_admin: true)
+       user = insert(:user, nickname: "bob", local: false, deactivated: true)
  
-       insert(:user, nickname: "ken", local: true, info: %{deactivated: true})
-       insert(:user, nickname: "bobb", local: false, info: %{deactivated: false})
+       insert(:user, nickname: "ken", local: true, deactivated: true)
+       insert(:user, nickname: "bobb", local: false, deactivated: false)
  
        conn =
          build_conn()
                 "page_size" => 50,
                 "users" => [
                   %{
-                    "deactivated" => user.info.deactivated,
+                    "deactivated" => user.deactivated,
                     "id" => user.id,
                     "nickname" => user.nickname,
                     "roles" => %{"admin" => false, "moderator" => false},
                 ]
               }
      end
+     test "it omits relay user", %{admin: admin} do
+       assert %User{} = Relay.get_actor()
+       conn =
+         build_conn()
+         |> assign(:user, admin)
+         |> get("/api/pleroma/admin/users")
+       assert json_response(conn, 200) == %{
+                "count" => 1,
+                "page_size" => 50,
+                "users" => [
+                  %{
+                    "deactivated" => admin.deactivated,
+                    "id" => admin.id,
+                    "nickname" => admin.nickname,
+                    "roles" => %{"admin" => true, "moderator" => false},
+                    "local" => true,
+                    "tags" => [],
+                    "avatar" => User.avatar_url(admin) |> MediaProxy.url(),
+                    "display_name" => HTML.strip_tags(admin.name || admin.nickname)
+                  }
+                ]
+              }
+     end
+   end
+   test "PATCH /api/pleroma/admin/users/activate" do
+     admin = insert(:user, is_admin: true)
+     user_one = insert(:user, deactivated: true)
+     user_two = insert(:user, deactivated: true)
+     conn =
+       build_conn()
+       |> assign(:user, admin)
+       |> patch(
+         "/api/pleroma/admin/users/activate",
+         %{nicknames: [user_one.nickname, user_two.nickname]}
+       )
+     response = json_response(conn, 200)
+     assert Enum.map(response["users"], & &1["deactivated"]) == [false, false]
+     log_entry = Repo.one(ModerationLog)
+     assert ModerationLog.get_log_entry_message(log_entry) ==
+              "@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}"
+   end
+   test "PATCH /api/pleroma/admin/users/deactivate" do
+     admin = insert(:user, is_admin: true)
+     user_one = insert(:user, deactivated: false)
+     user_two = insert(:user, deactivated: false)
+     conn =
+       build_conn()
+       |> assign(:user, admin)
+       |> patch(
+         "/api/pleroma/admin/users/deactivate",
+         %{nicknames: [user_one.nickname, user_two.nickname]}
+       )
+     response = json_response(conn, 200)
+     assert Enum.map(response["users"], & &1["deactivated"]) == [true, true]
+     log_entry = Repo.one(ModerationLog)
+     assert ModerationLog.get_log_entry_message(log_entry) ==
+              "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}"
    end
  
    test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation" do
-     admin = insert(:user, info: %{is_admin: true})
+     admin = insert(:user, is_admin: true)
      user = insert(:user)
  
      conn =
  
      assert json_response(conn, 200) ==
               %{
-                "deactivated" => !user.info.deactivated,
+                "deactivated" => !user.deactivated,
                 "id" => user.id,
                 "nickname" => user.nickname,
                 "roles" => %{"admin" => false, "moderator" => false},
      log_entry = Repo.one(ModerationLog)
  
      assert ModerationLog.get_log_entry_message(log_entry) ==
-              "@#{admin.nickname} deactivated user @#{user.nickname}"
+              "@#{admin.nickname} deactivated users: @#{user.nickname}"
    end
  
    describe "POST /api/pleroma/admin/users/invite_token" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
  
    describe "GET /api/pleroma/admin/users/invites" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
  
    describe "POST /api/pleroma/admin/users/revoke_invite" do
      test "with token" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        {:ok, invite} = UserInviteToken.create_invite()
  
        conn =
      end
  
      test "with invalid token" do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        conn =
          build_conn()
  
    describe "GET /api/pleroma/admin/reports/:id" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        %{conn: assign(conn, :user, admin)}
      end
      end
    end
  
 -  describe "PUT /api/pleroma/admin/reports/:id" do
 +  describe "PATCH /api/pleroma/admin/reports" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        [reporter, target_user] = insert_pair(:user)
        activity = insert(:note_activity, user: target_user)
  
            "status_ids" => [activity.id]
          })
  
 -      %{conn: assign(conn, :user, admin), id: report_id, admin: admin}
 +      {:ok, %{id: second_report_id}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "comment" => "I feel very offended",
 +          "status_ids" => [activity.id]
 +        })
 +
 +      %{
 +        conn: assign(conn, :user, admin),
 +        id: report_id,
 +        admin: admin,
 +        second_report_id: second_report_id
 +      }
      end
  
      test "mark report as resolved", %{conn: conn, id: id, admin: admin} do
 -      response =
 -        conn
 -        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "resolved"})
 -        |> json_response(:ok)
 +      conn
 +      |> patch("/api/pleroma/admin/reports", %{
 +        "reports" => [
 +          %{"state" => "resolved", "id" => id}
 +        ]
 +      })
 +      |> json_response(:no_content)
  
 -      assert response["state"] == "resolved"
 +      activity = Activity.get_by_id(id)
 +      assert activity.data["state"] == "resolved"
  
        log_entry = Repo.one(ModerationLog)
  
      end
  
      test "closes report", %{conn: conn, id: id, admin: admin} do
 -      response =
 -        conn
 -        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "closed"})
 -        |> json_response(:ok)
 +      conn
 +      |> patch("/api/pleroma/admin/reports", %{
 +        "reports" => [
 +          %{"state" => "closed", "id" => id}
 +        ]
 +      })
 +      |> json_response(:no_content)
  
 -      assert response["state"] == "closed"
 +      activity = Activity.get_by_id(id)
 +      assert activity.data["state"] == "closed"
  
        log_entry = Repo.one(ModerationLog)
  
      test "returns 400 when state is unknown", %{conn: conn, id: id} do
        conn =
          conn
 -        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "test"})
 +        |> patch("/api/pleroma/admin/reports", %{
 +          "reports" => [
 +            %{"state" => "test", "id" => id}
 +          ]
 +        })
  
 -      assert json_response(conn, :bad_request) == "Unsupported state"
 +      assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state"
      end
  
      test "returns 404 when report is not exist", %{conn: conn} do
        conn =
          conn
 -        |> put("/api/pleroma/admin/reports/test", %{"state" => "closed"})
 +        |> patch("/api/pleroma/admin/reports", %{
 +          "reports" => [
 +            %{"state" => "closed", "id" => "test"}
 +          ]
 +        })
  
 -      assert json_response(conn, :not_found) == "Not found"
 +      assert hd(json_response(conn, :bad_request))["error"] == "not_found"
 +    end
 +
 +    test "updates state of multiple reports", %{
 +      conn: conn,
 +      id: id,
 +      admin: admin,
 +      second_report_id: second_report_id
 +    } do
 +      conn
 +      |> patch("/api/pleroma/admin/reports", %{
 +        "reports" => [
 +          %{"state" => "resolved", "id" => id},
 +          %{"state" => "closed", "id" => second_report_id}
 +        ]
 +      })
 +      |> json_response(:no_content)
 +
 +      activity = Activity.get_by_id(id)
 +      second_activity = Activity.get_by_id(second_report_id)
 +      assert activity.data["state"] == "resolved"
 +      assert second_activity.data["state"] == "closed"
 +
 +      [first_log_entry, second_log_entry] = Repo.all(ModerationLog)
 +
 +      assert ModerationLog.get_log_entry_message(first_log_entry) ==
 +               "@#{admin.nickname} updated report ##{id} with 'resolved' state"
 +
 +      assert ModerationLog.get_log_entry_message(second_log_entry) ==
 +               "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state"
      end
    end
  
    describe "GET /api/pleroma/admin/reports" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        %{conn: assign(conn, :user, admin)}
      end
      end
    end
  
 -  #
 +  describe "GET /api/pleroma/admin/grouped_reports" do
 +    setup %{conn: conn} do
 +      admin = insert(:user, info: %{is_admin: true})
 +      [reporter, target_user] = insert_pair(:user)
 +
 +      date1 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!()
 +      date2 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!()
 +      date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!()
 +
 +      first_status =
 +        insert(:note_activity, user: target_user, data_attrs: %{"published" => date1})
 +
 +      second_status =
 +        insert(:note_activity, user: target_user, data_attrs: %{"published" => date2})
 +
 +      third_status =
 +        insert(:note_activity, user: target_user, data_attrs: %{"published" => date3})
 +
 +      %{
 +        conn: assign(conn, :user, admin),
 +        reporter: reporter,
 +        target_user: target_user,
 +        first_status: first_status,
 +        second_status: second_status,
 +        third_status: third_status
 +      }
 +    end
 +
 +    test "returns reports grouped by status", %{
 +      conn: conn,
 +      reporter: reporter,
 +      target_user: target_user,
 +      first_status: first_status,
 +      second_status: second_status,
 +      third_status: third_status
 +    } do
 +      {:ok, %{id: _}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "status_ids" => [first_status.id, second_status.id, third_status.id]
 +        })
 +
 +      {:ok, %{id: _}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "status_ids" => [first_status.id, second_status.id]
 +        })
 +
 +      {:ok, %{id: _}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "status_ids" => [first_status.id]
 +        })
 +
 +      response =
 +        conn
 +        |> get("/api/pleroma/admin/grouped_reports")
 +        |> json_response(:ok)
 +
 +      assert length(response["reports"]) == 3
 +      [third_group, second_group, first_group] = response["reports"]
 +
 +      assert length(third_group["reports"]) == 3
 +      assert length(second_group["reports"]) == 2
 +      assert length(first_group["reports"]) == 1
 +    end
 +  end
 +
    describe "POST /api/pleroma/admin/reports/:id/respond" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        %{conn: assign(conn, :user, admin), admin: admin}
      end
  
    describe "PUT /api/pleroma/admin/statuses/:id" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        activity = insert(:note_activity)
  
        %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
  
    describe "DELETE /api/pleroma/admin/statuses/:id" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        activity = insert(:note_activity)
  
        %{conn: assign(conn, :user, admin), id: activity.id, admin: admin}
  
    describe "GET /api/pleroma/admin/config" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        %{conn: assign(conn, :user, admin)}
      end
  
    describe "POST /api/pleroma/admin/config" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        temp_file = "config/test.exported_from_db.secret.exs"
  
  
    describe "config mix tasks run" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
  
        temp_file = "config/test.exported_from_db.secret.exs"
  
  
    describe "GET /api/pleroma/admin/users/:nickname/statuses" do
      setup do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        date1 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!()
  
    describe "GET /api/pleroma/admin/moderation_log" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
-       moderator = insert(:user, info: %{is_moderator: true})
+       admin = insert(:user, is_admin: true)
+       moderator = insert(:user, is_moderator: true)
  
        %{conn: assign(conn, :user, admin), admin: admin, moderator: moderator}
      end
  
    describe "PATCH /users/:nickname/force_password_reset" do
      setup %{conn: conn} do
-       admin = insert(:user, info: %{is_admin: true})
+       admin = insert(:user, is_admin: true)
        user = insert(:user)
  
        %{conn: assign(conn, :user, admin), admin: admin, user: user}
      end
  
      test "sets password_reset_pending to true", %{admin: admin, user: user} do
-       assert user.info.password_reset_pending == false
+       assert user.password_reset_pending == false
  
        conn =
          build_conn()
  
        ObanHelpers.perform_all()
  
-       assert User.get_by_id(user.id).info.password_reset_pending == true
+       assert User.get_by_id(user.id).password_reset_pending == true
+     end
+   end
+   describe "relays" do
+     setup %{conn: conn} do
+       admin = insert(:user, is_admin: true)
+       %{conn: assign(conn, :user, admin), admin: admin}
+     end
+     test "POST /relay", %{admin: admin} do
+       conn =
+         build_conn()
+         |> assign(:user, admin)
+         |> post("/api/pleroma/admin/relay", %{
+           relay_url: "http://mastodon.example.org/users/admin"
+         })
+       assert json_response(conn, 200) == "http://mastodon.example.org/users/admin"
+       log_entry = Repo.one(ModerationLog)
+       assert ModerationLog.get_log_entry_message(log_entry) ==
+                "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin"
+     end
+     test "GET /relay", %{admin: admin} do
+       relay_user = Pleroma.Web.ActivityPub.Relay.get_actor()
+       ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"]
+       |> Enum.each(fn ap_id ->
+         {:ok, user} = User.get_or_fetch_by_ap_id(ap_id)
+         User.follow(relay_user, user)
+       end)
+       conn =
+         build_conn()
+         |> assign(:user, admin)
+         |> get("/api/pleroma/admin/relay")
+       assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == []
+     end
+     test "DELETE /relay", %{admin: admin} do
+       build_conn()
+       |> assign(:user, admin)
+       |> post("/api/pleroma/admin/relay", %{
+         relay_url: "http://mastodon.example.org/users/admin"
+       })
+       conn =
+         build_conn()
+         |> assign(:user, admin)
+         |> delete("/api/pleroma/admin/relay", %{
+           relay_url: "http://mastodon.example.org/users/admin"
+         })
+       assert json_response(conn, 200) == "http://mastodon.example.org/users/admin"
+       [log_entry_one, log_entry_two] = Repo.all(ModerationLog)
+       assert ModerationLog.get_log_entry_message(log_entry_one) ==
+                "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin"
+       assert ModerationLog.get_log_entry_message(log_entry_two) ==
+                "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin"
      end
    end
  end
index 2b34f1d1e906061f6562e3706b9fd63e87a2614a,8e6fbd7f09c65f2795eb9dac90cb2b95996341b2..89990483959331edd99e5be346ad65580281547a
@@@ -10,6 -10,7 +10,7 @@@ defmodule Pleroma.Web.CommonAPITest d
    alias Pleroma.User
    alias Pleroma.Web.ActivityPub.ActivityPub
    alias Pleroma.Web.ActivityPub.Visibility
+   alias Pleroma.Web.AdminAPI.AccountView
    alias Pleroma.Web.CommonAPI
  
    import Pleroma.Factory
  
      {:ok, activity} = CommonAPI.update(user)
      user = User.get_cached_by_ap_id(user.ap_id)
-     [firefox] = user.info.source_data["tag"]
+     [firefox] = user.source_data["tag"]
  
      assert firefox["name"] == ":firefox:"
  
  
        object = Object.normalize(activity)
  
-       assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')"
+       assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
      end
  
      test "it filters out obviously bad tags when accepting a post as Markdown" do
  
        object = Object.normalize(activity)
  
-       assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')"
+       assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
      end
  
      test "it does not allow replies to direct messages that are not direct messages themselves" do
        id = activity.id
        user = refresh_record(user)
  
-       assert %User{info: %{pinned_activities: [^id]}} = user
+       assert %User{pinned_activities: [^id]} = user
      end
  
      test "unlisted statuses can be pinned", %{user: user} do
  
        user = refresh_record(user)
  
-       assert %User{info: %{pinned_activities: []}} = user
+       assert %User{pinned_activities: []} = user
      end
  
      test "should unpin when deleting a status", %{user: user, activity: activity} do
  
        user = refresh_record(user)
  
-       assert %User{info: %{pinned_activities: []}} = user
+       assert %User{pinned_activities: []} = user
      end
    end
  
          "status_ids" => [activity.id]
        }
  
+       note_obj = %{
+         "type" => "Note",
+         "id" => activity_ap_id,
+         "content" => "foobar",
+         "published" => activity.object.data["published"],
+         "actor" => AccountView.render("show.json", %{user: target_user})
+       }
        assert {:ok, flag_activity} = CommonAPI.report(reporter, report_data)
  
        assert %Activity{
                 data: %{
                   "type" => "Flag",
                   "content" => ^comment,
-                  "object" => [^target_ap_id, ^activity_ap_id],
+                  "object" => [^target_ap_id, ^note_obj],
                   "state" => "open"
                 }
               } = flag_activity
        {:ok, report} = CommonAPI.update_report_state(report_id, "resolved")
  
        assert report.data["state"] == "resolved"
+       [reported_user, activity_id] = report.data["object"]
+       assert reported_user == target_user.ap_id
+       assert activity_id == activity.data["id"]
      end
  
      test "does not update report state when state is unsupported" do
  
        assert CommonAPI.update_report_state(report_id, "test") == {:error, "Unsupported state"}
      end
 +
 +    test "updates state of multiple reports" do
 +      [reporter, target_user] = insert_pair(:user)
 +      activity = insert(:note_activity, user: target_user)
 +
 +      {:ok, %Activity{id: first_report_id}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "comment" => "I feel offended",
 +          "status_ids" => [activity.id]
 +        })
 +
 +      {:ok, %Activity{id: second_report_id}} =
 +        CommonAPI.report(reporter, %{
 +          "account_id" => target_user.id,
 +          "comment" => "I feel very offended!",
 +          "status_ids" => [activity.id]
 +        })
 +
 +      {:ok, report_ids} =
 +        CommonAPI.update_report_state([first_report_id, second_report_id], "resolved")
 +
 +      first_report = Activity.get_by_id(first_report_id)
 +      second_report = Activity.get_by_id(second_report_id)
 +
 +      assert report_ids -- [first_report_id, second_report_id] == []
 +      assert first_report.data["state"] == "resolved"
 +      assert second_report.data["state"] == "resolved"
 +    end
    end
  
    describe "reblog muting" do
  
    describe "accept_follow_request/2" do
      test "after acceptance, it sets all existing pending follow request states to 'accept'" do
-       user = insert(:user, info: %{locked: true})
+       user = insert(:user, locked: true)
        follower = insert(:user)
        follower_two = insert(:user)
  
      end
  
      test "after rejection, it sets all existing pending follow request states to 'reject'" do
-       user = insert(:user, info: %{locked: true})
+       user = insert(:user, locked: true)
        follower = insert(:user)
        follower_two = insert(:user)