From ef9930ed8050a309f2d95df8f0504de2b1da4677 Mon Sep 17 00:00:00 2001 From: ultem Date: Sat, 24 Aug 2019 10:16:27 +0000 Subject: [PATCH 001/138] Minor corrections and clarification for Alpine standard v.3.10 --- docs/installation/alpine_linux_en.md | 33 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 1f300f353..c77618936 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 v.3.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 From 880307e0d52444326eee8e79b2f66af706d85b4a Mon Sep 17 00:00:00 2001 From: ultem Date: Fri, 30 Aug 2019 19:41:31 +0000 Subject: [PATCH 002/138] minor: Fix version dot --- docs/installation/alpine_linux_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index c77618936..f200362ca 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -1,7 +1,7 @@ # Installing on Alpine Linux ## Installation -This guide is a step-by-step installation guide for Alpine Linux. The instructions were verified against Alpine v.3.10 standard image. You might miss additional dependencies if you use `netboot` 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. From 7808eee9aa4a02c289173a45e0b02def3bf51773 Mon Sep 17 00:00:00 2001 From: AkiraFukushima Date: Sat, 31 Aug 2019 16:23:15 +0900 Subject: [PATCH 003/138] Update Japanese document to follow English document --- docs/installation/debian_based_jp.md | 141 +++++++++++++-------------- 1 file changed, 70 insertions(+), 71 deletions(-) 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) From ab2f21e470f349f783f895f26da3041afcc3d73e Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 6 Sep 2019 21:50:00 +0300 Subject: [PATCH 004/138] tests for mastodon_api_controller.ex --- lib/pleroma/object.ex | 7 + lib/pleroma/user.ex | 22 +- .../controllers/mastodon_api_controller.ex | 143 +++---- lib/pleroma/web/oauth/app.ex | 26 ++ lib/pleroma/web/twitter_api/twitter_api.ex | 2 +- .../mastodon_api_controller_test.exs | 370 +++++++++++++++--- test/web/oauth/app_test.exs | 33 ++ 7 files changed, 438 insertions(+), 165 deletions(-) create mode 100644 test/web/oauth/app_test.exs diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index d58eb7f7d..4398b9739 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -228,4 +228,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/user.ex b/lib/pleroma/user.ex index 3aa245f2a..d9db985a6 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -499,6 +499,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 @@ -770,6 +775,19 @@ def update_note_count(%User{} = user) do |> update_and_set_cache() end + def update_mascot(user, url) do + info_changeset = + User.Info.mascot_update( + user.info, + url + ) + + user + |> change() + |> put_embed(:info, info_changeset) + |> update_and_set_cache() + end + @spec maybe_fetch_follow_information(User.t()) :: User.t() def maybe_fetch_follow_information(user) do with {:ok, user} <- fetch_follow_information(user) do @@ -917,9 +935,7 @@ def subscribe(subscriber, %{ap_id: ap_id}) do 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) + info_cng = User.Info.remove_from_subscribers(user.info, unsubscriber.ap_id) change(user) |> put_embed(:info, info_cng) 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 8dfad7a54..e4e0a7ac9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -447,8 +447,7 @@ def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do result = %{ ancestors: - StatusView.render( - "index.json", + StatusView.render("index.json", for: user, activities: grouped_activities[true] || [], as: :activity @@ -456,8 +455,7 @@ def get_context(%{assigns: %{user: user}} = conn, %{"id" => id}) do |> Enum.reverse(), # credo:disable-for-previous-line Credo.Check.Refactor.PipeChainStart descendants: - StatusView.render( - "index.json", + StatusView.render("index.json", for: user, activities: grouped_activities[false] || [], as: :activity @@ -746,9 +744,7 @@ def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params 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) + targets = User.get_all_by_ids(List.wrap(id)) conn |> put_view(AccountView) @@ -758,19 +754,15 @@ def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do # 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"]), + def update_media( + %{assigns: %{user: user}} = conn, + %{"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) @@ -778,6 +770,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( @@ -796,34 +790,23 @@ def upload(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do 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 + %{type: "image"} = rendered <- + StatusView.render("attachment.json", %{attachment: attachment_data}), + {:ok, _user} = User.update_mascot(user, rendered) do + json(conn, rendered) + else + %{type: _type} = _ -> render_error(conn, :unsupported_media_type, "mascots can only be images") - end + + e -> + e end end def get_mascot(%{assigns: %{user: user}} = conn, _params) do mascot = User.get_mascot(user) - conn - |> json(mascot) + json(conn, mascot) end def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do @@ -1119,10 +1102,8 @@ def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do |> put_view(AccountView) |> render("relationship.json", %{user: user, target: subscription_target}) else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + nil -> {:error, :not_found} + e -> e end end @@ -1133,10 +1114,8 @@ def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do |> put_view(AccountView) |> render("relationship.json", %{user: user, target: subscription_target}) else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + nil -> {:error, :not_found} + e -> e end end @@ -1207,8 +1186,10 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do 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) + + conn + |> put_view(ListView) + |> render("index.json", %{lists: lists}) end def list_timeline(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do @@ -1363,7 +1344,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) @@ -1375,9 +1356,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: ".", @@ -1399,31 +1378,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 @@ -1432,26 +1392,13 @@ 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, []) end - def empty_object(conn, _) do - Logger.debug("Unimplemented, returning an empty object") - json(conn, %{}) - end - def get_filters(%{assigns: %{user: user}} = conn, _) do filters = Filter.get_filters(user) res = FilterView.render("filters.json", filters: filters) @@ -1570,7 +1517,7 @@ def status_card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do json(conn, data) else _e -> - %{} + json(conn, %{}) end end @@ -1623,7 +1570,7 @@ def account_register( end end - def account_register(%{assigns: %{app: _app}} = conn, _params) do + def account_register(%{assigns: %{app: _app}} = conn, _) do render_error(conn, :bad_request, "Missing parameters") end @@ -1682,15 +1629,15 @@ def account_confirmation_resend(conn, params) do end end - def try_render(conn, target, params) - when is_binary(target) do + defp try_render(conn, target, params) + when is_binary(target) do case render(conn, target, params) do nil -> render_error(conn, :not_implemented, "Can't display this activity") res -> res end end - def try_render(conn, _, _) do + defp try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") 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/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/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index e18f8f0d1..a331d6455 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1551,6 +1551,17 @@ test "returns the relationships for the current user", %{conn: conn} do assert to_string(other_user.id) == relationship["id"] end + + test "returns an empty list when 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 describe "media upload" do @@ -1752,70 +1763,72 @@ test "respects limit_to_local_content == :unauthenticated for remote user nickna end end - test "mascot upload", %{conn: conn} do - user = insert(:user) + describe "/api/v1/pleroma/mascot" do + 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" - } + 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}) + conn = + conn + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => non_image_file}) - assert json_response(conn, 415) + assert json_response(conn, 415) - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } + 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}) + conn = + build_conn() + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => file}) - assert %{"id" => _, "type" => image} = json_response(conn, 200) - end + 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") + 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" + 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" - } + # 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}) + conn = + build_conn() + |> assign(:user, user) + |> put("/api/v1/pleroma/mascot", %{"file" => file}) - assert json_response(conn, 200) + assert json_response(conn, 200) - user = User.get_cached_by_id(user.id) + user = User.get_cached_by_id(user.id) - conn = - build_conn() - |> assign(:user, user) - |> get("/api/v1/pleroma/mascot") + conn = + build_conn() + |> assign(:user, user) + |> get("/api/v1/pleroma/mascot") - assert %{"url" => url, "type" => "image"} = json_response(conn, 200) - assert url =~ "an_image" + assert %{"url" => url, "type" => "image"} = json_response(conn, 200) + assert url =~ "an_image" + end end test "hashtag timeline", %{conn: conn} do @@ -2183,23 +2196,51 @@ test "without notifications", %{conn: conn} do end end - test "subscribing / unsubscribing to a user", %{conn: conn} do - user = insert(:user) - subscription_target = insert(:user) + 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") + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe") - assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200) + assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200) - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") - assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200) + 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 test "getting a list of mutes", %{conn: conn} do @@ -2814,6 +2855,15 @@ test "replaces missing description with an empty string", %{conn: conn, user: us } } end + + test "returns empty object when id invalid", %{conn: conn} do + response = + conn + |> get("/api/v1/statuses/9eoozpwTul5mjSEDRI/card") + |> json_response(200) + + assert response == %{} + end end test "bookmarks" do @@ -3133,6 +3183,18 @@ test "redirects to the saved path after log in", %{conn: conn, path: path} do assert conn.status == 302 assert redirected_to(conn) == path end + end + + describe "GET /web/login" do + test "redirects to /oauth/authorize", %{conn: conn} do + app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") + conn = get(conn, "/web/login", %{}) + + assert conn.status == 302 + + assert redirected_to(conn) == + "/oauth/authorize?response_type=code&client_id=#{app.client_id}&redirect_uri=.&scope=read+write+follow+push" + end test "redirects to the getting-started page when referer is not present", %{conn: conn} do app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") @@ -3143,6 +3205,18 @@ test "redirects to the getting-started page when referer is not present", %{conn assert conn.status == 302 assert redirected_to(conn) == "/web/getting-started" end + + test "redirects to the getting-started page when user assigned", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> get("/web/login", %{}) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/getting-started" + end end describe "scheduled activities" do @@ -3401,6 +3475,17 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c 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 @@ -3444,6 +3529,7 @@ test "Account registration via Application", %{conn: conn} do username: "lain", email: "lain@example.org", password: "PlzDontHackLain", + bio: "Test Bio", agreement: true }) @@ -3462,6 +3548,18 @@ test "Account registration via Application", %{conn: conn} do 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) @@ -3505,6 +3603,41 @@ test "rate limit", %{conn: conn} do 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) + + Enum.each(valid_params, fn {attr, _} -> + res = + conn + |> Map.put( + :remote_ip, + {:rand.uniform(15), :rand.uniform(15), :rand.uniform(15), :rand.uniform(15)} + ) + |> post("/api/v1/accounts", Map.delete(valid_params, attr)) + + assert json_response(res, 400) == %{"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/polls/:id" do @@ -3988,4 +4121,115 @@ 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 "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 "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/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 From 58b17196fa3f2583db5ee0534766350ed25727e0 Mon Sep 17 00:00:00 2001 From: Maksim Date: Fri, 13 Sep 2019 03:58:58 +0000 Subject: [PATCH 005/138] Apply suggestion to test/web/mastodon_api/mastodon_api_controller_test.exs --- test/web/mastodon_api/mastodon_api_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index a331d6455..7b337044c 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1552,7 +1552,7 @@ test "returns the relationships for the current user", %{conn: conn} do assert to_string(other_user.id) == relationship["id"] end - test "returns an empty list when bad request", %{conn: conn} do + test "returns an empty list on a bad request", %{conn: conn} do user = insert(:user) conn = From d8a178274bd1eb642270e52f207849014cba12bc Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 13 Sep 2019 07:12:34 +0300 Subject: [PATCH 006/138] fix Activity.get_by_id --- lib/pleroma/activity.ex | 15 +++++++++++---- .../mastodon_api/mastodon_api_controller_test.exs | 9 +++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 2d4e9da0c..56c51aef8 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -150,11 +150,18 @@ def get_by_ap_id_with_object(ap_id) do ) 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 Pleroma.FlakeId.is_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/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 7b337044c..35c2236c8 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -2864,6 +2864,15 @@ test "returns empty object when id invalid", %{conn: conn} do assert response == %{} end + + test "returns empty object when id isn't FlakeID", %{conn: conn} do + response = + conn + |> get("/api/v1/statuses/3ebbadd1-eb14-4e20-8118/card") + |> json_response(200) + + assert response == %{} + end end test "bookmarks" do From ec5aaf5bd72c91db93a9dbfbe73b58cf7ae5e566 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 13 Sep 2019 14:59:58 +0300 Subject: [PATCH 007/138] fix tests --- .../mastodon_api/mastodon_api_controller_test.exs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 35c2236c8..f899d77d9 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -3626,16 +3626,16 @@ test "returns bad_request if missing required params", %{ res = post(conn, "/api/v1/accounts", valid_params) assert json_response(res, 200) - Enum.each(valid_params, fn {attr, _} -> + [{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, - {:rand.uniform(15), :rand.uniform(15), :rand.uniform(15), :rand.uniform(15)} - ) + |> Map.put(:remote_ip, ip) |> post("/api/v1/accounts", Map.delete(valid_params, attr)) + |> json_response(400) - assert json_response(res, 400) == %{"error" => "Missing parameters"} + assert res == %{"error" => "Missing parameters"} end) end From bc3e8c033bbef303890ff6afa92d1fe365e530fb Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 13 Sep 2019 15:06:34 +0300 Subject: [PATCH 008/138] fix formatting --- test/web/mastodon_api/mastodon_api_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index f899d77d9..58efbba38 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -3626,7 +3626,7 @@ test "returns bad_request if missing required params", %{ 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}] + [{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 = From 0bd2b85edbf3b7062570778649cf2b77cc7a0bce Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 13 Sep 2019 18:25:27 +0300 Subject: [PATCH 009/138] Separate Subscription Notifications from regular Notifications --- lib/pleroma/notification.ex | 1 - lib/pleroma/subscription_notification.ex | 266 ++++++++++++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 2 + .../controllers/mastodon_api_controller.ex | 48 ++++ lib/pleroma/web/mastodon_api/mastodon_api.ex | 10 + .../views/subscription_notification_view.ex | 61 ++++ .../web/pleroma_api/pleroma_api_controller.ex | 26 ++ lib/pleroma/web/push/impl.ex | 3 +- lib/pleroma/web/router.ex | 28 ++ lib/pleroma/web/streamer.ex | 14 +- ...5028_create_subscription_notifications.exs | 15 + test/notification_test.exs | 12 +- .../mastodon_api_controller_test.exs | 192 +++++++++++++ test/web/mastodon_api/mastodon_api_test.exs | 4 +- 14 files changed, 670 insertions(+), 12 deletions(-) create mode 100644 lib/pleroma/subscription_notification.ex create mode 100644 lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex create mode 100644 priv/repo/migrations/20190824195028_create_subscription_notifications.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b7c880c51..716d98733 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -228,7 +228,6 @@ def get_notified_from_activity( [] |> Utils.maybe_notify_to_recipients(activity) |> Utils.maybe_notify_mentioned_recipients(activity) - |> Utils.maybe_notify_subscribers(activity) |> Enum.uniq() User.get_users_from_set(recipients, local_only) diff --git a/lib/pleroma/subscription_notification.ex b/lib/pleroma/subscription_notification.ex new file mode 100644 index 000000000..7ae25a7b1 --- /dev/null +++ b/lib/pleroma/subscription_notification.ex @@ -0,0 +1,266 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.SubscriptionNotification do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Pagination + alias Pleroma.Repo + alias Pleroma.SubscriptionNotification + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.Push + alias Pleroma.Web.Streamer + + import Ecto.Query + import Ecto.Changeset + + @type t :: %__MODULE__{} + + schema "subscription_notifications" do + belongs_to(:user, User, type: Pleroma.FlakeId) + belongs_to(:activity, Activity, type: Pleroma.FlakeId) + + timestamps() + end + + def changeset(%SubscriptionNotification{} = notification, attrs) do + cast(notification, attrs, []) + end + + def for_user_query(user, opts \\ []) do + query = + SubscriptionNotification + |> where(user_id: ^user.id) + |> where( + [n, a], + fragment( + "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", + a.actor + ) + ) + |> join(:inner, [n], activity in assoc(n, :activity)) + |> join(:left, [n, a], object in Object, + on: + fragment( + "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", + object.data, + a.data + ) + ) + |> preload([n, a, o], activity: {a, object: o}) + + if opts[:with_muted] do + query + else + where(query, [n, a], a.actor not in ^user.info.muted_notifications) + |> where([n, a], a.actor not in ^user.info.blocks) + |> where( + [n, a], + fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks + ) + |> join(:left, [n, a], tm in Pleroma.ThreadMute, + on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) + ) + |> where([n, a, o, tm], is_nil(tm.user_id)) + end + end + + def for_user(user, opts \\ %{}) do + user + |> for_user_query(opts) + |> Pagination.fetch_paginated(opts) + end + + @doc """ + Returns notifications for user received since given date. + + ## Examples + + iex> Pleroma.SubscriptionNotification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33]) + [%Pleroma.SubscriptionNotification{}, %Pleroma.SubscriptionNotification{}] + + iex> Pleroma.SubscriptionNotification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33]) + [] + """ + @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()] + def for_user_since(user, date) do + from(n in for_user_query(user), + where: n.updated_at > ^date + ) + |> Repo.all() + end + + def clear_up_to(%{id: user_id} = _user, id) do + from( + n in SubscriptionNotification, + where: n.user_id == ^user_id, + where: n.id <= ^id + ) + |> Repo.delete_all([]) + end + + def get(%{id: user_id} = _user, id) do + query = + from( + n in SubscriptionNotification, + where: n.id == ^id, + join: activity in assoc(n, :activity), + preload: [activity: activity] + ) + + notification = Repo.one(query) + + case notification do + %{user_id: ^user_id} -> + {:ok, notification} + + _ -> + {:error, "Cannot get notification"} + end + end + + def clear(user) do + from(n in SubscriptionNotification, where: n.user_id == ^user.id) + |> Repo.delete_all() + end + + def destroy_multiple(%{id: user_id} = _user, ids) do + from(n in SubscriptionNotification, + where: n.id in ^ids, + where: n.user_id == ^user_id + ) + |> Repo.delete_all() + end + + def dismiss(%{id: user_id} = _user, id) do + notification = Repo.get(SubscriptionNotification, id) + + case notification do + %{user_id: ^user_id} -> + Repo.delete(notification) + + _ -> + {:error, "Cannot dismiss notification"} + end + end + + def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do + object = Object.normalize(activity) + + unless object && object.data["type"] == "Answer" do + users = get_notified_from_activity(activity) + notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + {:ok, notifications} + else + {:ok, []} + end + end + + def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) + when type in ["Like", "Announce", "Follow"] do + users = get_notified_from_activity(activity) + notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + {:ok, notifications} + end + + def create_notifications(_), do: {:ok, []} + + # TODO move to sql, too. + def create_notification(%Activity{} = activity, %User{} = user) do + unless skip?(activity, user) do + notification = %SubscriptionNotification{user_id: user.id, activity: activity} + {:ok, notification} = Repo.insert(notification) + Streamer.stream("user", notification) + Streamer.stream("user:subscription_notification", notification) + Push.send(notification) + notification + end + end + + def get_notified_from_activity(activity, local_only \\ true) + + def get_notified_from_activity( + %Activity{data: %{"to" => _, "type" => type} = _data} = activity, + local_only + ) + when type in ["Create", "Like", "Announce", "Follow"] do + recipients = + [] + |> Utils.maybe_notify_subscribers(activity) + |> Enum.uniq() + + User.get_users_from_set(recipients, local_only) + end + + def get_notified_from_activity(_, _local_only), do: [] + + @spec skip?(Activity.t(), User.t()) :: boolean() + def skip?(activity, user) do + [ + :self, + :followers, + :follows, + :non_followers, + :non_follows, + :recently_followed + ] + |> Enum.any?(&skip?(&1, activity, user)) + end + + @spec skip?(atom(), Activity.t(), User.t()) :: boolean() + def skip?(:self, activity, user) do + activity.data["actor"] == user.ap_id + end + + def skip?( + :followers, + activity, + %{info: %{notification_settings: %{"followers" => false}}} = user + ) do + actor = activity.data["actor"] + follower = User.get_cached_by_ap_id(actor) + User.following?(follower, user) + end + + def skip?( + :non_followers, + activity, + %{info: %{notification_settings: %{"non_followers" => false}}} = user + ) do + actor = activity.data["actor"] + follower = User.get_cached_by_ap_id(actor) + !User.following?(follower, user) + end + + def skip?(:follows, activity, %{info: %{notification_settings: %{"follows" => false}}} = user) do + actor = activity.data["actor"] + followed = User.get_cached_by_ap_id(actor) + User.following?(user, followed) + end + + def skip?( + :non_follows, + activity, + %{info: %{notification_settings: %{"non_follows" => false}}} = user + ) do + actor = activity.data["actor"] + followed = User.get_cached_by_ap_id(actor) + !User.following?(user, followed) + end + + def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do + actor = activity.data["actor"] + + SubscriptionNotification.for_user(user) + |> Enum.any?(fn + %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true + _ -> false + end) + end + + def skip?(_, _, _), do: false +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d23ec66ac..bc9a7a2d6 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Object.Fetcher alias Pleroma.Pagination alias Pleroma.Repo + alias Pleroma.SubscriptionNotification alias Pleroma.Upload alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -148,6 +149,7 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when PleromaJobQueue.enqueue(:background, Pleroma.Web.RichMedia.Helpers, [:fetch, activity]) Notification.create_notifications(activity) + SubscriptionNotification.create_notifications(activity) participations = activity 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 c54462bb3..3730c962c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -23,6 +23,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.Stats + alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub @@ -39,6 +40,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.ReportView alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MastodonAPI.SubscriptionNotificationView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization @@ -725,6 +727,28 @@ def notifications(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{notifications: notifications, for: user}) end + def subscription_notifications(%{assigns: %{user: user}} = conn, params) do + notifications = MastodonAPI.get_subscription_notifications(user, params) + + conn + |> add_link_headers(:subscription_notifications, notifications) + |> put_view(SubscriptionNotificationView) + |> render("index.json", %{notifications: notifications, for: user}) + end + + def get_subscription_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, notification} <- SubscriptionNotification.get(user, id) do + conn + |> put_view(SubscriptionNotificationView) + |> render("show.json", %{subscription_notification: notification, for: user}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- Notification.get(user, id) do conn @@ -743,6 +767,11 @@ def clear_notifications(%{assigns: %{user: user}} = conn, _params) do json(conn, %{}) end + def clear_subscription_notifications(%{assigns: %{user: user}} = conn, _params) do + SubscriptionNotification.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, %{}) @@ -754,11 +783,30 @@ def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _para end end + def dismiss_subscription_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, _notif} <- SubscriptionNotification.dismiss(user, id) do + json(conn, %{}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end + def destroy_multiple_subscription_notifications( + %{assigns: %{user: user}} = conn, + %{"ids" => ids} = _params + ) do + SubscriptionNotification.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) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index ac01d1ff3..6751e24d8 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do alias Pleroma.Notification alias Pleroma.Pagination alias Pleroma.ScheduledActivity + alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -62,6 +63,15 @@ def get_notifications(user, params \\ %{}) do |> Pagination.fetch_paginated(params) end + def get_subscription_notifications(user, params \\ %{}) do + options = cast_params(params) + + user + |> SubscriptionNotification.for_user_query(options) + |> restrict(:exclude_types, options) + |> Pagination.fetch_paginated(params) + end + def get_scheduled_activities(user, params \\ %{}) do user |> ScheduledActivity.for_user_query() diff --git a/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex new file mode 100644 index 000000000..c6f0b5064 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SubscriptionNotificationView do + use Pleroma.Web, :view + + alias Pleroma.Activity + # alias Pleroma.SubscriptionNotification + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.SubscriptionNotificationView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("index.json", %{notifications: notifications, for: user}) do + safe_render_many(notifications, SubscriptionNotificationView, "show.json", %{for: user}) + end + + def render("show.json", %{ + subscription_notification: %{activity: activity} = notification, + for: user + }) do + actor = User.get_cached_by_ap_id(activity.data["actor"]) + parent_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) + mastodon_type = Activity.mastodon_notification_type(activity) + + response = %{ + 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}) + } + + case mastodon_type do + "mention" -> + response + |> Map.merge(%{ + status: StatusView.render("status.json", %{activity: activity, for: user}) + }) + + "favourite" -> + response + |> Map.merge(%{ + status: StatusView.render("status.json", %{activity: parent_activity, for: user}) + }) + + "reblog" -> + response + |> Map.merge(%{ + status: StatusView.render("status.json", %{activity: parent_activity, for: user}) + }) + + "follow" -> + response + + _ -> + nil + end + end +end diff --git a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex index f4df3b024..71792d913 100644 --- a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do alias Pleroma.Conversation.Participation alias Pleroma.Notification + alias Pleroma.SubscriptionNotification alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView @@ -95,4 +96,29 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) d |> render("index.json", %{notifications: notifications, for: user}) end end + + def delete_subscription_notification(%{assigns: %{user: user}} = conn, %{ + "id" => notification_id + }) do + with {:ok, notification} <- SubscriptionNotification.dismiss(user, notification_id) do + conn + |> put_view(NotificationView) + |> render("show.json", %{notification: notification, for: user}) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end + end + + def read_subscription_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do + with notifications <- SubscriptionNotification.clear_up_to(user, max_id) do + notifications = Enum.take(notifications, 80) + + conn + |> put_view(NotificationView) + |> render("index.json", %{notifications: notifications, for: user}) + end + end end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 35d3ff07c..7ea5607fa 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.Push.Impl do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Push.Subscription @@ -19,7 +20,7 @@ defmodule Pleroma.Web.Push.Impl do @types ["Create", "Follow", "Announce", "Like"] @doc "Performs sending notifications for user subscriptions" - @spec perform(Notification.t()) :: list(any) | :error + @spec perform(Notification.t() | SubscriptionNotification.t()) :: list(any) | :error def perform( %{ activity: %{data: %{"type" => activity_type}, id: activity_id} = activity, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index b0464037e..dbd0deecd 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -300,11 +300,39 @@ defmodule Pleroma.Web.Router do get("/bookmarks", MastodonAPIController, :bookmarks) post("/notifications/clear", MastodonAPIController, :clear_notifications) + + post( + "/notifications/subscription/clear", + MastodonAPIController, + :clear_subscription_notifications + ) + post("/notifications/dismiss", MastodonAPIController, :dismiss_notification) + + post( + "/notifications/subscription/dismiss", + MastodonAPIController, + :dismiss_subscription_notification + ) + get("/notifications", MastodonAPIController, :notifications) + get("/notifications/subscription", MastodonAPIController, :subscription_notifications) get("/notifications/:id", MastodonAPIController, :get_notification) + + get( + "/notifications/subscription/:id", + MastodonAPIController, + :get_subscription_notification + ) + delete("/notifications/destroy_multiple", MastodonAPIController, :destroy_multiple) + delete( + "/notifications/subscription/destroy_multiple", + MastodonAPIController, + :destroy_multiple_subscription_notifications + ) + get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses) get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 587c43f40..42d95e33a 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility @@ -208,10 +209,17 @@ def represent_conversation(%Participation{} = participation) do |> Jason.encode!() end - @spec represent_notification(User.t(), Notification.t()) :: binary() - defp represent_notification(%User{} = user, %Notification{} = notify) do + @spec represent_notification(User.t(), Notification.t() | %SubscriptionNotification{}) :: + binary() + defp represent_notification(%User{} = user, notify) do + event = + case notify do + %Notification{} -> "notification" + %SubscriptionNotification{} -> "subscription_norification" + end + %{ - event: "notification", + event: event, payload: NotificationView.render( "show.json", diff --git a/priv/repo/migrations/20190824195028_create_subscription_notifications.exs b/priv/repo/migrations/20190824195028_create_subscription_notifications.exs new file mode 100644 index 000000000..fcceb4386 --- /dev/null +++ b/priv/repo/migrations/20190824195028_create_subscription_notifications.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.CreateSubscriptionNotifications do + use Ecto.Migration + + def change do + create_if_not_exists table(:subscription_notifications) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + add(:activity_id, references(:activities, type: :uuid, on_delete: :delete_all)) + + timestamps() + end + + create_if_not_exists(index(:subscription_notifications, [:user_id])) + create_if_not_exists(index(:subscription_notifications, ["id desc nulls last"])) + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index 2a52dad8d..0e2635aad 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -32,16 +32,16 @@ test "notifies someone when they are directly addressed" do assert other_notification.activity_id == activity.id end - test "it creates a notification for subscribed users" do + test "it does not create a notification for subscribed users" do user = insert(:user) subscriber = insert(:user) User.subscribe(subscriber, user) {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) - {:ok, [notification]} = Notification.create_notifications(status) + {:ok, notifications} = Notification.create_notifications(status) - assert notification.user_id == subscriber.id + assert notifications == [] end test "does not create a notification for subscribed users if status is a reply" do @@ -190,14 +190,16 @@ test "it doesn't create a notification for follow-unfollow-follow chains" do refute Notification.create_notification(activity_dupe, followed_user) end - test "it doesn't create duplicate notifications for follow+subscribed users" do + test "it doesn't create notifications for follow+subscribed users" do user = insert(:user) subscriber = insert(:user) {:ok, _, _, _} = CommonAPI.follow(subscriber, user) User.subscribe(subscriber, user) {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) - {:ok, [_notif]} = Notification.create_notifications(status) + {:ok, notifications} = Notification.create_notifications(status) + + assert notifications == [] end test "it doesn't create subscription notifications if the recipient cannot see the status" do diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index f4902d043..95fcecc52 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.ScheduledActivity + alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -1273,6 +1274,197 @@ test "see notifications after muting user with notifications and with_muted para end end + describe "subscription_notifications" do + setup do + user = insert(:user) + subscriber = insert(:user) + + User.subscribe(subscriber, user) + + {:ok, %{user: user, subscriber: subscriber}} + end + + test "list of notifications", %{conn: conn, user: user, subscriber: subscriber} do + status_text = "Hello" + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + + conn = + conn + |> assign(:user, subscriber) + |> get("/api/v1/notifications/subscription") + + assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) + assert response == status_text + end + + test "getting a single notification", %{conn: conn, user: user, subscriber: subscriber} do + status_text = "Hello" + + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + [notification] = Repo.all(SubscriptionNotification) + + conn = + conn + |> assign(:user, subscriber) + |> get("/api/v1/notifications/subscription/#{notification.id}") + + assert %{"status" => %{"content" => response}} = json_response(conn, 200) + assert response == status_text + end + + test "dismissing a single notification also deletes it", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + status_text = "Hello" + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + + [notification] = Repo.all(SubscriptionNotification) + + conn = + conn + |> assign(:user, subscriber) + |> post("/api/v1/notifications/subscription/dismiss", %{"id" => notification.id}) + + assert %{} = json_response(conn, 200) + + assert Repo.all(SubscriptionNotification) == [] + end + + test "clearing all notifications also deletes them", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + status_text1 = "Hello" + status_text2 = "Hello again" + {:ok, _activity1} = CommonAPI.post(user, %{"status" => status_text1}) + {:ok, _activity2} = CommonAPI.post(user, %{"status" => status_text2}) + + conn = + conn + |> assign(:user, subscriber) + |> post("/api/v1/notifications/subscription/clear") + + assert %{} = json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, subscriber) + |> get("/api/v1/notifications/subscription") + + assert json_response(conn, 200) == [] + + assert Repo.all(SubscriptionNotification) == [] + end + + test "paginates notifications using min_id, since_id, max_id, and limit", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + {:ok, activity1} = CommonAPI.post(user, %{"status" => "Hello 1"}) + {:ok, activity2} = CommonAPI.post(user, %{"status" => "Hello 2"}) + {:ok, activity3} = CommonAPI.post(user, %{"status" => "Hello 3"}) + {:ok, activity4} = CommonAPI.post(user, %{"status" => "Hello 4"}) + + notification1_id = + Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() + + notification2_id = + Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() + + notification3_id = + Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() + + notification4_id = + Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() + + conn = assign(conn, :user, subscriber) + + # min_id + conn_res = + get(conn, "/api/v1/notifications/subscription?limit=2&min_id=#{notification1_id}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + + # since_id + conn_res = + get(conn, "/api/v1/notifications/subscription?limit=2&since_id=#{notification1_id}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result + + # max_id + conn_res = + get(conn, "/api/v1/notifications/subscription?limit=2&max_id=#{notification4_id}") + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + end + + test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do + # mutual subscription + User.subscribe(user1, user2) + + {:ok, activity1} = CommonAPI.post(user1, %{"status" => "Hello 1"}) + {:ok, activity2} = CommonAPI.post(user1, %{"status" => "World 1"}) + {:ok, activity3} = CommonAPI.post(user2, %{"status" => "Hello 2"}) + {:ok, activity4} = CommonAPI.post(user2, %{"status" => "World 2"}) + + notification1_id = + Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() + + notification2_id = + Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() + + notification3_id = + Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() + + notification4_id = + Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() + + conn = assign(conn, :user, user1) + + conn_res = get(conn, "/api/v1/notifications/subscription") + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification3_id, notification4_id] + end) + + conn2 = assign(conn, :user, user2) + + conn_res = get(conn2, "/api/v1/notifications/subscription") + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification1_id, notification2_id] + end) + + conn_destroy = + delete(conn, "/api/v1/notifications/subscription/destroy_multiple", %{ + "ids" => [notification3_id, notification4_id] + }) + + assert json_response(conn_destroy, 200) == %{} + + conn_res = get(conn2, "/api/v1/notifications/subscription") + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification1_id, notification2_id] + end) + + assert length(Repo.all(SubscriptionNotification)) == 2 + end + end + describe "reblogging" do test "reblogs and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs index 7fcb2bd55..848fce7ad 100644 --- a/test/web/mastodon_api/mastodon_api_test.exs +++ b/test/web/mastodon_api/mastodon_api_test.exs @@ -75,9 +75,9 @@ test "returns notifications for user" do User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) + {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin @#{subscriber.nickname}"}) - {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi"}) + {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi @#{subscriber.nickname}"}) {:ok, [notification]} = Notification.create_notifications(status) {:ok, [notification1]} = Notification.create_notifications(status1) res = MastodonAPI.get_notifications(subscriber) From 56b60798c2282055089424f5dc6770a10876626b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Mon, 16 Sep 2019 20:50:14 +0300 Subject: [PATCH 010/138] Code style fixes --- lib/pleroma/subscription_notification.ex | 72 +++++++++---------- .../views/subscription_notification_view.ex | 1 - 2 files changed, 33 insertions(+), 40 deletions(-) diff --git a/lib/pleroma/subscription_notification.ex b/lib/pleroma/subscription_notification.ex index 7ae25a7b1..9ce0c6598 100644 --- a/lib/pleroma/subscription_notification.ex +++ b/lib/pleroma/subscription_notification.ex @@ -56,7 +56,8 @@ def for_user_query(user, opts \\ []) do if opts[:with_muted] do query else - where(query, [n, a], a.actor not in ^user.info.muted_notifications) + query + |> where([n, a], a.actor not in ^user.info.muted_notifications) |> where([n, a], a.actor not in ^user.info.blocks) |> where( [n, a], @@ -88,9 +89,9 @@ def for_user(user, opts \\ %{}) do """ @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()] def for_user_since(user, date) do - from(n in for_user_query(user), - where: n.updated_at > ^date - ) + user + |> for_user_query() + |> where([n], n.updated_at > ^date) |> Repo.all() end @@ -112,10 +113,8 @@ def get(%{id: user_id} = _user, id) do preload: [activity: activity] ) - notification = Repo.one(query) - - case notification do - %{user_id: ^user_id} -> + case Repo.one(query) do + %{user_id: ^user_id} = notification -> {:ok, notification} _ -> @@ -137,10 +136,8 @@ def destroy_multiple(%{id: user_id} = _user, ids) do end def dismiss(%{id: user_id} = _user, id) do - notification = Repo.get(SubscriptionNotification, id) - - case notification do - %{user_id: ^user_id} -> + case Repo.get(SubscriptionNotification, id) do + %{user_id: ^user_id} = notification -> Repo.delete(notification) _ -> @@ -149,21 +146,24 @@ def dismiss(%{id: user_id} = _user, id) do end def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do - object = Object.normalize(activity) + case Object.normalize(activity) do + %{data: %{"type" => "Answer"}} -> + {:ok, []} - unless object && object.data["type"] == "Answer" do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} - else - {:ok, []} + _ -> + users = get_notified_from_activity(activity) + notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + {:ok, notifications} end end def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) when type in ["Like", "Announce", "Follow"] do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + notifications = + activity + |> get_notified_from_activity() + |> Enum.map(&create_notification(activity, &1)) + {:ok, notifications} end @@ -188,12 +188,10 @@ def get_notified_from_activity( local_only ) when type in ["Create", "Like", "Announce", "Follow"] do - recipients = - [] - |> Utils.maybe_notify_subscribers(activity) - |> Enum.uniq() - - User.get_users_from_set(recipients, local_only) + [] + |> Utils.maybe_notify_subscribers(activity) + |> Enum.uniq() + |> User.get_users_from_set(local_only) end def get_notified_from_activity(_, _local_only), do: [] @@ -218,12 +216,12 @@ def skip?(:self, activity, user) do def skip?( :followers, - activity, + %{data: %{"actor" => actor}}, %{info: %{notification_settings: %{"followers" => false}}} = user ) do - actor = activity.data["actor"] - follower = User.get_cached_by_ap_id(actor) - User.following?(follower, user) + actor + |> User.get_cached_by_ap_id() + |> User.following?(user) end def skip?( @@ -252,14 +250,10 @@ def skip?( !User.following?(user, followed) end - def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do - actor = activity.data["actor"] - - SubscriptionNotification.for_user(user) - |> Enum.any?(fn - %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true - _ -> false - end) + def skip?(:recently_followed, %{data: %{"type" => "Follow", "actor" => actor}}, user) do + user + |> SubscriptionNotification.for_user() + |> Enum.any?(&match?(%{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}}, &1)) end def skip?(_, _, _), do: false diff --git a/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex index c6f0b5064..83d2b647f 100644 --- a/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionNotificationView do use Pleroma.Web, :view alias Pleroma.Activity - # alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView From 6042e21b25885f9c3214d3296d9d2fdf35ad58ea Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Mon, 16 Sep 2019 21:59:49 +0300 Subject: [PATCH 011/138] Move subscription notifications to a separate controller --- .../controllers/mastodon_api_controller.ex | 48 ---- lib/pleroma/web/mastodon_api/mastodon_api.ex | 10 - lib/pleroma/web/pleroma_api/pleroma_api.ex | 40 +++ .../subscription_notification_controller.ex | 59 +++++ .../views/subscription_notification_view.ex | 4 +- lib/pleroma/web/router.ex | 33 +-- .../mastodon_api_controller_test.exs | 192 -------------- ...scription_notification_controller_test.exs | 234 ++++++++++++++++++ 8 files changed, 343 insertions(+), 277 deletions(-) create mode 100644 lib/pleroma/web/pleroma_api/pleroma_api.ex create mode 100644 lib/pleroma/web/pleroma_api/subscription_notification_controller.ex rename lib/pleroma/web/{mastodon_api => pleroma_api}/views/subscription_notification_view.ex (93%) create mode 100644 test/web/pleroma_api/subscription_notification_controller_test.exs 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 eefdb8c06..060137b80 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -23,7 +23,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.Stats - alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub @@ -40,7 +39,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.ReportView alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.MastodonAPI.SubscriptionNotificationView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization @@ -727,28 +725,6 @@ def notifications(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{notifications: notifications, for: user}) end - def subscription_notifications(%{assigns: %{user: user}} = conn, params) do - notifications = MastodonAPI.get_subscription_notifications(user, params) - - conn - |> add_link_headers(:subscription_notifications, notifications) - |> put_view(SubscriptionNotificationView) - |> render("index.json", %{notifications: notifications, for: user}) - end - - def get_subscription_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, notification} <- SubscriptionNotification.get(user, id) do - conn - |> put_view(SubscriptionNotificationView) - |> render("show.json", %{subscription_notification: notification, for: user}) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- Notification.get(user, id) do conn @@ -767,11 +743,6 @@ def clear_notifications(%{assigns: %{user: user}} = conn, _params) do json(conn, %{}) end - def clear_subscription_notifications(%{assigns: %{user: user}} = conn, _params) do - SubscriptionNotification.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, %{}) @@ -783,30 +754,11 @@ def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _para end end - def dismiss_subscription_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, _notif} <- SubscriptionNotification.dismiss(user, id) do - json(conn, %{}) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end - def destroy_multiple_subscription_notifications( - %{assigns: %{user: user}} = conn, - %{"ids" => ids} = _params - ) do - SubscriptionNotification.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) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 6751e24d8..ac01d1ff3 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do alias Pleroma.Notification alias Pleroma.Pagination alias Pleroma.ScheduledActivity - alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -63,15 +62,6 @@ def get_notifications(user, params \\ %{}) do |> Pagination.fetch_paginated(params) end - def get_subscription_notifications(user, params \\ %{}) do - options = cast_params(params) - - user - |> SubscriptionNotification.for_user_query(options) - |> restrict(:exclude_types, options) - |> Pagination.fetch_paginated(params) - end - def get_scheduled_activities(user, params \\ %{}) do user |> ScheduledActivity.for_user_query() diff --git a/lib/pleroma/web/pleroma_api/pleroma_api.ex b/lib/pleroma/web/pleroma_api/pleroma_api.ex new file mode 100644 index 000000000..480964845 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/pleroma_api.ex @@ -0,0 +1,40 @@ +defmodule Pleroma.Web.PleromaAPI.PleromaAPI do + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Activity + alias Pleroma.Pagination + alias Pleroma.SubscriptionNotification + + def get_subscription_notifications(user, params \\ %{}) do + options = cast_params(params) + + user + |> SubscriptionNotification.for_user_query(options) + |> restrict(:exclude_types, options) + |> Pagination.fetch_paginated(params) + end + + defp cast_params(params) do + param_types = %{ + exclude_types: {:array, :string}, + reblogs: :boolean, + with_muted: :boolean + } + + changeset = cast({%{}, param_types}, params, Map.keys(param_types)) + changeset.changes + end + + defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do + ap_types = + mastodon_types + |> Enum.map(&Activity.from_mastodon_notification_type/1) + |> Enum.filter(& &1) + + query + |> where([q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + end + + defp restrict(query, _, _), do: query +end diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex new file mode 100644 index 000000000..bfc2631dd --- /dev/null +++ b/lib/pleroma/web/pleroma_api/subscription_notification_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.PleromaAPI.SubscriptionNotificationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.SubscriptionNotification + alias Pleroma.Web.PleromaAPI.PleromaAPI + alias Pleroma.Web.PleromaAPI.SubscriptionNotificationView + + def list(%{assigns: %{user: user}} = conn, params) do + notifications = PleromaAPI.get_subscription_notifications(user, params) + + conn + |> add_link_headers(notifications) + |> put_view(SubscriptionNotificationView) + |> render("index.json", %{notifications: notifications, for: user}) + end + + def get(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, notification} <- SubscriptionNotification.get(user, id) do + conn + |> put_view(SubscriptionNotificationView) + |> render("show.json", %{subscription_notification: notification, for: user}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + def clear(%{assigns: %{user: user}} = conn, _params) do + SubscriptionNotification.clear(user) + json(conn, %{}) + end + + def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, _notif} <- SubscriptionNotification.dismiss(user, id) do + json(conn, %{}) + else + {:error, reason} -> + conn + |> put_status(:forbidden) + |> json(%{"error" => reason}) + end + end + + def destroy_multiple( + %{assigns: %{user: user}} = conn, + %{"ids" => ids} = _params + ) do + SubscriptionNotification.destroy_multiple(user, ids) + json(conn, %{}) + end +end diff --git a/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex similarity index 93% rename from lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex rename to lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex index 83d2b647f..d7f7f4c5a 100644 --- a/lib/pleroma/web/mastodon_api/views/subscription_notification_view.ex +++ b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex @@ -2,15 +2,15 @@ # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.MastodonAPI.SubscriptionNotificationView do +defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationView do use Pleroma.Web, :view alias Pleroma.Activity alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.SubscriptionNotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.PleromaAPI.SubscriptionNotificationView def render("index.json", %{notifications: notifications, for: user}) do safe_render_many(notifications, SubscriptionNotificationView, "show.json", %{for: user}) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 409fc9eca..05891b6c0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -268,6 +268,14 @@ defmodule Pleroma.Web.Router do pipe_through(:oauth_read) get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id", PleromaAPIController, :conversation) + + scope "/subscription_notifications" do + post("/clear", SubscriptionNotificationController, :clear) + post("/dismiss", SubscriptionNotificationController, :dismiss) + delete("/destroy_multiple", SubscriptionNotificationController, :destroy_multiple) + get("/", SubscriptionNotificationController, :list) + get("/id", SubscriptionNotificationController, :get) + end end scope [] do @@ -302,38 +310,13 @@ defmodule Pleroma.Web.Router do post("/notifications/clear", MastodonAPIController, :clear_notifications) - post( - "/notifications/subscription/clear", - MastodonAPIController, - :clear_subscription_notifications - ) - post("/notifications/dismiss", MastodonAPIController, :dismiss_notification) - post( - "/notifications/subscription/dismiss", - MastodonAPIController, - :dismiss_subscription_notification - ) - get("/notifications", MastodonAPIController, :notifications) - get("/notifications/subscription", MastodonAPIController, :subscription_notifications) get("/notifications/:id", MastodonAPIController, :get_notification) - get( - "/notifications/subscription/:id", - MastodonAPIController, - :get_subscription_notification - ) - delete("/notifications/destroy_multiple", MastodonAPIController, :destroy_multiple) - delete( - "/notifications/subscription/destroy_multiple", - MastodonAPIController, - :destroy_multiple_subscription_notifications - ) - get("/scheduled_statuses", MastodonAPIController, :scheduled_statuses) get("/scheduled_statuses/:id", MastodonAPIController, :show_scheduled_status) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 1d2d9e134..fb04748bb 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.ScheduledActivity - alias Pleroma.SubscriptionNotification alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -1275,197 +1274,6 @@ test "see notifications after muting user with notifications and with_muted para end end - describe "subscription_notifications" do - setup do - user = insert(:user) - subscriber = insert(:user) - - User.subscribe(subscriber, user) - - {:ok, %{user: user, subscriber: subscriber}} - end - - test "list of notifications", %{conn: conn, user: user, subscriber: subscriber} do - status_text = "Hello" - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - - conn = - conn - |> assign(:user, subscriber) - |> get("/api/v1/notifications/subscription") - - assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) - assert response == status_text - end - - test "getting a single notification", %{conn: conn, user: user, subscriber: subscriber} do - status_text = "Hello" - - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - [notification] = Repo.all(SubscriptionNotification) - - conn = - conn - |> assign(:user, subscriber) - |> get("/api/v1/notifications/subscription/#{notification.id}") - - assert %{"status" => %{"content" => response}} = json_response(conn, 200) - assert response == status_text - end - - test "dismissing a single notification also deletes it", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - status_text = "Hello" - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - - [notification] = Repo.all(SubscriptionNotification) - - conn = - conn - |> assign(:user, subscriber) - |> post("/api/v1/notifications/subscription/dismiss", %{"id" => notification.id}) - - assert %{} = json_response(conn, 200) - - assert Repo.all(SubscriptionNotification) == [] - end - - test "clearing all notifications also deletes them", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - status_text1 = "Hello" - status_text2 = "Hello again" - {:ok, _activity1} = CommonAPI.post(user, %{"status" => status_text1}) - {:ok, _activity2} = CommonAPI.post(user, %{"status" => status_text2}) - - conn = - conn - |> assign(:user, subscriber) - |> post("/api/v1/notifications/subscription/clear") - - assert %{} = json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, subscriber) - |> get("/api/v1/notifications/subscription") - - assert json_response(conn, 200) == [] - - assert Repo.all(SubscriptionNotification) == [] - end - - test "paginates notifications using min_id, since_id, max_id, and limit", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - {:ok, activity1} = CommonAPI.post(user, %{"status" => "Hello 1"}) - {:ok, activity2} = CommonAPI.post(user, %{"status" => "Hello 2"}) - {:ok, activity3} = CommonAPI.post(user, %{"status" => "Hello 3"}) - {:ok, activity4} = CommonAPI.post(user, %{"status" => "Hello 4"}) - - notification1_id = - Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() - - notification2_id = - Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() - - notification3_id = - Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() - - notification4_id = - Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() - - conn = assign(conn, :user, subscriber) - - # min_id - conn_res = - get(conn, "/api/v1/notifications/subscription?limit=2&min_id=#{notification1_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - - # since_id - conn_res = - get(conn, "/api/v1/notifications/subscription?limit=2&since_id=#{notification1_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - - # max_id - conn_res = - get(conn, "/api/v1/notifications/subscription?limit=2&max_id=#{notification4_id}") - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - end - - test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do - # mutual subscription - User.subscribe(user1, user2) - - {:ok, activity1} = CommonAPI.post(user1, %{"status" => "Hello 1"}) - {:ok, activity2} = CommonAPI.post(user1, %{"status" => "World 1"}) - {:ok, activity3} = CommonAPI.post(user2, %{"status" => "Hello 2"}) - {:ok, activity4} = CommonAPI.post(user2, %{"status" => "World 2"}) - - notification1_id = - Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() - - notification2_id = - Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() - - notification3_id = - Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() - - notification4_id = - Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() - - conn = assign(conn, :user, user1) - - conn_res = get(conn, "/api/v1/notifications/subscription") - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification3_id, notification4_id] - end) - - conn2 = assign(conn, :user, user2) - - conn_res = get(conn2, "/api/v1/notifications/subscription") - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification1_id, notification2_id] - end) - - conn_destroy = - delete(conn, "/api/v1/notifications/subscription/destroy_multiple", %{ - "ids" => [notification3_id, notification4_id] - }) - - assert json_response(conn_destroy, 200) == %{} - - conn_res = get(conn2, "/api/v1/notifications/subscription") - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification1_id, notification2_id] - end) - - assert length(Repo.all(SubscriptionNotification)) == 2 - end - end - describe "reblogging" do test "reblogs and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) diff --git a/test/web/pleroma_api/subscription_notification_controller_test.exs b/test/web/pleroma_api/subscription_notification_controller_test.exs new file mode 100644 index 000000000..ee495f112 --- /dev/null +++ b/test/web/pleroma_api/subscription_notification_controller_test.exs @@ -0,0 +1,234 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Repo + alias Pleroma.SubscriptionNotification + alias Pleroma.User + alias Pleroma.Web.CommonAPI + import Pleroma.Factory + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + clear_config([:instance, :public]) + clear_config([:rich_media, :enabled]) + + describe "subscription_notifications" do + setup do + user = insert(:user) + subscriber = insert(:user) + + User.subscribe(subscriber, user) + + {:ok, %{user: user, subscriber: subscriber}} + end + + test "list of notifications", %{conn: conn, user: user, subscriber: subscriber} do + status_text = "Hello" + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + path = subscription_notification_path(conn, :list) + + conn = + conn + |> assign(:user, subscriber) + |> get(path) + + assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) + assert response == status_text + end + + test "getting a single notification", %{conn: conn, user: user, subscriber: subscriber} do + status_text = "Hello" + + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + [notification] = Repo.all(SubscriptionNotification) + + path = subscription_notification_path(conn, :get, id: notification.id) + + conn = + conn + |> assign(:user, subscriber) + |> get(path) + + assert %{"status" => %{"content" => response}} = json_response(conn, 200) + assert response == status_text + end + + test "dismissing a single notification also deletes it", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + status_text = "Hello" + {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) + + [notification] = Repo.all(SubscriptionNotification) + + conn = + conn + |> assign(:user, subscriber) + |> post(subscription_notification_path(conn, :dismiss), %{"id" => notification.id}) + + assert %{} = json_response(conn, 200) + + assert Repo.all(SubscriptionNotification) == [] + end + + test "clearing all notifications also deletes them", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + status_text1 = "Hello" + status_text2 = "Hello again" + {:ok, _activity1} = CommonAPI.post(user, %{"status" => status_text1}) + {:ok, _activity2} = CommonAPI.post(user, %{"status" => status_text2}) + + conn = + conn + |> assign(:user, subscriber) + |> post(subscription_notification_path(conn, :clear)) + + assert %{} = json_response(conn, 200) + + conn = + build_conn() + |> assign(:user, subscriber) + |> get(subscription_notification_path(conn, :list)) + + assert json_response(conn, 200) == [] + + assert Repo.all(SubscriptionNotification) == [] + end + + test "paginates notifications using min_id, since_id, max_id, and limit", %{ + conn: conn, + user: user, + subscriber: subscriber + } do + {:ok, activity1} = CommonAPI.post(user, %{"status" => "Hello 1"}) + {:ok, activity2} = CommonAPI.post(user, %{"status" => "Hello 2"}) + {:ok, activity3} = CommonAPI.post(user, %{"status" => "Hello 3"}) + {:ok, activity4} = CommonAPI.post(user, %{"status" => "Hello 4"}) + + notification1_id = + Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() + + notification2_id = + Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() + + notification3_id = + Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() + + notification4_id = + Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() + + conn = assign(conn, :user, subscriber) + + # min_id + conn_res = + get( + conn, + subscription_notification_path(conn, :list, %{ + "limit" => 2, + "min_id" => notification1_id + }) + ) + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + + # since_id + conn_res = + get( + conn, + subscription_notification_path(conn, :list, %{ + "limit" => 2, + "since_id" => notification1_id + }) + ) + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result + + # max_id + conn_res = + get( + conn, + subscription_notification_path(conn, :list, %{ + "limit" => 2, + "max_id" => notification4_id + }) + ) + + result = json_response(conn_res, 200) + assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result + end + + test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do + # mutual subscription + User.subscribe(user1, user2) + + {:ok, activity1} = CommonAPI.post(user1, %{"status" => "Hello 1"}) + {:ok, activity2} = CommonAPI.post(user1, %{"status" => "World 1"}) + {:ok, activity3} = CommonAPI.post(user2, %{"status" => "Hello 2"}) + {:ok, activity4} = CommonAPI.post(user2, %{"status" => "World 2"}) + + notification1_id = + Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() + + notification2_id = + Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() + + notification3_id = + Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() + + notification4_id = + Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() + + conn = assign(conn, :user, user1) + + conn_res = get(conn, subscription_notification_path(conn, :list)) + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification3_id, notification4_id] + end) + + conn2 = assign(conn, :user, user2) + + conn_res = get(conn2, subscription_notification_path(conn, :list)) + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification1_id, notification2_id] + end) + + conn_destroy = + delete(conn, subscription_notification_path(conn, :destroy_multiple), %{ + "ids" => [notification3_id, notification4_id] + }) + + assert json_response(conn_destroy, 200) == %{} + + conn_res = get(conn2, subscription_notification_path(conn, :list)) + + result = json_response(conn_res, 200) + + Enum.each(result, fn %{"id" => id} -> + assert id in [notification1_id, notification2_id] + end) + + assert length(Repo.all(SubscriptionNotification)) == 2 + end + end +end From 2688b876abf5ebd48d18e460eee7db992f984f5a Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 13:42:28 +0000 Subject: [PATCH 012/138] Apply suggestion to lib/pleroma/web/pleroma_api/subscription_notification_controller.ex --- .../web/pleroma_api/subscription_notification_controller.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex index bfc2631dd..d5da92946 100644 --- a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationController do alias Pleroma.SubscriptionNotification alias Pleroma.Web.PleromaAPI.PleromaAPI - alias Pleroma.Web.PleromaAPI.SubscriptionNotificationView def list(%{assigns: %{user: user}} = conn, params) do notifications = PleromaAPI.get_subscription_notifications(user, params) From c0f776faecfa91ed755760975da12b546ca89317 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 13:42:36 +0000 Subject: [PATCH 013/138] Apply suggestion to lib/pleroma/web/pleroma_api/subscription_notification_controller.ex --- .../web/pleroma_api/subscription_notification_controller.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex index d5da92946..fff307b4e 100644 --- a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex @@ -15,7 +15,6 @@ def list(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(notifications) - |> put_view(SubscriptionNotificationView) |> render("index.json", %{notifications: notifications, for: user}) end From f9be517c7f3e63cfaaca871a4458cbf7c8a6a3f4 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 13:42:40 +0000 Subject: [PATCH 014/138] Apply suggestion to lib/pleroma/web/pleroma_api/subscription_notification_controller.ex --- .../web/pleroma_api/subscription_notification_controller.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex index fff307b4e..969ce0179 100644 --- a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex @@ -20,9 +20,7 @@ def list(%{assigns: %{user: user}} = conn, params) do def get(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- SubscriptionNotification.get(user, id) do - conn - |> put_view(SubscriptionNotificationView) - |> render("show.json", %{subscription_notification: notification, for: user}) + render(conn, "show.json", %{subscription_notification: notification, for: user}) else {:error, reason} -> conn From a81f80233d63d98a0de7b57def76275182d5477e Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 13:43:10 +0000 Subject: [PATCH 015/138] Apply suggestion to lib/pleroma/web/router.ex --- lib/pleroma/web/router.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 05891b6c0..1fff94b38 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -274,7 +274,7 @@ defmodule Pleroma.Web.Router do post("/dismiss", SubscriptionNotificationController, :dismiss) delete("/destroy_multiple", SubscriptionNotificationController, :destroy_multiple) get("/", SubscriptionNotificationController, :list) - get("/id", SubscriptionNotificationController, :get) + get("/:id", SubscriptionNotificationController, :get) end end From 015597c2abbd9a78df76903bb2c3d229bf11e958 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 13:43:15 +0000 Subject: [PATCH 016/138] Apply suggestion to test/web/pleroma_api/subscription_notification_controller_test.exs --- .../pleroma_api/subscription_notification_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/pleroma_api/subscription_notification_controller_test.exs b/test/web/pleroma_api/subscription_notification_controller_test.exs index ee495f112..781d27ead 100644 --- a/test/web/pleroma_api/subscription_notification_controller_test.exs +++ b/test/web/pleroma_api/subscription_notification_controller_test.exs @@ -50,7 +50,7 @@ test "getting a single notification", %{conn: conn, user: user, subscriber: subs {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) [notification] = Repo.all(SubscriptionNotification) - path = subscription_notification_path(conn, :get, id: notification.id) + path = subscription_notification_path(conn, :get, notification) conn = conn From a76168e743c3dd193db6ebca029f287da9edd290 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 16:44:41 +0300 Subject: [PATCH 017/138] Cleanup PleromaAPIController --- .../web/pleroma_api/pleroma_api_controller.ex | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex index 246b351dc..d17ccf84d 100644 --- a/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/pleroma_api_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do alias Pleroma.Conversation.Participation alias Pleroma.Notification - alias Pleroma.SubscriptionNotification alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView @@ -87,29 +86,4 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) d |> render("index.json", %{notifications: notifications, for: user}) end end - - def delete_subscription_notification(%{assigns: %{user: user}} = conn, %{ - "id" => notification_id - }) do - with {:ok, notification} <- SubscriptionNotification.dismiss(user, notification_id) do - conn - |> put_view(NotificationView) - |> render("show.json", %{notification: notification, for: user}) - else - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(%{"error" => message}) - end - end - - def read_subscription_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do - with notifications <- SubscriptionNotification.clear_up_to(user, max_id) do - notifications = Enum.take(notifications, 80) - - conn - |> put_view(NotificationView) - |> render("index.json", %{notifications: notifications, for: user}) - end - end end From 7d1773bc6b01caad8666ef07a9b2f2ac326fd0cd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 16:48:24 +0300 Subject: [PATCH 018/138] Rename SubscriptionNotificationController list and get actions to index and show --- .../subscription_notification_controller.ex | 4 ++-- lib/pleroma/web/router.ex | 4 ++-- ...bscription_notification_controller_test.exs | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex index 969ce0179..fa8307668 100644 --- a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationController do alias Pleroma.SubscriptionNotification alias Pleroma.Web.PleromaAPI.PleromaAPI - def list(%{assigns: %{user: user}} = conn, params) do + def index(%{assigns: %{user: user}} = conn, params) do notifications = PleromaAPI.get_subscription_notifications(user, params) conn @@ -18,7 +18,7 @@ def list(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{notifications: notifications, for: user}) end - def get(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + def show(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- SubscriptionNotification.get(user, id) do render(conn, "show.json", %{subscription_notification: notification, for: user}) else diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 1fff94b38..502c67e74 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -273,8 +273,8 @@ defmodule Pleroma.Web.Router do post("/clear", SubscriptionNotificationController, :clear) post("/dismiss", SubscriptionNotificationController, :dismiss) delete("/destroy_multiple", SubscriptionNotificationController, :destroy_multiple) - get("/", SubscriptionNotificationController, :list) - get("/:id", SubscriptionNotificationController, :get) + get("/", SubscriptionNotificationController, :index) + get("/:id", SubscriptionNotificationController, :show) end end diff --git a/test/web/pleroma_api/subscription_notification_controller_test.exs b/test/web/pleroma_api/subscription_notification_controller_test.exs index 781d27ead..c6a71732d 100644 --- a/test/web/pleroma_api/subscription_notification_controller_test.exs +++ b/test/web/pleroma_api/subscription_notification_controller_test.exs @@ -33,7 +33,7 @@ defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationControllerTest do test "list of notifications", %{conn: conn, user: user, subscriber: subscriber} do status_text = "Hello" {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - path = subscription_notification_path(conn, :list) + path = subscription_notification_path(conn, :index) conn = conn @@ -50,7 +50,7 @@ test "getting a single notification", %{conn: conn, user: user, subscriber: subs {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) [notification] = Repo.all(SubscriptionNotification) - path = subscription_notification_path(conn, :get, notification) + path = subscription_notification_path(conn, :show, notification) conn = conn @@ -101,7 +101,7 @@ test "clearing all notifications also deletes them", %{ conn = build_conn() |> assign(:user, subscriber) - |> get(subscription_notification_path(conn, :list)) + |> get(subscription_notification_path(conn, :index)) assert json_response(conn, 200) == [] @@ -136,7 +136,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit", %{ conn_res = get( conn, - subscription_notification_path(conn, :list, %{ + subscription_notification_path(conn, :index, %{ "limit" => 2, "min_id" => notification1_id }) @@ -149,7 +149,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit", %{ conn_res = get( conn, - subscription_notification_path(conn, :list, %{ + subscription_notification_path(conn, :index, %{ "limit" => 2, "since_id" => notification1_id }) @@ -162,7 +162,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit", %{ conn_res = get( conn, - subscription_notification_path(conn, :list, %{ + subscription_notification_path(conn, :index, %{ "limit" => 2, "max_id" => notification4_id }) @@ -195,7 +195,7 @@ test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do conn = assign(conn, :user, user1) - conn_res = get(conn, subscription_notification_path(conn, :list)) + conn_res = get(conn, subscription_notification_path(conn, :index)) result = json_response(conn_res, 200) @@ -205,7 +205,7 @@ test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do conn2 = assign(conn, :user, user2) - conn_res = get(conn2, subscription_notification_path(conn, :list)) + conn_res = get(conn2, subscription_notification_path(conn, :index)) result = json_response(conn_res, 200) @@ -220,7 +220,7 @@ test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do assert json_response(conn_destroy, 200) == %{} - conn_res = get(conn2, subscription_notification_path(conn, :list)) + conn_res = get(conn2, subscription_notification_path(conn, :index)) result = json_response(conn_res, 200) From e9f69a3eb7f17ae8c2890972851de1139983ce3d Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 16:52:23 +0300 Subject: [PATCH 019/138] Move pleroma_api controllers into controllers sub-folders --- .../web/pleroma_api/{ => controllers}/pleroma_api_controller.ex | 0 .../{ => controllers}/subscription_notification_controller.ex | 0 .../pleroma_api/{ => controllers}/pleroma_api_controller_test.exs | 0 .../subscription_notification_controller_test.exs | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename lib/pleroma/web/pleroma_api/{ => controllers}/pleroma_api_controller.ex (100%) rename lib/pleroma/web/pleroma_api/{ => controllers}/subscription_notification_controller.ex (100%) rename test/web/pleroma_api/{ => controllers}/pleroma_api_controller_test.exs (100%) rename test/web/pleroma_api/{ => controllers}/subscription_notification_controller_test.exs (100%) 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 100% rename from lib/pleroma/web/pleroma_api/pleroma_api_controller.ex rename to lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex diff --git a/lib/pleroma/web/pleroma_api/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex similarity index 100% rename from lib/pleroma/web/pleroma_api/subscription_notification_controller.ex rename to lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex 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/subscription_notification_controller_test.exs b/test/web/pleroma_api/controllers/subscription_notification_controller_test.exs similarity index 100% rename from test/web/pleroma_api/subscription_notification_controller_test.exs rename to test/web/pleroma_api/controllers/subscription_notification_controller_test.exs From 9fa2586abd915342095574f05358642412db0f04 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 17 Sep 2019 17:44:10 +0300 Subject: [PATCH 020/138] Refactor SubscriptionNotificationView --- .../subscription_notification_controller.ex | 20 +++++++++++++++++-- .../views/subscription_notification_view.ex | 9 +++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex index fa8307668..37c2222de 100644 --- a/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex @@ -7,11 +7,16 @@ defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + alias Pleroma.Activity alias Pleroma.SubscriptionNotification + alias Pleroma.User alias Pleroma.Web.PleromaAPI.PleromaAPI def index(%{assigns: %{user: user}} = conn, params) do - notifications = PleromaAPI.get_subscription_notifications(user, params) + notifications = + user + |> PleromaAPI.get_subscription_notifications(params) + |> Enum.map(&build_notification_data/1) conn |> add_link_headers(notifications) @@ -20,7 +25,10 @@ def index(%{assigns: %{user: user}} = conn, params) do def show(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do with {:ok, notification} <- SubscriptionNotification.get(user, id) do - render(conn, "show.json", %{subscription_notification: notification, for: user}) + render(conn, "show.json", %{ + subscription_notification: build_notification_data(notification), + for: user + }) else {:error, reason} -> conn @@ -52,4 +60,12 @@ def destroy_multiple( SubscriptionNotification.destroy_multiple(user, ids) json(conn, %{}) end + + defp build_notification_data(%{activity: %{data: data}} = notification) do + %{ + notification: notification, + actor: User.get_cached_by_ap_id(data["actor"]), + parent_activity: Activity.get_create_by_object_ap_id(data["object"]) + } + end end diff --git a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex index d7f7f4c5a..0eccbcbb9 100644 --- a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex +++ b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationView do use Pleroma.Web, :view alias Pleroma.Activity - alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView @@ -17,11 +16,13 @@ def render("index.json", %{notifications: notifications, for: user}) do end def render("show.json", %{ - subscription_notification: %{activity: activity} = notification, + subscription_notification: %{ + notification: %{activity: activity} = notification, + actor: actor, + parent_activity: parent_activity + }, for: user }) do - actor = User.get_cached_by_ap_id(activity.data["actor"]) - parent_activity = Activity.get_create_by_object_ap_id(activity.data["object"]) mastodon_type = Activity.mastodon_notification_type(activity) response = %{ From 2ad50583f0cc341413663a595890047823c9abeb Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 23 Sep 2019 18:54:23 +0200 Subject: [PATCH 021/138] Document and test /api/ap/whoami --- .../web/activity_pub/activity_pub_controller.ex | 1 + .../activity_pub/activity_pub_controller_test.exs | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 01b34fb1d..34bf04a20 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -293,6 +293,7 @@ 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") diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 9e8e420ec..0f8638a94 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -976,4 +976,19 @@ 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 + end end From 815b9045087ff4f88355b4cfa6c0a9b8080c6db6 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 23 Sep 2019 19:16:36 +0200 Subject: [PATCH 022/138] Add support for AP C2S uploadMedia Closes: https://git.pleroma.social/pleroma/pleroma/issues/1171 --- .../activity_pub/activity_pub_controller.ex | 27 +++++++++++++++++++ .../web/activity_pub/views/user_view.ex | 3 ++- lib/pleroma/web/router.ex | 1 + .../activity_pub_controller_test.exs | 25 +++++++++++++++++ test/web/ostatus/ostatus_controller_test.exs | 6 +++-- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 34bf04a20..6b60132d4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -443,4 +443,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/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index a2f73e140..ff54b95ed 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -25,7 +25,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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index b9b85fd67..8ee188f08 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -572,6 +572,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_write) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) + post("/api/ap/uploadMedia", ActivityPubController, :upload_media) end scope [] do diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 0f8638a94..c2bcddf85 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -990,5 +990,30 @@ test "/api/ap/whoami", %{conn: conn} do 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/uploadMedia", %{"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/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index ec96f0012..fc1635a2f 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -400,7 +400,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/uploadMedia" } assert response["@context"] == [ @@ -462,7 +463,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/uploadMedia" } assert response["@context"] == [ From 494bb6bac64361860db194aed57618450a76177d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 23 Sep 2019 22:37:30 +0300 Subject: [PATCH 023/138] updated tests --- .../controllers/mastodon_api_controller.ex | 18 ++++----- .../web/mastodon_api/views/status_view.ex | 4 +- .../mastodon_api_controller_test.exs | 39 ++++++++++++------- 3 files changed, 35 insertions(+), 26 deletions(-) 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 0c2b8dbb7..da74e4aa2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -44,6 +44,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.RichMedia alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.ControllerHelper @@ -1530,19 +1531,16 @@ 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), + def status_card(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id), true <- Visibility.visible_for_user?(activity, user) do - data = - StatusView.render( - "card.json", - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - ) + data = RichMedia.Helpers.fetch_data_for_activity(activity) - json(conn, data) + conn + |> put_view(StatusView) + |> render("card.json", data) else - _e -> - json(conn, %{}) + _e -> {:error, :not_found} 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..0450ed4d9 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -343,9 +343,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"] diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 46e74fc75..14cd71aa8 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -2798,6 +2798,18 @@ test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do %{user: user} end + test "returns empty result when rich_media disabled", %{conn: conn, user: user} do + Config.put([:rich_media, :enabled], false) + {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"}) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/card") + |> json_response(200) + + assert response == nil + end + test "returns rich-media card", %{conn: conn, user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"}) @@ -2869,22 +2881,23 @@ test "replaces missing description with an empty string", %{conn: conn, user: us } end - test "returns empty object when id invalid", %{conn: conn} do - response = - conn - |> get("/api/v1/statuses/9eoozpwTul5mjSEDRI/card") - |> json_response(200) - - assert response == %{} + test "returns 404 response when id invalid", %{conn: conn} do + assert %{"error" => "Record not found"} = + conn + |> get("/api/v1/statuses/9eoozpwTul5mjSEDRI/card") + |> json_response(404) end - test "returns empty object when id isn't FlakeID", %{conn: conn} do - response = - conn - |> get("/api/v1/statuses/3ebbadd1-eb14-4e20-8118/card") - |> json_response(200) + test "returns 404 response when id isn't FlakeID", %{conn: conn} do + assert %{"error" => "Record not found"} = + conn + |> get("/api/v1/statuses/3ebbadd1-eb14-4e20-8118/card") + |> json_response(404) - assert response == %{} + assert %{"error" => "Record not found"} = + conn + |> get("/api/v1/statuses/8118/card") + |> json_response(404) end end From 5820dc40fcf325b882344258fe26fc662b7afb54 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Sep 2019 19:00:31 +0200 Subject: [PATCH 024/138] litepub-0.1.jsonld: Add uploadMedia --- priv/static/schemas/litepub-0.1.jsonld | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 57ed05eba..c312648ea 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -27,6 +27,10 @@ "oauthRegistrationEndpoint": { "@id": "litepub:oauthRegistrationEndpoint", "@type": "@id" + }, + "uploadMedia": { + "@id": "litepub:uploadMedia", + "@type": "@id" } } ] From 0dc8f3d6d2fa18261e9a6fa8da540c434f1fa67b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Sep 2019 19:03:06 +0200 Subject: [PATCH 025/138] =?UTF-8?q?/api/ap/uploadMedia=20=E2=86=92=20/api/?= =?UTF-8?q?ap/upload=5Fmedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pleroma/web/router.ex | 2 +- test/web/activity_pub/activity_pub_controller_test.exs | 2 +- test/web/ostatus/ostatus_controller_test.exs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8ee188f08..2e8fda4ab 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -572,7 +572,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_write) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) - post("/api/ap/uploadMedia", ActivityPubController, :upload_media) + post("/api/ap/upload_media", ActivityPubController, :upload_media) end scope [] do diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index c2bcddf85..8868d8a0d 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -1008,7 +1008,7 @@ test "uploadMedia", %{conn: conn} do conn = conn |> assign(:user, user) - |> post("/api/ap/uploadMedia", %{"file" => image, "description" => desc}) + |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) assert object = json_response(conn, :created) assert object["name"] == desc diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index fc1635a2f..3a867b8c0 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -401,7 +401,7 @@ test "activity+json format. it redirects on actual feed of user", %{conn: conn} "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", - "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/uploadMedia" + "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" } assert response["@context"] == [ @@ -464,7 +464,7 @@ test "json format. it redirects on actual feed of user", %{conn: conn} do "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", - "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/uploadMedia" + "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" } assert response["@context"] == [ From eed774d058bfac2d36fd79faa915394a97baa6db Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Sep 2019 16:10:54 +0700 Subject: [PATCH 026/138] Add CommonAPI.ActivityDraft --- lib/pleroma/web/common_api/activity_draft.ex | 222 ++++++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 103 ++------ lib/pleroma/web/common_api/utils.ex | 124 +++++----- lib/pleroma/web/controller_helper.ex | 2 +- .../controllers/mastodon_api_controller.ex | 47 ++-- 5 files changed, 322 insertions(+), 176 deletions(-) create mode 100644 lib/pleroma/web/common_api/activity_draft.ex 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..b4480bd18 --- /dev/null +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -0,0 +1,222 @@ +# 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() + |> attachments() + |> full_payload() + |> in_reply_to() + |> in_reply_to_conversation() + |> visibility() + |> expires_at() + |> poll() + |> content() + |> to_and_cc() + |> context() + |> sensitive() + |> object() + |> preview?() + |> changes() + |> 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(%{valid?: false} = draft), do: draft + + 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(%{valid?: false} = draft), do: draft + + 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(%{valid?: false} = draft), do: draft + + 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 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 4a74dc16f..d34bb7285 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.Emoji alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -173,9 +172,7 @@ defp normalize_and_validate_choice_indices(choices, count) do 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}, @@ -201,9 +198,9 @@ def get_replied_to_visibility(activity) do 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,97 +210,27 @@ 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 <- put_emoji(object, 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 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 - # parse and put emoji to object data - defp put_emoji(map, text, emojis) do - Map.put( - map, - "emoji", - Map.merge(Emoji.Formatter.get_emoji_map(text), emojis) - ) + 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) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 52fbc162b..8093a56a6 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do import Pleroma.Web.Gettext + import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1] alias Calendar.Strftime alias Pleroma.Activity @@ -41,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 @@ -159,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, 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( @@ -347,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 @@ -571,15 +568,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..e90bf842e 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 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 1e88ff7fe..28d0e58f3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -575,14 +575,11 @@ def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => schedule 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 + def post_status( + %{assigns: %{user: user}} = conn, + %{"status" => _, "scheduled_at" => scheduled_at} = params + ) do + if ScheduledActivity.far_enough?(scheduled_at) do with {:ok, scheduled_activity} <- ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do conn @@ -590,24 +587,26 @@ def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do |> render("show.json", %{scheduled_activity: scheduled_activity}) end else - params = Map.drop(params, ["scheduled_at"]) + post_status(conn, Map.drop(params, ["scheduled_at"])) + end + end - case CommonAPI.post(user, params) do - {:error, message} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{error: message}) + def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do + case CommonAPI.post(user, params) do + {:ok, activity} -> + conn + |> put_view(StatusView) + |> try_render("status.json", %{ + activity: activity, + for: user, + as: :activity, + with_direct_conversation_id: true + }) - {:ok, activity} -> - conn - |> put_view(StatusView) - |> try_render("status.json", %{ - activity: activity, - for: user, - as: :activity, - with_direct_conversation_id: true - }) - end + {:error, message} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) end end From de3e90e536ea0759b024155aee307cc340db3cf1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 23 Sep 2019 18:52:41 +0700 Subject: [PATCH 027/138] Add ActivityDraft.with_valid/2 --- lib/pleroma/web/common_api/activity_draft.ex | 25 +++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index b4480bd18..aa7c8c381 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -40,20 +40,20 @@ def create(user, params) do |> put_params(params) |> status() |> summary() - |> attachments() |> full_payload() - |> in_reply_to() - |> in_reply_to_conversation() - |> visibility() |> expires_at() |> poll() + |> with_valid(&in_reply_to/1) + |> with_valid(&attachments/1) + |> with_valid(&in_reply_to_conversation/1) + |> with_valid(&visibility/1) |> content() - |> to_and_cc() - |> context() + |> with_valid(&to_and_cc/1) + |> with_valid(&context/1) |> sensitive() - |> object() + |> with_valid(&object/1) |> preview?() - |> changes() + |> with_valid(&changes/1) |> validate() end @@ -136,8 +136,6 @@ defp content(draft) do %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} end - defp to_and_cc(%{valid?: false} = draft), do: draft - defp to_and_cc(draft) do addressed_users = draft.mentions @@ -166,8 +164,6 @@ defp sensitive(draft) do %__MODULE__{draft | sensitive: sensitive} end - defp object(%{valid?: false} = draft), do: draft - defp object(draft) do emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) @@ -195,8 +191,6 @@ defp preview?(draft) do %__MODULE__{draft | preview?: preview?} end - defp changes(%{valid?: false} = draft), do: draft - defp changes(draft) do direct? = draft.visibility == "direct" @@ -213,6 +207,9 @@ defp changes(draft) do %__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 From c57ad0a4020fe88521b83d471fb8b71d637fddf1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Sep 2019 15:56:20 +0700 Subject: [PATCH 028/138] Cleanup CommonAPI --- lib/pleroma/web/common_api/common_api.ex | 159 ++++++++++------------- lib/pleroma/web/common_api/utils.ex | 12 +- 2 files changed, 79 insertions(+), 92 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index d34bb7285..a00e4b0d8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -17,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 @@ -75,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 @@ -86,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 @@ -107,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"]) @@ -149,27 +136,37 @@ 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"} @@ -194,7 +191,7 @@ 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 @@ -234,13 +231,12 @@ 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) + source_data = user.info |> Map.get(:source_data, %{}) |> Map.put("tag", emoji) user = - with {:ok, user} <- User.update_info(user, &User.Info.set_source_data(&1, source_data)) 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(%{ @@ -255,14 +251,8 @@ 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), {:ok, _user} <- User.update_info(user, &User.Info.add_pinnned_activity(&1, activity)) do @@ -299,51 +289,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} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 8093a56a6..88a5f434a 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -231,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"]) @@ -431,12 +431,14 @@ def confirm_current_password(user, password) do end end - def emoji_from_profile(%{info: _info} = user) do - (Emoji.Formatter.get_emoji(user.bio) ++ Emoji.Formatter.get_emoji(user.name)) - |> Enum.map(fn {shortcode, %Emoji{file: 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) From 3572cf29b7374947ebfbb42a20d370a75f5f0a40 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 26 Sep 2019 10:53:42 +0700 Subject: [PATCH 029/138] Extract timeline actions from `MastodonAPIController` into `TimelineController` --- lib/pleroma/web/controller_helper.ex | 2 +- .../controllers/mastodon_api_controller.ex | 125 +------- .../controllers/timeline_controller.ex | 136 ++++++++ lib/pleroma/web/router.ex | 10 +- .../controllers/timeline_controller_test.exs | 291 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 283 +---------------- 6 files changed, 438 insertions(+), 409 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex create mode 100644 test/web/mastodon_api/controllers/timeline_controller_test.exs diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b53a01955..e90bf842e 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 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 1e88ff7fe..74a8b5055 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -6,7 +6,7 @@ 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] + only: [json_response: 3, add_link_headers: 2, truthy_param?: 1] alias Ecto.Changeset alias Pleroma.Activity @@ -44,7 +44,6 @@ 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 @@ -156,7 +155,7 @@ def update_credentials(%{assigns: %{user: user}} = conn, params) do ] |> Enum.reduce(%{}, fn key, acc -> add_if_present(acc, params, to_string(key), key, fn value -> - {:ok, ControllerHelper.truthy_param?(value)} + {:ok, truthy_param?(value)} end) end) |> add_if_present(params, "default_scope", :default_scope) @@ -344,43 +343,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) - |> 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 = @@ -400,25 +362,6 @@ def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do 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 @@ -822,45 +765,6 @@ def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do 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 @@ -1173,31 +1077,6 @@ def account_lists(%{assigns: %{user: user}} = conn, %{"id" => account_id}) do 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) 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..bb8b0eb32 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -0,0 +1,136 @@ +# 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.Web.ActivityPub.ActivityPub + + 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/router.ex b/lib/pleroma/web/router.ex index 316c895ee..2575481ff 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -319,8 +319,8 @@ defmodule Pleroma.Web.Router do 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) get("/bookmarks", MastodonAPIController, :bookmarks) @@ -466,9 +466,9 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_read_or_public) - get("/timelines/public", MastodonAPIController, :public_timeline) - get("/timelines/tag/:tag", MastodonAPIController, :hashtag_timeline) - get("/timelines/list/:list_id", MastodonAPIController, :list_timeline) + get("/timelines/public", TimelineController, :public) + get("/timelines/tag/:tag", TimelineController, :hashtag) + get("/timelines/list/:list_id", TimelineController, :list) get("/statuses", MastodonAPIController, :get_statuses) get("/statuses/:id", MastodonAPIController, :get_status) 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 cd672132b..7f7a89516 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -20,12 +20,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do 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 Pleroma.Factory import Swoosh.TestAssertions + import Tesla.Mock @image "data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7" @@ -37,82 +37,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 - - 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 - describe "posting statuses" do setup do user = insert(:user) @@ -419,80 +343,6 @@ test "maximum date limit is enforced", %{conn: conn} do 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) @@ -556,33 +406,6 @@ test "Conversations", %{conn: conn} do 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) @@ -955,50 +778,6 @@ test "delete a filter", %{conn: conn} do 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 "reblogging" do test "reblogs and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) @@ -1554,62 +1333,6 @@ test "mascot retrieving", %{conn: conn} do 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) From fc16bec3176bad683dfef1be472f09be1a86928b Mon Sep 17 00:00:00 2001 From: Ekaterina Vaartis Date: Thu, 26 Sep 2019 09:52:11 +0300 Subject: [PATCH 030/138] Add list_from endpoint to the pleroma_api docs --- docs/api/pleroma_api.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index a469ddfbf..ac5489aa3 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -423,6 +423,15 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * 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` From b736312123bd11e8ba87b8b39245c0f441ebd7fb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Thu, 26 Sep 2019 15:17:36 +0300 Subject: [PATCH 031/138] Update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a76e6cf8..291c961ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Refreshing poll results for remote polls - Admin API: Add ability to require password reset +- Pleroma API: `GET /api/v1/pleroma/subscription_notifications/` to get list of subscription notifications +- Pleroma API: `GET /api/v1/pleroma/subscription_notifications/:id` to get a subscription notification +- Pleroma API: `POST /api/v1/pleroma/subscription_notifications/clear` to clear all subscription notifications +- Pleroma API: `POST /api/v1/pleroma/subscription_notifications/dismiss` to clear a subscription notification +- Pleroma API: `DELETE /api/v1/pleroma/subscription_notifications/destroy_multiple` to clear multiple subscription notifications ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) @@ -15,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Admin API: Return `total` when querying for reports - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`) - Admin API: Return link alongside with token on password reset +- Mastodon API: notifications no longer include subscription notifications - they are now served from new endpoints in Pleroma API ### 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`) From f249b2381f15f089f5f87f16e467b63d34bbea70 Mon Sep 17 00:00:00 2001 From: kPherox Date: Thu, 26 Sep 2019 16:10:34 +0000 Subject: [PATCH 032/138] Fix code block for admin api document --- docs/api/admin_api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index d4e08f221..d7ab808d5 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -711,6 +711,7 @@ Compile time settings (need instance reboot): } ] } +``` - Response: From 73ae38ca04df02656bfb239ceba4ffe64879e927 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 26 Sep 2019 21:08:04 +0300 Subject: [PATCH 033/138] add deprecated tag --- .../web/mastodon_api/controllers/mastodon_api_controller.ex | 2 ++ 1 file changed, 2 insertions(+) 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 5e1977b8e..8f6b3456a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -1474,6 +1474,8 @@ defp fetch_suggestion_id(attrs) do end end + @doc false + @deprecated "https://github.com/tootsuite/mastodon/pull/11213" def status_card(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id(id), true <- Visibility.visible_for_user?(activity, user) do From 98d1347a4ea1c296d2f07b9467addc56ef2dc676 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 9 Sep 2019 21:49:02 +0700 Subject: [PATCH 034/138] Extract status actions from `MastodonAPIController` into `StatusController` --- lib/pleroma/bbs/handler.ex | 2 +- .../web/admin_api/admin_api_controller.ex | 4 +- .../controllers/mastodon_api_controller.ex | 264 ---------------- .../controllers/status_controller.ex | 294 ++++++++++++++++++ .../mastodon_api/views/conversation_view.ex | 2 +- .../mastodon_api/views/notification_view.ex | 6 +- .../web/mastodon_api/views/status_view.ex | 16 +- lib/pleroma/web/router.ex | 36 +-- lib/pleroma/web/views/streamer_view.ex | 4 +- test/integration/mastodon_websocket_test.exs | 2 +- test/web/admin_api/views/report_view_test.exs | 2 +- .../views/notification_view_test.exs | 6 +- .../mastodon_api/views/status_view_test.exs | 44 +-- 13 files changed, 354 insertions(+), 328 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/status_controller.ex 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/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 90aef99f7..21da8a7ff 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -513,7 +513,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} @@ -537,7 +537,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 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 e4ae63231..82bba43e5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -51,28 +51,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do @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) @@ -362,63 +346,6 @@ def user_statuses(%{assigns: %{user: reading_user}} = conn, params) do end 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"]), @@ -518,143 +445,6 @@ def delete_scheduled_status(%{assigns: %{user: user}} = conn, %{"id" => schedule end end - def post_status( - %{assigns: %{user: user}} = conn, - %{"status" => _, "scheduled_at" => scheduled_at} = params - ) do - 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 - post_status(conn, Map.drop(params, ["scheduled_at"])) - end - end - - def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do - case CommonAPI.post(user, params) do - {:ok, activity} -> - conn - |> put_view(StatusView) - |> try_render("status.json", %{ - activity: activity, - for: user, - as: :activity, - with_direct_conversation_id: true - }) - - {:error, message} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{error: message}) - 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 relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do id = List.wrap(id) q = from(u in User, where: u.id in ^id) @@ -726,44 +516,6 @@ def get_mascot(%{assigns: %{user: user}} = conn, _params) do |> 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 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 @@ -1394,22 +1146,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 reports(%{assigns: %{user: user}} = conn, params) do case CommonAPI.report(user, params) do {:ok, activity} -> 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..89869bda0 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -0,0 +1,294 @@ +# 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.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 + alias Pleroma.Web.MastodonAPI.StatusView + + @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 + + @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" + 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("accounts.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("accounts.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 + }) + + # TODO: Move to view + grouped_activities = Enum.group_by(activities, fn %{id: id} -> id < activity.id end) + + 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 +end diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 40acc07b3..4aeb79d81 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -24,7 +24,7 @@ def render("participation.json", %{participation: participation, for: user}) do activity = Activity.get_by_id_with_object(last_activity_id) - last_status = StatusView.render("status.json", %{activity: activity, for: user}) + last_status = StatusView.render("show.json", %{activity: activity, for: user}) # Conversations return all users except the current user. users = diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index ec8eadcaa..05110a192 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -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/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index ef796cddd..59bef30f2 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"] || []) @@ -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"]) @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 2575481ff..7a20b6d75 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -355,19 +355,19 @@ defmodule Pleroma.Web.Router do patch("/accounts/update_credentials", MastodonAPIController, :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) - post("/statuses/:id/bookmark", MastodonAPIController, :bookmark_status) - 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) @@ -448,10 +448,10 @@ 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) @@ -470,9 +470,9 @@ defmodule Pleroma.Web.Router do get("/timelines/tag/:tag", TimelineController, :hashtag) get("/timelines/list/:list_id", TimelineController, :list) - 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) 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/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/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index 40df01101..35b6947a0 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -61,7 +61,7 @@ test "includes reported statuses" do AccountView.render("account.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 } diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 9231aaec8..86268fcfa 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -28,7 +28,7 @@ test "Mention notification" do 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}), + status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -51,7 +51,7 @@ test "Favourite notification" do 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}), + status: StatusView.render("show.json", %{activity: create_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -73,7 +73,7 @@ test "Reblog notification" do 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}), + status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), 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..c17d0ef95 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"] || "") @@ -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,7 +604,7 @@ 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 From 76b7e5cd5b34e68055bab7353cb845f3429d897f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 26 Sep 2019 13:38:45 +0700 Subject: [PATCH 035/138] Move StatusController tests from MastodonAPIControllerTest to StatusControllerTest --- .../controllers/status_controller_test.exs | 1056 +++++++++++++++++ .../mastodon_api_controller_test.exs | 1027 ---------------- 2 files changed, 1056 insertions(+), 1027 deletions(-) create mode 100644 test/web/mastodon_api/controllers/status_controller_test.exs 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..f80ce7704 --- /dev/null +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -0,0 +1,1056 @@ +# 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.User + 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 "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 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 +end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 7f7a89516..6435ad7a9 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Ecto.Changeset alias Pleroma.Activity - alias Pleroma.ActivityExpiration alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Object @@ -37,312 +36,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) - 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} = 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 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 "Conversations", %{conn: conn} do user_one = insert(:user) user_two = insert(:user) @@ -575,81 +268,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) @@ -778,160 +396,6 @@ test "delete a filter", %{conn: conn} do 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) @@ -2098,10 +1562,6 @@ test "put settings", %{conn: conn} do [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) @@ -2115,257 +1575,6 @@ test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do 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 @@ -2811,51 +2020,6 @@ test "deletes a scheduled activity", %{conn: conn} do 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 = @@ -3135,197 +2299,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) From 5ea5c58a85b7be56370b151ce8977982d47fde8c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 26 Sep 2019 14:28:35 +0700 Subject: [PATCH 036/138] Move view logic from StatusController.context to StatusView and add a test --- .../controllers/status_controller.ex | 27 +------------------ .../web/mastodon_api/views/status_view.ex | 14 ++++++++++ .../controllers/status_controller_test.exs | 21 +++++++++++++++ 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 89869bda0..f7da10289 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -21,7 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ScheduledActivityView - alias Pleroma.Web.MastodonAPI.StatusView @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a @@ -264,31 +263,7 @@ def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do "exclude_id" => activity.id }) - # TODO: Move to view - grouped_activities = Enum.group_by(activities, fn %{id: id} -> id < activity.id end) - - 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) + render(conn, "context.json", activity: activity, activities: activities, user: user) 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 59bef30f2..715d40766 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -439,6 +439,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/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index f80ce7704..14c7bd6d9 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1053,4 +1053,25 @@ test "requires authentification for private posts", %{conn: conn, user: user} do 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 From 14294243a294d764b449e1eae19c4cd87b9d4d82 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 04:22:40 +0000 Subject: [PATCH 037/138] mastodon api: implement follow_requests_count --- .../web/mastodon_api/views/account_view.ex | 16 ++++ .../mastodon_api/views/account_view_test.exs | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index a23aeea9b..8cf9e9d5c 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -166,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 @@ -174,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/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index f2f334992..d965f76bf 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -419,4 +419,79 @@ test "shows actual follower/following count to the account owner" do } = AccountView.render("account.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("account.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("account.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("account.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("account.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("account.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("account.json", %{user: user, for: user}) + + {:ok, _other_user} = CommonAPI.accept_follow_request(other_user, user) + + assert %{locked: true, follow_requests_count: 0} = + AccountView.render("account.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("account.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("account.json", %{user: user, for: user}) + + {:ok, _other_user} = CommonAPI.reject_follow_request(other_user, user) + + assert %{locked: true, follow_requests_count: 0} = + AccountView.render("account.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("account.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("account.json", %{user: user, for: user}) + end + end end From 843c11d1f6f2455d5169952592b9b9d18be2a8fb Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 04:24:20 +0000 Subject: [PATCH 038/138] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8163135..2ea4dcc72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Pleroma API: `POST /api/v1/pleroma/subscription_notifications/clear` to clear all subscription notifications - Pleroma API: `POST /api/v1/pleroma/subscription_notifications/dismiss` to clear a subscription notification - Pleroma API: `DELETE /api/v1/pleroma/subscription_notifications/destroy_multiple` to clear multiple subscription notifications +- Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition) ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) From 6c7c35dbe11c3871eea1a1c5745befdc2068e526 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 11:55:47 +0700 Subject: [PATCH 039/138] Fix SubscriptionNotificationView --- .../web/pleroma_api/views/subscription_notification_view.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex index 0eccbcbb9..fc41a7389 100644 --- a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex +++ b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex @@ -36,19 +36,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" -> From 621377f378c7cfde88f87356e9fd65ed6d9f6d50 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 13:06:25 +0700 Subject: [PATCH 040/138] Extract filter actions from `MastodonAPIController` to `FilterController` --- .../controllers/filter_controller.ex | 72 +++++++++ .../controllers/mastodon_api_controller.ex | 61 -------- .../views/subscription_notification_view.ex | 6 +- lib/pleroma/web/router.ex | 10 +- .../controllers/filter_controller_test.exs | 137 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 129 ----------------- 6 files changed, 217 insertions(+), 198 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex create mode 100644 test/web/mastodon_api/controllers/filter_controller_test.exs 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..19041304e --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -0,0 +1,72 @@ +# 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 + + @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/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 82bba43e5..9e2382483 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -14,7 +14,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Emoji - alias Pleroma.Filter alias Pleroma.HTTP alias Pleroma.Object alias Pleroma.Pagination @@ -30,7 +29,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do 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 @@ -1040,65 +1038,6 @@ def empty_object(conn, _) do json(conn, %{}) end - 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) diff --git a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex index 0eccbcbb9..fc41a7389 100644 --- a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex +++ b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex @@ -36,19 +36,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/router.ex b/lib/pleroma/web/router.ex index 8bf55631e..30ebf435c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -348,7 +348,7 @@ defmodule Pleroma.Web.Router do get("/domain_blocks", MastodonAPIController, :domain_blocks) - get("/filters", MastodonAPIController, :get_filters) + get("/filters", FilterController, :index) get("/suggestions", MastodonAPIController, :suggestions) @@ -392,10 +392,10 @@ 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) 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 6435ad7a9..9c6cdbd7a 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -16,7 +16,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do 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.Push @@ -268,134 +267,6 @@ test "creates an oauth app", %{conn: conn} do assert expected == json_response(conn, 200) 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 "user timelines" do test "gets a users statuses", %{conn: conn} do user_one = insert(:user) From 0a5b106ddd333f2dec2b62badeca98e6091ba805 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 13:35:45 +0700 Subject: [PATCH 041/138] Extract scheduled statuses actions from `MastodonAPIController` to `ScheduledActivityController` --- .../controllers/mastodon_api_controller.ex | 51 ----- .../scheduled_activity_controller.ex | 51 +++++ lib/pleroma/web/router.ex | 8 +- .../scheduled_activity_controller_test.exs | 113 ++++++++++ .../controllers/status_controller_test.exs | 112 +++++++++ .../mastodon_api_controller_test.exs | 212 ------------------ 6 files changed, 280 insertions(+), 267 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex create mode 100644 test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs 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 82bba43e5..1f6211917 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -20,7 +20,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Pagination alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo - alias Pleroma.ScheduledActivity alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web @@ -35,7 +34,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.ReportView - alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App @@ -396,55 +394,6 @@ 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( - %{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 relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do id = List.wrap(id) q = from(u in User, where: u.id in ^id) 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..0a56b10b6 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -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.ScheduledActivityController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.ScheduledActivity + alias Pleroma.Web.MastodonAPI.MastodonAPI + + plug(:assign_scheduled_activity when action != :index) + + 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/router.ex b/lib/pleroma/web/router.ex index 8bf55631e..e12e6d313 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -339,8 +339,8 @@ defmodule Pleroma.Web.Router do post("/notifications/dismiss", NotificationController, :dismiss) delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) - 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) @@ -377,8 +377,8 @@ defmodule Pleroma.Web.Router do 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) 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 index 14c7bd6d9..cbd4bafe8 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -9,7 +9,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do 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 @@ -224,6 +227,115 @@ test "posting a direct status", %{conn: conn} do 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) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 6435ad7a9..e77610ba3 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -6,12 +6,10 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do use Pleroma.Web.ConnCase alias Ecto.Changeset - alias Pleroma.Activity 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 @@ -1810,216 +1808,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 - describe "create account by app" do test "Account registration via Application", %{conn: conn} do conn = From 8d315301193a39575291db5c5d9fabe492261664 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 11:46:20 +0700 Subject: [PATCH 042/138] Cleanup ScheduledActivityView --- .../views/scheduled_activity_view.ex | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) 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 From 99c5a35890f470c30661e900380bcd7c4b75d6d0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 14:25:17 +0700 Subject: [PATCH 043/138] Extract follow requests actions from `MastodonAPIController` to `FollowRequestController` --- .../controllers/follow_request_controller.ex | 49 +++++++++++ .../controllers/mastodon_api_controller.ex | 36 --------- lib/pleroma/web/router.ex | 6 +- .../follow_request_controller_test.exs | 81 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 67 --------------- 5 files changed, 133 insertions(+), 106 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex create mode 100644 test/web/mastodon_api/controllers/follow_request_controller_test.exs 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..267014b97 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -0,0 +1,49 @@ +# 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.User + alias Pleroma.Web.CommonAPI + + plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + plug(:assign_follower when action != :index) + + action_fallback(:errors) + + @doc "GET /api/v1/follow_requests" + def index(%{assigns: %{user: followed}} = conn, _params) do + follow_requests = User.get_follow_requests(followed) + + render(conn, "accounts.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/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 82bba43e5..0ee9f034a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -550,42 +550,6 @@ def following(%{assigns: %{user: for_user}} = conn, %{"id" => id} = params) do end end - def follow_requests(%{assigns: %{user: followed}} = conn, _params) do - follow_requests = User.get_follow_requests(followed) - - conn - |> put_view(AccountView) - |> render("accounts.json", %{for: followed, users: follow_requests, as: :user}) - 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}, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8bf55631e..72d3827a5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -323,7 +323,7 @@ defmodule Pleroma.Web.Router do get("/accounts/:id/lists", MastodonAPIController, :account_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) @@ -419,8 +419,8 @@ defmodule Pleroma.Web.Router do 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("/follow_requests/:id/authorize", FollowRequestController, :authorize) + post("/follow_requests/:id/reject", FollowRequestController, :reject) post("/domain_blocks", MastodonAPIController, :block_domain) delete("/domain_blocks", MastodonAPIController, :unblock_domain) 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 6435ad7a9..60ade00d2 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -570,51 +570,6 @@ test "returns uploaded image", %{conn: conn, image: image} do 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"}}) @@ -626,28 +581,6 @@ test "verify_credentials", %{conn: conn} do 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 From 408750b94e1374c815c80a1060892231f40b54fb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 27 Sep 2019 14:28:05 +0700 Subject: [PATCH 044/138] Extract domain blocks actions from `MastodonAPIController` to `DomainBlockController` --- .../controllers/domain_block_controller.ex | 26 ++++++++++ .../controllers/mastodon_api_controller.ex | 14 ----- lib/pleroma/web/router.ex | 6 +-- .../domain_block_controller_test.exs | 51 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 40 --------------- 5 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex create mode 100644 test/web/mastodon_api/controllers/domain_block_controller_test.exs 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..03db6c9b8 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -0,0 +1,26 @@ +# 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.User + + @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/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 82bba43e5..e96bf6fd9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -715,20 +715,6 @@ def blocks(%{assigns: %{user: user}} = conn, _) do 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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8bf55631e..d370f30db 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -346,7 +346,7 @@ defmodule Pleroma.Web.Router do 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) @@ -422,8 +422,8 @@ defmodule Pleroma.Web.Router do 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("/domain_blocks", DomainBlockController, :create) + delete("/domain_blocks", DomainBlockController, :delete) post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe) post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe) 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..3c3558385 --- /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, async: true + + 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 6435ad7a9..5bd76d431 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1176,46 +1176,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) From f9380289eb251c818e87e8f0ad0a41fc8bdd90aa Mon Sep 17 00:00:00 2001 From: minibikini Date: Fri, 27 Sep 2019 21:59:23 +0000 Subject: [PATCH 045/138] Add `remote_ip` plug --- CHANGELOG.md | 1 + config/config.exs | 2 + config/description.exs | 36 +++++++++++++++++ docs/config.md | 15 +++++++ installation/pleroma.nginx | 1 + lib/pleroma/plugs/remote_ip.ex | 54 +++++++++++++++++++++++++ lib/pleroma/web/endpoint.ex | 5 +-- mix.exs | 3 ++ mix.lock | 2 + test/plugs/remote_ip_test.exs | 72 ++++++++++++++++++++++++++++++++++ 10 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/plugs/remote_ip.ex create mode 100644 test/plugs/remote_ip_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8163135..1d307f0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ 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. diff --git a/config/config.exs b/config/config.exs index 403ade60d..36bea19a0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -591,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 38b30bbf6..4547ea368 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2687,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/docs/config.md b/docs/config.md index ed119fd32..262d15bba 100644 --- a/docs/config.md +++ b/docs/config.md @@ -730,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. @@ -756,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/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/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/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/mix.exs b/mix.exs index 861b94ad0..3a605b455 100644 --- a/mix.exs +++ b/mix.exs @@ -159,6 +159,9 @@ defp deps do {: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 32443fb51..5f740638c 100644 --- a/mix.lock +++ b/mix.lock @@ -48,6 +48,7 @@ "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"}, @@ -87,6 +88,7 @@ "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"]}, + "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"}, 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 From 374f83d29b5793d75a3a6be7c18cf52cfed42b64 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 28 Sep 2019 01:56:20 +0300 Subject: [PATCH 046/138] Fix not being able to post empty statuses with attachments Attachment field was filled in after the empty status check --- lib/pleroma/web/common_api/activity_draft.ex | 2 +- .../controllers/status_controller_test.exs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index aa7c8c381..f7da81b34 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -40,11 +40,11 @@ def create(user, params) do |> put_params(params) |> status() |> summary() + |> with_valid(&attachments/1) |> full_payload() |> expires_at() |> poll() |> with_valid(&in_reply_to/1) - |> with_valid(&attachments/1) |> with_valid(&in_reply_to_conversation/1) |> with_valid(&visibility/1) |> content() diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index cbd4bafe8..c0121ac63 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -99,6 +99,28 @@ test "posting a status", %{conn: conn} do NaiveDateTime.to_iso8601(expiration.scheduled_at) end + test "posting an empty 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)], + "status" => "" + }) + + 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"}) From 9202904da9b48eb2a3884b8e89ea879e01d44b9a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 28 Sep 2019 01:21:28 +0200 Subject: [PATCH 047/138] status_controller.ex: Posting media status without content defined --- .../web/mastodon_api/controllers/status_controller.ex | 4 ++++ test/web/mastodon_api/controllers/status_controller_test.exs | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index f7da10289..ae3d51575 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -103,6 +103,10 @@ def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do 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), diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index c0121ac63..b194feae6 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -99,7 +99,7 @@ test "posting a status", %{conn: conn} do NaiveDateTime.to_iso8601(expiration.scheduled_at) end - test "posting an empty status with an attachment", %{conn: conn} do + test "posting an undefined status with an attachment", %{conn: conn} do user = insert(:user) file = %Plug.Upload{ @@ -114,8 +114,7 @@ test "posting an empty status with an attachment", %{conn: conn} do conn |> assign(:user, user) |> post("/api/v1/statuses", %{ - "media_ids" => [to_string(upload.id)], - "status" => "" + "media_ids" => [to_string(upload.id)] }) assert json_response(conn, 200) From 0e59d1dc04db64decc0c3bd47e3bf459cb768710 Mon Sep 17 00:00:00 2001 From: Maxim Filippov Date: Sun, 29 Sep 2019 00:01:35 +0300 Subject: [PATCH 048/138] Update admin_api.md --- docs/api/admin_api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index fcdb33944..8795c2628 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -330,10 +330,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### 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: From 51a4a211a3d1ac650e452a1ee9e9dd0d40040595 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Sep 2019 02:13:17 +0200 Subject: [PATCH 049/138] CHANGELOG.md: !1691 is a breaking change !1691: https://git.pleroma.social/pleroma/pleroma/merge_requests/1691 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d307f0e1..71110622d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 +- **Breaking** Admin API: Return link alongside with token on password reset - Mastodon API: notifications no longer include subscription notifications - they are now served from new endpoints in Pleroma API ### 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`) From 717cb4f9332a9cb71d6965ad9eea13861892881e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Sep 2019 02:14:53 +0200 Subject: [PATCH 050/138] admin_api.md: Put data-type info as the values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Similar to how the other responses examples are done, this also makes it proper JSON (as it doesn’t have comments). --- docs/api/admin_api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index 8795c2628..ee9e68cb1 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -312,8 +312,8 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ```json { - "token": "U13DX6muOvpRsj35_ij9wLxUbkU-eFvfKttxs6gIajo=", // password reset token (base64 string) - "link": "https://pleroma.social/api/pleroma/password_reset/U13DX6muOvpRsj35_ij9wLxUbkU-eFvfKttxs6gIajo%3D" + "token": "base64 reset token", + "link": "https://pleroma.social/api/pleroma/password_reset/url-encoded-base64-token" } ``` From e46dfd929548758cb99f8ea1bd7bf7e60b65833b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Sep 2019 02:16:52 +0200 Subject: [PATCH 051/138] CHANGELOG.md: Sorting, colon after breaking [ci skip] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71110622d..43a204be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 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`) -- **Breaking** Admin API: Return link alongside with token on password reset - Mastodon API: notifications no longer include subscription notifications - they are now served from new endpoints in Pleroma API ### 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`) From e9d1aa75d5dc0859b692e891f6e65949208a5f0f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 29 Sep 2019 18:43:27 +0300 Subject: [PATCH 052/138] Revert subscription refactoring. As discussed in pleroma-meta#2 This reverts commit eb9aa7aa1095de150d036839c78c402019efb4b1, reversing changes made to c4fbb56984d8f86df948cfd9b0f7c081d688c365. --- CHANGELOG.md | 9 +- lib/pleroma/notification.ex | 1 + lib/pleroma/subscription_notification.ex | 260 ------------------ lib/pleroma/web/activity_pub/activity_pub.ex | 2 - .../subscription_notification_controller.ex | 71 ----- lib/pleroma/web/pleroma_api/pleroma_api.ex | 40 --- .../views/subscription_notification_view.ex | 61 ---- lib/pleroma/web/push/impl.ex | 3 +- lib/pleroma/web/router.ex | 8 - ...5028_create_subscription_notifications.exs | 15 - test/notification_test.exs | 12 +- test/web/mastodon_api/mastodon_api_test.exs | 4 +- ...scription_notification_controller_test.exs | 234 ---------------- .../pleroma_api_controller_test.exs | 0 14 files changed, 11 insertions(+), 709 deletions(-) delete mode 100644 lib/pleroma/subscription_notification.ex delete mode 100644 lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex delete mode 100644 lib/pleroma/web/pleroma_api/pleroma_api.ex delete mode 100644 lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex delete mode 100644 priv/repo/migrations/20190824195028_create_subscription_notifications.exs delete mode 100644 test/web/pleroma_api/controllers/subscription_notification_controller_test.exs rename test/web/pleroma_api/{controllers => }/pleroma_api_controller_test.exs (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e859e318a..61323970a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Refreshing poll results for remote polls - Admin API: Add ability to require password reset -- Pleroma API: `GET /api/v1/pleroma/subscription_notifications/` to get list of subscription notifications -- Pleroma API: `GET /api/v1/pleroma/subscription_notifications/:id` to get a subscription notification -- Pleroma API: `POST /api/v1/pleroma/subscription_notifications/clear` to clear all subscription notifications -- Pleroma API: `POST /api/v1/pleroma/subscription_notifications/dismiss` to clear a subscription notification -- Pleroma API: `DELETE /api/v1/pleroma/subscription_notifications/destroy_multiple` to clear multiple subscription notifications -- Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition) ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) @@ -21,7 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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`) -- Mastodon API: notifications no longer include subscription notifications - they are now served from new endpoints in Pleroma API +- 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`) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d19924289..d94ae5971 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -230,6 +230,7 @@ def get_notified_from_activity( [] |> Utils.maybe_notify_to_recipients(activity) |> Utils.maybe_notify_mentioned_recipients(activity) + |> Utils.maybe_notify_subscribers(activity) |> Enum.uniq() User.get_users_from_set(recipients, local_only) diff --git a/lib/pleroma/subscription_notification.ex b/lib/pleroma/subscription_notification.ex deleted file mode 100644 index 1349d988c..000000000 --- a/lib/pleroma/subscription_notification.ex +++ /dev/null @@ -1,260 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.SubscriptionNotification do - use Ecto.Schema - - alias Pleroma.Activity - alias Pleroma.Object - alias Pleroma.Pagination - alias Pleroma.Repo - alias Pleroma.SubscriptionNotification - alias Pleroma.User - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.Push - alias Pleroma.Web.Streamer - - import Ecto.Query - import Ecto.Changeset - - @type t :: %__MODULE__{} - - schema "subscription_notifications" do - belongs_to(:user, User, type: FlakeId.Ecto.CompatType) - belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) - - timestamps() - end - - def changeset(%SubscriptionNotification{} = notification, attrs) do - cast(notification, attrs, []) - end - - def for_user_query(user, opts \\ []) do - query = - SubscriptionNotification - |> where(user_id: ^user.id) - |> where( - [n, a], - fragment( - "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", - a.actor - ) - ) - |> join(:inner, [n], activity in assoc(n, :activity)) - |> join(:left, [n, a], object in Object, - on: - fragment( - "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", - object.data, - a.data - ) - ) - |> preload([n, a, o], activity: {a, object: o}) - - if opts[:with_muted] do - query - else - query - |> where([n, a], a.actor not in ^user.info.muted_notifications) - |> where([n, a], a.actor not in ^user.info.blocks) - |> where( - [n, a], - fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks - ) - |> join(:left, [n, a], tm in Pleroma.ThreadMute, - on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) - ) - |> where([n, a, o, tm], is_nil(tm.user_id)) - end - end - - def for_user(user, opts \\ %{}) do - user - |> for_user_query(opts) - |> Pagination.fetch_paginated(opts) - end - - @doc """ - Returns notifications for user received since given date. - - ## Examples - - iex> Pleroma.SubscriptionNotification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33]) - [%Pleroma.SubscriptionNotification{}, %Pleroma.SubscriptionNotification{}] - - iex> Pleroma.SubscriptionNotification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33]) - [] - """ - @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()] - def for_user_since(user, date) do - user - |> for_user_query() - |> where([n], n.updated_at > ^date) - |> Repo.all() - end - - def clear_up_to(%{id: user_id} = _user, id) do - from( - n in SubscriptionNotification, - where: n.user_id == ^user_id, - where: n.id <= ^id - ) - |> Repo.delete_all([]) - end - - def get(%{id: user_id} = _user, id) do - query = - from( - n in SubscriptionNotification, - where: n.id == ^id, - join: activity in assoc(n, :activity), - preload: [activity: activity] - ) - - case Repo.one(query) do - %{user_id: ^user_id} = notification -> - {:ok, notification} - - _ -> - {:error, "Cannot get notification"} - end - end - - def clear(user) do - from(n in SubscriptionNotification, where: n.user_id == ^user.id) - |> Repo.delete_all() - end - - def destroy_multiple(%{id: user_id} = _user, ids) do - from(n in SubscriptionNotification, - where: n.id in ^ids, - where: n.user_id == ^user_id - ) - |> Repo.delete_all() - end - - def dismiss(%{id: user_id} = _user, id) do - case Repo.get(SubscriptionNotification, id) do - %{user_id: ^user_id} = notification -> - Repo.delete(notification) - - _ -> - {:error, "Cannot dismiss notification"} - end - end - - def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do - case Object.normalize(activity) do - %{data: %{"type" => "Answer"}} -> - {:ok, []} - - _ -> - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} - end - end - - def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) - when type in ["Like", "Announce", "Follow"] do - notifications = - activity - |> get_notified_from_activity() - |> Enum.map(&create_notification(activity, &1)) - - {:ok, notifications} - end - - def create_notifications(_), do: {:ok, []} - - # TODO move to sql, too. - def create_notification(%Activity{} = activity, %User{} = user) do - unless skip?(activity, user) do - notification = %SubscriptionNotification{user_id: user.id, activity: activity} - {:ok, notification} = Repo.insert(notification) - Streamer.stream("user", notification) - Streamer.stream("user:subscription_notification", notification) - Push.send(notification) - notification - end - end - - def get_notified_from_activity(activity, local_only \\ true) - - def get_notified_from_activity( - %Activity{data: %{"to" => _, "type" => type} = _data} = activity, - local_only - ) - when type in ["Create", "Like", "Announce", "Follow"] do - [] - |> Utils.maybe_notify_subscribers(activity) - |> Enum.uniq() - |> User.get_users_from_set(local_only) - end - - def get_notified_from_activity(_, _local_only), do: [] - - @spec skip?(Activity.t(), User.t()) :: boolean() - def skip?(activity, user) do - [ - :self, - :followers, - :follows, - :non_followers, - :non_follows, - :recently_followed - ] - |> Enum.any?(&skip?(&1, activity, user)) - end - - @spec skip?(atom(), Activity.t(), User.t()) :: boolean() - def skip?(:self, activity, user) do - activity.data["actor"] == user.ap_id - end - - def skip?( - :followers, - %{data: %{"actor" => actor}}, - %{info: %{notification_settings: %{"followers" => false}}} = user - ) do - actor - |> User.get_cached_by_ap_id() - |> User.following?(user) - end - - def skip?( - :non_followers, - activity, - %{info: %{notification_settings: %{"non_followers" => false}}} = user - ) do - actor = activity.data["actor"] - follower = User.get_cached_by_ap_id(actor) - !User.following?(follower, user) - end - - def skip?(:follows, activity, %{info: %{notification_settings: %{"follows" => false}}} = user) do - actor = activity.data["actor"] - followed = User.get_cached_by_ap_id(actor) - User.following?(user, followed) - end - - def skip?( - :non_follows, - activity, - %{info: %{notification_settings: %{"non_follows" => false}}} = user - ) do - actor = activity.data["actor"] - followed = User.get_cached_by_ap_id(actor) - !User.following?(user, followed) - end - - def skip?(:recently_followed, %{data: %{"type" => "Follow", "actor" => actor}}, user) do - user - |> SubscriptionNotification.for_user() - |> Enum.any?(&match?(%{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}}, &1)) - end - - def skip?(_, _, _), do: false -end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 7e83e27e5..8d0a57623 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Object.Fetcher alias Pleroma.Pagination alias Pleroma.Repo - alias Pleroma.SubscriptionNotification alias Pleroma.Upload alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -152,7 +151,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) Notification.create_notifications(activity) - SubscriptionNotification.create_notifications(activity) participations = activity diff --git a/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex deleted file mode 100644 index 37c2222de..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/subscription_notification_controller.ex +++ /dev/null @@ -1,71 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationController do - use Pleroma.Web, :controller - - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - - alias Pleroma.Activity - alias Pleroma.SubscriptionNotification - alias Pleroma.User - alias Pleroma.Web.PleromaAPI.PleromaAPI - - def index(%{assigns: %{user: user}} = conn, params) do - notifications = - user - |> PleromaAPI.get_subscription_notifications(params) - |> Enum.map(&build_notification_data/1) - - conn - |> add_link_headers(notifications) - |> render("index.json", %{notifications: notifications, for: user}) - end - - def show(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, notification} <- SubscriptionNotification.get(user, id) do - render(conn, "show.json", %{ - subscription_notification: build_notification_data(notification), - for: user - }) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - - def clear(%{assigns: %{user: user}} = conn, _params) do - SubscriptionNotification.clear(user) - json(conn, %{}) - end - - def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do - with {:ok, _notif} <- SubscriptionNotification.dismiss(user, id) do - json(conn, %{}) - else - {:error, reason} -> - conn - |> put_status(:forbidden) - |> json(%{"error" => reason}) - end - end - - def destroy_multiple( - %{assigns: %{user: user}} = conn, - %{"ids" => ids} = _params - ) do - SubscriptionNotification.destroy_multiple(user, ids) - json(conn, %{}) - end - - defp build_notification_data(%{activity: %{data: data}} = notification) do - %{ - notification: notification, - actor: User.get_cached_by_ap_id(data["actor"]), - parent_activity: Activity.get_create_by_object_ap_id(data["object"]) - } - end -end diff --git a/lib/pleroma/web/pleroma_api/pleroma_api.ex b/lib/pleroma/web/pleroma_api/pleroma_api.ex deleted file mode 100644 index 480964845..000000000 --- a/lib/pleroma/web/pleroma_api/pleroma_api.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Pleroma.Web.PleromaAPI.PleromaAPI do - import Ecto.Query - import Ecto.Changeset - - alias Pleroma.Activity - alias Pleroma.Pagination - alias Pleroma.SubscriptionNotification - - def get_subscription_notifications(user, params \\ %{}) do - options = cast_params(params) - - user - |> SubscriptionNotification.for_user_query(options) - |> restrict(:exclude_types, options) - |> Pagination.fetch_paginated(params) - end - - defp cast_params(params) do - param_types = %{ - exclude_types: {:array, :string}, - reblogs: :boolean, - with_muted: :boolean - } - - changeset = cast({%{}, param_types}, params, Map.keys(param_types)) - changeset.changes - end - - defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do - ap_types = - mastodon_types - |> Enum.map(&Activity.from_mastodon_notification_type/1) - |> Enum.filter(& &1) - - query - |> where([q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) - end - - defp restrict(query, _, _), do: query -end diff --git a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex b/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex deleted file mode 100644 index fc41a7389..000000000 --- a/lib/pleroma/web/pleroma_api/views/subscription_notification_view.ex +++ /dev/null @@ -1,61 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationView do - use Pleroma.Web, :view - - alias Pleroma.Activity - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.SubscriptionNotificationView - - def render("index.json", %{notifications: notifications, for: user}) do - safe_render_many(notifications, SubscriptionNotificationView, "show.json", %{for: user}) - end - - def render("show.json", %{ - subscription_notification: %{ - notification: %{activity: activity} = notification, - actor: actor, - parent_activity: parent_activity - }, - for: user - }) do - mastodon_type = Activity.mastodon_notification_type(activity) - - response = %{ - 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}) - } - - case mastodon_type do - "mention" -> - response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: activity, for: user}) - }) - - "favourite" -> - response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: parent_activity, for: user}) - }) - - "reblog" -> - response - |> Map.merge(%{ - status: StatusView.render("show.json", %{activity: parent_activity, for: user}) - }) - - "follow" -> - response - - _ -> - nil - end - end -end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 7ea5607fa..35d3ff07c 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.Push.Impl do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo - alias Pleroma.SubscriptionNotification alias Pleroma.User alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Push.Subscription @@ -20,7 +19,7 @@ defmodule Pleroma.Web.Push.Impl do @types ["Create", "Follow", "Announce", "Like"] @doc "Performs sending notifications for user subscriptions" - @spec perform(Notification.t() | SubscriptionNotification.t()) :: list(any) | :error + @spec perform(Notification.t()) :: list(any) | :error def perform( %{ activity: %{data: %{"type" => activity_type}, id: activity_id} = activity, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a025474e2..805bef16f 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -293,14 +293,6 @@ defmodule Pleroma.Web.Router do pipe_through(:oauth_read) get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id", PleromaAPIController, :conversation) - - scope "/subscription_notifications" do - post("/clear", SubscriptionNotificationController, :clear) - post("/dismiss", SubscriptionNotificationController, :dismiss) - delete("/destroy_multiple", SubscriptionNotificationController, :destroy_multiple) - get("/", SubscriptionNotificationController, :index) - get("/:id", SubscriptionNotificationController, :show) - end end scope [] do diff --git a/priv/repo/migrations/20190824195028_create_subscription_notifications.exs b/priv/repo/migrations/20190824195028_create_subscription_notifications.exs deleted file mode 100644 index fcceb4386..000000000 --- a/priv/repo/migrations/20190824195028_create_subscription_notifications.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Pleroma.Repo.Migrations.CreateSubscriptionNotifications do - use Ecto.Migration - - def change do - create_if_not_exists table(:subscription_notifications) do - add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) - add(:activity_id, references(:activities, type: :uuid, on_delete: :delete_all)) - - timestamps() - end - - create_if_not_exists(index(:subscription_notifications, [:user_id])) - create_if_not_exists(index(:subscription_notifications, ["id desc nulls last"])) - end -end diff --git a/test/notification_test.exs b/test/notification_test.exs index 1dbad34c1..54c0f9877 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -33,16 +33,16 @@ test "notifies someone when they are directly addressed" do assert other_notification.activity_id == activity.id end - test "it does not create a notification for subscribed users" do + test "it creates a notification for subscribed users" do user = insert(:user) subscriber = insert(:user) User.subscribe(subscriber, user) {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) - {:ok, notifications} = Notification.create_notifications(status) + {:ok, [notification]} = Notification.create_notifications(status) - assert notifications == [] + assert notification.user_id == subscriber.id end test "does not create a notification for subscribed users if status is a reply" do @@ -182,16 +182,14 @@ test "it doesn't create a notification for follow-unfollow-follow chains" do refute Notification.create_notification(activity_dupe, followed_user) end - test "it doesn't create notifications for follow+subscribed users" do + test "it doesn't create duplicate notifications for follow+subscribed users" do user = insert(:user) subscriber = insert(:user) {:ok, _, _, _} = CommonAPI.follow(subscriber, user) User.subscribe(subscriber, user) {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) - {:ok, notifications} = Notification.create_notifications(status) - - assert notifications == [] + {:ok, [_notif]} = Notification.create_notifications(status) end test "it doesn't create subscription notifications if the recipient cannot see the status" do diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs index 848fce7ad..7fcb2bd55 100644 --- a/test/web/mastodon_api/mastodon_api_test.exs +++ b/test/web/mastodon_api/mastodon_api_test.exs @@ -75,9 +75,9 @@ test "returns notifications for user" do User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin @#{subscriber.nickname}"}) + {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) - {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi @#{subscriber.nickname}"}) + {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi"}) {:ok, [notification]} = Notification.create_notifications(status) {:ok, [notification1]} = Notification.create_notifications(status1) res = MastodonAPI.get_notifications(subscriber) diff --git a/test/web/pleroma_api/controllers/subscription_notification_controller_test.exs b/test/web/pleroma_api/controllers/subscription_notification_controller_test.exs deleted file mode 100644 index c6a71732d..000000000 --- a/test/web/pleroma_api/controllers/subscription_notification_controller_test.exs +++ /dev/null @@ -1,234 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.SubscriptionNotificationControllerTest do - use Pleroma.Web.ConnCase - - alias Pleroma.Repo - alias Pleroma.SubscriptionNotification - alias Pleroma.User - alias Pleroma.Web.CommonAPI - import Pleroma.Factory - import Tesla.Mock - - setup do - mock(fn env -> apply(HttpRequestMock, :request, [env]) end) - :ok - end - - clear_config([:instance, :public]) - clear_config([:rich_media, :enabled]) - - describe "subscription_notifications" do - setup do - user = insert(:user) - subscriber = insert(:user) - - User.subscribe(subscriber, user) - - {:ok, %{user: user, subscriber: subscriber}} - end - - test "list of notifications", %{conn: conn, user: user, subscriber: subscriber} do - status_text = "Hello" - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - path = subscription_notification_path(conn, :index) - - conn = - conn - |> assign(:user, subscriber) - |> get(path) - - assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) - assert response == status_text - end - - test "getting a single notification", %{conn: conn, user: user, subscriber: subscriber} do - status_text = "Hello" - - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - [notification] = Repo.all(SubscriptionNotification) - - path = subscription_notification_path(conn, :show, notification) - - conn = - conn - |> assign(:user, subscriber) - |> get(path) - - assert %{"status" => %{"content" => response}} = json_response(conn, 200) - assert response == status_text - end - - test "dismissing a single notification also deletes it", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - status_text = "Hello" - {:ok, _activity} = CommonAPI.post(user, %{"status" => status_text}) - - [notification] = Repo.all(SubscriptionNotification) - - conn = - conn - |> assign(:user, subscriber) - |> post(subscription_notification_path(conn, :dismiss), %{"id" => notification.id}) - - assert %{} = json_response(conn, 200) - - assert Repo.all(SubscriptionNotification) == [] - end - - test "clearing all notifications also deletes them", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - status_text1 = "Hello" - status_text2 = "Hello again" - {:ok, _activity1} = CommonAPI.post(user, %{"status" => status_text1}) - {:ok, _activity2} = CommonAPI.post(user, %{"status" => status_text2}) - - conn = - conn - |> assign(:user, subscriber) - |> post(subscription_notification_path(conn, :clear)) - - assert %{} = json_response(conn, 200) - - conn = - build_conn() - |> assign(:user, subscriber) - |> get(subscription_notification_path(conn, :index)) - - assert json_response(conn, 200) == [] - - assert Repo.all(SubscriptionNotification) == [] - end - - test "paginates notifications using min_id, since_id, max_id, and limit", %{ - conn: conn, - user: user, - subscriber: subscriber - } do - {:ok, activity1} = CommonAPI.post(user, %{"status" => "Hello 1"}) - {:ok, activity2} = CommonAPI.post(user, %{"status" => "Hello 2"}) - {:ok, activity3} = CommonAPI.post(user, %{"status" => "Hello 3"}) - {:ok, activity4} = CommonAPI.post(user, %{"status" => "Hello 4"}) - - notification1_id = - Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() - - notification2_id = - Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() - - notification3_id = - Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() - - notification4_id = - Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() - - conn = assign(conn, :user, subscriber) - - # min_id - conn_res = - get( - conn, - subscription_notification_path(conn, :index, %{ - "limit" => 2, - "min_id" => notification1_id - }) - ) - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - - # since_id - conn_res = - get( - conn, - subscription_notification_path(conn, :index, %{ - "limit" => 2, - "since_id" => notification1_id - }) - ) - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - - # max_id - conn_res = - get( - conn, - subscription_notification_path(conn, :index, %{ - "limit" => 2, - "max_id" => notification4_id - }) - ) - - result = json_response(conn_res, 200) - assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result - end - - test "destroy multiple", %{conn: conn, user: user1, subscriber: user2} do - # mutual subscription - User.subscribe(user1, user2) - - {:ok, activity1} = CommonAPI.post(user1, %{"status" => "Hello 1"}) - {:ok, activity2} = CommonAPI.post(user1, %{"status" => "World 1"}) - {:ok, activity3} = CommonAPI.post(user2, %{"status" => "Hello 2"}) - {:ok, activity4} = CommonAPI.post(user2, %{"status" => "World 2"}) - - notification1_id = - Repo.get_by(SubscriptionNotification, activity_id: activity1.id).id |> to_string() - - notification2_id = - Repo.get_by(SubscriptionNotification, activity_id: activity2.id).id |> to_string() - - notification3_id = - Repo.get_by(SubscriptionNotification, activity_id: activity3.id).id |> to_string() - - notification4_id = - Repo.get_by(SubscriptionNotification, activity_id: activity4.id).id |> to_string() - - conn = assign(conn, :user, user1) - - conn_res = get(conn, subscription_notification_path(conn, :index)) - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification3_id, notification4_id] - end) - - conn2 = assign(conn, :user, user2) - - conn_res = get(conn2, subscription_notification_path(conn, :index)) - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification1_id, notification2_id] - end) - - conn_destroy = - delete(conn, subscription_notification_path(conn, :destroy_multiple), %{ - "ids" => [notification3_id, notification4_id] - }) - - assert json_response(conn_destroy, 200) == %{} - - conn_res = get(conn2, subscription_notification_path(conn, :index)) - - result = json_response(conn_res, 200) - - Enum.each(result, fn %{"id" => id} -> - assert id in [notification1_id, notification2_id] - end) - - assert length(Repo.all(SubscriptionNotification)) == 2 - end - end -end diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/pleroma_api_controller_test.exs similarity index 100% rename from test/web/pleroma_api/controllers/pleroma_api_controller_test.exs rename to test/web/pleroma_api/pleroma_api_controller_test.exs From 81b4243173f31fd47eb598fb7ed95cadadf90c2f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 29 Sep 2019 23:52:40 +0300 Subject: [PATCH 053/138] Remove subscription_notifications table if it existed Followup to !1741 --- ...0190929201536_drop_subscription_if_exists.exs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 priv/repo/migrations/20190929201536_drop_subscription_if_exists.exs 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 From d4d88b3361ea57d763c5093470b7ebaee6bcf11c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 16:52:07 +0700 Subject: [PATCH 054/138] Extract conversation actions from `MastodonAPIController` to ConversationController --- .../controllers/conversation_controller.ex | 32 ++++++++ .../controllers/mastodon_api_controller.ex | 27 ------- .../mastodon_api/views/conversation_view.ex | 21 ++---- lib/pleroma/web/router.ex | 4 +- .../conversation_controller_test.exs | 75 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 63 ---------------- 6 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex create mode 100644 test/web/mastodon_api/controllers/conversation_controller_test.exs 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..ea1e36a12 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -0,0 +1,32 @@ +# 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.Repo + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @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/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 0878f7ba6..650fb74cd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -12,7 +12,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config - alias Pleroma.Conversation.Participation alias Pleroma.Emoji alias Pleroma.HTTP alias Pleroma.Object @@ -27,7 +26,6 @@ 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.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView @@ -1003,31 +1001,6 @@ 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"] diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 4aeb79d81..2c5767dd8 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("show.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, "accounts.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/router.ex b/lib/pleroma/web/router.ex index 805bef16f..5dafa3693 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -344,8 +344,8 @@ defmodule Pleroma.Web.Router do 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, :empty_array) 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index b3acb7a22..8080d3941 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -33,69 +33,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) - 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 "verify_credentials", %{conn: conn} do user = insert(:user) From 5fd29edac47de145fb7025a99137a69072dca3bb Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 11:04:52 +0000 Subject: [PATCH 055/138] docs: add scrobble API description --- docs/api/pleroma_api.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index ac5489aa3..183cf8a28 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -439,3 +439,33 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * 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/:uid/now-playing` +### 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 +[ + { + "id": "1234", + "title": "Some Title", + "artist": "Some Artist", + "album": "Some Album", + "length": 180000 + } +] +``` + +## `POST /api/v1/pleroma/now-playing` +### 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 From c3d09921e4dd13f02ab141bba9ba8372f70bab76 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 11:15:20 +0000 Subject: [PATCH 056/138] test: factory: implement support for generating mock audio and listen objects --- test/support/factory.ex | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/support/factory.ex b/test/support/factory.ex index 719115003..4f3244025 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) From b7877e9b1c61e42d60bb65deef0cec7f1103dd89 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 11:40:40 +0000 Subject: [PATCH 057/138] mastodon api: implement rendering of listen activities --- .../web/mastodon_api/views/status_view.ex | 17 +++++++++++++++++ .../web/mastodon_api/views/status_view_test.exs | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 2321d0de2..cf024a83c 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -368,6 +368,23 @@ 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("account.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("poll.json", %{object: object} = opts) do {multiple, options} = case object.data do diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index c17d0ef95..683132f8d 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -608,4 +608,13 @@ test "visibility/list" do 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 From 1f9de2a8cdc1913b26afab1f914aea526db608d8 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 12:22:35 +0000 Subject: [PATCH 058/138] activitypub: implement IR-level considerations for Listen activities --- lib/pleroma/web/activity_pub/activity_pub.ex | 20 +++++++++++ lib/pleroma/web/activity_pub/utils.ex | 17 ++++++++- test/web/activity_pub/activity_pub_test.exs | 36 ++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8d0a57623..425073541 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) 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/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index f28fd6871..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) From 172c74a77baf5b8910987e19c620158d0497d16a Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 27 Sep 2019 12:40:31 +0000 Subject: [PATCH 059/138] activitypub: transmogrifier: implement support for Listen activities --- .../web/activity_pub/transmogrifier.ex | 33 ++++++++++++++++++- test/web/activity_pub/transmogrifier_test.exs | 29 ++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index dad2fead8..63877248a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -430,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 @@ -765,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() diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index a35db71dc..9040c87ca 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) From 2c82d8603bb4c3f7281023752dc78aa31a814ab6 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 00:24:32 +0000 Subject: [PATCH 060/138] common api: implement scrobbling --- lib/pleroma/web/common_api/common_api.ex | 18 +++++++++++ test/web/common_api/common_api_test.exs | 39 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index a00e4b0d8..a040a6ce2 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -212,6 +212,24 @@ def check_expiry_date(expiry_str) do |> check_expiry_date() end + 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"), + {: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 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 From 7cad6ea67a47df2776a15dd69b9e408c517800e6 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 02:12:12 +0000 Subject: [PATCH 061/138] pleroma api: hook up scrobbler controller --- lib/pleroma/web/activity_pub/activity_pub.ex | 17 +++++ .../web/mastodon_api/views/status_view.ex | 4 ++ .../controllers/pleroma_api_controller.ex | 42 ++++++++++++- lib/pleroma/web/router.ex | 11 ++++ .../controllers/scrobble_controller_test.exs | 63 +++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 test/web/pleroma_api/controllers/scrobble_controller_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 425073541..95f994c17 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -608,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 diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index cf024a83c..d398f7853 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -385,6 +385,10 @@ def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = a } 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 diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index d17ccf84d..1b0ed1f40 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,11 +5,13 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] alias Pleroma.Conversation.Participation alias Pleroma.Notification + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView @@ -86,4 +88,42 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) d |> render("index.json", %{notifications: notifications, for: user}) end end + + def update_now_playing(%{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_now_playing(%{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/router.ex b/lib/pleroma/web/router.ex index 805bef16f..bd5f02af1 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -300,6 +300,17 @@ defmodule Pleroma.Web.Router do patch("/conversations/:id", PleromaAPIController, :update_conversation) post("/notifications/read", PleromaAPIController, :read_notification) end + + scope [] do + pipe_through(:oauth_write) + post("/now-playing", PleromaAPIController, :update_now_playing) + end + end + + scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do + pipe_through([:api, :oauth_read_or_public]) + + get("/accounts/:id/now-playing", PleromaAPIController, :user_now_playing) end scope "/api/v1", Pleroma.Web.MastodonAPI do 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..8cbb5889e --- /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/now-playing" do + test "works correctly", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/now-playing", %{ + "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/now-playing" 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}/now-playing") + + result = json_response(conn, 200) + + assert length(result) == 3 + end + end +end From 53506da414f6377b6c7afdb686f3d25e55d29c05 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 02:13:26 +0000 Subject: [PATCH 062/138] update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61323970a..80d5e1ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 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/now-playing` to get a list of recently scrobbled items +- Pleroma API: `POST /api/v1/pleroma/now-playing` to scrobble a media item ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) From e7309d3b606f4ede3282cf559b30ba23f62cbea5 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 11:57:24 +0000 Subject: [PATCH 063/138] test: transmogrifier: add test proving that transmogrifier can handle outgoing listens --- test/web/activity_pub/transmogrifier_test.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 9040c87ca..f77311b3c 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1219,6 +1219,14 @@ 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" + end end describe "user upgrade" do From 71eff09e564ae3eeaf02acecbb8d89b7d4e2e511 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 12:12:35 +0000 Subject: [PATCH 064/138] common api: make sure the generated IR is actually federatable --- lib/pleroma/web/common_api/common_api.ex | 6 ++++-- test/web/activity_pub/transmogrifier_test.exs | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index a040a6ce2..b02c47059 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -217,14 +217,16 @@ def listen(user, %{"title" => _} = data) do {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("type", "Audio") + |> Map.put("to", to) + |> Map.put("cc", cc), {:ok, activity} <- ActivityPub.listen(%{ actor: user, to: to, object: listen_data, context: Utils.generate_context_id(), - additional: %{cc: cc} + additional: %{"cc" => cc} }) do {:ok, activity} end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index f77311b3c..2c6357fe6 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1226,6 +1226,12 @@ test "it can handle Listen activities" do {: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 From 84712c35f9b316b0891edfa791aeb5e358613bd2 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 12:28:39 +0000 Subject: [PATCH 065/138] activitypub: object view: include child object for Listen activities --- lib/pleroma/web/activity_pub/views/object_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex index 0d63f0707..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) From 8b34b221cbec366e0a605b9e64dafceb76ed3fd3 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 28 Sep 2019 12:29:00 +0000 Subject: [PATCH 066/138] common api: add some missing IR bits for listen activities' children --- lib/pleroma/web/common_api/common_api.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index b02c47059..2ec017ff8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -219,7 +219,8 @@ def listen(user, %{"title" => _} = data) do Map.take(data, ["album", "artist", "title", "length"]) |> Map.put("type", "Audio") |> Map.put("to", to) - |> Map.put("cc", cc), + |> Map.put("cc", cc) + |> Map.put("actor", user.ap_id), {:ok, activity} <- ActivityPub.listen(%{ actor: user, From a6e1469767cd716eccf1106e3704130a4fc909b8 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 29 Sep 2019 00:18:06 +0000 Subject: [PATCH 067/138] router: change scrobble timeline route from now-playing to scrobbles --- docs/api/pleroma_api.md | 6 ++++-- .../web/pleroma_api/controllers/pleroma_api_controller.ex | 2 +- lib/pleroma/web/router.ex | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index 183cf8a28..33116b4b9 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -440,7 +440,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * 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/:uid/now-playing` +## `GET /api/v1/pleroma/accounts/:id/scrobbles` ### Requests a list of current and recent Listen activities for an account * Method `GET` * Authentication: not required @@ -450,11 +450,13 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa ```json [ { + "account": {...}, "id": "1234", "title": "Some Title", "artist": "Some Artist", "album": "Some Album", - "length": 180000 + "length": 180000, + "created_at": "2019-09-28T12:40:45.000Z" } ] ``` diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 1b0ed1f40..6010732db 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -110,7 +110,7 @@ def update_now_playing(%{assigns: %{user: user}} = conn, %{"title" => _} = param end end - def user_now_playing(%{assigns: %{user: reading_user}} = conn, params) do + 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"]) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index bd5f02af1..8966e8cc0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -310,7 +310,7 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through([:api, :oauth_read_or_public]) - get("/accounts/:id/now-playing", PleromaAPIController, :user_now_playing) + get("/accounts/:id/scrobbles", PleromaAPIController, :user_scrobbles) end scope "/api/v1", Pleroma.Web.MastodonAPI do From e653edd182338fa8f4396341cea26cd5568f0107 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 29 Sep 2019 00:25:42 +0000 Subject: [PATCH 068/138] split scrobble functions into their own controller --- .../controllers/pleroma_api_controller.ex | 42 +-------------- .../controllers/scrobble_controller.ex | 52 +++++++++++++++++++ lib/pleroma/web/router.ex | 4 +- 3 files changed, 55 insertions(+), 43 deletions(-) create mode 100644 lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 6010732db..d17ccf84d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,13 +5,11 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Conversation.Participation alias Pleroma.Notification - alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView @@ -88,42 +86,4 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) d |> render("index.json", %{notifications: notifications, for: user}) end end - - def update_now_playing(%{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/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex new file mode 100644 index 000000000..ac6cd8edd --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -0,0 +1,52 @@ +# 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.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + def update_now_playing(%{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/router.ex b/lib/pleroma/web/router.ex index 8966e8cc0..8e3a72656 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -303,14 +303,14 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_write) - post("/now-playing", PleromaAPIController, :update_now_playing) + post("/now-playing", ScrobbleController, :update_now_playing) end end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through([:api, :oauth_read_or_public]) - get("/accounts/:id/scrobbles", PleromaAPIController, :user_scrobbles) + get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) end scope "/api/v1", Pleroma.Web.MastodonAPI do From 211008ae2f1ea97490a0ac70b8c801e58af6834c Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 29 Sep 2019 00:35:40 +0000 Subject: [PATCH 069/138] test: fix scrobble controller tests --- test/web/pleroma_api/controllers/scrobble_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/web/pleroma_api/controllers/scrobble_controller_test.exs index 8cbb5889e..b86bd2250 100644 --- a/test/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -26,7 +26,7 @@ test "works correctly", %{conn: conn} do end end - describe "GET /api/v1/pleroma/accounts/:id/now-playing" do + describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do test "works correctly", %{conn: conn} do user = insert(:user) @@ -53,7 +53,7 @@ test "works correctly", %{conn: conn} do conn = conn - |> get("/api/v1/pleroma/accounts/#{user.id}/now-playing") + |> get("/api/v1/pleroma/accounts/#{user.id}/scrobbles") result = json_response(conn, 200) From 1d7cbdaf7b2f3ff6576959ed26885d7545f31a14 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 29 Sep 2019 02:18:34 +0000 Subject: [PATCH 070/138] change new scrobble endpoint --- CHANGELOG.md | 4 ++-- docs/api/pleroma_api.md | 2 +- .../web/pleroma_api/controllers/scrobble_controller.ex | 2 +- lib/pleroma/web/router.ex | 2 +- test/web/pleroma_api/controllers/scrobble_controller_test.exs | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d5e1ac9..3d9424c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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/now-playing` to get a list of recently scrobbled items -- Pleroma API: `POST /api/v1/pleroma/now-playing` to scrobble a media item +- 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) diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index 33116b4b9..41889a0ef 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -461,7 +461,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa ] ``` -## `POST /api/v1/pleroma/now-playing` +## `POST /api/v1/pleroma/scrobble` ### Creates a new Listen activity for an account * Method `POST` * Authentication: required diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index ac6cd8edd..0fb978c5d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView - def update_now_playing(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do + def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do params = if !params["length"] do params diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8e3a72656..bf32cff1e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -303,7 +303,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_write) - post("/now-playing", ScrobbleController, :update_now_playing) + post("/scrobble", ScrobbleController, :new_scrobble) end end diff --git a/test/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/web/pleroma_api/controllers/scrobble_controller_test.exs index b86bd2250..881f8012c 100644 --- a/test/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -8,14 +8,14 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do alias Pleroma.Web.CommonAPI import Pleroma.Factory - describe "POST /api/v1/pleroma/now-playing" do + 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/now-playing", %{ + |> post("/api/v1/pleroma/scrobble", %{ "title" => "lain radio episode 1", "artist" => "lain", "album" => "lain radio", From b7f27a4f584e54b13d0b7c1b288ad3e7bffcf95a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 17:04:03 +0700 Subject: [PATCH 071/138] Extract report actions from `MastodonAPIController` to `ReportController` Update MastodonAPI.ReportView --- .../controllers/mastodon_api_controller.ex | 15 ---- .../controllers/report_controller.ex | 16 ++++ .../web/mastodon_api/views/report_view.ex | 2 +- lib/pleroma/web/router.ex | 2 +- .../controllers/report_controller_test.exs | 88 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 79 ----------------- 6 files changed, 106 insertions(+), 96 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/report_controller.ex create mode 100644 test/web/mastodon_api/controllers/report_controller_test.exs 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 0878f7ba6..1ec699b6f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -31,7 +31,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView - alias Pleroma.Web.MastodonAPI.ReportView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App @@ -946,20 +945,6 @@ defp fetch_suggestion_id(attrs) do end end - def reports(%{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 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..1c084b740 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.ReportController do + use Pleroma.Web, :controller + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @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/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/router.ex b/lib/pleroma/web/router.ex index 805bef16f..7bdc80fcc 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -396,7 +396,7 @@ defmodule Pleroma.Web.Router do get("/pleroma/mascot", MastodonAPIController, :get_mascot) put("/pleroma/mascot", MastodonAPIController, :set_mascot) - post("/reports", MastodonAPIController, :reports) + post("/reports", ReportController, :create) end scope [] do 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..fcece40fb --- /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.ConversationControllerTest 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index b3acb7a22..d316a61c1 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1380,85 +1380,6 @@ test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do 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) From 1207e8819507aac55e5725f383987b0078bb1cbe Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 18:30:10 +0700 Subject: [PATCH 072/138] Fix ReportControllerTest --- test/web/mastodon_api/controllers/report_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/report_controller_test.exs b/test/web/mastodon_api/controllers/report_controller_test.exs index fcece40fb..979ca48f3 100644 --- a/test/web/mastodon_api/controllers/report_controller_test.exs +++ b/test/web/mastodon_api/controllers/report_controller_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do +defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do use Pleroma.Web.ConnCase alias Pleroma.Web.CommonAPI From e7aef27c0011d3fd0b569ebdb9196a1e011eae5d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 19:10:54 +0700 Subject: [PATCH 073/138] Fix merge --- lib/pleroma/list.ex | 21 +- .../web/admin_api/views/report_view.ex | 2 +- lib/pleroma/web/chat_channel.ex | 2 +- .../controllers/account_controller.ex | 227 +++++ .../controllers/follow_request_controller.ex | 2 +- .../controllers/list_controller.ex | 2 +- .../controllers/mastodon_api_controller.ex | 249 +----- .../controllers/search_controller.ex | 4 +- .../controllers/status_controller.ex | 4 +- .../web/mastodon_api/views/account_view.ex | 10 +- .../mastodon_api/views/conversation_view.ex | 2 +- .../mastodon_api/views/notification_view.ex | 2 +- .../web/mastodon_api/views/status_view.ex | 6 +- lib/pleroma/web/router.ex | 31 +- test/list_test.exs | 4 +- test/web/admin_api/views/report_view_test.exs | 8 +- .../controllers/account_controller_test.exs | 810 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 773 ----------------- .../mastodon_api/views/account_view_test.exs | 48 +- .../views/notification_view_test.exs | 8 +- .../mastodon_api/views/status_view_test.exs | 2 +- test/web/twitter_api/twitter_api_test.exs | 28 +- 22 files changed, 1131 insertions(+), 1114 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/account_controller.ex create mode 100644 test/web/mastodon_api/controllers/account_controller_test.exs diff --git a/lib/pleroma/list.ex b/lib/pleroma/list.ex index c5db1cb62..08a94c62c 100644 --- a/lib/pleroma/list.ex +++ b/lib/pleroma/list.ex @@ -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/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index 8c06364a3..101a74c63 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -43,7 +43,7 @@ def render("show.json", %{report: report, user: user, account: account, statuses 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/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex new file mode 100644 index 000000000..844de2e79 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -0,0 +1,227 @@ +# 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] + + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.ListView + alias Pleroma.Plugs.RateLimiter + + require Pleroma.Constants + + @relations ~w(follow unfollow)a + + plug(RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @relations) + plug(RateLimiter, :relations_actions when action in @relations) + plug(:assign_account when action not in [:show, :statuses, :follows]) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @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 "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} -> + conn + |> put_status(:forbidden) + |> json(%{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} -> + conn + |> put_status(:forbidden) + |> json(%{error: message}) + end + 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} -> + conn + |> put_status(:forbidden) + |> json(%{error: message}) + end + end + + @doc "POST /api/v1/pleroma/: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} -> + conn + |> put_status(:forbidden) + |> json(%{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} -> + conn + |> put_status(:forbidden) + |> json(%{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} -> + conn + |> put_status(:forbidden) + |> json(%{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} -> + conn + |> put_status(:forbidden) + |> json(%{error: message}) + end + end + + defp assign_account(%{params: %{"id" => id}} = conn, _) do + case User.get_cached_by_id(id) do + %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/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 267014b97..ce7b625ee 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do def index(%{assigns: %{user: followed}} = conn, _params) do follow_requests = User.get_follow_requests(followed) - render(conn, "accounts.json", for: followed, users: follow_requests, as: :user) + render(conn, "index.json", for: followed, users: follow_requests, as: :user) end @doc "POST /api/v1/follow_requests/:id/authorize" diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index 2873deda8..50f42bee5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -49,7 +49,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 3bdcea0f7..394599146 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -26,8 +26,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.MastodonAPI.ListView - alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy @@ -38,16 +36,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.TwitterAPI.TwitterAPI require Logger - require Pleroma.Constants - @rate_limited_relations_actions ~w(follow unfollow)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, :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) @@ -171,7 +160,7 @@ def update_credentials(%{assigns: %{user: user}} = conn, params) do json( conn, - AccountView.render("account.json", %{user: user, for: user, with_pleroma_settings: true}) + AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true}) ) else _e -> render_error(conn, :forbidden, "Invalid request") @@ -238,7 +227,7 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) account = - AccountView.render("account.json", %{ + AccountView.render("show.json", %{ user: user, for: user, with_pleroma_settings: true, @@ -256,16 +245,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 @@ -318,25 +297,6 @@ def custom_emojis(conn, _params) do json(conn, mastodon_emoji) 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 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"]), @@ -453,65 +413,13 @@ def get_mascot(%{assigns: %{user: user}} = conn, _params) do json(conn, mascot) 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(%{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} @@ -523,123 +431,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 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 - nil -> {:error, :not_found} - e -> e - 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 - nil -> {:error, :not_found} - e -> e - end - end - def favourites(%{assigns: %{user: user}} = conn, params) do params = params @@ -657,37 +462,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) @@ -705,14 +479,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) - - conn - |> put_view(ListView) - |> render("index.json", %{lists: lists}) - end - def index(%{assigns: %{user: user}} = conn, _params) do token = get_session(conn, :oauth_token) @@ -721,8 +487,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 = %{ diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index c91713773..3fc89d645 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -22,7 +22,7 @@ def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) d conn |> put_view(AccountView) - |> render("accounts.json", users: accounts, for: user, as: :user) + |> render("index.json", users: accounts, for: user, as: :user) end def search2(conn, params), do: do_search(:v2, conn, params) @@ -72,7 +72,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 index f4de9285b..3c6987a5f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -231,7 +231,7 @@ def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do conn |> put_view(AccountView) - |> render("accounts.json", for: user, users: users, as: :user) + |> render("index.json", for: user, users: users, as: :user) else {:visible, false} -> {:error, :not_found} _ -> json(conn, []) @@ -251,7 +251,7 @@ def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do conn |> put_view(AccountView) - |> render("accounts.json", for: user, users: users, as: :user) + |> render("index.json", for: user, users: users, as: :user) else {:visible, false} -> {:error, :not_found} _ -> json(conn, []) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8cf9e9d5c..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() diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 2c5767dd8..e9d2735b3 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -32,7 +32,7 @@ def render("participation.json", %{participation: participation, for: user}) do %{ id: participation.id |> to_string(), - accounts: render(AccountView, "accounts.json", users: users, as: :user), + accounts: render(AccountView, "index.json", users: users, as: :user), unread: !participation.read, last_status: render(StatusView, "show.json", activity: activity, for: user) } diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 05110a192..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 } diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index d398f7853..bc527ad1b 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -108,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, @@ -258,7 +258,7 @@ def render("show.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, @@ -376,7 +376,7 @@ def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = a %{ id: activity.id, - account: AccountView.render("account.json", %{user: user, for: opts[:for]}), + 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(), diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 9fd13c2fd..a57bc75d7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -323,7 +323,7 @@ defmodule Pleroma.Web.Router do get("/accounts/relationships", MastodonAPIController, :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", FollowRequestController, :index) @@ -413,14 +413,13 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_follow) - post("/follows", MastodonAPIController, :follow) - post("/accounts/:id/follow", MastodonAPIController, :follow) - - 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("/follows", MastodonAPIController, :follows) + 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("/follow_requests/:id/authorize", FollowRequestController, :authorize) post("/follow_requests/:id/reject", FollowRequestController, :reject) @@ -428,8 +427,8 @@ defmodule Pleroma.Web.Router do post("/domain_blocks", DomainBlockController, :create) delete("/domain_blocks", DomainBlockController, :delete) - post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe) - post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe) + post("/pleroma/accounts/:id/subscribe", AccountController, :subscribe) + post("/pleroma/accounts/:id/unsubscribe", AccountController, :unsubscribe) end scope [] do @@ -487,14 +486,14 @@ defmodule Pleroma.Web.Router do 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) + get("/pleroma/accounts/:id/favourites", AccountController, :favourites) end end 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/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index 35b6947a0..475705857 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -21,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: [], @@ -53,12 +53,12 @@ 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("show.json", %{activity: activity})], 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..6cf929011 --- /dev/null +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -0,0 +1,810 @@ +# 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.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + + 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 "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 "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 "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 "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 +end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 46b035770..7cdefdcdd 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -202,125 +202,6 @@ test "creates an oauth app", %{conn: conn} do assert expected == json_response(conn, 200) 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) @@ -400,87 +281,6 @@ test "verify_credentials", %{conn: conn} do 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 - describe "/api/v1/pleroma/mascot" do test "mascot upload", %{conn: conn} do user = insert(:user) @@ -548,316 +348,6 @@ test "mascot retrieving", %{conn: conn} do assert url =~ "an_image" end 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 - describe "subscribing / unsubscribing" do test "subscribing / unsubscribing to a user", %{conn: conn} do user = insert(:user) @@ -920,27 +410,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) @@ -1017,199 +486,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) @@ -1294,29 +570,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 - - 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 - describe "link headers" do test "preserves parameters in link headers", %{conn: conn} do user = insert(:user) @@ -1349,32 +602,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] = diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index d965f76bf..62b2ab7e3 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -88,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 @@ -106,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 @@ -160,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 @@ -348,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 @@ -391,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 @@ -404,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 @@ -416,7 +416,7 @@ 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 @@ -425,65 +425,65 @@ test "shows zero when no follow requests are pending" do user = insert(:user) assert %{follow_requests_count: 0} = - AccountView.render("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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("account.json", %{user: user, for: user}) + 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) @@ -491,7 +491,7 @@ test "shows non-zero when historical unapproved requests are present" do {:ok, user} = User.update_info(user, &User.Info.user_upgrade(&1, %{locked: false})) assert %{locked: false, follow_requests_count: 1} = - AccountView.render("account.json", %{user: user, for: user}) + 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 86268fcfa..81ab82e2b 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -27,7 +27,7 @@ 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}), + 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,7 +50,7 @@ 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}), + 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,7 +72,7 @@ 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}), + 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 683132f8d..8df23d0a8 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -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, diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index bf1e233f5..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 @@ -148,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 @@ -213,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} @@ -288,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", @@ -339,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 @@ -363,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", From 3c5ecb70b45ae3db193e58b9a3b4a6100b411e4d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 14:28:12 +0700 Subject: [PATCH 074/138] Add PleromaAPI.AccountController --- lib/pleroma/web/controller_helper.ex | 7 + .../controllers/account_controller.ex | 95 +---- .../controllers/mastodon_api_controller.ex | 71 +--- .../controllers/account_controller.ex | 143 +++++++ lib/pleroma/web/router.ex | 39 +- .../controllers/account_controller_test.exs | 212 ---------- .../mastodon_api_controller_test.exs | 179 -------- .../controllers/account_controller_test.exs | 395 ++++++++++++++++++ .../emoji_api_controller_test.exs | 4 + .../pleroma_api_controller_test.exs | 0 10 files changed, 579 insertions(+), 566 deletions(-) create mode 100644 lib/pleroma/web/pleroma_api/controllers/account_controller.ex create mode 100644 test/web/pleroma_api/controllers/account_controller_test.exs rename test/web/pleroma_api/{ => controllers}/emoji_api_controller_test.exs (98%) rename test/web/pleroma_api/{ => controllers}/pleroma_api_controller_test.exs (100%) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index e90bf842e..83b884ba9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -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/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 844de2e79..38d53fd10 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -5,7 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, truthy_param?: 1] + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -15,13 +16,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Plugs.RateLimiter - require Pleroma.Constants - @relations ~w(follow unfollow)a plug(RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @relations) plug(RateLimiter, :relations_actions when action in @relations) - plug(:assign_account when action not in [:show, :statuses, :follows]) + plug(:assign_account_by_id when action not in [:show, :statuses]) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -85,60 +84,6 @@ def lists(%{assigns: %{user: user, account: account}} = conn, _params) do |> render("index.json", lists: lists) 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} -> - conn - |> put_status(:forbidden) - |> json(%{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} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - @doc "POST /api/v1/accounts/:id/follow" def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, :not_found} @@ -148,14 +93,11 @@ 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} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + {:error, message} -> json_response(conn, :forbidden, %{error: message}) end end - @doc "POST /api/v1/pleroma/:id/unfollow" + @doc "POST /api/v1/accounts/:id/unfollow" def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, :not_found} end @@ -173,10 +115,7 @@ def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do with {:ok, muter} <- User.mute(muter, muted, notifications?) do render(conn, "relationship.json", user: muter, target: muted) else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + {:error, message} -> json_response(conn, :forbidden, %{error: message}) end end @@ -185,10 +124,7 @@ 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} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + {:error, message} -> json_response(conn, :forbidden, %{error: message}) end end @@ -198,10 +134,7 @@ def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do {:ok, _activity} <- ActivityPub.block(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) + {:error, message} -> json_response(conn, :forbidden, %{error: message}) end end @@ -211,17 +144,7 @@ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else - {:error, message} -> - conn - |> put_status(:forbidden) - |> json(%{error: message}) - end - end - - defp assign_account(%{params: %{"id" => id}} = conn, _) do - case User.get_cached_by_id(id) do - %User{} = account -> assign(conn, :account, account) - nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + {:error, message} -> json_response(conn, :forbidden, %{error: message}) end 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 394599146..197316794 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -5,10 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, - only: [json_response: 3, add_link_headers: 2, truthy_param?: 1] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, truthy_param?: 1] - alias Ecto.Changeset alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config @@ -40,7 +38,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do 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" @@ -167,61 +164,6 @@ def update_credentials(%{assigns: %{user: user}} = conn, params) do 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 - 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 - - 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 def verify_credentials(%{assigns: %{user: user}} = conn, _) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) @@ -236,7 +178,6 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do 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 @@ -767,16 +708,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/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex new file mode 100644 index 000000000..63c44086c --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -0,0 +1,143 @@ +# 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.RateLimiter + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + require Pleroma.Constants + + 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/router.ex b/lib/pleroma/web/router.ex index a57bc75d7..5c3fe34e5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -287,24 +287,40 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do - pipe_through(:authenticated_api) - scope [] do + pipe_through(:authenticated_api) pipe_through(:oauth_read) get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id", PleromaAPIController, :conversation) end scope [] do + pipe_through(:authenticated_api) pipe_through(:oauth_write) 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) + post("/scrobble", ScrobbleController, :new_scrobble) end scope [] do - pipe_through(:oauth_write) - post("/scrobble", ScrobbleController, :new_scrobble) + pipe_through(:api) + pipe_through(:oauth_read_or_public) + get("/accounts/:id/favourites", AccountController, :favourites) end + + scope [] do + pipe_through(:authenticated_api) + pipe_through(:oauth_follow) + + 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 @@ -400,10 +416,6 @@ defmodule Pleroma.Web.Router do 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) - get("/pleroma/mascot", MastodonAPIController, :get_mascot) put("/pleroma/mascot", MastodonAPIController, :set_mascot) @@ -426,9 +438,6 @@ defmodule Pleroma.Web.Router do post("/domain_blocks", DomainBlockController, :create) delete("/domain_blocks", DomainBlockController, :delete) - - post("/pleroma/accounts/:id/subscribe", AccountController, :subscribe) - post("/pleroma/accounts/:id/unsubscribe", AccountController, :unsubscribe) end scope [] do @@ -467,12 +476,6 @@ defmodule Pleroma.Web.Router do get("/accounts/search", SearchController, :account_search) - post( - "/pleroma/accounts/confirmation_resend", - MastodonAPIController, - :account_confirmation_resend - ) - scope [] do pipe_through(:oauth_read_or_public) @@ -492,8 +495,6 @@ defmodule Pleroma.Web.Router do get("/accounts/:id", AccountController, :show) get("/search", SearchController, :search) - - get("/pleroma/accounts/:id/favourites", AccountController, :favourites) end end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 6cf929011..32ccc5351 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -552,199 +552,6 @@ test "without notifications", %{conn: conn} do 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 "pinned statuses" do setup do user = insert(:user) @@ -768,25 +575,6 @@ test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do 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 "blocking / unblocking a user", %{conn: conn} do user = insert(:user) other_user = insert(:user) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 7cdefdcdd..671f9f254 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -23,8 +23,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do import Swoosh.TestAssertions import Tesla.Mock - @image "data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7" - setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok @@ -80,101 +78,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) @@ -348,52 +251,6 @@ test "mascot retrieving", %{conn: conn} do assert url =~ "an_image" 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 test "getting a list of mutes", %{conn: conn} do user = insert(:user) @@ -1088,42 +945,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 - {: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 "GET /api/v1/suggestions" do setup do user = insert(:user) 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/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs similarity index 98% rename from test/web/pleroma_api/emoji_api_controller_test.exs rename to test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 93a507a01..5f74460e8 100644 --- a/test/web/pleroma_api/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -1,3 +1,7 @@ +# 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 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 From 38db4878a451b5e78a8a74bd916390dea5c21643 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 14:28:37 +0700 Subject: [PATCH 075/138] Disable async in DomainBlockControllerTest --- .../mastodon_api/controllers/domain_block_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs index 3c3558385..25a279cdc 100644 --- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase alias Pleroma.User From 122cc050abb84b94fa57a15576c2a38b2912d559 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 14:43:54 +0700 Subject: [PATCH 076/138] Move account_lists test to MastodonAPI.AccountControllerTest --- .../mastodon_api_controller_test.exs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 671f9f254..0acc5d067 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1082,23 +1082,6 @@ test "redirect to root page", %{conn: conn} do 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 "empty_array, stubs for mastodon api" do test "GET /api/v1/accounts/:id/identity_proofs", %{conn: conn} do user = insert(:user) From c0ce2d5faf872da219e724f4fc2e4deecb89e978 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 16:08:29 +0700 Subject: [PATCH 077/138] Move account_register, relationships and verify_credentials to MastodonAPI.AccountController --- .../controllers/account_controller.ex | 83 +++++- .../controllers/mastodon_api_controller.ex | 70 ----- lib/pleroma/web/router.ex | 6 +- .../controllers/account_controller_test.exs | 254 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 236 ---------------- 5 files changed, 332 insertions(+), 317 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 38d53fd10..be863d8ed 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -8,22 +8,89 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] - alias Pleroma.User - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.MastodonAPI.MastodonAPI - alias Pleroma.Web.MastodonAPI.ListView 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 - @relations ~w(follow unfollow)a + @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(:assign_account_by_id when action not in [:show, :statuses]) + 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 "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), 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 197316794..32a58d929 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -35,8 +35,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do require Logger - 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) @local_mastodon_name "Mastodon-Local" @@ -164,20 +162,6 @@ def update_credentials(%{assigns: %{user: user}} = conn, params) do end end - - def verify_credentials(%{assigns: %{user: user}} = conn, _) do - chat_token = Phoenix.Token.sign(conn, "user socket", user.id) - - account = - AccountView.render("show.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 @@ -288,17 +272,6 @@ def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choic end end - def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do - targets = User.get_all_by_ids(List.wrap(id)) - - 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, %{"id" => id, "description" => description} = _ @@ -649,49 +622,6 @@ defp fetch_suggestion_id(attrs) do 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, _) do - render_error(conn, :bad_request, "Missing parameters") - end - - def account_register(conn, _) do - render_error(conn, :forbidden, "Invalid credentials") - end - def password_reset(conn, params) do nickname_or_email = params["email"] || params["nickname"] diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5c3fe34e5..a4db5564d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -335,9 +335,9 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_read) - 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", AccountController, :lists) get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) @@ -459,7 +459,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) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 32ccc5351..8c8017838 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -5,9 +5,11 @@ 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 @@ -595,4 +597,256 @@ test "blocking / unblocking a user", %{conn: conn} do 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/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 0acc5d067..f2f8c0578 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -15,7 +15,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.App - alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Push import ExUnit.CaptureLog @@ -31,33 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) - 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) @@ -105,34 +77,6 @@ test "creates an oauth app", %{conn: conn} do assert expected == json_response(conn, 200) 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 - describe "media upload" do setup do user = insert(:user) @@ -170,20 +114,6 @@ test "returns uploaded image", %{conn: conn, image: image} do end end - describe "locked accounts" do - 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 - end - describe "/api/v1/pleroma/mascot" do test "mascot upload", %{conn: conn} do user = insert(:user) @@ -555,172 +485,6 @@ test "redirects to the getting-started page when referer is not present", %{conn end 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/polls/:id" do test "returns poll entity for object id", %{conn: conn} do user = insert(:user) From 987e0b8be8a7e0c40eacc96aaec53a6534563cab Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 15:47:01 +0700 Subject: [PATCH 078/138] Move update_credentials to MastodonAPI.AccountController --- .../controllers/account_controller.ex | 87 ++++++++++++++ .../controllers/mastodon_api_controller.ex | 107 +----------------- lib/pleroma/web/router.ex | 2 +- .../update_credentials_test.exs | 0 4 files changed, 89 insertions(+), 107 deletions(-) rename test/web/mastodon_api/controllers/{mastodon_api_controller => account_controller}/update_credentials_test.exs (100%) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index be863d8ed..df14ad66f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do 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.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -81,6 +82,92 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do ) 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)) 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 32a58d929..30a2bf0e0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -5,12 +5,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, truthy_param?: 1] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config - alias Pleroma.Emoji alias Pleroma.HTTP alias Pleroma.Object alias Pleroma.Pagination @@ -58,110 +57,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(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, fn value -> - {:ok, 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) - - 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) - - json( - conn, - AccountView.render("show.json", %{user: user, for: user, with_pleroma_settings: true}) - ) - else - _e -> render_error(conn, :forbidden, "Invalid request") - end - end - def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do with %Token{app: %App{} = app} <- Repo.preload(token, :app) do conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a4db5564d..f6c74896f 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -380,7 +380,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:oauth_write) - patch("/accounts/update_credentials", MastodonAPIController, :update_credentials) + patch("/accounts/update_credentials", AccountController, :update_credentials) post("/statuses", StatusController, :create) delete("/statuses/:id", StatusController, :delete) 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 100% 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 From c55facf78b6714947d8c5b02b76846f5f2ae7744 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 19:06:17 +0700 Subject: [PATCH 079/138] Fix warning in TransmogrifierTest --- test/web/activity_pub/transmogrifier_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 2c6357fe6..193d6d301 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1231,7 +1231,7 @@ test "it can handle Listen activities" do {:ok, activity} = CommonAPI.listen(user, %{"title" => "lain radio episode 1"}) - {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + {:ok, _modified} = Transmogrifier.prepare_outgoing(activity.data) end end From 0c6009dd2e475d3487123390885c46bf3fc5dea8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 30 Sep 2019 19:32:43 +0700 Subject: [PATCH 080/138] Extract mascot actions from `MastodonAPIController` to MascotController --- .../controllers/mastodon_api_controller.ex | 22 ------ .../controllers/mascot_controller.ex | 35 +++++++++ lib/pleroma/web/router.ex | 7 +- .../mastodon_api_controller_test.exs | 68 ---------------- .../controllers/mascot_controller_test.exs | 77 +++++++++++++++++++ 5 files changed, 116 insertions(+), 93 deletions(-) create mode 100644 lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex create mode 100644 test/web/pleroma_api/controllers/mascot_controller_test.exs 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 30a2bf0e0..1484a0174 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -200,28 +200,6 @@ 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), - # Reject if not an image - %{type: "image"} = rendered <- - StatusView.render("attachment.json", %{attachment: attachment_data}) do - # Sure! - # Save to the user's info - {:ok, _user} = User.update_info(user, &User.Info.mascot_update(&1, rendered)) - - json(conn, rendered) - else - %{type: _} -> render_error(conn, :unsupported_media_type, "mascots can only be images") - end - end - - def get_mascot(%{assigns: %{user: user}} = conn, _params) do - mascot = User.get_mascot(user) - - json(conn, mascot) - end - 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}, 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..7f6a76c0e --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -0,0 +1,35 @@ +# 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.User + alias Pleroma.Web.ActivityPub.ActivityPub + + @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/router.ex b/lib/pleroma/web/router.ex index f6c74896f..eab55a27c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -303,6 +303,10 @@ defmodule Pleroma.Web.Router do 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 @@ -416,9 +420,6 @@ defmodule Pleroma.Web.Router do put("/filters/:id", FilterController, :update) delete("/filters/:id", FilterController, :delete) - get("/pleroma/mascot", MastodonAPIController, :get_mascot) - put("/pleroma/mascot", MastodonAPIController, :set_mascot) - post("/reports", ReportController, :create) end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index f2f8c0578..feeaf079b 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -114,74 +114,6 @@ test "returns uploaded image", %{conn: conn, image: image} do end end - describe "/api/v1/pleroma/mascot" do - 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 - test "getting a list of mutes", %{conn: conn} do user = insert(:user) other_user = insert(:user) 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 From 28be12b38e0eb68ac702bf5e214f32f5bec4695b Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Mon, 30 Sep 2019 12:52:28 +0000 Subject: [PATCH 081/138] update admin fe --- .../{app.40438ff5.css => app.f774664e.css} | Bin ...a.bcc01554.css => chunk-15fa.9e804910.css} | Bin ...7.c0efd1fc.css => chunk-1bbd.dc6c5fb2.css} | Bin ...3.33f0e7ff.css => chunk-3871.820645ae.css} | Bin 3252 -> 3252 bytes ...c.2880a519.css => chunk-3d1c.a6b92ca7.css} | Bin ...b.75709645.css => chunk-538a.6ef5bd70.css} | Bin ...f.dc5869e7.css => chunk-598f.14eeccbb.css} | Bin ...2.d1c82a11.css => chunk-6292.8ee9eaaa.css} | Bin ...b.4a8663a9.css => chunk-7c6b.dece6ace.css} | Bin priv/static/adminfe/chunk-7f8e.52359c55.css | Bin 0 -> 314 bytes ...d.38eb00cf.css => chunk-a9e5.15079754.css} | Bin ...d8ab1.css => chunk-elementUI.d2a55ce6.css} | Bin ...s.00388c73.css => chunk-libs.36b859a1.css} | Bin priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.90c455c5.js | Bin 161629 -> 0 bytes .../adminfe/static/js/app.90c455c5.js.map | Bin 354948 -> 0 bytes priv/static/adminfe/static/js/app.9d5375ac.js | Bin 0 -> 167236 bytes .../adminfe/static/js/app.9d5375ac.js.map | Bin 0 -> 366548 bytes ...5fa.b0633695.js => chunk-15fa.6dcb4448.js} | Bin 7919 -> 7919 bytes ...3695.js.map => chunk-15fa.6dcb4448.js.map} | Bin 17438 -> 17438 bytes ...f27.d3c35fbc.js => chunk-1bbd.bc68e218.js} | Bin 2080 -> 2080 bytes ...5fbc.js.map => chunk-1bbd.bc68e218.js.map} | Bin 9090 -> 9090 bytes .../adminfe/static/js/chunk-3871.4ac23900.js | Bin 0 -> 28092 bytes .../static/js/chunk-3871.4ac23900.js.map | Bin 0 -> 91362 bytes ...d1c.20303ef7.js => chunk-3d1c.47c8fa87.js} | Bin 4822 -> 4822 bytes ...3ef7.js.map => chunk-3d1c.47c8fa87.js.map} | Bin 18519 -> 18519 bytes ...6db.12facc20.js => chunk-538a.18908e98.js} | Bin 5112 -> 5112 bytes ...cc20.js.map => chunk-538a.18908e98.js.map} | Bin 19586 -> 19586 bytes .../adminfe/static/js/chunk-5913.1d21a547.js | Bin 27091 -> 0 bytes .../static/js/chunk-5913.1d21a547.js.map | Bin 88770 -> 0 bytes ...98f.dd8089ce.js => chunk-598f.b02acd71.js} | Bin 17765 -> 17765 bytes ...89ce.js.map => chunk-598f.b02acd71.js.map} | Bin 66937 -> 66937 bytes ...292.0e668979.js => chunk-6292.b3aa39da.js} | Bin 231394 -> 231394 bytes ...8979.js.map => chunk-6292.b3aa39da.js.map} | Bin 689117 -> 689117 bytes ...c6b.c306c730.js => chunk-7c6b.24877470.js} | Bin 7947 -> 7947 bytes ...c730.js.map => chunk-7c6b.24877470.js.map} | Bin 26432 -> 26432 bytes .../adminfe/static/js/chunk-7f8e.b2353c0a.js | Bin 0 -> 9618 bytes .../static/js/chunk-7f8e.b2353c0a.js.map | Bin 0 -> 39890 bytes ...a7d.8173d81f.js => chunk-a9e5.f5bb9b33.js} | Bin 16157 -> 16157 bytes ...d81f.js.map => chunk-a9e5.f5bb9b33.js.map} | Bin 57112 -> 57112 bytes ...08d6b68.js => chunk-elementUI.374aa2ca.js} | Bin 638936 -> 638936 bytes ...js.map => chunk-elementUI.374aa2ca.js.map} | Bin 2312798 -> 2312798 bytes ...ibs.14514767.js => chunk-libs.3ed10ef6.js} | Bin 275816 -> 275816 bytes ...4767.js.map => chunk-libs.3ed10ef6.js.map} | Bin 1641569 -> 1641569 bytes .../adminfe/static/js/runtime.c6b7511a.js | Bin 0 -> 3922 bytes .../adminfe/static/js/runtime.c6b7511a.js.map | Bin 0 -> 16658 bytes .../adminfe/static/js/runtime.e85850af.js | Bin 3859 -> 0 bytes .../adminfe/static/js/runtime.e85850af.js.map | Bin 16537 -> 0 bytes 48 files changed, 1 insertion(+), 1 deletion(-) rename priv/static/adminfe/{app.40438ff5.css => app.f774664e.css} (100%) rename priv/static/adminfe/{chunk-15fa.bcc01554.css => chunk-15fa.9e804910.css} (100%) rename priv/static/adminfe/{chunk-1f27.c0efd1fc.css => chunk-1bbd.dc6c5fb2.css} (100%) rename priv/static/adminfe/{chunk-5913.33f0e7ff.css => chunk-3871.820645ae.css} (86%) rename priv/static/adminfe/{chunk-3d1c.2880a519.css => chunk-3d1c.a6b92ca7.css} (100%) rename priv/static/adminfe/{chunk-06db.75709645.css => chunk-538a.6ef5bd70.css} (100%) rename priv/static/adminfe/{chunk-598f.dc5869e7.css => chunk-598f.14eeccbb.css} (100%) rename priv/static/adminfe/{chunk-6292.d1c82a11.css => chunk-6292.8ee9eaaa.css} (100%) rename priv/static/adminfe/{chunk-7c6b.4a8663a9.css => chunk-7c6b.dece6ace.css} (100%) create mode 100644 priv/static/adminfe/chunk-7f8e.52359c55.css rename priv/static/adminfe/{chunk-1a7d.38eb00cf.css => chunk-a9e5.15079754.css} (100%) rename priv/static/adminfe/{chunk-elementUI.f35d8ab1.css => chunk-elementUI.d2a55ce6.css} (100%) rename priv/static/adminfe/{chunk-libs.00388c73.css => chunk-libs.36b859a1.css} (100%) delete mode 100644 priv/static/adminfe/static/js/app.90c455c5.js delete mode 100644 priv/static/adminfe/static/js/app.90c455c5.js.map create mode 100644 priv/static/adminfe/static/js/app.9d5375ac.js create mode 100644 priv/static/adminfe/static/js/app.9d5375ac.js.map rename priv/static/adminfe/static/js/{chunk-15fa.b0633695.js => chunk-15fa.6dcb4448.js} (99%) rename priv/static/adminfe/static/js/{chunk-15fa.b0633695.js.map => chunk-15fa.6dcb4448.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-1f27.d3c35fbc.js => chunk-1bbd.bc68e218.js} (94%) rename priv/static/adminfe/static/js/{chunk-1f27.d3c35fbc.js.map => chunk-1bbd.bc68e218.js.map} (98%) create mode 100644 priv/static/adminfe/static/js/chunk-3871.4ac23900.js create mode 100644 priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map rename priv/static/adminfe/static/js/{chunk-3d1c.20303ef7.js => chunk-3d1c.47c8fa87.js} (99%) rename priv/static/adminfe/static/js/{chunk-3d1c.20303ef7.js.map => chunk-3d1c.47c8fa87.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-06db.12facc20.js => chunk-538a.18908e98.js} (97%) rename priv/static/adminfe/static/js/{chunk-06db.12facc20.js.map => chunk-538a.18908e98.js.map} (99%) delete mode 100644 priv/static/adminfe/static/js/chunk-5913.1d21a547.js delete mode 100644 priv/static/adminfe/static/js/chunk-5913.1d21a547.js.map rename priv/static/adminfe/static/js/{chunk-598f.dd8089ce.js => chunk-598f.b02acd71.js} (99%) rename priv/static/adminfe/static/js/{chunk-598f.dd8089ce.js.map => chunk-598f.b02acd71.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-6292.0e668979.js => chunk-6292.b3aa39da.js} (99%) rename priv/static/adminfe/static/js/{chunk-6292.0e668979.js.map => chunk-6292.b3aa39da.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7c6b.c306c730.js => chunk-7c6b.24877470.js} (99%) rename priv/static/adminfe/static/js/{chunk-7c6b.c306c730.js.map => chunk-7c6b.24877470.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js create mode 100644 priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js.map rename priv/static/adminfe/static/js/{chunk-1a7d.8173d81f.js => chunk-a9e5.f5bb9b33.js} (99%) rename priv/static/adminfe/static/js/{chunk-1a7d.8173d81f.js.map => chunk-a9e5.f5bb9b33.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-elementUI.708d6b68.js => chunk-elementUI.374aa2ca.js} (99%) rename priv/static/adminfe/static/js/{chunk-elementUI.708d6b68.js.map => chunk-elementUI.374aa2ca.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-libs.14514767.js => chunk-libs.3ed10ef6.js} (99%) rename priv/static/adminfe/static/js/{chunk-libs.14514767.js.map => chunk-libs.3ed10ef6.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/runtime.c6b7511a.js create mode 100644 priv/static/adminfe/static/js/runtime.c6b7511a.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.e85850af.js delete mode 100644 priv/static/adminfe/static/js/runtime.e85850af.js.map diff --git a/priv/static/adminfe/app.40438ff5.css b/priv/static/adminfe/app.f774664e.css similarity index 100% rename from priv/static/adminfe/app.40438ff5.css rename to priv/static/adminfe/app.f774664e.css diff --git a/priv/static/adminfe/chunk-15fa.bcc01554.css b/priv/static/adminfe/chunk-15fa.9e804910.css similarity index 100% rename from priv/static/adminfe/chunk-15fa.bcc01554.css rename to priv/static/adminfe/chunk-15fa.9e804910.css diff --git a/priv/static/adminfe/chunk-1f27.c0efd1fc.css b/priv/static/adminfe/chunk-1bbd.dc6c5fb2.css similarity index 100% rename from priv/static/adminfe/chunk-1f27.c0efd1fc.css rename to priv/static/adminfe/chunk-1bbd.dc6c5fb2.css diff --git a/priv/static/adminfe/chunk-5913.33f0e7ff.css b/priv/static/adminfe/chunk-3871.820645ae.css similarity index 86% rename from priv/static/adminfe/chunk-5913.33f0e7ff.css rename to priv/static/adminfe/chunk-3871.820645ae.css index f98c967ee21dd1b937e79796330c7c4162d9c1d4..172bce31757c9858b49e0291c6e5ecb51690ee23 100644 GIT binary patch delta 117 zcmdlYxkYlqea=Lq#MBfEqvY7hH(87)OEXF!iP&)Y0pFiE43>F}tA`i)O zog#scSS|(#_=6vZ+i;T8bbZZGtdB~te#~oi{VpM8UYTH0G_1qG$He6&i`?!I8*3kKA*D>r4UIzPQ3rw-#*yC4+p{>>p=i9_%Admin FE
\ No newline at end of file +Admin FE
\ No newline at end of file diff --git a/priv/static/adminfe/static/js/app.90c455c5.js b/priv/static/adminfe/static/js/app.90c455c5.js deleted file mode 100644 index d4c607af87dafe7afd49a344d48799dfb78d042c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 161629 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 E0hSkb{{R30 diff --git a/priv/static/adminfe/static/js/app.90c455c5.js.map b/priv/static/adminfe/static/js/app.90c455c5.js.map deleted file mode 100644 index 242ad185b85359e60f6499e2f86014718ff6c5eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 354948 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

^}Az41G!oxspNf106?* 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 delta 23 ecmaEF``&iL13CUA12bb|GfPvwtYW>~!~y_uj|g7? diff --git a/priv/static/adminfe/static/js/chunk-15fa.b0633695.js.map b/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-15fa.b0633695.js.map rename to priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map index 5caa78e074be372059a0f14d2e5de5779654cb43..9a7d1241ae773d0c11ad5f0ee12b2bba103bb6ef 100644 GIT binary patch delta 23 ecmbQ&!8osjal;`F4zrZxBoh-8i_NDvLaYI1od_5J delta 23 ecmbQ&!8osjal;`FjwAy!V`DQ*)6J(iLaYH|ya%@c diff --git a/priv/static/adminfe/static/js/chunk-1f27.d3c35fbc.js b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js similarity index 94% rename from priv/static/adminfe/static/js/chunk-1f27.d3c35fbc.js rename to priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js index 14fa24f54e6c5a4e267867944bf1fb902c5a4e68..ecce144d91e4633a891c27351f9b427c37bb2879 100644 GIT binary patch delta 34 ocmZ1=us~pfEpt**%0`E??7~1=FDco~BGt&yLNBXWFE_CO0LZutF#rGn delta 34 ocmZ1=us~pfEpwWY`9_Dc?7~1=FU2_7*fcFESud+tFE_CO0K8%f*#H0l diff --git a/priv/static/adminfe/static/js/chunk-1f27.d3c35fbc.js.map b/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map similarity index 98% rename from priv/static/adminfe/static/js/chunk-1f27.d3c35fbc.js.map rename to priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map index 1ddd765a5c8654ec38f7ab00bf78f24d04a2ace1..c901677be02bbede1933e44a6c3603ab72c8712d 100644 GIT binary patch delta 25 gcmZp2Z*t%8jf*EKDMc?S*~}u<$k1Xl6L*6I0C*n=FaQ7m delta 25 gcmZp2Z*t%8jf*GE$XqYQIN8`VEh%|36L*6I0Cb=TF#rGn 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 0000000000000000000000000000000000000000..e957e4552174d28ddfefae63819467f07fa577a5 GIT binary patch literal 28092 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 M-VT2%=?{@~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8bb213374d36bdbbba08d7ccc645fe9e350cb393 GIT binary patch literal 91362 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 literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/static/js/chunk-3d1c.20303ef7.js b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-3d1c.20303ef7.js rename to priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js index 2128c604d70d6dcc83f4140d4b0f19f1d86c9aef..d3a26d496e5bca06e8dbb6f9d59b31381c44c4d4 100644 GIT binary patch delta 23 ecmcbndQEl1aUp&a^JI&(L<@7htYW>~!~y_n;0Qth delta 23 ecmcbndQEl1aUp&q17ic@)HHLwtYW>~!~y_kB?tfj diff --git a/priv/static/adminfe/static/js/chunk-3d1c.20303ef7.js.map b/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-3d1c.20303ef7.js.map rename to priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map index b3d1eb3ae5d3bea2f9886b4071a2eade2a57946a..d10007b9158c47d2a97749252f77ccbe1256114e 100644 GIT binary patch delta 22 ecmcaUf${nT#toI?>?Y>P7HNqVn;XQh8vp=hx(IFn delta 22 ecmcaUf${nT#toI?>_!I02F9srn;XQh8vp=e#Rx0_ diff --git a/priv/static/adminfe/static/js/chunk-06db.12facc20.js b/priv/static/adminfe/static/js/chunk-538a.18908e98.js similarity index 97% rename from priv/static/adminfe/static/js/chunk-06db.12facc20.js rename to priv/static/adminfe/static/js/chunk-538a.18908e98.js index c8b2a5ce99bf70e27ef9b300e062a0a712533e77..334e111c10e52090e8a52149bbc2bd062d14ce0c 100644 GIT binary patch delta 36 pcmeyN{zH9&4U4ICBh;ghMu8?rGZ7NrG;Krv0iRs0RZ4R3sL|8 delta 36 pcmeyN{zH9&4U2(UO43G$CBh;ghMu8OT4Hjtk%3-Tv0iRs0RZhk3>p9c diff --git a/priv/static/adminfe/static/js/chunk-06db.12facc20.js.map b/priv/static/adminfe/static/js/chunk-538a.18908e98.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-06db.12facc20.js.map rename to priv/static/adminfe/static/js/chunk-538a.18908e98.js.map index b07a40083fe91a8a81dc36e365dd72ba0c9ad26e..4bb072450dde46da4dcb16ca1e8d8de1741f9b24 100644 GIT binary patch delta 28 jcmZpg$=EcLaYK|8uc@&`qMo6JrGZ7NrN!oSDQP1BeM1NO delta 28 jcmZpg$=EcLaYK|8uYp-glAfVaT4Hjtk-_G4DQP1BgpLT8 diff --git a/priv/static/adminfe/static/js/chunk-5913.1d21a547.js b/priv/static/adminfe/static/js/chunk-5913.1d21a547.js deleted file mode 100644 index 8730899632a1cce824d0413cd382162d9359cdaa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27091 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 diff --git a/priv/static/adminfe/static/js/chunk-5913.1d21a547.js.map b/priv/static/adminfe/static/js/chunk-5913.1d21a547.js.map deleted file mode 100644 index 3841396c45564bf1a7e72fad1aa2cb8cbbef1d01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88770 zcmeHw3u6;UvhH6|IBaYd$!`pJ!xCA3;DCW(SWY%@eaF&R9;}DaNHz}3{q6Vrs`@pf z(aRV@vdOZtG}B#Oudc4HuI~PL<2>%ANw;%jW3_xE?GAeNIK8p)>y3-}=q#$AZY(Y? zE-a?K`r>&KU!;qJ6vY>lP9y%Xa6X71E=R}ljq>=?^=|vD+lf2Tw%Vz~PPY*sw!4i%D^3sFNhdk1r|Dt*q2G(@{ll}?pqX?I8%f$fY;~hX z+&g@iX6vxHc_nzVKrcQ%TwXq0&X$D1vn5-}k@=b)rv1xS{Jfj?pA{gv`XoJx4^XXE1o|w6JFAdyS|c;m>*08gK|;{Xsu&;A@g@#p!9kdxr1*lO)BT zn`yt>W3jX!_4zkC@bvJ!%}*A-Sw{=~c)Jz1!81N38i#4zqQ^!7CiPki;|YF6{l4(L-io7Mbkrh4 z&RS7DKIyhFGUC@wLTDQw5MMDb01hY6BbLP|8M zQc7=Z1y2oH`c*NJ?;rSS`!l{aGM$^XSAA{$s_cH?Qz$;5g~E_njUFjNgG0=gV6r=bw9?4Zmr{{W$+r zc~H;;9X*Au`#wk-*)u8mn}!N}HQzP|{XY7s>SjIa)Z

Q3f4Oa=|YXq5zN-Mizhi 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 UZ=~G;5^G=J1lb!KH*S3T|BmKn5C8xG diff --git a/priv/static/adminfe/static/js/chunk-598f.dd8089ce.js b/priv/static/adminfe/static/js/chunk-598f.b02acd71.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-598f.dd8089ce.js rename to priv/static/adminfe/static/js/chunk-598f.b02acd71.js index 618a2ee9f9fcdb66358fe2ebc2df8202de3813eb..fb2374e3bf0730e33889eeb49442937c9f524331 100644 GIT binary patch delta 25 gcmaFb#rU*~aYL9Zf0BVwVseVPp~!~y`wvkR#J delta 31 mcmaFV&-bXGuc3vpg=q`(<#v99R5LRROLI%TtYW>~!~y`punPkK diff --git a/priv/static/adminfe/static/js/chunk-6292.0e668979.js.map b/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-6292.0e668979.js.map rename to priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map index ecc2a300373be0bf645cb4ccb1d80e949f81bfe4..577df8f956b636635318b4152c749f4a21db9390 100644 GIT binary patch delta 56 zcmccHu64IvtD%K)3)72V97)EBiN=;GiR~YLF#$0%5VHU=D-g2*F*^`*Z2$0!^KAzJ DC$Ao( delta 56 zcmccHu64IvtD%K)3)72V90sXoW)_y_mhB&YF#$0%5VHU=D-g2*F*^`*Z2$0!^KAzJ D4=Nq~ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.c306c730.js b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.c306c730.js rename to priv/static/adminfe/static/js/chunk-7c6b.24877470.js index 24d1d447a55c29cfd7bbb909753650a8888604b3..059bcf3223073733caee5a87825e1315313ee296 100644 GIT binary patch delta 23 ecmeCS>$cnQM~>gf#KPR%#N0qHt5`2Lu>b&I5eJF@ delta 23 ecmeCS>$cnQM~*+)*uX5=+}J=bt5`2Lu>b&MOb6Tm diff --git a/priv/static/adminfe/static/js/chunk-7c6b.c306c730.js.map b/priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.c306c730.js.map rename to priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map index 0384ad316856cf09bb091056341117487ce61340..cb00fc3ebf9d73ae9e6b644b7f6921233c517e33 100644 GIT binary patch delta 22 ecmX?bj`6@b#tpv|*o{mq%*{>AH!~{Qy8!@e=?B38 delta 22 ecmX?bj`6@b#tpv|*prP7%#zKGH!~{Qy8!@iE(j9< 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 0000000000000000000000000000000000000000..9a0afaf67616d6957479e1da58d923d3acbd1caf GIT binary patch literal 9618 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 qTYGcV5?*XP$n8%I$A$?`-=WY#H!_d-gvlF~)2F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7b1d18c708c340b2cb3fcad2507909b1e80c3385 GIT binary patch literal 39890 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 diff --git a/priv/static/adminfe/static/js/chunk-1a7d.8173d81f.js.map b/priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-1a7d.8173d81f.js.map rename to priv/static/adminfe/static/js/chunk-a9e5.f5bb9b33.js.map index d5a2b4a2007fdd81c6add44ce450be1435a75edd..1bde6592f1a5dce1874569501566b2023109e81a 100644 GIT binary patch delta 28 kcmbQSk9o#E<_&djyor{nrg~|nNlBJT#>Shw-FjyL0Gs;?p8x;= delta 28 kcmbQSk9o#E<_&djyoQP9DS8%$=Ef-&hH0C--FjyL0G37yJ^%m! diff --git a/priv/static/adminfe/static/js/chunk-elementUI.708d6b68.js b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-elementUI.708d6b68.js rename to priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js index 9ead2e76397abbf4d756da2ecceadbb4b73e1278..b221f866c0a926a4b135bee418f1356742b95492 100644 GIT binary patch delta 43 zcmccdU+uF7M2#)7Pc1l7LF~PC-?Iko0}vi8YL&{Wfkk?CKdnyec}(` delta 43 zcmccdU+uF7M2#)7Pc1l7LF~PC-?K48(5^6C7D_1Wfkk?CKdnydp-}7 diff --git a/priv/static/adminfe/static/js/chunk-elementUI.708d6b68.js.map b/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-elementUI.708d6b68.js.map rename to priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map index b49ada1f78a23cbb7bebab63294afa66ca7af744..b58957727727c3da9697ab9dd8d61d42a8039edd 100644 GIT binary patch delta 142 zcmWN=yA6U+0EW>ZDi>5lQ64@J7(q>(=f=vC!rtOftn3`c#6@n+2JYZ@lGDFmUeAX- z4diR6k;V#S6n>(cGpEbh+T+zeI~Q@nDQAp1XTk-SOu6Ek8FLmax#5;O?pg7`n$5R! H?eG2vNGLu} delta 142 zcmWN=yA6U+0EW?^q6mtL;9Eo{pu#&3v7x*Ly~Uqc**S`di`<$G+`;c8r+>e^o)38% zXsD5VjWtoA=_hPmaI4I{Zff(iE@Z?R=Zv}Fk}D=$GiAmNx7=~h1CKoM%$ye%ynd}K Gf88G_@je0o diff --git a/priv/static/adminfe/static/js/chunk-libs.14514767.js b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-libs.14514767.js rename to priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js index f1452865b1af0334cbc57c53ae0f3691d00b5823..b31c6cd5b7b053c709327382aff35cb72c746e4b 100644 GIT binary patch delta 33 ocmaFyO5nvSfrb{w7N!>FEi93y{Kly%h6br=W_nr0dbx=O0OdUl0RR91 delta 33 ocmaFyO5nvSfrb{w7N!>FEi93y{Dvl`h9>4_=6YGhdbx=O0Nln4MgRZ+ diff --git a/priv/static/adminfe/static/js/chunk-libs.14514767.js.map b/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-libs.14514767.js.map rename to priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map index b0a81d9bc2e6b252324dc579d4cf07fbd2d32d51..61fd05273efccadb908e1cf006ef1f95246e1f47 100644 GIT binary patch delta 108 zcmaDjBjw?Yl!g|@7N!>F7M2#)Eo}QHaTuqj7#gIenYACA#0JFdK+FNeoIuP4#N0s4 n1H`;Q%m>8$Kr8^nfq*~EC$5l+mB6>m|Y0~5F{}? delta 108 zcmWN=w+Vny6hKkT-<-o5f?*+-u(Nnz|0}^Y1UpOja1-x=^Bv1~ESnf&iY2xoCQl(tl6-=U)`PS57WUgfdBvi 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 0000000000000000000000000000000000000000..0e13fe45aab0135a56a601af9ecd6ea99bdaecf6 GIT binary patch literal 3922 zcmai1ZI9cy5&qs^;bj3sgt?4lTefUR2+|;5g1cVPTtB(Sfucq$C0f#@sN37K_1`p)Q>01Q#jMc`(2ocVUp=s?fnDG@FzI10)OTQ zmd8FTwSJ;^_= zS&w>jXTa7*O~ge5E{@0Y*%PcdpZBN-e$ou~;$$!vm_q&3ThYr@XwtB2W3g8UX5u@a z$vD`_x3>RIy6+rdS!~hToqrd{^*%!kJp4SMjlq%N)v+ujcpAngRXF^4~n+SqF6WZKv*#iwGc zSh>9mShu-7HBAo~xI-7zZGVd8GE-l^6J+?Z}1 zE^aiPo`}qA_4R%ctpl`Tv^4A$4f>(NX@>^s{1h4@i#2}}Im;|A}-mJsru%2eC7v;2X zK!PQJlu0st-#!Kj=ZkP7!;moj{?qdddIYQVMZDVABP+qk(QKY;yMf*V%Yy9{8K!FN6JEdC6ozd0P(i&BU_A-?d2HL;y~T`_tu&s zsV$yMg%$U?fsD}eIsay}UEg77lFB}pP~`xY#BrQ+10|9|hGTO+yB|sW&{arKE{cl3 z()l8jw1n6yUVNu`?rz6>Q2pY^>eFjoIu_|wNk>(r0b!v-u*YqeI)mbCD_P1Nr^m#? zANI}VF;cEosYMzq+|&w;(Prchy z^5OI6$%RlRWCk-b$wW~?%Kxi8GBYzm5u+Lv{9g?f(jaDlnl`9{d!AUbzwnG4L`o>K zv`ZwQ(hqr2rd~=L%54s6fRMHej>zstkT$l=MOlq6mM}%eEB^O=WIGFoP zb0w$KjN=zq?ya7N!9}LO9a}yV1`1ZmU^39j(4Vk^hi4X_~lD#L6$2CnjoVXP1JMZmx?r) zq><*vY=*gP48|cs>#}6B1Yv${eZ(tXc4}95U^*Nk7a0MA(s3NdFvkBog2}VOASK&& zfg&fr8sVte3*r5P(B5Li%QnJA%QuFJA=D+aShaeBHkT0kT^H%Z$8x7-SM>DWxB(4% z65h$_y(L|65Y|%BHfxYHf;lW*EDh|{113fqafETh71>bUb+rU{g{?wynm@*X|0S}L z=IQtf>xd%GZW5zyFru^OrS3_Onjw5=cYYYlq2izd_Y~ z)*rh=#&qi18}ly}Z7@Yw@m#44l(J~5`Wy+4R+ny<=t~!Y9@p6f)2aL8<4xls_wY9E zlxvyNQ|Ri{i9^89m0wZaGAx)fQJ0o{i+F21pw;t>Le&Ms5oDl4L*@D&p!m|BWrhu2 zhpDfx|FHsZcv2|rZd?c&%q88Hbr`8DjQU#p8*ASUVn?aG)4kcgusM|fPA|m>vW=ol zx$E@Rb-37T*#UU`VdLif*2y@=#3c}Y+a}oD=% zjzWI@RB3g5Y0b5C*p+5&r+2|EbMfg~Pb@Ps+^H^IS6d~y_s8ZH`_`NM8KB;tVB>aroA4B6m|NbfN+A@v}$T|!H!M{}eQ5^pdV7~|- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0eadd3e06b2d64ca6d6f0fda6e6a4e4de128644c GIT binary patch literal 16658 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 qH|y7Exc252-y%y Date: Mon, 30 Sep 2019 18:23:29 +0300 Subject: [PATCH 082/138] CI: Enable OTP release building for maint/* branches --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d0c540b16..7bee30e08 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -151,6 +151,7 @@ amd64: only: &release-only - master@pleroma/pleroma - develop@pleroma/pleroma + - /^maint/.*$/@pleroma/pleroma artifacts: &release-artifacts name: "pleroma-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA-$CI_JOB_NAME" paths: From dae744478e7a5b789f2fb541b47eea558a0f2d53 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 30 Sep 2019 18:13:05 +0200 Subject: [PATCH 083/138] Transmogrifier: Handle compact objects in undos. --- .../web/activity_pub/transmogrifier.ex | 18 ++++++++++++ .../mastodon-undo-like-compact-object.json | 29 +++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 25 ++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 test/fixtures/mastodon-undo-like-compact-object.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 63877248a..3ca2e8773 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -753,6 +753,24 @@ def handle_incoming( end end + # For Undos that don't have the complete object attached, try to find it in our database. + def handle_incoming( + %{ + "type" => "Undo", + "object" => object + } = activity, + options + ) + when is_binary(object) do + with %Activity{data: data} <- Activity.get_by_ap_id(object) do + activity + |> Map.put("object", data) + |> handle_incoming(options) + else + _e -> :error + end + end + def handle_incoming(_, _), do: :error @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil diff --git a/test/fixtures/mastodon-undo-like-compact-object.json b/test/fixtures/mastodon-undo-like-compact-object.json new file mode 100644 index 000000000..ae66a0d19 --- /dev/null +++ b/test/fixtures/mastodon-undo-like-compact-object.json @@ -0,0 +1,29 @@ +{ + "type": "Undo", + "signature": { + "type": "RsaSignature2017", + "signatureValue": "fdxMfQSMwbC6wP6sh6neS/vM5879K67yQkHTbiT5Npr5wAac0y6+o3Ij+41tN3rL6wfuGTosSBTHOtta6R4GCOOhCaCSLMZKypnp1VltCzLDoyrZELnYQIC8gpUXVmIycZbREk22qWUe/w7DAFaKK4UscBlHDzeDVcA0K3Se5Sluqi9/Zh+ldAnEzj/rSEPDjrtvf5wGNf3fHxbKSRKFt90JvKK6hS+vxKUhlRFDf6/SMETw+EhwJSNW4d10yMUakqUWsFv4Acq5LW7l+HpYMvlYY1FZhNde1+uonnCyuQDyvzkff8zwtEJmAXC4RivO/VVLa17SmqheJZfI8oluVg==", + "creator": "http://mastodon.example.org/users/admin#main-key", + "created": "2018-05-19T16:36:58Z" + }, + "object": "http://mastodon.example.org/users/admin#likes/2", + "nickname": "lain", + "id": "http://mastodon.example.org/users/admin#likes/2/undo", + "actor": "http://mastodon.example.org/users/admin", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "toot": "http://joinmastodon.org/ns#", + "sensitive": "as:sensitive", + "ostatus": "http://ostatus.org#", + "movedTo": "as:movedTo", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "atomUri": "ostatus:atomUri", + "Hashtag": "as:Hashtag", + "Emoji": "toot:Emoji" + } + ] +} diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 193d6d301..6c64be10b 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -378,6 +378,31 @@ test "it works for incoming unlikes with an existing like activity" do assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" end + test "it works for incoming unlikes with an existing like activity and a compact object" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + + like_data = + File.read!("test/fixtures/mastodon-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) + + data = + File.read!("test/fixtures/mastodon-undo-like.json") + |> Poison.decode!() + |> Map.put("object", like_data["id"]) + |> Map.put("actor", like_data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Undo" + assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" + assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" + end + test "it works for incoming announces" do data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() From 36a34c36fe518dae23fb19d02ccb43de8c2621dd Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 1 Oct 2019 11:44:34 +0700 Subject: [PATCH 084/138] Extract poll actions from `MastodonAPIController` to `PollController` --- lib/pleroma/web/controller_helper.ex | 12 ++ .../controllers/mastodon_api_controller.ex | 64 ------ .../controllers/poll_controller.ex | 53 +++++ .../controllers/status_controller.ex | 2 +- .../web/mastodon_api/views/poll_view.ex | 74 +++++++ .../web/mastodon_api/views/status_view.ex | 72 +------ lib/pleroma/web/router.ex | 4 +- .../controllers/poll_controller_test.exs | 184 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 172 ---------------- .../web/mastodon_api/views/poll_view_test.exs | 126 ++++++++++++ .../mastodon_api/views/status_view_test.exs | 110 ----------- 11 files changed, 454 insertions(+), 419 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/views/poll_view.ex create mode 100644 test/web/mastodon_api/controllers/poll_controller_test.exs create mode 100644 test/web/mastodon_api/views/poll_view_test.exs diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 83b884ba9..9a4e322c9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,4 +75,16 @@ def assign_account_by_id(%{params: %{"id" => id}} = conn, _) do nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end end + + def try_render(conn, target, params) + when is_binary(target) do + case render(conn, target, params) do + nil -> render_error(conn, :not_implemented, "Can't display this activity") + res -> res + end + end + + def try_render(conn, _, _) do + render_error(conn, :not_implemented, "Can't display this activity") + 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 1484a0174..912dd181f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.HTTP @@ -19,7 +18,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AppView @@ -117,56 +115,6 @@ def custom_emojis(conn, _params) do json(conn, mastodon_emoji) 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"]), - true <- Visibility.visible_for_user?(activity, user) do - conn - |> put_view(StatusView) - |> try_render("poll.json", %{object: object, for: user}) - else - error when is_nil(error) or error == false -> - render_error(conn, :not_found, "Record not found") - end - end - - defp get_cached_vote_or_vote(user, object, choices) do - idempotency_key = "polls:#{user.id}:#{object.data["id"]}" - - {_, res} = - Cachex.fetch(:idempotency_cache, idempotency_key, fn _ -> - case CommonAPI.vote(user, object, choices) do - {:error, _message} = res -> {:ignore, res} - res -> {:commit, res} - end - end) - - res - end - - def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do - with %Object{} = object <- Object.get_by_id(id), - true <- object.data["type"] == "Question", - %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), - true <- Visibility.visible_for_user?(activity, user), - {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do - conn - |> put_view(StatusView) - |> try_render("poll.json", %{object: object, for: user}) - else - nil -> - render_error(conn, :not_found, "Record not found") - - false -> - render_error(conn, :not_found, "Record not found") - - {:error, message} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{error: message}) - end - end - def update_media( %{assigns: %{user: user}} = conn, %{"id" => id, "description" => description} = _ @@ -511,18 +459,6 @@ def password_reset(conn, params) do end end - def try_render(conn, target, params) - when is_binary(target) do - case render(conn, target, params) do - nil -> render_error(conn, :not_implemented, "Can't display this activity") - res -> res - end - end - - def try_render(conn, _, _) do - render_error(conn, :not_implemented, "Can't display this activity") - end - defp present?(nil), do: false defp present?(false), do: false defp present?(_), do: true diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex new file mode 100644 index 000000000..fbf7f8673 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [try_render: 3, json_response: 3] + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/polls/:id" + def show(%{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"]), + true <- Visibility.visible_for_user?(activity, user) do + try_render(conn, "show.json", %{object: object, for: user}) + else + error when is_nil(error) or error == false -> + render_error(conn, :not_found, "Record not found") + end + end + + @doc "POST /api/v1/polls/:id/votes" + def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do + with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _activities, object} <- get_cached_vote_or_vote(user, object, choices) do + try_render(conn, "show.json", %{object: object, for: user}) + else + nil -> render_error(conn, :not_found, "Record not found") + false -> render_error(conn, :not_found, "Record not found") + {:error, message} -> json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + defp get_cached_vote_or_vote(user, object, choices) do + idempotency_key = "polls:#{user.id}:#{object.data["id"]}" + + Cachex.fetch!(:idempotency_cache, idempotency_key, fn -> + case CommonAPI.vote(user, object, choices) do + {:error, _message} = res -> {:ignore, res} + res -> {:commit, res} + end + end) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 3c6987a5f..fb6fd7676 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.MastodonAPI.MastodonAPIController, only: [try_render: 3] + import Pleroma.Web.ControllerHelper, only: [try_render: 3] require Ecto.Query diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex new file mode 100644 index 000000000..753039da3 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollView do + use Pleroma.Web, :view + + alias Pleroma.HTML + alias Pleroma.Web.CommonAPI.Utils + + def render("show.json", %{object: object, multiple: multiple, options: options} = params) do + {end_time, expired} = end_time_and_expired(object) + {options, votes_count} = options_and_votes_count(options) + + %{ + # Mastodon uses separate ids for polls, but an object can't have + # more than one poll embedded so object id is fine + id: to_string(object.id), + expires_at: end_time, + expired: expired, + multiple: multiple, + votes_count: votes_count, + options: options, + voted: voted?(params), + emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]) + } + end + + def render("show.json", %{object: object} = params) do + case object.data do + %{"anyOf" => options} when is_list(options) -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) + + %{"oneOf" => options} when is_list(options) -> + render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) + + _ -> + nil + end + end + + defp end_time_and_expired(object) do + case object.data["closed"] || object.data["endTime"] do + end_time when is_binary(end_time) -> + end_time = NaiveDateTime.from_iso8601!(end_time) + expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt + + {Utils.to_masto_date(end_time), expired} + + _ -> + {nil, false} + end + end + + defp options_and_votes_count(options) do + Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> + current_count = option["replies"]["totalItems"] || 0 + + {%{ + title: HTML.strip_tags(name), + votes_count: current_count + }, current_count + count} + end) + end + + defp voted?(%{object: object} = opts) do + if opts[:for] do + existing_votes = Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) + existing_votes != [] or opts[:for].ap_id == object.data["actor"] + else + false + 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 bc527ad1b..3262427ec 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -18,6 +18,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.PollView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy @@ -277,7 +278,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} spoiler_text: summary_html, visibility: get_visibility(object), media_attachments: attachments, - poll: render("poll.json", %{object: object, for: opts[:for]}), + poll: render(PollView, "show.json", object: object, for: opts[:for]), mentions: mentions, tags: build_tags(tags), application: %{ @@ -389,75 +390,6 @@ 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 - %{"anyOf" => options} when is_list(options) -> {true, options} - %{"oneOf" => options} when is_list(options) -> {false, options} - _ -> {nil, nil} - end - - if options do - {end_time, expired} = - case object.data["closed"] || object.data["endTime"] do - end_time when is_binary(end_time) -> - end_time = - (object.data["closed"] || object.data["endTime"]) - |> NaiveDateTime.from_iso8601!() - - expired = - end_time - |> NaiveDateTime.compare(NaiveDateTime.utc_now()) - |> case do - :lt -> true - _ -> false - end - - end_time = Utils.to_masto_date(end_time) - - {end_time, expired} - - _ -> - {nil, false} - end - - voted = - if opts[:for] do - existing_votes = - Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) - - existing_votes != [] or opts[:for].ap_id == object.data["actor"] - else - false - end - - {options, votes_count} = - Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> - current_count = option["replies"]["totalItems"] || 0 - - {%{ - title: HTML.strip_tags(name), - votes_count: current_count - }, current_count + count} - end) - - %{ - # Mastodon uses separate ids for polls, but an object can't have - # more than one poll embedded so object id is fine - id: to_string(object.id), - expires_at: end_time, - expired: expired, - multiple: multiple, - votes_count: votes_count, - options: options, - voted: voted, - emojis: build_emojis(object.data["emoji"]) - } - else - nil - end - end - def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index eab55a27c..7af44c6be 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -403,7 +403,7 @@ defmodule Pleroma.Web.Router do put("/scheduled_statuses/:id", ScheduledActivityController, :update) delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - post("/polls/:id/votes", MastodonAPIController, :poll_vote) + post("/polls/:id/votes", PollController, :vote) post("/media", MastodonAPIController, :upload) put("/media/:id", MastodonAPIController, :update_media) @@ -488,7 +488,7 @@ defmodule Pleroma.Web.Router do get("/statuses/:id", StatusController, :show) get("/statuses/:id/context", StatusController, :context) - get("/polls/:id", MastodonAPIController, :get_poll) + get("/polls/:id", PollController, :show) get("/accounts/:id/statuses", AccountController, :statuses) get("/accounts/:id/followers", AccountController, :followers) diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs new file mode 100644 index 000000000..40cf3e879 --- /dev/null +++ b/test/web/mastodon_api/controllers/poll_controller_test.exs @@ -0,0 +1,184 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "GET /api/v1/polls/:id" do + test "returns poll entity for object id", %{conn: conn} do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Pleroma does", + "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, user) + |> get("/api/v1/polls/#{object.id}") + + response = json_response(conn, 200) + id = to_string(object.id) + assert %{"id" => ^id, "expired" => false, "multiple" => false} = response + end + + test "does not expose polls for private statuses", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Pleroma does", + "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20}, + "visibility" => "private" + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, other_user) + |> get("/api/v1/polls/#{object.id}") + + assert json_response(conn, 404) + end + end + + describe "POST /api/v1/polls/:id/votes" do + test "votes are added to the poll", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "A very delicious sandwich", + "poll" => %{ + "options" => ["Lettuce", "Grilled Bacon", "Tomato"], + "expires_in" => 20, + "multiple" => true + } + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) + + assert json_response(conn, 200) + object = Object.get_by_id(object.id) + + assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} -> + total_items == 1 + end) + end + + test "author can't vote", %{conn: conn} do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Am I cute?", + "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + assert conn + |> assign(:user, user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) + |> json_response(422) == %{"error" => "Poll's author can't vote"} + + object = Object.get_by_id(object.id) + + refute Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 1 + end + + test "does not allow multiple choices on a single-choice question", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "The glass is", + "poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + assert conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) + |> json_response(422) == %{"error" => "Too many choices"} + + object = Object.get_by_id(object.id) + + refute Enum.any?(object.data["oneOf"], fn %{"replies" => %{"totalItems" => total_items}} -> + total_items == 1 + end) + end + + test "does not allow choice index to be greater than options count", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Am I cute?", + "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]}) + + assert json_response(conn, 422) == %{"error" => "Invalid indices"} + end + + test "returns 404 error when object is not exist", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/polls/1/votes", %{"choices" => [0]}) + + assert json_response(conn, 404) == %{"error" => "Record not found"} + end + + test "returns 404 when poll is private and not available for user", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Am I cute?", + "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}, + "visibility" => "private" + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]}) + + assert json_response(conn, 404) == %{"error" => "Record not found"} + 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 feeaf079b..2ec46bc90 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -417,178 +417,6 @@ test "redirects to the getting-started page when referer is not present", %{conn end end - describe "GET /api/v1/polls/:id" do - test "returns poll entity for object id", %{conn: conn} do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Pleroma does", - "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20} - }) - - object = Object.normalize(activity) - - conn = - conn - |> assign(:user, user) - |> get("/api/v1/polls/#{object.id}") - - response = json_response(conn, 200) - id = to_string(object.id) - assert %{"id" => ^id, "expired" => false, "multiple" => false} = response - end - - test "does not expose polls for private statuses", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Pleroma does", - "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20}, - "visibility" => "private" - }) - - object = Object.normalize(activity) - - conn = - conn - |> assign(:user, other_user) - |> get("/api/v1/polls/#{object.id}") - - assert json_response(conn, 404) - end - end - - describe "POST /api/v1/polls/:id/votes" do - test "votes are added to the poll", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "A very delicious sandwich", - "poll" => %{ - "options" => ["Lettuce", "Grilled Bacon", "Tomato"], - "expires_in" => 20, - "multiple" => true - } - }) - - object = Object.normalize(activity) - - conn = - conn - |> assign(:user, other_user) - |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) - - assert json_response(conn, 200) - object = Object.get_by_id(object.id) - - assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} -> - total_items == 1 - end) - end - - test "author can't vote", %{conn: conn} do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} - }) - - object = Object.normalize(activity) - - assert conn - |> assign(:user, user) - |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) - |> json_response(422) == %{"error" => "Poll's author can't vote"} - - object = Object.get_by_id(object.id) - - refute Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 1 - end - - test "does not allow multiple choices on a single-choice question", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "The glass is", - "poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20} - }) - - object = Object.normalize(activity) - - assert conn - |> assign(:user, other_user) - |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) - |> json_response(422) == %{"error" => "Too many choices"} - - object = Object.get_by_id(object.id) - - refute Enum.any?(object.data["oneOf"], fn %{"replies" => %{"totalItems" => total_items}} -> - total_items == 1 - end) - end - - test "does not allow choice index to be greater than options count", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} - }) - - object = Object.normalize(activity) - - conn = - conn - |> assign(:user, other_user) - |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]}) - - assert json_response(conn, 422) == %{"error" => "Invalid indices"} - end - - test "returns 404 error when object is not exist", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/polls/1/votes", %{"choices" => [0]}) - - assert json_response(conn, 404) == %{"error" => "Record not found"} - end - - test "returns 404 when poll is private and not available for user", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}, - "visibility" => "private" - }) - - object = Object.normalize(activity) - - conn = - conn - |> assign(:user, other_user) - |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]}) - - assert json_response(conn, 404) == %{"error" => "Record not found"} - end - end - describe "POST /auth/password, with valid parameters" do setup %{conn: conn} do user = insert(:user) diff --git a/test/web/mastodon_api/views/poll_view_test.exs b/test/web/mastodon_api/views/poll_view_test.exs new file mode 100644 index 000000000..8cd7636a5 --- /dev/null +++ b/test/web/mastodon_api/views/poll_view_test.exs @@ -0,0 +1,126 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.PollViewTest do + use Pleroma.DataCase + + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.PollView + + import Pleroma.Factory + import Tesla.Mock + + setup do + mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "renders a poll" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Is Tenshi eating a corndog cute?", + "poll" => %{ + "options" => ["absolutely!", "sure", "yes", "why are you even asking?"], + "expires_in" => 20 + } + }) + + object = Object.normalize(activity) + + expected = %{ + emojis: [], + expired: false, + id: to_string(object.id), + multiple: false, + options: [ + %{title: "absolutely!", votes_count: 0}, + %{title: "sure", votes_count: 0}, + %{title: "yes", votes_count: 0}, + %{title: "why are you even asking?", votes_count: 0} + ], + voted: false, + votes_count: 0 + } + + result = PollView.render("show.json", %{object: object}) + expires_at = result.expires_at + result = Map.delete(result, :expires_at) + + assert result == expected + + expires_at = NaiveDateTime.from_iso8601!(expires_at) + assert NaiveDateTime.diff(expires_at, NaiveDateTime.utc_now()) in 15..20 + end + + test "detects if it is multiple choice" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Which Mastodon developer is your favourite?", + "poll" => %{ + "options" => ["Gargron", "Eugen"], + "expires_in" => 20, + "multiple" => true + } + }) + + object = Object.normalize(activity) + + assert %{multiple: true} = PollView.render("show.json", %{object: object}) + end + + test "detects emoji" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "What's with the smug face?", + "poll" => %{ + "options" => [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"], + "expires_in" => 20 + } + }) + + object = Object.normalize(activity) + + assert %{emojis: [%{shortcode: "blank"}]} = PollView.render("show.json", %{object: object}) + end + + test "detects vote status" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Which input devices do you use?", + "poll" => %{ + "options" => ["mouse", "trackball", "trackpoint"], + "multiple" => true, + "expires_in" => 20 + } + }) + + object = Object.normalize(activity) + + {:ok, _, object} = CommonAPI.vote(other_user, object, [1, 2]) + + result = PollView.render("show.json", %{object: object, for: other_user}) + + assert result[:voted] == true + assert Enum.at(result[:options], 1)[:votes_count] == 1 + assert Enum.at(result[:options], 2)[:votes_count] == 1 + end + + test "does not crash on polls with no end date" do + object = Object.normalize("https://skippers-bin.com/notes/7x9tmrp97i") + result = PollView.render("show.json", %{object: object}) + + assert result[:expires_at] == nil + assert result[:expired] == false + end +end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 8df23d0a8..1d5a6e956 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -451,116 +451,6 @@ test "a rich media card with all relevant data renders correctly" do end end - describe "poll view" do - test "renders a poll" do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Is Tenshi eating a corndog cute?", - "poll" => %{ - "options" => ["absolutely!", "sure", "yes", "why are you even asking?"], - "expires_in" => 20 - } - }) - - object = Object.normalize(activity) - - expected = %{ - emojis: [], - expired: false, - id: to_string(object.id), - multiple: false, - options: [ - %{title: "absolutely!", votes_count: 0}, - %{title: "sure", votes_count: 0}, - %{title: "yes", votes_count: 0}, - %{title: "why are you even asking?", votes_count: 0} - ], - voted: false, - votes_count: 0 - } - - result = StatusView.render("poll.json", %{object: object}) - expires_at = result.expires_at - result = Map.delete(result, :expires_at) - - assert result == expected - - expires_at = NaiveDateTime.from_iso8601!(expires_at) - assert NaiveDateTime.diff(expires_at, NaiveDateTime.utc_now()) in 15..20 - end - - test "detects if it is multiple choice" do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Which Mastodon developer is your favourite?", - "poll" => %{ - "options" => ["Gargron", "Eugen"], - "expires_in" => 20, - "multiple" => true - } - }) - - object = Object.normalize(activity) - - assert %{multiple: true} = StatusView.render("poll.json", %{object: object}) - end - - test "detects emoji" do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "What's with the smug face?", - "poll" => %{ - "options" => [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"], - "expires_in" => 20 - } - }) - - object = Object.normalize(activity) - - assert %{emojis: [%{shortcode: "blank"}]} = - StatusView.render("poll.json", %{object: object}) - end - - test "detects vote status" do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - "status" => "Which input devices do you use?", - "poll" => %{ - "options" => ["mouse", "trackball", "trackpoint"], - "multiple" => true, - "expires_in" => 20 - } - }) - - object = Object.normalize(activity) - - {:ok, _, object} = CommonAPI.vote(other_user, object, [1, 2]) - - result = StatusView.render("poll.json", %{object: object, for: other_user}) - - assert result[:voted] == true - assert Enum.at(result[:options], 1)[:votes_count] == 1 - assert Enum.at(result[:options], 2)[:votes_count] == 1 - end - - test "does not crash on polls with no end date" do - object = Object.normalize("https://skippers-bin.com/notes/7x9tmrp97i") - result = StatusView.render("poll.json", %{object: object}) - - assert result[:expires_at] == nil - assert result[:expired] == false - end - end - test "embeds a relationship in the account" do user = insert(:user) other_user = insert(:user) From 585bc57edbe10dcd19d2294824e0a0600f4bfe4c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 1 Oct 2019 14:36:35 +0700 Subject: [PATCH 085/138] Extract media actions from `MastodonAPIController` to `MediaController` --- .../controllers/mastodon_api_controller.ex | 34 ------- .../controllers/media_controller.ex | 42 +++++++++ lib/pleroma/web/router.ex | 4 +- .../controllers/media_controller_test.exs | 92 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 80 ---------------- 5 files changed, 136 insertions(+), 116 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/media_controller.ex create mode 100644 test/web/mastodon_api/controllers/media_controller_test.exs 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 912dd181f..f466ecbff 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.HTTP - alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo @@ -115,39 +114,6 @@ def custom_emojis(conn, _params) do json(conn, mastodon_emoji) end - def update_media( - %{assigns: %{user: user}} = conn, - %{"id" => id, "description" => description} = _ - ) - when is_binary(description) do - with %Object{} = object <- Repo.get(Object, id), - true <- Object.authorize_mutation(object, user), - {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do - attachment_data = Map.put(data, "id", object.id) - - conn - |> put_view(StatusView) - |> render("attachment.json", %{attachment: attachment_data}) - 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( - file, - actor: User.ap_id(user), - description: Map.get(data, "description") - ) do - attachment_data = Map.put(object.data, "id", object.id) - - conn - |> put_view(StatusView) - |> render("attachment.json", %{attachment: attachment_data}) - end - end - 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}, diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex new file mode 100644 index 000000000..57a5b60fb --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaController do + use Pleroma.Web, :controller + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + + @doc "POST /api/v1/media" + def create(%{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 + attachment_data = Map.put(object.data, "id", object.id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + @doc "PUT /api/v1/media/:id" + def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => description}) + when is_binary(description) do + with %Object{} = object <- Object.get_by_id(id), + true <- Object.authorize_mutation(object, user), + {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do + attachment_data = Map.put(data, "id", object.id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def update(_conn, _data), do: {:error, :bad_request} +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7af44c6be..8b482528b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -405,8 +405,8 @@ defmodule Pleroma.Web.Router do post("/polls/:id/votes", PollController, :vote) - post("/media", MastodonAPIController, :upload) - put("/media/:id", MastodonAPIController, :update_media) + post("/media", MediaController, :create) + put("/media/:id", MediaController, :update) delete("/lists/:id", ListController, :delete) post("/lists", ListController, :create) diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs new file mode 100644 index 000000000..06c6a1cb3 --- /dev/null +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -0,0 +1,92 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + + import Pleroma.Factory + + describe "media upload" do + setup do + user = insert(:user) + + conn = + build_conn() + |> assign(:user, user) + + image = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + [conn: conn, image: image] + end + + clear_config([:media_proxy]) + clear_config([Pleroma.Upload]) + + test "returns uploaded image", %{conn: conn, image: image} do + desc = "Description of the image" + + media = + conn + |> post("/api/v1/media", %{"file" => image, "description" => desc}) + |> json_response(:ok) + + assert media["type"] == "image" + assert media["description"] == desc + assert media["id"] + + object = Object.get_by_id(media["id"]) + assert object.data["actor"] == User.ap_id(conn.assigns[:user]) + 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 +end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 2ec46bc90..da5606165 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -12,7 +12,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.App alias Pleroma.Web.Push @@ -77,43 +76,6 @@ test "creates an oauth app", %{conn: conn} do assert expected == json_response(conn, 200) end - describe "media upload" do - setup do - user = insert(:user) - - conn = - build_conn() - |> assign(:user, user) - - image = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - [conn: conn, image: image] - end - - clear_config([:media_proxy]) - clear_config([Pleroma.Upload]) - - test "returns uploaded image", %{conn: conn, image: image} do - desc = "Description of the image" - - media = - conn - |> post("/api/v1/media", %{"file" => image, "description" => desc}) - |> json_response(:ok) - - assert media["type"] == "image" - assert media["description"] == desc - assert media["id"] - - object = Repo.get(Object, media["id"]) - assert object.data["actor"] == User.ap_id(conn.assigns[:user]) - end - end - test "getting a list of mutes", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -550,48 +512,6 @@ 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) From 39695c4436056db0b25cfa5e361630791923df84 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 1 Oct 2019 14:45:04 +0700 Subject: [PATCH 086/138] Extract suggestions actions from `MastodonAPIController` to `SuggestionController` --- .../controllers/mastodon_api_controller.ex | 49 ---------- .../controllers/suggestion_controller.ex | 63 +++++++++++++ lib/pleroma/web/router.ex | 2 +- .../suggestion_controller_test.exs | 92 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 83 ----------------- 5 files changed, 156 insertions(+), 133 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex create mode 100644 test/web/mastodon_api/controllers/suggestion_controller_test.exs 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 f466ecbff..ff6de425f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Bookmark alias Pleroma.Config - alias Pleroma.HTTP alias Pleroma.Pagination alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo @@ -22,7 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.MediaProxy alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Scopes @@ -362,53 +360,6 @@ def empty_object(conn, _) do json(conn, %{}) end - def suggestions(%{assigns: %{user: user}} = conn, _) do - suggestions = Config.get(:suggestions) - - if Keyword.get(suggestions, :enabled, false) do - api = Keyword.get(suggestions, :third_party_engine, "") - timeout = Keyword.get(suggestions, :timeout, 5000) - limit = Keyword.get(suggestions, :limit, 23) - - host = Config.get([Pleroma.Web.Endpoint, :url, :host]) - - user = user.nickname - - url = - api - |> String.replace("{{host}}", host) - |> String.replace("{{user}}", user) - - with {:ok, %{status: 200, body: body}} <- - HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]), - {:ok, data} <- Jason.decode(body) do - data = - data - |> Enum.slice(0, limit) - |> Enum.map(fn x -> - x - |> Map.put("id", fetch_suggestion_id(x)) - |> Map.put("avatar", MediaProxy.url(x["avatar"])) - |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"])) - end) - - json(conn, data) - else - e -> - Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") - end - else - json(conn, []) - end - end - - defp fetch_suggestion_id(attrs) do - case User.get_or_fetch(attrs["acct"]) do - {:ok, %User{id: id}} -> id - _ -> 0 - end - end - def password_reset(conn, params) do nickname_or_email = params["email"] || params["nickname"] diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex new file mode 100644 index 000000000..9076bb849 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -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.MastodonAPI.SuggestionController do + use Pleroma.Web, :controller + + require Logger + + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.MediaProxy + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/suggestions" + def index(%{assigns: %{user: user}} = conn, _) do + if Config.get([:suggestions, :enabled], false) do + with {:ok, data} <- fetch_suggestions(user) do + limit = Config.get([:suggestions, :limit], 23) + + data = + data + |> Enum.slice(0, limit) + |> Enum.map(fn x -> + x + |> Map.put("id", fetch_suggestion_id(x)) + |> Map.put("avatar", MediaProxy.url(x["avatar"])) + |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"])) + end) + + json(conn, data) + end + else + json(conn, []) + end + end + + defp fetch_suggestions(user) do + api = Config.get([:suggestions, :third_party_engine], "") + timeout = Config.get([:suggestions, :timeout], 5000) + host = Config.get([Pleroma.Web.Endpoint, :url, :host]) + + url = + api + |> String.replace("{{host}}", host) + |> String.replace("{{user}}", user.nickname) + + with {:ok, %{status: 200, body: body}} <- + Pleroma.HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]) do + Jason.decode(body) + else + e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}") + end + end + + defp fetch_suggestion_id(attrs) do + case User.get_or_fetch(attrs["acct"]) do + {:ok, %User{id: id}} -> id + _ -> 0 + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8b482528b..bb8e7bd72 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -373,7 +373,7 @@ defmodule Pleroma.Web.Router do get("/filters", FilterController, :index) - get("/suggestions", MastodonAPIController, :suggestions) + get("/suggestions", SuggestionController, :index) get("/conversations", ConversationController, :index) post("/conversations/:id/read", ConversationController, :read) diff --git a/test/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/web/mastodon_api/controllers/suggestion_controller_test.exs new file mode 100644 index 000000000..78620a873 --- /dev/null +++ b/test/web/mastodon_api/controllers/suggestion_controller_test.exs @@ -0,0 +1,92 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Config + + import ExUnit.CaptureLog + import Pleroma.Factory + import Tesla.Mock + + setup do + user = insert(:user) + other_user = insert(:user) + host = Config.get([Pleroma.Web.Endpoint, :url, :host]) + url500 = "http://test500?#{host}&#{user.nickname}" + url200 = "http://test200?#{host}&#{user.nickname}" + + mock(fn + %{method: :get, url: ^url500} -> + %Tesla.Env{status: 500, body: "bad request"} + + %{method: :get, url: ^url200} -> + %Tesla.Env{ + status: 200, + body: + ~s([{"acct":"yj455","avatar":"https://social.heldscal.la/avatar/201.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/201.jpeg"}, {"acct":"#{ + other_user.ap_id + }","avatar":"https://social.heldscal.la/avatar/202.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/202.jpeg"}]) + } + end) + + [user: user, other_user: other_user] + end + + clear_config(:suggestions) + + test "returns empty result when suggestions disabled", %{conn: conn, user: user} do + Config.put([:suggestions, :enabled], false) + + res = + conn + |> assign(:user, user) + |> get("/api/v1/suggestions") + |> json_response(200) + + assert res == [] + end + + test "returns error", %{conn: conn, user: user} do + Config.put([:suggestions, :enabled], true) + Config.put([:suggestions, :third_party_engine], "http://test500?{{host}}&{{user}}") + + assert capture_log(fn -> + res = + conn + |> assign(:user, user) + |> get("/api/v1/suggestions") + |> json_response(500) + + assert res == "Something went wrong" + end) =~ "Could not retrieve suggestions" + end + + test "returns suggestions", %{conn: conn, user: user, other_user: other_user} do + Config.put([:suggestions, :enabled], true) + Config.put([:suggestions, :third_party_engine], "http://test200?{{host}}&{{user}}") + + res = + conn + |> assign(:user, user) + |> get("/api/v1/suggestions") + |> json_response(200) + + assert res == [ + %{ + "acct" => "yj455", + "avatar" => "https://social.heldscal.la/avatar/201.jpeg", + "avatar_static" => "https://social.heldscal.la/avatar/s/201.jpeg", + "id" => 0 + }, + %{ + "acct" => other_user.ap_id, + "avatar" => "https://social.heldscal.la/avatar/202.jpeg", + "avatar_static" => "https://social.heldscal.la/avatar/s/202.jpeg", + "id" => other_user.id + } + ] + 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 da5606165..47357863c 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Ecto.Changeset alias Pleroma.Config alias Pleroma.Notification - alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User @@ -16,7 +15,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Web.OAuth.App alias Pleroma.Web.Push - import ExUnit.CaptureLog import Pleroma.Factory import Swoosh.TestAssertions import Tesla.Mock @@ -431,87 +429,6 @@ test "it returns 400 when user is not local", %{conn: conn, user: user} do end end - describe "GET /api/v1/suggestions" do - setup do - user = insert(:user) - other_user = insert(:user) - host = Config.get([Pleroma.Web.Endpoint, :url, :host]) - url500 = "http://test500?#{host}&#{user.nickname}" - url200 = "http://test200?#{host}&#{user.nickname}" - - mock(fn - %{method: :get, url: ^url500} -> - %Tesla.Env{status: 500, body: "bad request"} - - %{method: :get, url: ^url200} -> - %Tesla.Env{ - status: 200, - body: - ~s([{"acct":"yj455","avatar":"https://social.heldscal.la/avatar/201.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/201.jpeg"}, {"acct":"#{ - other_user.ap_id - }","avatar":"https://social.heldscal.la/avatar/202.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/202.jpeg"}]) - } - end) - - [user: user, other_user: other_user] - end - - clear_config(:suggestions) - - test "returns empty result when suggestions disabled", %{conn: conn, user: user} do - Config.put([:suggestions, :enabled], false) - - res = - conn - |> assign(:user, user) - |> get("/api/v1/suggestions") - |> json_response(200) - - assert res == [] - end - - test "returns error", %{conn: conn, user: user} do - Config.put([:suggestions, :enabled], true) - Config.put([:suggestions, :third_party_engine], "http://test500?{{host}}&{{user}}") - - assert capture_log(fn -> - res = - conn - |> assign(:user, user) - |> get("/api/v1/suggestions") - |> json_response(500) - - assert res == "Something went wrong" - end) =~ "Could not retrieve suggestions" - end - - test "returns suggestions", %{conn: conn, user: user, other_user: other_user} do - Config.put([:suggestions, :enabled], true) - Config.put([:suggestions, :third_party_engine], "http://test200?{{host}}&{{user}}") - - res = - conn - |> assign(:user, user) - |> get("/api/v1/suggestions") - |> json_response(200) - - assert res == [ - %{ - "acct" => "yj455", - "avatar" => "https://social.heldscal.la/avatar/201.jpeg", - "avatar_static" => "https://social.heldscal.la/avatar/s/201.jpeg", - "id" => 0 - }, - %{ - "acct" => other_user.ap_id, - "avatar" => "https://social.heldscal.la/avatar/202.jpeg", - "avatar_static" => "https://social.heldscal.la/avatar/s/202.jpeg", - "id" => other_user.id - } - ] - end - end - describe "DELETE /auth/sign_out" do test "redirect to root page", %{conn: conn} do user = insert(:user) From 2dad6dd0201135f5ab8ff50448b0787f36db0607 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 1 Oct 2019 15:21:46 +0700 Subject: [PATCH 087/138] Extract apps actions from `MastodonAPIController` to `AppController` --- .../web/activity_pub/views/user_view.ex | 2 +- .../controllers/app_controller.ex | 39 ++++++++++++ .../controllers/mastodon_api_controller.ex | 31 +--------- lib/pleroma/web/router.ex | 4 +- .../controllers/app_controller_test.exs | 60 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 49 --------------- 6 files changed, 103 insertions(+), 82 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/app_controller.ex create mode 100644 test/web/mastodon_api/controllers/app_controller_test.exs diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index c94c5a225..6bc55c85b 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -22,7 +22,7 @@ def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) 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), + "oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create), "oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex new file mode 100644 index 000000000..abbe16a88 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -0,0 +1,39 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AppController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Scopes + alias Pleroma.Web.OAuth.Token + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @local_mastodon_name "Mastodon-Local" + + @doc "POST /api/v1/apps" + def create(conn, params) do + scopes = Scopes.fetch_scopes(params, ["read"]) + + app_attrs = + params + |> Map.drop(["scope", "scopes"]) + |> Map.put("scopes", scopes) + + with cs <- App.register_changeset(%App{}, app_attrs), + false <- cs.changes[:client_name] == @local_mastodon_name, + {:ok, app} <- Repo.insert(cs) do + render(conn, "show.json", app: app) + end + end + + @doc "GET /api/v1/apps/verify_credentials" + def verify_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do + with %Token{app: %App{} = app} <- Repo.preload(token, :app) do + render(conn, "short.json", app: app) + end + 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 ff6de425f..80a7b5bef 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -11,19 +11,16 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Config alias Pleroma.Pagination alias Pleroma.Plugs.RateLimiter - alias Pleroma.Repo alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization - alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI @@ -31,35 +28,9 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do plug(RateLimiter, :password_reset when action == :password_reset) - @local_mastodon_name "Mastodon-Local" - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - def create_app(conn, params) do - scopes = Scopes.fetch_scopes(params, ["read"]) - - app_attrs = - params - |> Map.drop(["scope", "scopes"]) - |> Map.put("scopes", scopes) - - with cs <- App.register_changeset(%App{}, app_attrs), - false <- cs.changes[:client_name] == @local_mastodon_name, - {:ok, app} <- Repo.insert(cs) do - conn - |> put_view(AppView) - |> render("show.json", %{app: app}) - end - end - - def verify_app_credentials(%{assigns: %{user: _user, token: token}} = conn, _) do - with %Token{app: %App{} = app} <- Repo.preload(token, :app) do - conn - |> put_view(AppView) - |> render("short.json", %{app: app}) - end - end - + @local_mastodon_name "Mastodon-Local" @mastodon_api_level "2.7.2" def masto_instance(conn, _params) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index bb8e7bd72..29f53108c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -464,8 +464,8 @@ defmodule Pleroma.Web.Router do get("/instance", MastodonAPIController, :masto_instance) get("/instance/peers", MastodonAPIController, :peers) - post("/apps", MastodonAPIController, :create_app) - get("/apps/verify_credentials", MastodonAPIController, :verify_app_credentials) + post("/apps", AppController, :create) + get("/apps/verify_credentials", AppController, :verify_credentials) get("/custom_emojis", MastodonAPIController, :custom_emojis) get("/statuses/:id/card", StatusController, :card) diff --git a/test/web/mastodon_api/controllers/app_controller_test.exs b/test/web/mastodon_api/controllers/app_controller_test.exs new file mode 100644 index 000000000..51788155b --- /dev/null +++ b/test/web/mastodon_api/controllers/app_controller_test.exs @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AppControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.Push + + import Pleroma.Factory + + test "apps/verify_credentials", %{conn: conn} do + token = insert(:oauth_token) + + conn = + conn + |> assign(:user, token.user) + |> assign(:token, token) + |> get("/api/v1/apps/verify_credentials") + + app = Repo.preload(token, :app).app + + expected = %{ + "name" => app.client_name, + "website" => app.website, + "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) + } + + assert expected == json_response(conn, 200) + end + + test "creates an oauth app", %{conn: conn} do + user = insert(:user) + app_attrs = build(:oauth_app) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/apps", %{ + client_name: app_attrs.client_name, + redirect_uris: app_attrs.redirect_uris + }) + + [app] = Repo.all(App) + + expected = %{ + "name" => app.client_name, + "website" => app.website, + "client_id" => app.client_id, + "client_secret" => app.client_secret, + "id" => app.id |> to_string(), + "redirect_uri" => app.redirect_uris, + "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) + } + + assert expected == json_response(conn, 200) + 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 47357863c..68fe751e7 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -12,8 +12,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.OAuth.App - alias Pleroma.Web.Push import Pleroma.Factory import Swoosh.TestAssertions @@ -27,53 +25,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) - test "apps/verify_credentials", %{conn: conn} do - token = insert(:oauth_token) - - conn = - conn - |> assign(:user, token.user) - |> assign(:token, token) - |> get("/api/v1/apps/verify_credentials") - - app = Repo.preload(token, :app).app - - expected = %{ - "name" => app.client_name, - "website" => app.website, - "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) - } - - assert expected == json_response(conn, 200) - end - - test "creates an oauth app", %{conn: conn} do - user = insert(:user) - app_attrs = build(:oauth_app) - - conn = - conn - |> assign(:user, user) - |> post("/api/v1/apps", %{ - client_name: app_attrs.client_name, - redirect_uris: app_attrs.redirect_uris - }) - - [app] = Repo.all(App) - - expected = %{ - "name" => app.client_name, - "website" => app.website, - "client_id" => app.client_id, - "client_secret" => app.client_secret, - "id" => app.id |> to_string(), - "redirect_uri" => app.redirect_uris, - "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) - } - - assert expected == json_response(conn, 200) - end - test "getting a list of mutes", %{conn: conn} do user = insert(:user) other_user = insert(:user) From af690d10336124968e2a0fe0e73decb2d48819cb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 1 Oct 2019 15:54:45 +0700 Subject: [PATCH 088/138] Extract auth actions from `MastodonAPIController` to `AuthController` --- .../controllers/auth_controller.ex | 91 +++++++++++++ .../controllers/mastodon_api_controller.ex | 79 ------------ lib/pleroma/web/router.ex | 6 +- .../controllers/auth_controller_test.exs | 121 ++++++++++++++++++ .../mastodon_api_controller_test.exs | 91 ------------- 5 files changed, 215 insertions(+), 173 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex create mode 100644 test/web/mastodon_api/controllers/auth_controller_test.exs diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex new file mode 100644 index 000000000..0dee670af --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AuthController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.TwitterAPI.TwitterAPI + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @local_mastodon_name "Mastodon-Local" + + plug(Pleroma.Plugs.RateLimiter, :password_reset when action == :password_reset) + + @doc "GET /web/login" + def login(%{assigns: %{user: %User{}}} = conn, _params) do + redirect(conn, to: local_mastodon_root_path(conn)) + end + + @doc "Local Mastodon FE login init action" + def login(conn, %{"code" => auth_token}) do + with {:ok, app} <- get_or_make_app(), + {:ok, auth} <- Authorization.get_by_token(app, auth_token), + {:ok, token} <- Token.exchange_token(app, auth) do + conn + |> put_session(:oauth_token, token.token) + |> redirect(to: local_mastodon_root_path(conn)) + end + end + + @doc "Local Mastodon FE callback action" + def login(conn, _) do + with {:ok, app} <- get_or_make_app() do + path = + o_auth_path(conn, :authorize, + response_type: "code", + client_id: app.client_id, + redirect_uri: ".", + scope: Enum.join(app.scopes, " ") + ) + + redirect(conn, to: path) + end + end + + @doc "DELETE /auth/sign_out" + def logout(conn, _) do + conn + |> clear_session + |> redirect(to: "/") + end + + @doc "POST /auth/password" + def password_reset(conn, params) do + nickname_or_email = params["email"] || params["nickname"] + + with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do + conn + |> put_status(:no_content) + |> json("") + else + {:error, "unknown user"} -> + send_resp(conn, :not_found, "") + + {:error, _} -> + send_resp(conn, :bad_request, "") + end + end + + defp local_mastodon_root_path(conn) do + case get_session(conn, :return_to) do + nil -> + mastodon_api_path(conn, :index, ["getting-started"]) + + return_to -> + delete_session(conn, :return_to) + return_to + end + end + + @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + defp get_or_make_app do + %{client_name: @local_mastodon_name, redirect_uris: "."} + |> App.get_or_make(["read", "write", "follow", "push"]) + 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 80a7b5bef..4fa0e1bcc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.Pagination - alias Pleroma.Plugs.RateLimiter alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web @@ -19,18 +18,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.OAuth.App - alias Pleroma.Web.OAuth.Authorization - alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.TwitterAPI.TwitterAPI require Logger - plug(RateLimiter, :password_reset when action == :password_reset) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - @local_mastodon_name "Mastodon-Local" @mastodon_api_level "2.7.2" def masto_instance(conn, _params) do @@ -264,61 +256,6 @@ def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _para end end - def login(%{assigns: %{user: %User{}}} = conn, _params) do - redirect(conn, to: local_mastodon_root_path(conn)) - end - - @doc "Local Mastodon FE login init action" - def login(conn, %{"code" => auth_token}) do - with {:ok, app} <- get_or_make_app(), - {:ok, auth} <- Authorization.get_by_token(app, auth_token), - {:ok, token} <- Token.exchange_token(app, auth) do - conn - |> put_session(:oauth_token, token.token) - |> redirect(to: local_mastodon_root_path(conn)) - end - end - - @doc "Local Mastodon FE callback action" - def login(conn, _) do - with {:ok, app} <- get_or_make_app() do - path = - o_auth_path(conn, :authorize, - response_type: "code", - client_id: app.client_id, - redirect_uri: ".", - scope: Enum.join(app.scopes, " ") - ) - - redirect(conn, to: path) - end - end - - defp local_mastodon_root_path(conn) do - case get_session(conn, :return_to) do - nil -> - mastodon_api_path(conn, :index, ["getting-started"]) - - return_to -> - delete_session(conn, :return_to) - return_to - end - end - - @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} - defp get_or_make_app do - App.get_or_make( - %{client_name: @local_mastodon_name, redirect_uris: "."}, - ["read", "write", "follow", "push"] - ) - end - - def logout(conn, _) do - conn - |> clear_session - |> redirect(to: "/") - end - # Stubs for unimplemented mastodon api # def empty_array(conn, _) do @@ -331,22 +268,6 @@ def empty_object(conn, _) do json(conn, %{}) end - def password_reset(conn, params) do - nickname_or_email = params["email"] || params["nickname"] - - with {:ok, _} <- TwitterAPI.password_reset(nickname_or_email) do - conn - |> put_status(:no_content) - |> json("") - else - {:error, "unknown user"} -> - send_resp(conn, :not_found, "") - - {:error, _} -> - send_resp(conn, :bad_request, "") - end - end - defp present?(nil), do: false defp present?(false), do: false defp present?(_), do: true diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 29f53108c..501978994 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -661,10 +661,10 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web.MastodonAPI do pipe_through(:mastodon_html) - get("/web/login", MastodonAPIController, :login) - delete("/auth/sign_out", MastodonAPIController, :logout) + get("/web/login", AuthController, :login) + delete("/auth/sign_out", AuthController, :logout) - post("/auth/password", MastodonAPIController, :password_reset) + post("/auth/password", AuthController, :password_reset) scope [] do pipe_through(:oauth_read) diff --git a/test/web/mastodon_api/controllers/auth_controller_test.exs b/test/web/mastodon_api/controllers/auth_controller_test.exs new file mode 100644 index 000000000..98b2a82e7 --- /dev/null +++ b/test/web/mastodon_api/controllers/auth_controller_test.exs @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.AuthControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers + + import Pleroma.Factory + import Swoosh.TestAssertions + + describe "GET /web/login" do + setup %{conn: conn} do + session_opts = [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + conn = + conn + |> Plug.Session.call(Plug.Session.init(session_opts)) + |> fetch_session() + + test_path = "/web/statuses/test" + %{conn: conn, path: test_path} + end + + test "redirects to the saved path after log in", %{conn: conn, path: path} do + app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") + auth = insert(:oauth_authorization, app: app) + + conn = + conn + |> put_session(:return_to, path) + |> get("/web/login", %{code: auth.token}) + + assert conn.status == 302 + assert redirected_to(conn) == path + end + + test "redirects to the getting-started page when referer is not present", %{conn: conn} do + app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") + auth = insert(:oauth_authorization, app: app) + + conn = get(conn, "/web/login", %{code: auth.token}) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/getting-started" + end + end + + describe "POST /auth/password, with valid parameters" do + setup %{conn: conn} do + user = insert(:user) + conn = post(conn, "/auth/password?email=#{user.email}") + %{conn: conn, user: user} + end + + test "it returns 204", %{conn: conn} do + assert json_response(conn, :no_content) + end + + test "it creates a PasswordResetToken record for user", %{user: user} do + token_record = Repo.get_by(Pleroma.PasswordResetToken, user_id: user.id) + assert token_record + end + + test "it sends an email to user", %{user: user} do + ObanHelpers.perform_all() + token_record = Repo.get_by(Pleroma.PasswordResetToken, user_id: user.id) + + email = Pleroma.Emails.UserEmail.password_reset_email(user, token_record.token) + 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 "POST /auth/password, with invalid parameters" do + setup do + user = insert(:user) + {:ok, user: user} + end + + test "it returns 404 when user is not found", %{conn: conn, user: user} do + conn = post(conn, "/auth/password?email=nonexisting_#{user.email}") + assert conn.status == 404 + assert conn.resp_body == "" + end + + test "it returns 400 when user is not local", %{conn: conn, user: user} do + {:ok, user} = Repo.update(Ecto.Changeset.change(user, local: false)) + conn = post(conn, "/auth/password?email=#{user.email}") + assert conn.status == 400 + assert conn.resp_body == "" + 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 +end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 68fe751e7..2ec5ad2be 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -9,12 +9,10 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Repo - alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.CommonAPI import Pleroma.Factory - import Swoosh.TestAssertions import Tesla.Mock setup do @@ -303,95 +301,6 @@ test "saves referer path to session", %{conn: conn, path: path} do assert return_to == path end - - test "redirects to the saved path after log in", %{conn: conn, path: path} do - app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") - auth = insert(:oauth_authorization, app: app) - - conn = - conn - |> put_session(:return_to, path) - |> get("/web/login", %{code: auth.token}) - - assert conn.status == 302 - assert redirected_to(conn) == path - end - - test "redirects to the getting-started page when referer is not present", %{conn: conn} do - app = insert(:oauth_app, client_name: "Mastodon-Local", redirect_uris: ".") - auth = insert(:oauth_authorization, app: app) - - conn = get(conn, "/web/login", %{code: auth.token}) - - assert conn.status == 302 - assert redirected_to(conn) == "/web/getting-started" - end - end - - describe "POST /auth/password, with valid parameters" do - setup %{conn: conn} do - user = insert(:user) - conn = post(conn, "/auth/password?email=#{user.email}") - %{conn: conn, user: user} - end - - test "it returns 204", %{conn: conn} do - assert json_response(conn, :no_content) - end - - test "it creates a PasswordResetToken record for user", %{user: user} do - token_record = Repo.get_by(Pleroma.PasswordResetToken, user_id: user.id) - assert token_record - end - - test "it sends an email to user", %{user: user} do - ObanHelpers.perform_all() - token_record = Repo.get_by(Pleroma.PasswordResetToken, user_id: user.id) - - email = Pleroma.Emails.UserEmail.password_reset_email(user, token_record.token) - 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 "POST /auth/password, with invalid parameters" do - setup do - user = insert(:user) - {:ok, user: user} - end - - test "it returns 404 when user is not found", %{conn: conn, user: user} do - conn = post(conn, "/auth/password?email=nonexisting_#{user.email}") - assert conn.status == 404 - assert conn.resp_body == "" - end - - test "it returns 400 when user is not local", %{conn: conn, user: user} do - {:ok, user} = Repo.update(Changeset.change(user, local: false)) - conn = post(conn, "/auth/password?email=#{user.email}") - assert conn.status == 400 - assert conn.resp_body == "" - 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 From 0f9c2c8b87672517aa040a2cbe1c297b29acc317 Mon Sep 17 00:00:00 2001 From: Maxim Filippov Date: Tue, 1 Oct 2019 18:10:04 +0300 Subject: [PATCH 089/138] Send an identifier alongside with error message in OAuthController --- lib/pleroma/web/oauth/oauth_controller.ex | 24 ++++++++++++++++++++--- lib/pleroma/web/translation_helpers.ex | 11 +++++++++-- test/web/oauth/oauth_controller_test.exs | 1 + 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index a57670e02..e418dc70d 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -212,13 +212,31 @@ def token_exchange( {:auth_active, false} -> # Per https://github.com/tootsuite/mastodon/blob/ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L76 - render_error(conn, :forbidden, "Your login is missing a confirmed e-mail address") + render_error( + conn, + :forbidden, + "Your login is missing a confirmed e-mail address", + %{}, + "missing_confirmed_email" + ) {:user_active, false} -> - render_error(conn, :forbidden, "Your account is currently disabled") + render_error( + conn, + :forbidden, + "Your account is currently disabled", + %{}, + "account_is_disabled" + ) {:password_reset_pending, true} -> - render_error(conn, :forbidden, "Password reset is required") + render_error( + conn, + :forbidden, + "Password reset is required", + %{}, + "password_reset_required" + ) _error -> render_invalid_credentials_error(conn) diff --git a/lib/pleroma/web/translation_helpers.ex b/lib/pleroma/web/translation_helpers.ex index 8f5a43bf6..7a2ddc008 100644 --- a/lib/pleroma/web/translation_helpers.ex +++ b/lib/pleroma/web/translation_helpers.ex @@ -3,14 +3,21 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TranslationHelpers do - defmacro render_error(conn, status, msgid, bindings \\ Macro.escape(%{})) do + defmacro render_error( + conn, + status, + msgid, + bindings \\ Macro.escape(%{}), + identifier \\ Macro.escape("") + ) do quote do require Pleroma.Web.Gettext unquote(conn) |> Plug.Conn.put_status(unquote(status)) |> Phoenix.Controller.json(%{ - error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)) + error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)), + identifier: unquote(identifier) }) end end diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index 0cf755806..4d0741d14 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -852,6 +852,7 @@ test "rejects token exchange for user with password_reset_pending set to true" d assert resp = json_response(conn, 403) assert resp["error"] == "Password reset is required" + assert resp["identifier"] == "password_reset_required" refute Map.has_key?(resp, "access_token") end From 1f0be71ea433971de874a71ba1dafd101f4301b6 Mon Sep 17 00:00:00 2001 From: KokaKiwi Date: Sun, 24 Feb 2019 18:45:29 +0100 Subject: [PATCH 090/138] Make activity announceable by its author. --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/visibility.ex | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 95f994c17..c58b48443 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -346,7 +346,7 @@ def announce( local \\ true, public \\ true ) do - with true <- is_public?(object), + with true <- is_announceable?(object, user), announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index dfb166b65..021efd30f 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -27,6 +27,10 @@ def is_private?(activity) do end end + def is_announceable?(activity, user) do + is_public?(activity) || activity.data["actor"] == user.ap_id + end + def is_direct?(%Activity{data: %{"directMessage" => true}}), do: true def is_direct?(%Object{data: %{"directMessage" => true}}), do: true From fe538973ddbdb7b3216e8da1defaa57adb63e890 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 17:49:52 +0200 Subject: [PATCH 091/138] Ensure self-announces do not widen the audience of the original post --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/visibility.ex | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c58b48443..c52efb578 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -346,7 +346,7 @@ def announce( local \\ true, public \\ true ) do - with true <- is_announceable?(object, user), + with true <- is_announceable?(object, user, public), announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 021efd30f..270d0fa02 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -27,8 +27,9 @@ def is_private?(activity) do end end - def is_announceable?(activity, user) do - is_public?(activity) || activity.data["actor"] == user.ap_id + def is_announceable?(activity, user, public \\ true) do + is_public?(activity) || + (!public && is_private?(activity) && activity.data["actor"] == user.ap_id) end def is_direct?(%Activity{data: %{"directMessage" => true}}), do: true From e0b654e202554f001a5de3df4ccb8021fd3a517a Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 17:51:27 +0200 Subject: [PATCH 092/138] Add tests --- test/web/activity_pub/activity_pub_test.exs | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index a203d1d30..f29497847 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -839,6 +839,39 @@ test "adds an announce activity to the db" do end end + describe "announcing a private object" do + test "adds an announce activity to the db if the audience is not widened" do + user = insert(:user) + {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + object = Object.normalize(note_activity) + + {:ok, announce_activity, object} = ActivityPub.announce(user, object, nil, true, false) + + assert announce_activity.data["to"] == [User.ap_followers(user)] + + assert announce_activity.data["object"] == object.data["id"] + assert announce_activity.data["actor"] == user.ap_id + assert announce_activity.data["context"] == object.data["context"] + end + + test "does not add an announce activity to the db if the audience is widened" do + user = insert(:user) + {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + object = Object.normalize(note_activity) + + assert {:error, _} = ActivityPub.announce(user, object, nil, true, true) + end + + test "does not add an announce activity to the db if the announcer is not the author" do + user = insert(:user) + announcer = insert(:user) + {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + object = Object.normalize(note_activity) + + assert {:error, _} = ActivityPub.announce(announcer, object, nil, true, false) + end + end + describe "unannouncing an object" do test "unannouncing a previously announced object" do note_activity = insert(:note_activity) From b2273c695ec3a84dfb7a3a83019a71cade08b8d4 Mon Sep 17 00:00:00 2001 From: Maxim Filippov Date: Tue, 1 Oct 2019 19:43:22 +0300 Subject: [PATCH 093/138] Discard identifier, if empty --- lib/pleroma/web/translation_helpers.ex | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/translation_helpers.ex b/lib/pleroma/web/translation_helpers.ex index 7a2ddc008..a104ea6b8 100644 --- a/lib/pleroma/web/translation_helpers.ex +++ b/lib/pleroma/web/translation_helpers.ex @@ -13,12 +13,17 @@ defmacro render_error( quote do require Pleroma.Web.Gettext + error_map = + %{ + error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)), + identifier: unquote(identifier) + } + |> Enum.reject(fn {_k, v} -> v == "" end) + |> Map.new() + unquote(conn) |> Plug.Conn.put_status(unquote(status)) - |> Phoenix.Controller.json(%{ - error: Pleroma.Web.Gettext.dgettext("errors", unquote(msgid), unquote(bindings)), - identifier: unquote(identifier) - }) + |> Phoenix.Controller.json(error_map) end end end From 4c1f158f5de95581f1489be32614e0e75bc77ba4 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 18:38:23 +0200 Subject: [PATCH 094/138] Allow users to announce privately, including own private notes --- lib/pleroma/web/common_api/common_api.ex | 15 ++++++++++++--- .../mastodon_api/controllers/status_controller.ex | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2ec017ff8..677a53ddf 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -76,11 +76,12 @@ def delete(activity_id, user) do end end - def repeat(id_or_ap_id, user) do + def repeat(id_or_ap_id, user, params \\ %{}) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), object <- Object.normalize(activity), - nil <- Utils.get_existing_announce(user.ap_id, object) do - ActivityPub.announce(user, object) + nil <- Utils.get_existing_announce(user.ap_id, object), + public <- get_announce_visibility(object, params) do + ActivityPub.announce(user, object, nil, true, public) else _ -> {:error, dgettext("errors", "Could not repeat")} end @@ -169,6 +170,14 @@ defp normalize_and_validate_choices(choices, object) do end end + def get_announce_visibility(_, %{"visibility" => visibility}) + when visibility in ~w{public unlisted private direct}, + do: visibility in ~w(public unlisted) + + def get_announce_visibility(object, _) do + Visibility.is_public?(object) + end + def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} def get_visibility(%{"visibility" => visibility}, in_reply_to, _) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index fb6fd7676..51456d453 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -125,8 +125,8 @@ def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do 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), + def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do + with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user, params), %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) end From 7d5a9f3f6d393c744364148568cfb9b0249789fc Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 19:08:25 +0200 Subject: [PATCH 095/138] Add tests for privately announcing statuses via API --- test/web/common_api/common_api_test.exs | 12 ++++++++++++ .../controllers/status_controller_test.exs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 0f4a5eb25..2d3c41e82 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -231,6 +231,18 @@ test "repeating a status" do {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user) end + test "repeating a status privately" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + + {:ok, %Activity{} = announce_activity, _} = + CommonAPI.repeat(activity.id, user, %{"visibility" => "private"}) + + assert Visibility.is_private?(announce_activity) + end + test "favoriting a status" do user = insert(:user) other_user = insert(:user) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index b194feae6..727a233e7 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -547,6 +547,24 @@ test "reblogs and returns the reblogged status", %{conn: conn} do assert to_string(activity.id) == id end + test "reblogs privately 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", %{"visibility" => "private"}) + + assert %{ + "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 0}, + "reblogged" => true, + "visibility" => "private" + } = 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) From 43e3db0951c34859932f20d8c82284343a82fcf1 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 19:28:51 +0200 Subject: [PATCH 096/138] Fix returned visibility of announces in MastodonAPI --- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 3262427ec..9b8dd3086 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -125,7 +125,7 @@ def render( pinned: pinned?(activity, user), sensitive: false, spoiler_text: "", - visibility: "public", + visibility: get_visibility(activity), media_attachments: reblogged[:media_attachments] || [], mentions: mentions, tags: reblogged[:tags] || [], From c541b83befdaaea93304cc3a33782222e6dee1d1 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Tue, 1 Oct 2019 20:00:27 +0000 Subject: [PATCH 097/138] Track failed proxy urls and don't request them again --- CHANGELOG.md | 1 + lib/pleroma/application.ex | 3 +- lib/pleroma/reverse_proxy/reverse_proxy.ex | 27 +++++++++- test/reverse_proxy_test.exs | 58 +++++++++++++++++++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9424c8f..f61efcc22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Added an endpoint to get multiple statuses by IDs (`GET /api/v1/statuses/?ids[]=1&ids[]=2`) - ActivityPub: Add ActivityPub actor's `discoverable` parameter. - Admin API: Added moderation log filters (user/start date/end date/search/pagination) +- Reverse Proxy: Do not retry failed requests to limit pressure on the peer ### Changed - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 7aec2c545..9e35b02c0 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -102,7 +102,8 @@ defp cachex_children do build_cachex("scrubber", limit: 2500), build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500), build_cachex("web_resp", limit: 2500), - build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10) + build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), + build_cachex("failed_proxy_url", limit: 2500) ] end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 03efad30a..78144cae3 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -15,6 +15,7 @@ defmodule Pleroma.ReverseProxy do @valid_resp_codes [200, 206, 304] @max_read_duration :timer.seconds(30) @max_body_length :infinity + @failed_request_ttl :timer.seconds(60) @methods ~w(GET HEAD) @moduledoc """ @@ -48,6 +49,8 @@ defmodule Pleroma.ReverseProxy do * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to read from the remote upstream. + * `failed_request_ttl` (default `#{inspect(@failed_request_ttl)}` ms): the time the failed request is cached and cannot be retried. + * `inline_content_types`: * `true` will not alter `content-disposition` (up to the upstream), * `false` will add `content-disposition: attachment` to any request, @@ -83,6 +86,7 @@ defmodule Pleroma.ReverseProxy do {:keep_user_agent, boolean} | {:max_read_duration, :timer.time() | :infinity} | {:max_body_length, non_neg_integer() | :infinity} + | {:failed_request_ttl, :timer.time() | :infinity} | {:http, []} | {:req_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]} @@ -108,7 +112,8 @@ def call(conn = %{method: method}, url, opts) when method in @methods do opts end - with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), + with {:ok, nil} <- Cachex.get(:failed_proxy_url_cache, url), + {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), :ok <- header_length_constraint( headers, @@ -116,12 +121,18 @@ def call(conn = %{method: method}, url, opts) when method in @methods do ) do response(conn, client, url, code, headers, opts) else + {:ok, true} -> + conn + |> error_or_redirect(url, 500, "Request failed", opts) + |> halt() + {:ok, code, headers} -> head_response(conn, url, code, headers, opts) |> halt() {:error, {:invalid_http_response, code}} -> Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}") + track_failed_url(url, code, opts) conn |> error_or_redirect( @@ -134,6 +145,7 @@ def call(conn = %{method: method}, url, opts) when method in @methods do {:error, error} -> Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}") + track_failed_url(url, error, opts) conn |> error_or_redirect(url, 500, "Request failed", opts) @@ -388,4 +400,17 @@ defp increase_read_duration(_) do end defp client, do: Pleroma.ReverseProxy.Client + + defp track_failed_url(url, code, opts) do + code = to_string(code) + + ttl = + if code in ["403", "404"] or String.starts_with?(code, "5") do + Keyword.get(opts, :failed_request_ttl, @failed_request_ttl) + else + nil + end + + Cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl) + end end diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy_test.exs index 3a83c4c48..0672f57db 100644 --- a/test/reverse_proxy_test.exs +++ b/test/reverse_proxy_test.exs @@ -42,6 +42,18 @@ defp user_agent_mock(user_agent, invokes) do end) end + describe "reverse proxy" do + test "do not track successful request", %{conn: conn} do + user_agent_mock("hackney/1.15.1", 2) + url = "/success" + + conn = ReverseProxy.call(conn, url) + + assert conn.status == 200 + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil} + end + end + describe "user-agent" do test "don't keep", %{conn: conn} do user_agent_mock("hackney/1.15.1", 2) @@ -71,9 +83,15 @@ test "length returns error if content-length more than option", %{conn: conn} do user_agent_mock("hackney/1.15.1", 0) assert capture_log(fn -> - ReverseProxy.call(conn, "/user-agent", max_body_length: 4) + ReverseProxy.call(conn, "/huge-file", max_body_length: 4) end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to \"/user-agent\" failed: :body_too_large" + "[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large" + + assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file") + + assert capture_log(fn -> + ReverseProxy.call(conn, "/huge-file", max_body_length: 4) + end) == "" end defp stream_mock(invokes, with_close? \\ false) do @@ -140,28 +158,54 @@ defp error_mock(status) when is_integer(status) do describe "returns error on" do test "500", %{conn: conn} do error_mock(500) + url = "/status/500" - capture_log(fn -> ReverseProxy.call(conn, "/status/500") end) =~ + capture_log(fn -> ReverseProxy.call(conn, url) end) =~ "[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + + {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) + assert ttl <= 60_000 end test "400", %{conn: conn} do error_mock(400) + url = "/status/400" - capture_log(fn -> ReverseProxy.call(conn, "/status/400") end) =~ + capture_log(fn -> ReverseProxy.call(conn, url) end) =~ "[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} + end + + test "403", %{conn: conn} do + error_mock(403) + url = "/status/403" + + capture_log(fn -> + ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120)) + end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403" + + {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) + assert ttl > 100_000 end test "204", %{conn: conn} do - ClientMock - |> expect(:request, fn :get, "/status/204", _, _, _ -> {:ok, 204, [], %{}} end) + url = "/status/204" + expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end) capture_log(fn -> - conn = ReverseProxy.call(conn, "/status/204") + conn = ReverseProxy.call(conn, url) assert conn.resp_body == "Request failed: No Content" assert conn.halted end) =~ "[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} end end From 427d0c2a007db6c8424c64a8f3504420e5203bef Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 1 Oct 2019 21:40:35 +0200 Subject: [PATCH 098/138] Store private announcements in object.data["announcements"], filter them on display --- lib/pleroma/web/activity_pub/utils.ex | 2 +- .../controllers/status_controller.ex | 14 +++++++++++++- .../controllers/status_controller_test.exs | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 2ba182f4e..0828591ee 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -494,7 +494,7 @@ def make_unlike_data( @spec add_announce_to_object(Activity.t(), Object.t()) :: {:ok, Object.t()} | {:error, Ecto.Changeset.t()} def add_announce_to_object( - %Activity{data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}}, + %Activity{data: %{"actor" => actor}}, object ) do announcements = take_announcements(object) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 51456d453..79cced163 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -242,7 +242,19 @@ def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do 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 + %Object{data: %{"announcements" => announces, "id" => ap_id}} <- + Object.normalize(activity) do + announces = + "Announce" + |> Activity.Queries.by_type() + |> Ecto.Query.where([a], a.actor in ^announces) + # this is to use the index + |> Activity.Queries.by_object_id(ap_id) + |> Repo.all() + |> Enum.filter(&Visibility.visible_for_user?(&1, user)) + |> Enum.map(& &1.actor) + |> Enum.uniq() + users = User |> Ecto.Query.where([u], u.ap_id in ^announces) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 727a233e7..b648ad6ff 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -557,7 +557,7 @@ test "reblogs privately and returns the reblogged status", %{conn: conn} do |> post("/api/v1/statuses/#{activity.id}/reblog", %{"visibility" => "private"}) assert %{ - "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 0}, + "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}, "reblogged" => true, "visibility" => "private" } = json_response(conn, 200) @@ -1167,6 +1167,23 @@ test "does not return users who have reblogged the status but are blocked", %{ assert Enum.empty?(response) end + test "does not return users who have reblogged the status privately", %{ + conn: %{assigns: %{user: user}} = conn, + activity: activity + } do + other_user = insert(:user) + + {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{"visibility" => "private"}) + + 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) From 1255ec888d5f1a186b499bf9e5c23c8c7332ed4d Mon Sep 17 00:00:00 2001 From: feld Date: Tue, 1 Oct 2019 22:16:29 +0000 Subject: [PATCH 099/138] Revert "Add upload limits to /api/v1/instance" This reverts commit db27c0dd8b18763ff2abb124ee8d641a4580cdaa. --- CHANGELOG.md | 1 + .../web/mastodon_api/controllers/mastodon_api_controller.ex | 6 +++++- test/web/mastodon_api/mastodon_api_controller_test.exs | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61efcc22..a71a9dae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 +- Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance` ### Changed - **Breaking:** Elixir >=1.8 is now required (was >= 1.7) 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 80a7b5bef..33988bbbd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -51,7 +51,11 @@ def masto_instance(conn, _params) do registrations: Pleroma.Config.get([:instance, :registrations_open]), # Extra (not present in Mastodon): max_toot_chars: Keyword.get(instance, :limit), - poll_limits: Keyword.get(instance, :poll_limits) + poll_limits: Keyword.get(instance, :poll_limits), + upload_limit: Keyword.get(instance, :upload_limit), + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit) } json(conn, response) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 68fe751e7..ae67ee89d 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -135,7 +135,11 @@ test "get instance information", %{conn: conn} do "thumbnail" => _, "languages" => _, "registrations" => _, - "poll_limits" => _ + "poll_limits" => _, + "upload_limit" => _, + "avatar_upload_limit" => _, + "background_upload_limit" => _, + "banner_upload_limit" => _ } = result assert email == from_config_email From a562cb2c3404f707f574d54024c7ee61f808e827 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 1 Oct 2019 18:29:39 -0500 Subject: [PATCH 100/138] Update AdminFE bundle --- .../{app.f774664e.css => app.8589ec81.css} | Bin ...5.15079754.css => chunk-0cb6.8d811a09.css} | Bin ...a.9e804910.css => chunk-15fa.6e185c68.css} | Bin ...d.dc6c5fb2.css => chunk-18e1.5bd2ca85.css} | Bin ...1.820645ae.css => chunk-23b2.723b6cc5.css} | Bin ...2.8ee9eaaa.css => chunk-2943.1b6fd9a7.css} | Bin ...c.a6b92ca7.css => chunk-3d1c.b2eb7234.css} | Bin ...f.14eeccbb.css => chunk-4df4.e217dea0.css} | Bin ...a.6ef5bd70.css => chunk-538a.062aa087.css} | Bin ...b.dece6ace.css => chunk-7c6b.c7882778.css} | Bin ...e.52359c55.css => chunk-7f8e.b6944d38.css} | Bin ...55ce6.css => chunk-elementUI.a842fb0a.css} | Bin ...s.36b859a1.css => chunk-libs.57fe98a3.css} | Bin priv/static/adminfe/index.html | 2 +- .../js/{app.9d5375ac.js => app.9c4316f1.js} | Bin 167236 -> 167236 bytes ...pp.9d5375ac.js.map => app.9c4316f1.js.map} | Bin 366548 -> 366548 bytes ...9e5.f5bb9b33.js => chunk-0cb6.b9f32e0c.js} | Bin 16157 -> 16157 bytes ...9b33.js.map => chunk-0cb6.b9f32e0c.js.map} | Bin 57112 -> 57112 bytes ...5fa.6dcb4448.js => chunk-15fa.34dcb9d8.js} | Bin 7919 -> 7919 bytes ...4448.js.map => chunk-15fa.34dcb9d8.js.map} | Bin 17438 -> 17438 bytes ...bbd.bc68e218.js => chunk-18e1.f8bb78f3.js} | Bin 2080 -> 2080 bytes ...e218.js.map => chunk-18e1.f8bb78f3.js.map} | Bin 9090 -> 9090 bytes ...871.4ac23900.js => chunk-23b2.442bb8df.js} | Bin 28092 -> 28092 bytes ...3900.js.map => chunk-23b2.442bb8df.js.map} | Bin 91362 -> 91362 bytes ...292.b3aa39da.js => chunk-2943.8ab5d0d9.js} | Bin 231394 -> 231394 bytes ...39da.js.map => chunk-2943.8ab5d0d9.js.map} | Bin 689117 -> 689117 bytes ...d1c.47c8fa87.js => chunk-3d1c.3334d3f1.js} | Bin 4822 -> 4822 bytes ...fa87.js.map => chunk-3d1c.3334d3f1.js.map} | Bin 18519 -> 18519 bytes ...98f.b02acd71.js => chunk-4df4.9655f394.js} | Bin 17765 -> 17765 bytes ...cd71.js.map => chunk-4df4.9655f394.js.map} | Bin 66937 -> 66937 bytes ...38a.18908e98.js => chunk-538a.04530055.js} | Bin 5112 -> 5112 bytes ...8e98.js.map => chunk-538a.04530055.js.map} | Bin 19586 -> 19586 bytes ...c6b.24877470.js => chunk-7c6b.5240e052.js} | Bin 7947 -> 7947 bytes ...7470.js.map => chunk-7c6b.5240e052.js.map} | Bin 26432 -> 26432 bytes ...f8e.b2353c0a.js => chunk-7f8e.c1eb619d.js} | Bin 9618 -> 9618 bytes ...3c0a.js.map => chunk-7f8e.c1eb619d.js.map} | Bin 39890 -> 39890 bytes ...74aa2ca.js => chunk-elementUI.fa319e7b.js} | Bin 638936 -> 638936 bytes ...js.map => chunk-elementUI.fa319e7b.js.map} | Bin 2312798 -> 2312798 bytes ...ibs.3ed10ef6.js => chunk-libs.35c18287.js} | Bin 275816 -> 275816 bytes ...0ef6.js.map => chunk-libs.35c18287.js.map} | Bin 1641569 -> 1641569 bytes .../adminfe/static/js/runtime.46db235c.js | Bin 0 -> 3922 bytes ...6b7511a.js.map => runtime.46db235c.js.map} | Bin 16658 -> 16658 bytes .../adminfe/static/js/runtime.c6b7511a.js | Bin 3922 -> 0 bytes 43 files changed, 1 insertion(+), 1 deletion(-) rename priv/static/adminfe/{app.f774664e.css => app.8589ec81.css} (100%) rename priv/static/adminfe/{chunk-a9e5.15079754.css => chunk-0cb6.8d811a09.css} (100%) rename priv/static/adminfe/{chunk-15fa.9e804910.css => chunk-15fa.6e185c68.css} (100%) rename priv/static/adminfe/{chunk-1bbd.dc6c5fb2.css => chunk-18e1.5bd2ca85.css} (100%) rename priv/static/adminfe/{chunk-3871.820645ae.css => chunk-23b2.723b6cc5.css} (100%) rename priv/static/adminfe/{chunk-6292.8ee9eaaa.css => chunk-2943.1b6fd9a7.css} (100%) rename priv/static/adminfe/{chunk-3d1c.a6b92ca7.css => chunk-3d1c.b2eb7234.css} (100%) rename priv/static/adminfe/{chunk-598f.14eeccbb.css => chunk-4df4.e217dea0.css} (100%) rename priv/static/adminfe/{chunk-538a.6ef5bd70.css => chunk-538a.062aa087.css} (100%) rename priv/static/adminfe/{chunk-7c6b.dece6ace.css => chunk-7c6b.c7882778.css} (100%) rename priv/static/adminfe/{chunk-7f8e.52359c55.css => chunk-7f8e.b6944d38.css} (100%) rename priv/static/adminfe/{chunk-elementUI.d2a55ce6.css => chunk-elementUI.a842fb0a.css} (100%) rename priv/static/adminfe/{chunk-libs.36b859a1.css => chunk-libs.57fe98a3.css} (100%) rename priv/static/adminfe/static/js/{app.9d5375ac.js => app.9c4316f1.js} (99%) rename priv/static/adminfe/static/js/{app.9d5375ac.js.map => app.9c4316f1.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-a9e5.f5bb9b33.js => chunk-0cb6.b9f32e0c.js} (99%) rename priv/static/adminfe/static/js/{chunk-a9e5.f5bb9b33.js.map => chunk-0cb6.b9f32e0c.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-15fa.6dcb4448.js => chunk-15fa.34dcb9d8.js} (99%) rename priv/static/adminfe/static/js/{chunk-15fa.6dcb4448.js.map => chunk-15fa.34dcb9d8.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-1bbd.bc68e218.js => chunk-18e1.f8bb78f3.js} (85%) rename priv/static/adminfe/static/js/{chunk-1bbd.bc68e218.js.map => chunk-18e1.f8bb78f3.js.map} (98%) rename priv/static/adminfe/static/js/{chunk-3871.4ac23900.js => chunk-23b2.442bb8df.js} (99%) rename priv/static/adminfe/static/js/{chunk-3871.4ac23900.js.map => chunk-23b2.442bb8df.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-6292.b3aa39da.js => chunk-2943.8ab5d0d9.js} (99%) rename priv/static/adminfe/static/js/{chunk-6292.b3aa39da.js.map => chunk-2943.8ab5d0d9.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-3d1c.47c8fa87.js => chunk-3d1c.3334d3f1.js} (99%) rename priv/static/adminfe/static/js/{chunk-3d1c.47c8fa87.js.map => chunk-3d1c.3334d3f1.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-598f.b02acd71.js => chunk-4df4.9655f394.js} (99%) rename priv/static/adminfe/static/js/{chunk-598f.b02acd71.js.map => chunk-4df4.9655f394.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-538a.18908e98.js => chunk-538a.04530055.js} (99%) rename priv/static/adminfe/static/js/{chunk-538a.18908e98.js.map => chunk-538a.04530055.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7c6b.24877470.js => chunk-7c6b.5240e052.js} (99%) rename priv/static/adminfe/static/js/{chunk-7c6b.24877470.js.map => chunk-7c6b.5240e052.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-7f8e.b2353c0a.js => chunk-7f8e.c1eb619d.js} (99%) rename priv/static/adminfe/static/js/{chunk-7f8e.b2353c0a.js.map => chunk-7f8e.c1eb619d.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-elementUI.374aa2ca.js => chunk-elementUI.fa319e7b.js} (99%) rename priv/static/adminfe/static/js/{chunk-elementUI.374aa2ca.js.map => chunk-elementUI.fa319e7b.js.map} (99%) rename priv/static/adminfe/static/js/{chunk-libs.3ed10ef6.js => chunk-libs.35c18287.js} (99%) rename priv/static/adminfe/static/js/{chunk-libs.3ed10ef6.js.map => chunk-libs.35c18287.js.map} (99%) create mode 100644 priv/static/adminfe/static/js/runtime.46db235c.js rename priv/static/adminfe/static/js/{runtime.c6b7511a.js.map => runtime.46db235c.js.map} (92%) delete mode 100644 priv/static/adminfe/static/js/runtime.c6b7511a.js diff --git a/priv/static/adminfe/app.f774664e.css b/priv/static/adminfe/app.8589ec81.css similarity index 100% rename from priv/static/adminfe/app.f774664e.css rename to priv/static/adminfe/app.8589ec81.css diff --git a/priv/static/adminfe/chunk-a9e5.15079754.css b/priv/static/adminfe/chunk-0cb6.8d811a09.css similarity index 100% rename from priv/static/adminfe/chunk-a9e5.15079754.css rename to priv/static/adminfe/chunk-0cb6.8d811a09.css diff --git a/priv/static/adminfe/chunk-15fa.9e804910.css b/priv/static/adminfe/chunk-15fa.6e185c68.css similarity index 100% rename from priv/static/adminfe/chunk-15fa.9e804910.css rename to priv/static/adminfe/chunk-15fa.6e185c68.css diff --git a/priv/static/adminfe/chunk-1bbd.dc6c5fb2.css b/priv/static/adminfe/chunk-18e1.5bd2ca85.css similarity index 100% rename from priv/static/adminfe/chunk-1bbd.dc6c5fb2.css rename to priv/static/adminfe/chunk-18e1.5bd2ca85.css diff --git a/priv/static/adminfe/chunk-3871.820645ae.css b/priv/static/adminfe/chunk-23b2.723b6cc5.css similarity index 100% rename from priv/static/adminfe/chunk-3871.820645ae.css rename to priv/static/adminfe/chunk-23b2.723b6cc5.css diff --git a/priv/static/adminfe/chunk-6292.8ee9eaaa.css b/priv/static/adminfe/chunk-2943.1b6fd9a7.css similarity index 100% rename from priv/static/adminfe/chunk-6292.8ee9eaaa.css rename to priv/static/adminfe/chunk-2943.1b6fd9a7.css diff --git a/priv/static/adminfe/chunk-3d1c.a6b92ca7.css b/priv/static/adminfe/chunk-3d1c.b2eb7234.css similarity index 100% rename from priv/static/adminfe/chunk-3d1c.a6b92ca7.css rename to priv/static/adminfe/chunk-3d1c.b2eb7234.css diff --git a/priv/static/adminfe/chunk-598f.14eeccbb.css b/priv/static/adminfe/chunk-4df4.e217dea0.css similarity index 100% rename from priv/static/adminfe/chunk-598f.14eeccbb.css rename to priv/static/adminfe/chunk-4df4.e217dea0.css diff --git a/priv/static/adminfe/chunk-538a.6ef5bd70.css b/priv/static/adminfe/chunk-538a.062aa087.css similarity index 100% rename from priv/static/adminfe/chunk-538a.6ef5bd70.css rename to priv/static/adminfe/chunk-538a.062aa087.css diff --git a/priv/static/adminfe/chunk-7c6b.dece6ace.css b/priv/static/adminfe/chunk-7c6b.c7882778.css similarity index 100% rename from priv/static/adminfe/chunk-7c6b.dece6ace.css rename to priv/static/adminfe/chunk-7c6b.c7882778.css diff --git a/priv/static/adminfe/chunk-7f8e.52359c55.css b/priv/static/adminfe/chunk-7f8e.b6944d38.css similarity index 100% rename from priv/static/adminfe/chunk-7f8e.52359c55.css rename to priv/static/adminfe/chunk-7f8e.b6944d38.css diff --git a/priv/static/adminfe/chunk-elementUI.d2a55ce6.css b/priv/static/adminfe/chunk-elementUI.a842fb0a.css similarity index 100% rename from priv/static/adminfe/chunk-elementUI.d2a55ce6.css rename to priv/static/adminfe/chunk-elementUI.a842fb0a.css diff --git a/priv/static/adminfe/chunk-libs.36b859a1.css b/priv/static/adminfe/chunk-libs.57fe98a3.css similarity index 100% rename from priv/static/adminfe/chunk-libs.36b859a1.css rename to priv/static/adminfe/chunk-libs.57fe98a3.css diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 47901efe8..70bb8bd3b 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/app.9d5375ac.js b/priv/static/adminfe/static/js/app.9c4316f1.js similarity index 99% rename from priv/static/adminfe/static/js/app.9d5375ac.js rename to priv/static/adminfe/static/js/app.9c4316f1.js index 9f86a99578e572425dbdf5ff834406e31cd8a594..6af94c36b294d4fe94092fe13b31e39466c577d7 100644 GIT binary patch delta 83 zcmV-Z0IdJSnhM043b2gs1Tr}^Gn0_+{{%E-W;BzK?iaJF@5urLFk@mivv>0U0s}Z@ pF|&E~76Ah?Gh&mE?ihor_P46`0gRRoV>B}{HfAv{YI81aVQ?6OBZmM0 delta 84 zcmX>yi|fcNt_?l!Sj>zpjV4cc_n*bo(jpDW5Z}D&{YfU4M9Wmu&E=o|GchM6rEISJ nD#plSY+-H)Qqa8W+xAu87<;DirI;FOV delta 39 scmcbzUhK+xv4$4LEli1w>?x+k=BA0s?HPShw-FjyL0Gs;?p8x;= diff --git a/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js b/priv/static/adminfe/static/js/chunk-15fa.34dcb9d8.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js rename to priv/static/adminfe/static/js/chunk-15fa.34dcb9d8.js index 70df4d3a2b7de00d491607f8276939b87dc417a3..b0819b13814e652c4d30a53b0d9af0904098b58f 100644 GIT binary patch delta 23 ecmaEF``&iL137+Ula%Bn%M=T}tYW>~!~y_!S_tw0 delta 23 ecmaEF``&iL137-Pl;k866B7%)tYW>~!~y_zG6=u` diff --git a/priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map b/priv/static/adminfe/static/js/chunk-15fa.34dcb9d8.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-15fa.6dcb4448.js.map rename to priv/static/adminfe/static/js/chunk-15fa.34dcb9d8.js.map index 9a7d1241ae773d0c11ad5f0ee12b2bba103bb6ef..2ec54c8aa2f83f9d2b6b922d61b8b96b54073658 100644 GIT binary patch delta 22 dcmbQ&!8osjal;`Fc4L#2 hRMNBtOHE$Ro+b>`sh4Jvlw@v^W~`T0te2Zu002-48SnrA delta 84 zcmZ1=us~pfEpt**%0ve*;bPlN4W%mg@+c)uomAUQy*!QLiF1t@Q#W2X#wG}sbaFE> hRMNBtOHE$Ro+b>`sh5;&W|3-SXrY%?te2Zu0047<8i@b^ diff --git a/priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map b/priv/static/adminfe/static/js/chunk-18e1.f8bb78f3.js.map similarity index 98% rename from priv/static/adminfe/static/js/chunk-1bbd.bc68e218.js.map rename to priv/static/adminfe/static/js/chunk-18e1.f8bb78f3.js.map index c901677be02bbede1933e44a6c3603ab72c8712d..b61e3bc20bd74f947ae4df312e94541d3e635fca 100644 GIT binary patch delta 25 gcmZp2Z*t%8jf=-3)le_ZA}Pt-BF%U+6L*6I0CXn^4FCWD delta 25 gcmZp2Z*t%8jf*EKDMc?S*~}u<$k1Xl6L*6I0C*n=FaQ7m diff --git a/priv/static/adminfe/static/js/chunk-3871.4ac23900.js b/priv/static/adminfe/static/js/chunk-23b2.442bb8df.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-3871.4ac23900.js rename to priv/static/adminfe/static/js/chunk-23b2.442bb8df.js index e957e4552174d28ddfefae63819467f07fa577a5..61cfc7826ab266058c442af7e28736b9f9cf2fb3 100644 GIT binary patch delta 38 rcmdmUn{m%=#tAkoM#f1-8yzxoML-Na6BDDPB#V?Zy{uxr+{6L^3hxZk delta 38 rcmdmUn{m%=#tAko#uny=8yzxoML-Nalf+~rV@m@Ay{uxr+{6L^0fh`4 diff --git a/priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map b/priv/static/adminfe/static/js/chunk-23b2.442bb8df.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-3871.4ac23900.js.map rename to priv/static/adminfe/static/js/chunk-23b2.442bb8df.js.map index 8bb213374d36bdbbba08d7ccc645fe9e350cb393..474d1086ec7955c933c7d8ecc812ccfe1c81ceda 100644 GIT binary patch delta 28 kcmaEKlJ(I^)(t)-yhg@JMtUYDMoCE)DQTM{OFBOQ0Ho<(AkX^KILrCwICUT$Im0C(ID A=l}o! delta 47 zcmaFV&-bXGZ-NbrnUSSYqeH6$W2*zxRtM(3b`g+>UXpQQqOoO4qFz?9UT$Im0C|cJ A5dZ)H diff --git a/priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map b/priv/static/adminfe/static/js/chunk-2943.8ab5d0d9.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-6292.b3aa39da.js.map rename to priv/static/adminfe/static/js/chunk-2943.8ab5d0d9.js.map index 577df8f956b636635318b4152c749f4a21db9390..0ecc45de4a905986aac70cdb59387c2ae2a24edf 100644 GIT binary patch delta 61 zcmccHu64IvtD%K)3)ACYyhfHL#(EZsNv0_VDVFUYelY~!~y_kg$Mxv delta 23 ecmcbndQEl1aUp&a^JI&(L<@7htYW>~!~y_n;0Qth diff --git a/priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map b/priv/static/adminfe/static/js/chunk-3d1c.3334d3f1.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-3d1c.47c8fa87.js.map rename to priv/static/adminfe/static/js/chunk-3d1c.3334d3f1.js.map index d10007b9158c47d2a97749252f77ccbe1256114e..3dd0d77a91c5c7f2a108859b07f8cbb89eec519a 100644 GIT binary patch delta 23 fcmcaUf${nT#toI?9LC1RCMm{ghMOD3uNeRUYFG$m delta 23 fcmcaUf${nT#toI?946+;7HNqV=9?SEuNeRUZ7~R@ diff --git a/priv/static/adminfe/static/js/chunk-598f.b02acd71.js b/priv/static/adminfe/static/js/chunk-4df4.9655f394.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-598f.b02acd71.js rename to priv/static/adminfe/static/js/chunk-4df4.9655f394.js index fb2374e3bf0730e33889eeb49442937c9f524331..afed4bab68aef7058b0a9ebe7e5ce2426be8ec31 100644 GIT binary patch delta 38 rcmaFb#rU*~ae@ttNlKc@Mh8Jx5fDSq(#+H}&DhdJFRNHDH?aT!`kf2( delta 38 rcmaFb#rU*~ae@ttsij5QMh8Jx5fDQ!$-pQvImO&iFRNHDH?aT!_|yy? diff --git a/priv/static/adminfe/static/js/chunk-598f.b02acd71.js.map b/priv/static/adminfe/static/js/chunk-4df4.9655f394.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-598f.b02acd71.js.map rename to priv/static/adminfe/static/js/chunk-4df4.9655f394.js.map index da8e8c4ad497d772165dc4482b493aa0b1b41eed..a1e9bca7a714b35ebb21bde786ea8cb8ccc79d82 100644 GIT binary patch delta 28 kcmey_#qzU@Wy8#1UXzqG6Fo~aQ`0nKOOwsZf-i3b0HkRPCjbBd delta 28 kcmey_#qzU@Wy8#1UQ~!~y_pA_xZn delta 23 ecmeyN{zHAkOJROP3rhowR7(rJtYW>~!~y_s9|%zZ diff --git a/priv/static/adminfe/static/js/chunk-538a.18908e98.js.map b/priv/static/adminfe/static/js/chunk-538a.04530055.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-538a.18908e98.js.map rename to priv/static/adminfe/static/js/chunk-538a.04530055.js.map index 4bb072450dde46da4dcb16ca1e8d8de1741f9b24..d3741c30a8623898659c2acc2652830f5efbd51d 100644 GIT binary patch delta 23 ecmZpg$=EcLaYMWmhk=Qyv4Mf9>E?7PX(Iqv0tSHq delta 23 ecmZpg$=EcLaYMWmhoOa~fkmpN#pZM=X(IqxYX-{z diff --git a/priv/static/adminfe/static/js/chunk-7c6b.24877470.js b/priv/static/adminfe/static/js/chunk-7c6b.5240e052.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.24877470.js rename to priv/static/adminfe/static/js/chunk-7c6b.5240e052.js index 059bcf3223073733caee5a87825e1315313ee296..12eb54a3218f85db6682af398d1500ef4e5d3de5 100644 GIT binary patch delta 23 ecmeCS>$cnQM~>gr$iyJkz|=@Dt5`2Lu>b&JY6q$S delta 23 ecmeCS>$cnQM~>gf#KPR%#N0qHt5`2Lu>b&I5eJF@ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map b/priv/static/adminfe/static/js/chunk-7c6b.5240e052.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.24877470.js.map rename to priv/static/adminfe/static/js/chunk-7c6b.5240e052.js.map index cb00fc3ebf9d73ae9e6b644b7f6921233c517e33..1463b8ba42b9b24554f433f4e2d88a7f2a6ba8cd 100644 GIT binary patch delta 23 fcmX?bj`6@b#tpv|I82R93{nkDjW#nW+PMJ$beRYe delta 23 fcmX?bj`6@b#tpv|IE+jz%*{>A4K_0=+PMJ$bASi( diff --git a/priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js b/priv/static/adminfe/static/js/chunk-7f8e.c1eb619d.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-7f8e.b2353c0a.js rename to priv/static/adminfe/static/js/chunk-7f8e.c1eb619d.js index 9a0afaf67616d6957479e1da58d923d3acbd1caf..56ce1d5efd036969bcae6e75f3e904039c7df589 100644 GIT binary patch delta 23 ecmbQ_J;{4RvnqeGVQP|@p=F9*RoWjyWC+Xv delta 23 ecmcb#o$1ncrVZZ497#sTrpCzziJOCs>oWjvp9qiu diff --git a/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js b/priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js rename to priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js index b221f866c0a926a4b135bee418f1356742b95492..90ae35a35dbe03f30c43c5fa6dec711114d84e76 100644 GIT binary patch delta 43 zcmccdU+uF7M2#)7Pc1l7LF~PC-?KGB^n!ArkW?|Wfkk?CKdnyf1nTS delta 43 zcmccdU+uF7M2#)7Pc1l7LF~PC-?Iko0}vi8YL&{Wfkk?CKdnyec}(` diff --git a/priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map b/priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-elementUI.374aa2ca.js.map rename to priv/static/adminfe/static/js/chunk-elementUI.fa319e7b.js.map index b58957727727c3da9697ab9dd8d61d42a8039edd..678122a98dbc772133199ba1ae1b5b9c87f406e4 100644 GIT binary patch delta 142 zcmWN=KMI0i0Eb~LE48xH{%VsThxaE#}9vCs^ktZfRGv%dg IO!j;J0Zv*zTL1t6 delta 142 zcmWN=yA6U+0EW>ZDi>5lQ64@J7(q>(=f=vC!rtOftn3`c#6@n+2JYZ@lGDFmUeAX- z4diR6k;V#S6n>(cGpEbh+T+zeI~Q@nDQAp1XTk-SOu6Ek8FLmax#5;O?pg7`n$5R! H?eG2vNGLu} diff --git a/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js b/priv/static/adminfe/static/js/chunk-libs.35c18287.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js rename to priv/static/adminfe/static/js/chunk-libs.35c18287.js index b31c6cd5b7b053c709327382aff35cb72c746e4b..4b76d98e60506d3cbe105610495b04ae18cc44ec 100644 GIT binary patch delta 32 ncmaFyO5nvSfrb{w7N!>FEi6%{e5T2U7Dg84dRfJKxrqe;)h!D} delta 32 ncmaFyO5nvSfrb{w7N!>FEi6%{e5omh2B~RgdRfJKxrqe;+ZGGX diff --git a/priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map b/priv/static/adminfe/static/js/chunk-libs.35c18287.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-libs.3ed10ef6.js.map rename to priv/static/adminfe/static/js/chunk-libs.35c18287.js.map index 61fd05273efccadb908e1cf006ef1f95246e1f47..0a3580834454c3d7793040f9c76c0912e491c255 100644 GIT binary patch delta 107 zcmWN=w+Vny6hKkTIma~=MEB7p>?|JG|4MKT!P3q>+{Am}e8=(~%O;xWVu&f$Wk1Rp j=5FezaetLfhb}$(3>Y$E%!Da3<}6sUV$J4$ReP>K${jF( delta 107 zcmWN=xe0(k6hJ||@B1u66DwNn!OY-;`9^|m2!>|%VJE(U`406RY7;|DvBVbVav$x@ jY8>We+F$L`qtAdLBgRaaGGoqyB`el!*s{A{-Jk0Z_60Es diff --git a/priv/static/adminfe/static/js/runtime.46db235c.js b/priv/static/adminfe/static/js/runtime.46db235c.js new file mode 100644 index 0000000000000000000000000000000000000000..898c5b505f5c39fa557cbfd14f3ebfbb59aed333 GIT binary patch literal 3922 zcmai1TW{mG5`OQmaCiVigc(M%Eniny>{;x~qL+iS?UQR16fco>71E`srm5}!-l6W< zCR-qgNs1iKe4P1a=0;VTyWHqN;&V5i=s?5F*x(OlCzf!+5`74dM8QvVBsu)dk3=LP zDU!o+FWxX(NPX|Jy_n4~PuM{mNp;}I>9nAys>}jE0SUNXO~SnwZYQ~(DEw3US#n2D z);QyyPLgJ<{OM8u&zh68?ul|%6oDcZPp5%A*khvLAFd^2(`g2(?u+hvT+lMhl?!ly z_g1>f>WQf}pzL%io&rPi%jE!iz?N%oFHCxKCDEXteq%bB8cgcHy2Kpxk=W!WBs_`s z{Eh4Oalg+4rV!@&xMSJxZxBR$z1hsy>p{+1NqD4@J)NF4#3)KvN^IG>mm}2b#r@42AUTiL zLNdUmmD4;1N>|(EQY_$J^X}u*3-?7U@giQ$>!ENpXDr;Tk$(<&3SO27f{4-6%tH7G z!>b3W9le+CLm~aJ{O{Ax?EXjgAOlq7`U}Fu00WpeRTtT2i0nW>Oq(P2=ifPJ^R#ks zPB6!AGb%g#f85e?g91lZ>x{Ty0k3PsgezxJAlI)?$gt$rjA(vdKl5t^2>$8L;9Hf;Ex9Adk~iE`^o!H zpC&b-Owdfu&_uDkkb?fI12nTUgaM-p6#TuCCD0&bfC^th1^+w=C;#FZnPdWCU}>L7 zq!#b9ybwW#ub8?_>VX&V*^^rmUNLSNw}QFH-xHt1FmSkK7YAt+?`2WS&ghq!#^SRB zzXP!cP3-ZC_agy`Z7wp9o_iy{dY_Wp{N%ioP@XYcN|&I@saps$MSXHHl}n;|NEp?8 zI#m>QjNDrr%_XWoVmJVgFgzHLed{JZ#^CLl%lX~s@(#^j<3t%T8SyP}PQ!_6o3dr> z=g&EjuU9dTwgX2ri2^N`^EBNEHSDi521>VTF_$6l*IQ0pECo-uV&Jl<;Y77b)AdFz z`u!~@E*5ZNp`w1h<-~Zt3}qP4hxLXNm+?kz1sk}vNjqr6(pZFI;OLeUS3Hiwn5}v_ zEhlbQajX_299mA~kxW<7R&<=WS%xZ&_@GD2i7PJGo9&3RJ5F52i!hAijuStZ=n6?1 zXl}@6kjsW(>?5=)3c?G??9Yvl1o_KO?duLmhkfJ%BS27kj)NG6_dU*fmY}|{)gX@1heX1E4y?qe z7+wWxQD0>+Y62KSYH4d_AyuwrS0VdvMjC2uC+-{r~Z!)Hw}mU!@Hza zu3^eRgR5gN4gdpJenoXh5TM7Z!lADbZ;Y2<^}JT7tT7xwmbh=IeBUE6yma7*CB4@{ z>g(!%sKBo@&9$iYg5Ia^W}L@Jk1* zu(t8+(IhKap800ypZRt=X-p@L_fE4*D3I(lGq(@-cOzs#gstX&OF10!=AcV7qf5%H zXXS0vw!_QzX873HD?c{oYl}xLT$lShkLztFED*{~`MgIut_m1u=i`hq0-&rPXQ$&@ zUSgm{oP|qV=MKMECZwwkQzB*$RSR`RXZ_4846UaD%rxu-flR&%^^i5cubU~R)^|FVr3rwDOgUL4I^z28Ie@}p$4 zF+5B*2g9>oWiw7$btI!t!?(uDl8j@7F}xOD&G}i^DvS%HYiwyrIIOr4|61Z;i76CM zmVh{jA%Cu0STa&6BBctidCd|A;jJ!Y_{wXR9Kq7M0@aq03riq0i@!pJo0DiskfK0J Jma~(GgKs(Lvi1M~ delta 735 zcmZvZy-Qp{6vf%6gw+kkO07g+@PoMGe1F)4ZERBp!fWo_nTHTaC)x@A2@cq8VJ(Ef z>uvHErM7Y2+jaICwi%dvI5X$|&i&os{%-JRt=Q#8({fzm<<+mBr&QLkxH#vdC_e8h zW?7zs4KiP(HFa}Y_~Azw!(=iUe^1Ac`1t80KCO?MjnQ;kmUzPD@Im zdDvoWw{4iJJg-Ut$hak>D#ukXTc;_HP6%Q8T8i3f4wVUv_BHM)&fe5$YB0Lz)9L$8 zW5Q@H3Qc0zJ34I_o;}9_xb@JpwEK5jEuFKD^Ege87R1a!7@%*r$ia1O!c1daawHJ{ z?OhD1vBU&xny>rMD~I#NX*RV@BvWtDwQ+EEI1jf_=7|=Au9cgE--88D!-x_RQjY(B zs7f0Q5thDV{l?RyLRq^+Bl|K>P|h-hr96GWprF7I{~q;tnh2ZAr;~^X{N_lJFkx>(nSCO diff --git a/priv/static/adminfe/static/js/runtime.c6b7511a.js b/priv/static/adminfe/static/js/runtime.c6b7511a.js deleted file mode 100644 index 0e13fe45aab0135a56a601af9ecd6ea99bdaecf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3922 zcmai1ZI9cy5&qs^;bj3sgt?4lTefUR2+|;5g1cVPTtB(Sfucq$C0f#@sN37K_1`p)Q>01Q#jMc`(2ocVUp=s?fnDG@FzI10)OTQ zmd8FTwSJ;^_= zS&w>jXTa7*O~ge5E{@0Y*%PcdpZBN-e$ou~;$$!vm_q&3ThYr@XwtB2W3g8UX5u@a z$vD`_x3>RIy6+rdS!~hToqrd{^*%!kJp4SMjlq%N)v+ujcpAngRXF^4~n+SqF6WZKv*#iwGc zSh>9mShu-7HBAo~xI-7zZGVd8GE-l^6J+?Z}1 zE^aiPo`}qA_4R%ctpl`Tv^4A$4f>(NX@>^s{1h4@i#2}}Im;|A}-mJsru%2eC7v;2X zK!PQJlu0st-#!Kj=ZkP7!;moj{?qdddIYQVMZDVABP+qk(QKY;yMf*V%Yy9{8K!FN6JEdC6ozd0P(i&BU_A-?d2HL;y~T`_tu&s zsV$yMg%$U?fsD}eIsay}UEg77lFB}pP~`xY#BrQ+10|9|hGTO+yB|sW&{arKE{cl3 z()l8jw1n6yUVNu`?rz6>Q2pY^>eFjoIu_|wNk>(r0b!v-u*YqeI)mbCD_P1Nr^m#? zANI}VF;cEosYMzq+|&w;(Prchy z^5OI6$%RlRWCk-b$wW~?%Kxi8GBYzm5u+Lv{9g?f(jaDlnl`9{d!AUbzwnG4L`o>K zv`ZwQ(hqr2rd~=L%54s6fRMHej>zstkT$l=MOlq6mM}%eEB^O=WIGFoP zb0w$KjN=zq?ya7N!9}LO9a}yV1`1ZmU^39j(4Vk^hi4X_~lD#L6$2CnjoVXP1JMZmx?r) zq><*vY=*gP48|cs>#}6B1Yv${eZ(tXc4}95U^*Nk7a0MA(s3NdFvkBog2}VOASK&& zfg&fr8sVte3*r5P(B5Li%QnJA%QuFJA=D+aShaeBHkT0kT^H%Z$8x7-SM>DWxB(4% z65h$_y(L|65Y|%BHfxYHf;lW*EDh|{113fqafETh71>bUb+rU{g{?wynm@*X|0S}L z=IQtf>xd%GZW5zyFru^OrS3_Onjw5=cYYYlq2izd_Y~ z)*rh=#&qi18}ly}Z7@Yw@m#44l(J~5`Wy+4R+ny<=t~!Y9@p6f)2aL8<4xls_wY9E zlxvyNQ|Ri{i9^89m0wZaGAx)fQJ0o{i+F21pw;t>Le&Ms5oDl4L*@D&p!m|BWrhu2 zhpDfx|FHsZcv2|rZd?c&%q88Hbr`8DjQU#p8*ASUVn?aG)4kcgusM|fPA|m>vW=ol zx$E@Rb-37T*#UU`VdLif*2y@=#3c}Y+a}oD=% zjzWI@RB3g5Y0b5C*p+5&r+2|EbMfg~Pb@Ps+^H^IS6d~y_s8ZH`_`NM8KB;tVB>aroA4B6m|NbfN+A@v}$T|!H!M{}eQ5^pdV7~|- From c8b01f6667a9b5b158103de449a7769c9274bcc3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Oct 2019 14:13:52 +0700 Subject: [PATCH 101/138] Extract instance actions from `MastodonAPIController` to `InstanceController` --- .../controllers/instance_controller.ex | 17 ++++ .../controllers/mastodon_api_controller.ex | 35 -------- .../web/mastodon_api/views/instance_view.ex | 35 ++++++++ lib/pleroma/web/router.ex | 7 +- .../controllers/instance_controller_test.exs | 84 +++++++++++++++++++ .../mastodon_api_controller_test.exs | 75 ----------------- 6 files changed, 140 insertions(+), 113 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/views/instance_view.ex create mode 100644 test/web/mastodon_api/controllers/instance_controller_test.exs diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex new file mode 100644 index 000000000..a55f60fec --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceController do + use Pleroma.Web, :controller + + @doc "GET /api/v1/instance" + def show(conn, _params) do + render(conn, "show.json") + end + + @doc "GET /api/v1/instance/peers" + def peers(conn, _params) do + json(conn, Pleroma.Stats.get_peers()) + 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 81a95bc4a..98dd9f375 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.Pagination - alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub @@ -23,40 +22,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - @mastodon_api_level "2.7.2" - - def masto_instance(conn, _params) do - instance = Config.get(:instance) - - response = %{ - uri: Web.base_url(), - title: Keyword.get(instance, :name), - description: Keyword.get(instance, :description), - version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", - email: Keyword.get(instance, :email), - urls: %{ - streaming_api: Pleroma.Web.Endpoint.websocket_url() - }, - stats: Stats.get_stats(), - thumbnail: Web.base_url() <> "/instance/thumbnail.jpeg", - languages: ["en"], - registrations: Pleroma.Config.get([:instance, :registrations_open]), - # Extra (not present in Mastodon): - max_toot_chars: Keyword.get(instance, :limit), - poll_limits: Keyword.get(instance, :poll_limits), - upload_limit: Keyword.get(instance, :upload_limit), - avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), - background_upload_limit: Keyword.get(instance, :background_upload_limit), - banner_upload_limit: Keyword.get(instance, :banner_upload_limit) - } - - json(conn, response) - end - - def peers(conn, _params) do - json(conn, Stats.get_peers()) - end - defp mastodonized_emoji do Pleroma.Emoji.get_all() |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} -> diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex new file mode 100644 index 000000000..c4866e510 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.InstanceView do + use Pleroma.Web, :view + + @mastodon_api_level "2.7.2" + + def render("show.json", _) do + instance = Pleroma.Config.get(:instance) + + %{ + uri: Pleroma.Web.base_url(), + title: Keyword.get(instance, :name), + description: Keyword.get(instance, :description), + version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", + email: Keyword.get(instance, :email), + urls: %{ + streaming_api: Pleroma.Web.Endpoint.websocket_url() + }, + stats: Pleroma.Stats.get_stats(), + thumbnail: Pleroma.Web.base_url() <> "/instance/thumbnail.jpeg", + languages: ["en"], + registrations: Keyword.get(instance, :registrations_open), + # Extra (not present in Mastodon): + max_toot_chars: Keyword.get(instance, :limit), + poll_limits: Keyword.get(instance, :poll_limits), + upload_limit: Keyword.get(instance, :upload_limit), + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit) + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 501978994..a355a14bd 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -462,14 +462,15 @@ defmodule Pleroma.Web.Router do post("/accounts", AccountController, :create) - get("/instance", MastodonAPIController, :masto_instance) - get("/instance/peers", MastodonAPIController, :peers) + get("/instance", InstanceController, :show) + get("/instance/peers", InstanceController, :peers) + post("/apps", AppController, :create) get("/apps/verify_credentials", AppController, :verify_credentials) + get("/custom_emojis", MastodonAPIController, :custom_emojis) get("/statuses/:id/card", StatusController, :card) - get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs new file mode 100644 index 000000000..f8049f81f --- /dev/null +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -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.InstanceControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User + import Pleroma.Factory + + test "get instance information", %{conn: conn} do + conn = get(conn, "/api/v1/instance") + assert result = json_response(conn, 200) + + email = Pleroma.Config.get([:instance, :email]) + # Note: not checking for "max_toot_chars" since it's optional + assert %{ + "uri" => _, + "title" => _, + "description" => _, + "version" => _, + "email" => from_config_email, + "urls" => %{ + "streaming_api" => _ + }, + "stats" => _, + "thumbnail" => _, + "languages" => _, + "registrations" => _, + "poll_limits" => _, + "upload_limit" => _, + "avatar_upload_limit" => _, + "background_upload_limit" => _, + "banner_upload_limit" => _ + } = result + + assert email == from_config_email + end + + test "get instance stats", %{conn: conn} do + user = insert(:user, %{local: true}) + + user2 = insert(:user, %{local: true}) + {:ok, _user2} = User.deactivate(user2, !user2.info.deactivated) + + insert(:user, %{local: false, nickname: "u@peer1.com"}) + insert(:user, %{local: false, nickname: "u@peer2.com"}) + + {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"}) + + # Stats should count users with missing or nil `info.deactivated` value + + {:ok, _user} = + user.id + |> User.get_cached_by_id() + |> User.update_info(&Ecto.Changeset.change(&1, %{deactivated: nil})) + + Pleroma.Stats.force_update() + + conn = get(conn, "/api/v1/instance") + + assert result = json_response(conn, 200) + + stats = result["stats"] + + assert stats + assert stats["user_count"] == 1 + assert stats["status_count"] == 1 + assert stats["domain_count"] == 2 + end + + test "get peers", %{conn: conn} do + insert(:user, %{local: false, nickname: "u@peer1.com"}) + insert(:user, %{local: false, nickname: "u@peer2.com"}) + + Pleroma.Stats.force_update() + + conn = get(conn, "/api/v1/instance/peers") + + assert result = json_response(conn, 200) + + assert ["peer1.com", "peer2.com"] == Enum.sort(result) + 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 e642e3c1a..7a58b13dc 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do use Pleroma.Web.ConnCase - alias Ecto.Changeset alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Repo @@ -114,80 +113,6 @@ test "returns the favorites of a user", %{conn: conn} do assert [] = json_response(third_conn, 200) end - test "get instance information", %{conn: conn} do - conn = get(conn, "/api/v1/instance") - assert result = json_response(conn, 200) - - email = Config.get([:instance, :email]) - # Note: not checking for "max_toot_chars" since it's optional - assert %{ - "uri" => _, - "title" => _, - "description" => _, - "version" => _, - "email" => from_config_email, - "urls" => %{ - "streaming_api" => _ - }, - "stats" => _, - "thumbnail" => _, - "languages" => _, - "registrations" => _, - "poll_limits" => _, - "upload_limit" => _, - "avatar_upload_limit" => _, - "background_upload_limit" => _, - "banner_upload_limit" => _ - } = result - - assert email == from_config_email - end - - test "get instance stats", %{conn: conn} do - user = insert(:user, %{local: true}) - - user2 = insert(:user, %{local: true}) - {:ok, _user2} = User.deactivate(user2, !user2.info.deactivated) - - insert(:user, %{local: false, nickname: "u@peer1.com"}) - insert(:user, %{local: false, nickname: "u@peer2.com"}) - - {:ok, _} = CommonAPI.post(user, %{"status" => "cofe"}) - - # Stats should count users with missing or nil `info.deactivated` value - - {:ok, _user} = - user.id - |> User.get_cached_by_id() - |> User.update_info(&Changeset.change(&1, %{deactivated: nil})) - - Pleroma.Stats.force_update() - - conn = get(conn, "/api/v1/instance") - - assert result = json_response(conn, 200) - - stats = result["stats"] - - assert stats - assert stats["user_count"] == 1 - assert stats["status_count"] == 1 - assert stats["domain_count"] == 2 - end - - test "get peers", %{conn: conn} do - insert(:user, %{local: false, nickname: "u@peer1.com"}) - insert(:user, %{local: false, nickname: "u@peer2.com"}) - - Pleroma.Stats.force_update() - - conn = get(conn, "/api/v1/instance/peers") - - assert result = json_response(conn, 200) - - assert ["peer1.com", "peer2.com"] == Enum.sort(result) - end - test "put settings", %{conn: conn} do user = insert(:user) From 3d61efa7c99e1ee9626bf77b3866cfc1562c663b Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Wed, 2 Oct 2019 10:48:34 +0200 Subject: [PATCH 102/138] Rename misleading `get_announce_visibility` to `public_announce?` --- lib/pleroma/web/common_api/common_api.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 677a53ddf..ce73b3270 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -80,7 +80,7 @@ def repeat(id_or_ap_id, user, params \\ %{}) do with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), object <- Object.normalize(activity), nil <- Utils.get_existing_announce(user.ap_id, object), - public <- get_announce_visibility(object, params) do + public <- public_announce?(object, params) do ActivityPub.announce(user, object, nil, true, public) else _ -> {:error, dgettext("errors", "Could not repeat")} @@ -170,11 +170,11 @@ defp normalize_and_validate_choices(choices, object) do end end - def get_announce_visibility(_, %{"visibility" => visibility}) + def public_announce?(_, %{"visibility" => visibility}) when visibility in ~w{public unlisted private direct}, do: visibility in ~w(public unlisted) - def get_announce_visibility(object, _) do + def public_announce?(object, _) do Visibility.is_public?(object) end From 86880b9821671ee07a65ca7e65b68b900759b483 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Wed, 2 Oct 2019 12:14:08 +0200 Subject: [PATCH 103/138] Inline object when Announcing a self-owned private object --- .../web/activity_pub/transmogrifier.ex | 21 +++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 14 +++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 3ca2e8773..64c470fc8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -830,6 +830,27 @@ def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data) {:ok, data} end + def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do + object = + object_id + |> Object.normalize() + + data = + if Visibility.is_private?(object) && object.data["actor"] == ap_id do + data |> Map.put("object", object |> Map.get(:data) |> prepare_object) + else + data |> maybe_fix_object_url + end + + data = + data + |> strip_internal_fields + |> Map.merge(Utils.make_json_ld_header()) + |> Map.delete("bcc") + + {:ok, data} + end + # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs, # because of course it does. def prepare_outgoing(%{"type" => "Accept"} = data) do diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6c64be10b..b995f0224 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1076,6 +1076,20 @@ test "it accepts Flag activities" do end describe "prepare outgoing" do + test "it inlines private announced objects" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey", "visibility" => "private"}) + + {:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user) + + {:ok, modified} = Transmogrifier.prepare_outgoing(announce_activity.data) + object = modified["object"] + + assert modified["object"]["content"] == "hey" + assert modified["object"]["actor"] == modified["object"]["attributedTo"] + end + test "it turns mentions into tags" do user = insert(:user) other_user = insert(:user) From 1c6e1055c876c7ac4a4d42259aebd07c942561e2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Oct 2019 19:16:34 +0700 Subject: [PATCH 104/138] Add CustomEmojiController --- .../controllers/custom_emoji_controller.ex | 11 ++++++++ .../controllers/mastodon_api_controller.ex | 28 +++---------------- .../mastodon_api/views/custom_emoji_view.ex | 28 +++++++++++++++++++ lib/pleroma/web/router.ex | 7 ++--- test/web/activity_pub/transmogrifier_test.exs | 1 - .../custom_emoji_controller_test.exs | 22 +++++++++++++++ .../mastodon_api_controller_test.exs | 17 ----------- 7 files changed, 68 insertions(+), 46 deletions(-) create mode 100644 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex create mode 100644 lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex create mode 100644 test/web/mastodon_api/controllers/custom_emoji_controller_test.exs diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex new file mode 100644 index 000000000..391c0648b --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do + use Pleroma.Web, :controller + + def index(conn, _params) do + render(conn, "index.json", custom_emojis: Pleroma.Emoji.get_all()) + 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 98dd9f375..a66335c02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Config alias Pleroma.Pagination alias Pleroma.User - alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView @@ -22,28 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - defp mastodonized_emoji do - Pleroma.Emoji.get_all() - |> Enum.map(fn {shortcode, %Pleroma.Emoji{file: relative_url, tags: tags}} -> - url = to_string(URI.merge(Web.base_url(), relative_url)) - - %{ - "shortcode" => shortcode, - "static_url" => url, - "visible_in_picker" => true, - "url" => url, - "tags" => tags, - # Assuming that a comma is authorized in the category name - "category" => (tags -- ["Custom"]) |> Enum.join(",") - } - end) - end - - def custom_emojis(conn, _params) do - mastodon_emoji = mastodonized_emoji() - json(conn, mastodon_emoji) - end - 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}, @@ -114,7 +91,10 @@ def index(%{assigns: %{user: user}} = conn, _params) do token = get_session(conn, :oauth_token) if user && token do - mastodon_emoji = mastodonized_emoji() + mastodon_emoji = + Pleroma.Web.MastodonAPI.CustomEmojiView.render("index.json", %{ + custom_emojis: Pleroma.Emoji.get_all() + }) limit = Config.get([:instance, :limit]) diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex new file mode 100644 index 000000000..cb8688941 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do + use Pleroma.Web, :view + + alias Pleroma.Emoji + alias Pleroma.Web + + def render("index.json", %{custom_emojis: custom_emojis}) do + render_many(custom_emojis, __MODULE__, "show.json") + end + + def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do + url = Web.base_url() |> URI.merge(relative_url) |> to_string() + + %{ + "shortcode" => shortcode, + "static_url" => url, + "visible_in_picker" => true, + "url" => url, + "tags" => tags, + # Assuming that a comma is authorized in the category name + "category" => tags |> List.delete("Custom") |> Enum.join(",") + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index a355a14bd..5d14c7742 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -461,6 +461,7 @@ defmodule Pleroma.Web.Router do pipe_through(:api) post("/accounts", AccountController, :create) + get("/accounts/search", SearchController, :account_search) get("/instance", InstanceController, :show) get("/instance/peers", InstanceController, :peers) @@ -468,15 +469,13 @@ defmodule Pleroma.Web.Router do post("/apps", AppController, :create) get("/apps/verify_credentials", AppController, :verify_credentials) - get("/custom_emojis", MastodonAPIController, :custom_emojis) - get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) - get("/trends", MastodonAPIController, :empty_array) + get("/custom_emojis", CustomEmojiController, :index) - get("/accounts/search", SearchController, :account_search) + get("/trends", MastodonAPIController, :empty_array) scope [] do pipe_through(:oauth_read_or_public) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index b995f0224..6c208bdc0 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1084,7 +1084,6 @@ test "it inlines private announced objects" do {:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user) {:ok, modified} = Transmogrifier.prepare_outgoing(announce_activity.data) - object = modified["object"] assert modified["object"]["content"] == "hey" assert modified["object"]["actor"] == modified["object"]["attributedTo"] diff --git a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs new file mode 100644 index 000000000..2d988b0b8 --- /dev/null +++ b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs @@ -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.MastodonAPI.CustomEmojiControllerTest do + use Pleroma.Web.ConnCase, async: true + + test "with tags", %{conn: conn} do + [emoji | _body] = + conn + |> get("/api/v1/custom_emojis") + |> json_response(200) + + assert Map.has_key?(emoji, "shortcode") + assert Map.has_key?(emoji, "static_url") + assert Map.has_key?(emoji, "tags") + assert is_list(emoji["tags"]) + assert Map.has_key?(emoji, "category") + assert Map.has_key?(emoji, "url") + assert Map.has_key?(emoji, "visible_in_picker") + 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 7a58b13dc..e8fd4827c 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -159,23 +159,6 @@ test "preserves parameters in link headers", %{conn: conn} do end end - describe "custom emoji" do - test "with tags", %{conn: conn} do - [emoji | _body] = - conn - |> get("/api/v1/custom_emojis") - |> json_response(200) - - assert Map.has_key?(emoji, "shortcode") - assert Map.has_key?(emoji, "static_url") - assert Map.has_key?(emoji, "tags") - assert is_list(emoji["tags"]) - assert Map.has_key?(emoji, "category") - assert Map.has_key?(emoji, "url") - assert Map.has_key?(emoji, "visible_in_picker") - end - end - describe "index/2 redirections" do setup %{conn: conn} do session_opts = [ From d3c404af124c7083b1f23466b9e82df5d2a407d0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Oct 2019 20:05:14 +0700 Subject: [PATCH 105/138] Add MastoFEController --- lib/pleroma/web/masto_fe_controller.ex | 36 +++++ .../controllers/auth_controller.ex | 2 +- .../controllers/mastodon_api_controller.ex | 124 ------------------ .../web/mastodon_api/views/mastodon_view.ex | 8 -- lib/pleroma/web/router.ex | 14 +- .../mastodon => masto_fe}/index.html.eex | 2 +- lib/pleroma/web/views/masto_fe_view.ex | 102 ++++++++++++++ test/web/masto_fe_controller_test.exs | 83 ++++++++++++ .../mastodon_api_controller_test.exs | 72 ---------- 9 files changed, 230 insertions(+), 213 deletions(-) create mode 100644 lib/pleroma/web/masto_fe_controller.ex delete mode 100644 lib/pleroma/web/mastodon_api/views/mastodon_view.ex rename lib/pleroma/web/templates/{mastodon_api/mastodon => masto_fe}/index.html.eex (91%) create mode 100644 lib/pleroma/web/views/masto_fe_view.ex create mode 100644 test/web/masto_fe_controller_test.exs diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex new file mode 100644 index 000000000..ac9af7502 --- /dev/null +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastoFEController do + use Pleroma.Web, :controller + + alias Pleroma.User + + @doc "GET /web/*path" + def index(%{assigns: %{user: user}} = conn, _params) do + token = get_session(conn, :oauth_token) + + if user && token do + conn + |> put_layout(false) + |> render("index.html", token: token, user: user, custom_emojis: Pleroma.Emoji.get_all()) + else + conn + |> put_session(:return_to, conn.request_path) + |> redirect(to: "/web/login") + end + end + + @doc "PUT /api/web/settings" + def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do + with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do + json(conn, %{}) + else + e -> + conn + |> put_status(:internal_server_error) + |> json(%{error: inspect(e)}) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 0dee670af..bfd5120ba 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -75,7 +75,7 @@ def password_reset(conn, params) do defp local_mastodon_root_path(conn) do case get_session(conn, :return_to) do nil -> - mastodon_api_path(conn, :index, ["getting-started"]) + masto_fe_path(conn, :index, ["getting-started"]) return_to -> delete_session(conn, :return_to) 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 a66335c02..e92f5d089 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -8,13 +8,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Bookmark - alias Pleroma.Config alias Pleroma.Pagination alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.MastodonAPI.MastodonView alias Pleroma.Web.MastodonAPI.StatusView require Logger @@ -87,124 +85,6 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do |> render("index.json", %{activities: activities, for: user, as: :activity}) end - def index(%{assigns: %{user: user}} = conn, _params) do - token = get_session(conn, :oauth_token) - - if user && token do - mastodon_emoji = - Pleroma.Web.MastodonAPI.CustomEmojiView.render("index.json", %{ - custom_emojis: Pleroma.Emoji.get_all() - }) - - limit = Config.get([:instance, :limit]) - - accounts = Map.put(%{}, user.id, AccountView.render("show.json", %{user: user, for: user})) - - initial_state = - %{ - meta: %{ - streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(), - access_token: token, - locale: "en", - domain: Pleroma.Web.Endpoint.host(), - admin: "1", - me: "#{user.id}", - unfollow_modal: false, - boost_modal: false, - delete_modal: true, - auto_play_gif: false, - display_sensitive_media: false, - reduce_motion: false, - max_toot_chars: limit, - mascot: User.get_mascot(user)["url"] - }, - poll_limits: Config.get([:instance, :poll_limits]), - rights: %{ - delete_others_notice: present?(user.info.is_moderator), - admin: present?(user.info.is_admin) - }, - compose: %{ - me: "#{user.id}", - default_privacy: user.info.default_scope, - default_sensitive: false, - allow_content_types: Config.get([:instance, :allowed_post_formats]) - }, - media_attachments: %{ - accept_content_types: [ - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webm", - ".mp4", - ".m4v", - "image\/jpeg", - "image\/png", - "image\/gif", - "video\/webm", - "video\/mp4" - ] - }, - settings: - user.info.settings || - %{ - onboarded: true, - home: %{ - shows: %{ - reblog: true, - reply: true - } - }, - notifications: %{ - alerts: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - }, - shows: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - }, - sounds: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - } - } - }, - push_subscription: nil, - accounts: accounts, - custom_emojis: mastodon_emoji, - char_limit: limit - } - |> Jason.encode!() - - conn - |> put_layout(false) - |> put_view(MastodonView) - |> render("index.html", %{initial_state: initial_state}) - else - conn - |> put_session(:return_to, conn.request_path) - |> redirect(to: "/web/login") - end - end - - def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do - with {:ok, _} <- User.update_info(user, &User.Info.mastodon_settings_update(&1, settings)) do - json(conn, %{}) - else - e -> - conn - |> put_status(:internal_server_error) - |> json(%{error: inspect(e)}) - end - end - # Stubs for unimplemented mastodon api # def empty_array(conn, _) do @@ -216,8 +96,4 @@ def empty_object(conn, _) do Logger.debug("Unimplemented, returning an empty object") json(conn, %{}) end - - defp present?(nil), do: false - defp present?(false), do: false - defp present?(_), do: true end diff --git a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex b/lib/pleroma/web/mastodon_api/views/mastodon_view.ex deleted file mode 100644 index 33b9a74be..000000000 --- a/lib/pleroma/web/mastodon_api/views/mastodon_view.ex +++ /dev/null @@ -1,8 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.MastodonAPI.MastodonView do - use Pleroma.Web, :view - import Phoenix.HTML -end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5d14c7742..f91af8137 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -451,10 +451,10 @@ defmodule Pleroma.Web.Router do end end - scope "/api/web", Pleroma.Web.MastodonAPI do + scope "/api/web", Pleroma.Web do pipe_through([:authenticated_api, :oauth_write]) - put("/settings", MastodonAPIController, :put_settings) + put("/settings", MastoFEController, :put_settings) end scope "/api/v1", Pleroma.Web.MastodonAPI do @@ -658,17 +658,17 @@ defmodule Pleroma.Web.Router do get("/:version", Nodeinfo.NodeinfoController, :nodeinfo) end - scope "/", Pleroma.Web.MastodonAPI do + scope "/", Pleroma.Web do pipe_through(:mastodon_html) - get("/web/login", AuthController, :login) - delete("/auth/sign_out", AuthController, :logout) + get("/web/login", MastodonAPI.AuthController, :login) + delete("/auth/sign_out", MastodonAPI.AuthController, :logout) - post("/auth/password", AuthController, :password_reset) + post("/auth/password", MastodonAPI.AuthController, :password_reset) scope [] do pipe_through(:oauth_read) - get("/web/*path", MastodonAPIController, :index) + get("/web/*path", MastoFEController, :index) end end diff --git a/lib/pleroma/web/templates/mastodon_api/mastodon/index.html.eex b/lib/pleroma/web/templates/masto_fe/index.html.eex similarity index 91% rename from lib/pleroma/web/templates/mastodon_api/mastodon/index.html.eex rename to lib/pleroma/web/templates/masto_fe/index.html.eex index 3325beca1..feff36fae 100644 --- a/lib/pleroma/web/templates/mastodon_api/mastodon/index.html.eex +++ b/lib/pleroma/web/templates/masto_fe/index.html.eex @@ -14,7 +14,7 @@ - + diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex new file mode 100644 index 000000000..21b086d4c --- /dev/null +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastoFEView do + use Pleroma.Web, :view + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.CustomEmojiView + + @default_settings %{ + onboarded: true, + home: %{ + shows: %{ + reblog: true, + reply: true + } + }, + notifications: %{ + alerts: %{ + follow: true, + favourite: true, + reblog: true, + mention: true + }, + shows: %{ + follow: true, + favourite: true, + reblog: true, + mention: true + }, + sounds: %{ + follow: true, + favourite: true, + reblog: true, + mention: true + } + } + } + + def initial_state(token, user, custom_emojis) do + limit = Config.get([:instance, :limit]) + + %{ + meta: %{ + streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(), + access_token: token, + locale: "en", + domain: Pleroma.Web.Endpoint.host(), + admin: "1", + me: "#{user.id}", + unfollow_modal: false, + boost_modal: false, + delete_modal: true, + auto_play_gif: false, + display_sensitive_media: false, + reduce_motion: false, + max_toot_chars: limit, + mascot: User.get_mascot(user)["url"] + }, + poll_limits: Config.get([:instance, :poll_limits]), + rights: %{ + delete_others_notice: present?(user.info.is_moderator), + admin: present?(user.info.is_admin) + }, + compose: %{ + me: "#{user.id}", + default_privacy: user.info.default_scope, + default_sensitive: false, + allow_content_types: Config.get([:instance, :allowed_post_formats]) + }, + media_attachments: %{ + accept_content_types: [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webm", + ".mp4", + ".m4v", + "image\/jpeg", + "image\/png", + "image\/gif", + "video\/webm", + "video\/mp4" + ] + }, + settings: user.info.settings || @default_settings, + push_subscription: nil, + accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)}, + custom_emojis: render(CustomEmojiView, "index.json", custom_emojis: custom_emojis), + char_limit: limit + } + |> Jason.encode!() + |> Phoenix.HTML.raw() + end + + defp present?(nil), do: false + defp present?(false), do: false + defp present?(_), do: true +end diff --git a/test/web/masto_fe_controller_test.exs b/test/web/masto_fe_controller_test.exs new file mode 100644 index 000000000..04f144049 --- /dev/null +++ b/test/web/masto_fe_controller_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.Web.MastodonAPI.MastoFEController do + use Pleroma.Web.ConnCase + + alias Pleroma.User + alias Pleroma.Config + import Pleroma.Factory + + clear_config([:instance, :public]) + + test "put settings", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> put("/api/web/settings", %{"data" => %{"programming" => "socks"}}) + + assert _result = json_response(conn, 200) + + user = User.get_cached_by_ap_id(user.ap_id) + assert user.info.settings == %{"programming" => "socks"} + end + + describe "index/2 redirections" do + setup %{conn: conn} do + session_opts = [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + conn = + conn + |> Plug.Session.call(Plug.Session.init(session_opts)) + |> fetch_session() + + test_path = "/web/statuses/test" + %{conn: conn, path: test_path} + end + + test "redirects not logged-in users to the login page", %{conn: conn, path: path} do + conn = get(conn, path) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/login" + end + + test "redirects not logged-in users to the login page on private instances", %{ + conn: conn, + path: path + } do + Config.put([:instance, :public], false) + + conn = get(conn, path) + + assert conn.status == 302 + assert redirected_to(conn) == "/web/login" + end + + test "does not redirect logged in users to the login page", %{conn: conn, path: path} do + token = insert(:oauth_token) + + conn = + conn + |> assign(:user, token.user) + |> put_session(:oauth_token, token.token) + |> get(path) + + assert conn.status == 200 + end + + test "saves referer path to session", %{conn: conn, path: path} do + conn = get(conn, path) + return_to = Plug.Conn.get_session(conn, :return_to) + + assert return_to == path + 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 e8fd4827c..c03003dac 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User @@ -19,7 +18,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do :ok end - clear_config([:instance, :public]) clear_config([:rich_media, :enabled]) test "getting a list of mutes", %{conn: conn} do @@ -113,20 +111,6 @@ test "returns the favorites of a user", %{conn: conn} do assert [] = json_response(third_conn, 200) end - test "put settings", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> put("/api/web/settings", %{"data" => %{"programming" => "socks"}}) - - assert _result = json_response(conn, 200) - - user = User.get_cached_by_ap_id(user.ap_id) - assert user.info.settings == %{"programming" => "socks"} - end - describe "link headers" do test "preserves parameters in link headers", %{conn: conn} do user = insert(:user) @@ -159,62 +143,6 @@ test "preserves parameters in link headers", %{conn: conn} do end end - describe "index/2 redirections" do - setup %{conn: conn} do - session_opts = [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] - - conn = - conn - |> Plug.Session.call(Plug.Session.init(session_opts)) - |> fetch_session() - - test_path = "/web/statuses/test" - %{conn: conn, path: test_path} - end - - test "redirects not logged-in users to the login page", %{conn: conn, path: path} do - conn = get(conn, path) - - assert conn.status == 302 - assert redirected_to(conn) == "/web/login" - end - - test "redirects not logged-in users to the login page on private instances", %{ - conn: conn, - path: path - } do - Config.put([:instance, :public], false) - - conn = get(conn, path) - - assert conn.status == 302 - assert redirected_to(conn) == "/web/login" - end - - test "does not redirect logged in users to the login page", %{conn: conn, path: path} do - token = insert(:oauth_token) - - conn = - conn - |> assign(:user, token.user) - |> put_session(:oauth_token, token.token) - |> get(path) - - assert conn.status == 200 - end - - test "saves referer path to session", %{conn: conn, path: path} do - conn = get(conn, path) - return_to = Plug.Conn.get_session(conn, :return_to) - - assert return_to == path - end - end - describe "empty_array, stubs for mastodon api" do test "GET /api/v1/accounts/:id/identity_proofs", %{conn: conn} do user = insert(:user) From 9b68aab8b3cf576f91566cd659e4e5719dccb15a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 2 Oct 2019 21:12:01 +0700 Subject: [PATCH 106/138] Fix credo warning --- test/web/masto_fe_controller_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/web/masto_fe_controller_test.exs b/test/web/masto_fe_controller_test.exs index 04f144049..ab9dab352 100644 --- a/test/web/masto_fe_controller_test.exs +++ b/test/web/masto_fe_controller_test.exs @@ -5,8 +5,9 @@ defmodule Pleroma.Web.MastodonAPI.MastoFEController do use Pleroma.Web.ConnCase - alias Pleroma.User alias Pleroma.Config + alias Pleroma.User + import Pleroma.Factory clear_config([:instance, :public]) From acc62f327d45c0a9a0414da56bc339ec3e22cb63 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 2 Oct 2019 23:28:45 +0300 Subject: [PATCH 107/138] Rename some directories because MkDocs uses them for categories --- docs/{api => API}/admin_api.md | 0 docs/{api => API}/differences_in_mastoapi_responses.md | 0 docs/{api => API}/pleroma_api.md | 0 docs/{api => API}/prometheus.md | 0 docs/{admin => administration}/backup.md | 0 docs/{admin => administration}/updating.md | 0 .../General-tips-for-customizing-Pleroma-FE.md | 0 docs/{ => configuration}/config.md | 0 docs/{config => configuration}/custom_emoji.md | 0 docs/{config => configuration}/hardening.md | 0 docs/{config => configuration}/howto_mediaproxy.md | 0 docs/{config => configuration}/howto_mongooseim.md | 0 docs/{config => configuration}/howto_proxy.md | 0 .../howto_set_richmedia_cache_ttl_based_on_image.md | 0 docs/{config => configuration}/howto_user_recomendation.md | 0 docs/{config => configuration}/i2p.md | 0 docs/{config => configuration}/mrf.md | 0 docs/{config => configuration}/onion_federation.md | 0 docs/{config => configuration}/small_customizations.md | 0 docs/{config => configuration}/static_dir.md | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename docs/{api => API}/admin_api.md (100%) rename docs/{api => API}/differences_in_mastoapi_responses.md (100%) rename docs/{api => API}/pleroma_api.md (100%) rename docs/{api => API}/prometheus.md (100%) rename docs/{admin => administration}/backup.md (100%) rename docs/{admin => administration}/updating.md (100%) rename docs/{config => configuration}/General-tips-for-customizing-Pleroma-FE.md (100%) rename docs/{ => configuration}/config.md (100%) rename docs/{config => configuration}/custom_emoji.md (100%) rename docs/{config => configuration}/hardening.md (100%) rename docs/{config => configuration}/howto_mediaproxy.md (100%) rename docs/{config => configuration}/howto_mongooseim.md (100%) rename docs/{config => configuration}/howto_proxy.md (100%) rename docs/{config => configuration}/howto_set_richmedia_cache_ttl_based_on_image.md (100%) rename docs/{config => configuration}/howto_user_recomendation.md (100%) rename docs/{config => configuration}/i2p.md (100%) rename docs/{config => configuration}/mrf.md (100%) rename docs/{config => configuration}/onion_federation.md (100%) rename docs/{config => configuration}/small_customizations.md (100%) rename docs/{config => configuration}/static_dir.md (100%) diff --git a/docs/api/admin_api.md b/docs/API/admin_api.md similarity index 100% rename from docs/api/admin_api.md rename to docs/API/admin_api.md diff --git a/docs/api/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md similarity index 100% rename from docs/api/differences_in_mastoapi_responses.md rename to docs/API/differences_in_mastoapi_responses.md diff --git a/docs/api/pleroma_api.md b/docs/API/pleroma_api.md similarity index 100% rename from docs/api/pleroma_api.md rename to docs/API/pleroma_api.md diff --git a/docs/api/prometheus.md b/docs/API/prometheus.md similarity index 100% rename from docs/api/prometheus.md rename to docs/API/prometheus.md diff --git a/docs/admin/backup.md b/docs/administration/backup.md similarity index 100% rename from docs/admin/backup.md rename to docs/administration/backup.md diff --git a/docs/admin/updating.md b/docs/administration/updating.md similarity index 100% rename from docs/admin/updating.md rename to docs/administration/updating.md diff --git a/docs/config/General-tips-for-customizing-Pleroma-FE.md b/docs/configuration/General-tips-for-customizing-Pleroma-FE.md similarity index 100% rename from docs/config/General-tips-for-customizing-Pleroma-FE.md rename to docs/configuration/General-tips-for-customizing-Pleroma-FE.md diff --git a/docs/config.md b/docs/configuration/config.md similarity index 100% rename from docs/config.md rename to docs/configuration/config.md diff --git a/docs/config/custom_emoji.md b/docs/configuration/custom_emoji.md similarity index 100% rename from docs/config/custom_emoji.md rename to docs/configuration/custom_emoji.md diff --git a/docs/config/hardening.md b/docs/configuration/hardening.md similarity index 100% rename from docs/config/hardening.md rename to docs/configuration/hardening.md diff --git a/docs/config/howto_mediaproxy.md b/docs/configuration/howto_mediaproxy.md similarity index 100% rename from docs/config/howto_mediaproxy.md rename to docs/configuration/howto_mediaproxy.md diff --git a/docs/config/howto_mongooseim.md b/docs/configuration/howto_mongooseim.md similarity index 100% rename from docs/config/howto_mongooseim.md rename to docs/configuration/howto_mongooseim.md diff --git a/docs/config/howto_proxy.md b/docs/configuration/howto_proxy.md similarity index 100% rename from docs/config/howto_proxy.md rename to docs/configuration/howto_proxy.md diff --git a/docs/config/howto_set_richmedia_cache_ttl_based_on_image.md b/docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md similarity index 100% rename from docs/config/howto_set_richmedia_cache_ttl_based_on_image.md rename to docs/configuration/howto_set_richmedia_cache_ttl_based_on_image.md diff --git a/docs/config/howto_user_recomendation.md b/docs/configuration/howto_user_recomendation.md similarity index 100% rename from docs/config/howto_user_recomendation.md rename to docs/configuration/howto_user_recomendation.md diff --git a/docs/config/i2p.md b/docs/configuration/i2p.md similarity index 100% rename from docs/config/i2p.md rename to docs/configuration/i2p.md diff --git a/docs/config/mrf.md b/docs/configuration/mrf.md similarity index 100% rename from docs/config/mrf.md rename to docs/configuration/mrf.md diff --git a/docs/config/onion_federation.md b/docs/configuration/onion_federation.md similarity index 100% rename from docs/config/onion_federation.md rename to docs/configuration/onion_federation.md diff --git a/docs/config/small_customizations.md b/docs/configuration/small_customizations.md similarity index 100% rename from docs/config/small_customizations.md rename to docs/configuration/small_customizations.md diff --git a/docs/config/static_dir.md b/docs/configuration/static_dir.md similarity index 100% rename from docs/config/static_dir.md rename to docs/configuration/static_dir.md From 03e1898917d161c2682ded202d335de582c04989 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 2 Oct 2019 23:54:55 +0300 Subject: [PATCH 108/138] Rename "Configuration" to "Configuration Cheat Sheet" and explain a bit about how Pleroma Configuration works --- docs/configuration/{config.md => cheatsheet.md} | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) rename docs/configuration/{config.md => cheatsheet.md} (97%) diff --git a/docs/configuration/config.md b/docs/configuration/cheatsheet.md similarity index 97% rename from docs/configuration/config.md rename to docs/configuration/cheatsheet.md index 262d15bba..fd936aed7 100644 --- a/docs/configuration/config.md +++ b/docs/configuration/cheatsheet.md @@ -1,7 +1,11 @@ -# Configuration +# Configuration Cheat Sheet + +This is a cheat sheet for Pleroma configuration file, any setting possible to configure should be listed here. + +Pleroma configuration works by first importing the base config (`config/config.exs` on source installs, compiled-in on OTP releases), then overriding it by the environment config (`config/$MIX_ENV.exs` on source installs, N/A to OTP releases) and then overriding it by user config (`config/$MIX_ENV.secret.exs` on source installs, typically `/etc/pleroma/config.exs` on OTP releases). + +You shouldn't edit the base config directly to avoid breakages and merge conflicts, but it can be used as a reference if you don't understand how an option is supposed to be formatted, the latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). -This file describe the configuration, it is recommended to edit the relevant *.secret.exs file instead of the others founds in the ``config`` directory. -If you run Pleroma with ``MIX_ENV=prod`` the file is ``prod.secret.exs``, otherwise it is ``dev.secret.exs``. ## Pleroma.Upload * `uploader`: Select which `Pleroma.Uploaders` to use From 838ff12ec5d930bbd0caa472ea602ce665370bbc Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 2 Oct 2019 23:58:57 +0300 Subject: [PATCH 109/138] Remove "General tips for customizing Pleroma FE" because it's no longer relevant and we have actual fe docs now --- .../General-tips-for-customizing-Pleroma-FE.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 docs/configuration/General-tips-for-customizing-Pleroma-FE.md diff --git a/docs/configuration/General-tips-for-customizing-Pleroma-FE.md b/docs/configuration/General-tips-for-customizing-Pleroma-FE.md deleted file mode 100644 index 15c4882dd..000000000 --- a/docs/configuration/General-tips-for-customizing-Pleroma-FE.md +++ /dev/null @@ -1,17 +0,0 @@ -# General tips for customizing Pleroma FE -There are some configuration scripts for Pleroma BE and FE: - -1. `config/prod.secret.exs` -1. `config/config.exs` -1. `priv/static/static/config.json` - -The `prod.secret.exs` affects first. `config.exs` is for fallback or default. `config.json` is for GNU-social-BE-Pleroma-FE instances. - -Usually all you have to do is: - -1. Copy the section in the `config/config.exs` which you want to activate. -1. Paste into `config/prod.secret.exs`. -1. Edit `config/prod.secret.exs`. -1. Restart the Pleroma daemon. - -`prod.secret.exs` is for the `MIX_ENV=prod` environment. `dev.secret.exs` is for the `MIX_ENV=dev` environment respectively. From 74d682a09ecaacb9f784e27cf0e815ddf81511f6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 00:00:53 +0300 Subject: [PATCH 110/138] Remove Small customizations as it's contents have been integrated into static_dir.md --- docs/configuration/small_customizations.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 docs/configuration/small_customizations.md diff --git a/docs/configuration/small_customizations.md b/docs/configuration/small_customizations.md deleted file mode 100644 index f91657a4c..000000000 --- a/docs/configuration/small_customizations.md +++ /dev/null @@ -1,12 +0,0 @@ -# Small customizations - -See also static_dir.md for visual settings. - -## Theme - -All users of your instance will be able to change the theme they use by going to the settings (the cog in the top-right hand corner). However, if you wish to change the default theme, you can do so by editing `theme` in `config/dev.secret.exs` accordingly. - -## Message Visibility - -To enable message visibility options when posting like in the Mastodon frontend, set -`scope_options_enabled` to `true` in `config/dev.secret.exs`. From b8e5e46fa8c15d0bf3f98c5704e994ffe82be35c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 00:18:32 +0300 Subject: [PATCH 111/138] Fix references to other pages --- docs/installation/alpine_linux_en.md | 10 ++++------ docs/installation/arch_linux_en.md | 10 ++++------ docs/installation/centos7_en.md | 10 ++++------ docs/installation/debian_based_en.md | 10 ++++------ docs/installation/debian_based_jp.md | 10 ++++------ docs/installation/gentoo_en.md | 10 ++++------ docs/installation/migrating_from_source_otp_en.md | 10 +++++----- docs/installation/otp_en.md | 10 +++++----- 8 files changed, 34 insertions(+), 46 deletions(-) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index f200362ca..f5d1fade1 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -225,12 +225,10 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new Date: Thu, 3 Oct 2019 00:22:14 +0300 Subject: [PATCH 112/138] Fix more links --- docs/API/pleroma_api.md | 2 +- docs/installation/otp_en.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 41889a0ef..3a8ef4e2c 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -124,7 +124,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi ``` ## `/api/pleroma/admin/`… -See [Admin-API](Admin-API.md) +See [Admin-API](admin_api.md) ## `/api/v1/pleroma/notifications/read` ### Mark notifications as read diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index f192c0cb1..b4e5254cd 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -42,7 +42,7 @@ apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot ## Setup ### Configuring PostgreSQL #### (Optional) Installing RUM indexes -RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. You can read more about them on the [Configuration page](config.html#rum-indexing-for-full-text-search). They are completely optional and most of the time are not worth it, especially if you are running a single user instance (unless you absolutely need ordered search results). +RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. You can read more about them on the [Configuration page](../configuration/cheatsheet.md#rum-indexing-for-full-text-search). They are completely optional and most of the time are not worth it, especially if you are running a single user instance (unless you absolutely need ordered search results). Debian/Ubuntu (available only on Buster/19.04): ```sh From 2767c413fb385580acc010dafa9282e4dcaecb60 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 00:36:18 +0300 Subject: [PATCH 113/138] Remove a reference to inline docs since everything it describes is described in the cheatsheet already and add A TODO --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index fd936aed7..35274c61b 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -743,7 +743,7 @@ A keyword list of rate limiters where a key is a limiter name and value is the l It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. -See [`Pleroma.Plugs.RateLimiter`](Pleroma.Plugs.RateLimiter.html) documentation for examples. +TODO: Add a list of available limiters Supported rate limiters: From bd9c7807fbf21402cc0444c711c40677ca5de2a0 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 01:03:47 +0300 Subject: [PATCH 114/138] Move emoji task docs to a separate file --- docs/administration/CLI_tasks/emoji.md | 33 ++++++++++++++++++ lib/mix/tasks/pleroma/emoji.ex | 48 -------------------------- 2 files changed, 33 insertions(+), 48 deletions(-) create mode 100644 docs/administration/CLI_tasks/emoji.md diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md new file mode 100644 index 000000000..5b8dc11ab --- /dev/null +++ b/docs/administration/CLI_tasks/emoji.md @@ -0,0 +1,33 @@ +# Managing emoji packs + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl emoji` and in case of source installs it's `mix pleroma.emoji`. + +## ls-packs + +```sh +$PREFIX ls-packs [OPTION...] +``` + +Lists the emoji packs and metadata specified in the manifest. + +### Options +- `-m, --manifest PATH/URL` - path to a custom manifest, it can either be an URL starting with `http`, in that case the manifest will be fetched from that address, or a local path + +## get-packs +```sh +$PREFIX get-packs [OPTION...] PACKS +``` +Fetches, verifies and installs the specified PACKS from the manifest into the `STATIC-DIR/emoji/PACK-NAME` + +### Options +- `-m, --manifest PATH/URL` - same as [`ls-packs`](#ls-packs) + +## gen-pack +```sh +$PREFIX gen-pack PACK-URL +``` +Creates a new manifest entry and a file list from the specified remote pack file. Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. + + The manifest entry will either be written to a newly created `index.json` file or appended to the existing one, *replacing* the old pack with the same name if it was in the file previously. + + The file list will be written to the file specified previously, *replacing* that file. You _should_ check that the file list doesn't contain anything you don't need in the pack, that is, anything that is not an emoji (the whole pack is downloaded, but only emoji files are extracted). diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 881a6f725..32b92e6af 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -6,54 +6,6 @@ defmodule Mix.Tasks.Pleroma.Emoji do use Mix.Task @shortdoc "Manages emoji packs" - @moduledoc """ - Manages emoji packs - - ## ls-packs - - mix pleroma.emoji ls-packs [OPTION...] - - Lists the emoji packs and metadata specified in the manifest. - - ### Options - - - `-m, --manifest PATH/URL` - path to a custom manifest, it can - either be an URL starting with `http`, in that case the - manifest will be fetched from that address, or a local path - - ## get-packs - - mix pleroma.emoji get-packs [OPTION...] PACKS - - Fetches, verifies and installs the specified PACKS from the - manifest into the `STATIC-DIR/emoji/PACK-NAME` - - ### Options - - - `-m, --manifest PATH/URL` - same as ls-packs - - ## gen-pack - - mix pleroma.emoji gen-pack PACK-URL - - Creates a new manifest entry and a file list from the specified - remote pack file. Currently, only .zip archives are recognized - as remote pack files and packs are therefore assumed to be zip - archives. This command is intended to run interactively and will - first ask you some basic questions about the pack, then download - the remote file and generate an SHA256 checksum for it, then - generate an emoji file list for you. - - The manifest entry will either be written to a newly created - `index.json` file or appended to the existing one, *replacing* - the old pack with the same name if it was in the file previously. - - The file list will be written to the file specified previously, - *replacing* that file. You _should_ check that the file list doesn't - contain anything you don't need in the pack, that is, anything that is - not an emoji (the whole pack is downloaded, but only emoji files - are extracted). - """ def run(["ls-packs" | args]) do Application.ensure_all_started(:hackney) From 869ea2ab90bb461ad3dd06ac974f227da369fcf8 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 01:09:51 +0300 Subject: [PATCH 115/138] Move digest email docs to a separate file and improve styling --- docs/administration/CLI_tasks/digest.md | 15 +++++++++++++++ docs/administration/CLI_tasks/emoji.md | 6 +++--- lib/mix/tasks/pleroma/digest.ex | 9 --------- 3 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 docs/administration/CLI_tasks/digest.md diff --git a/docs/administration/CLI_tasks/digest.md b/docs/administration/CLI_tasks/digest.md new file mode 100644 index 000000000..89b3ed237 --- /dev/null +++ b/docs/administration/CLI_tasks/digest.md @@ -0,0 +1,15 @@ +# Managing digest emails +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl digest` and in case of source installs it's `mix pleroma.digest`. + +## `test` + +```sh +$PREFIX test +``` + +Send digest email since given date (user registration date by default) ignoring user activity status. + +Example: +```sh +$PREFIX test donaldtheduck 2019-05-20 +``` diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index 5b8dc11ab..39216a897 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -2,7 +2,7 @@ Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl emoji` and in case of source installs it's `mix pleroma.emoji`. -## ls-packs +## `ls-packs` ```sh $PREFIX ls-packs [OPTION...] @@ -13,7 +13,7 @@ Lists the emoji packs and metadata specified in the manifest. ### Options - `-m, --manifest PATH/URL` - path to a custom manifest, it can either be an URL starting with `http`, in that case the manifest will be fetched from that address, or a local path -## get-packs +## `get-packs` ```sh $PREFIX get-packs [OPTION...] PACKS ``` @@ -22,7 +22,7 @@ Fetches, verifies and installs the specified PACKS from the manifest into the `S ### Options - `-m, --manifest PATH/URL` - same as [`ls-packs`](#ls-packs) -## gen-pack +## `gen-pack` ```sh $PREFIX gen-pack PACK-URL ``` diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 430116a50..100a81060 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -2,16 +2,7 @@ defmodule Mix.Tasks.Pleroma.Digest do use Mix.Task @shortdoc "Manages digest emails" - @moduledoc """ - Manages digest emails - ## Send digest email since given date (user registration date by default) - ignoring user activity status. - - ``mix pleroma.digest test `` - - Example: ``mix pleroma.digest test donaldtheduck 2019-05-20`` - """ def run(["test", nickname | opts]) do Mix.Pleroma.start_pleroma() From a54739a530a291483893b0c334d35fb893026a2a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 08:51:57 +0300 Subject: [PATCH 116/138] Improve styling of CLI tasks --- docs/administration/CLI_tasks/digest.md | 6 ++---- docs/administration/CLI_tasks/emoji.md | 15 ++++++--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/administration/CLI_tasks/digest.md b/docs/administration/CLI_tasks/digest.md index 89b3ed237..547702031 100644 --- a/docs/administration/CLI_tasks/digest.md +++ b/docs/administration/CLI_tasks/digest.md @@ -1,14 +1,12 @@ # Managing digest emails Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl digest` and in case of source installs it's `mix pleroma.digest`. -## `test` +## Send digest email since given date (user registration date by default) ignoring user activity status. ```sh -$PREFIX test +$PREFIX test [] ``` -Send digest email since given date (user registration date by default) ignoring user activity status. - Example: ```sh $PREFIX test donaldtheduck 2019-05-20 diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index 39216a897..d274953eb 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -2,31 +2,28 @@ Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl emoji` and in case of source installs it's `mix pleroma.emoji`. -## `ls-packs` +## Lists the emoji packs and metadata specified in the manifest. ```sh -$PREFIX ls-packs [OPTION...] +$PREFIX ls-packs [] ``` -Lists the emoji packs and metadata specified in the manifest. - ### Options - `-m, --manifest PATH/URL` - path to a custom manifest, it can either be an URL starting with `http`, in that case the manifest will be fetched from that address, or a local path -## `get-packs` +## Fetch, verify and install the specified packs from the manifest into `STATIC-DIR/emoji/PACK-NAME` ```sh -$PREFIX get-packs [OPTION...] PACKS +$PREFIX get-packs [] ``` -Fetches, verifies and installs the specified PACKS from the manifest into the `STATIC-DIR/emoji/PACK-NAME` ### Options - `-m, --manifest PATH/URL` - same as [`ls-packs`](#ls-packs) -## `gen-pack` +## Create a new manifest entry and a file list from the specified remote pack file ```sh $PREFIX gen-pack PACK-URL ``` -Creates a new manifest entry and a file list from the specified remote pack file. Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. +Currently, only .zip archives are recognized as remote pack files and packs are therefore assumed to be zip archives. This command is intended to run interactively and will first ask you some basic questions about the pack, then download the remote file and generate an SHA256 checksum for it, then generate an emoji file list for you. The manifest entry will either be written to a newly created `index.json` file or appended to the existing one, *replacing* the old pack with the same name if it was in the file previously. From 6435ba83cd07162a9ad9a386253814e2f12d951d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 09:02:48 +0300 Subject: [PATCH 117/138] Move instance CLI task docs to a text file --- docs/administration/CLI_tasks/emoji.md | 2 +- docs/administration/CLI_tasks/instance.md | 30 +++++++++++++++++++++++ lib/mix/tasks/pleroma/instance.ex | 30 ----------------------- 3 files changed, 31 insertions(+), 31 deletions(-) create mode 100644 docs/administration/CLI_tasks/instance.md diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index d274953eb..eee02f2ef 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -2,7 +2,7 @@ Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl emoji` and in case of source installs it's `mix pleroma.emoji`. -## Lists the emoji packs and metadata specified in the manifest. +## Lists emoji packs and metadata specified in the manifest ```sh $PREFIX ls-packs [] diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md new file mode 100644 index 000000000..975ee61d9 --- /dev/null +++ b/docs/administration/CLI_tasks/instance.md @@ -0,0 +1,30 @@ +# Managing instance configuration + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl instance` and in case of source installs it's `mix pleroma.instance`. + +## Generate a new configuration file +```sh +$PREFIX gen [] +``` + +If any of the options are left unspecified, you will be prompted interactively. + +## Options +- `-f`, `--force` - overwrite any output files +- `-o `, `--output ` - the output file for the generated configuration +- `--output-psql ` - the output file for the generated PostgreSQL setup +- `--domain ` - the domain of your instance +- `--instance-name ` - the name of your instance +- `--admin-email ` - the email address of the instance admin +- `--notify-email ` - email address for notifications +- `--dbhost ` - the hostname of the PostgreSQL database to use +- `--dbname ` - the name of the database to use +- `--dbuser ` - the user (aka role) to use for the database connection +- `--dbpass ` - the password to use for the database connection +- `--rum ` - Whether to enable RUM indexes +- `--indexable ` - Allow/disallow indexing site by search engines +- `--db-configurable ` - Allow/disallow configuring instance from admin part +- `--uploads-dir ` - the directory uploads go in when using a local uploader +- `--static-dir ` - the directory custom public files should be read from (custom emojis, frontend bundle overrides, robots.txt, etc.) +- `--listen-ip ` - the ip the app should listen to, defaults to 127.0.0.1 +- `--listen-port ` - the port the app should listen to, defaults to 4000 diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 1a1634fe9..25f94eceb 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -7,36 +7,6 @@ defmodule Mix.Tasks.Pleroma.Instance do import Mix.Pleroma @shortdoc "Manages Pleroma instance" - @moduledoc """ - Manages Pleroma instance. - - ## Generate a new instance config. - - mix pleroma.instance gen [OPTION...] - - If any options are left unspecified, you will be prompted interactively - - ## Options - - - `-f`, `--force` - overwrite any output files - - `-o PATH`, `--output PATH` - the output file for the generated configuration - - `--output-psql PATH` - the output file for the generated PostgreSQL setup - - `--domain DOMAIN` - the domain of your instance - - `--instance-name INSTANCE_NAME` - the name of your instance - - `--admin-email ADMIN_EMAIL` - the email address of the instance admin - - `--notify-email NOTIFY_EMAIL` - email address for notifications - - `--dbhost HOSTNAME` - the hostname of the PostgreSQL database to use - - `--dbname DBNAME` - the name of the database to use - - `--dbuser DBUSER` - the user (aka role) to use for the database connection - - `--dbpass DBPASS` - the password to use for the database connection - - `--rum Y/N` - Whether to enable RUM indexes - - `--indexable Y/N` - Allow/disallow indexing site by search engines - - `--db-configurable Y/N` - Allow/disallow configuring instance from admin part - - `--uploads-dir` - the directory uploads go in when using a local uploader - - `--static-dir` - the directory custom public files should be read from (custom emojis, frontend bundle overrides, robots.txt, etc.) - - `--listen-ip` - the ip the app should listen to, defaults to 127.0.0.1 - - `--listen-port` - the port the app should listen to, defaults to 4000 - """ def run(["gen" | rest]) do {options, [], []} = From 808d0a0170577155d0f1097c66c4e0b23c8303b9 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 09:10:31 +0300 Subject: [PATCH 118/138] Move relay docs to a separate file --- docs/administration/CLI_tasks/relay.md | 30 ++++++++++++++++++++++++++ lib/mix/tasks/pleroma/relay.ex | 18 ---------------- 2 files changed, 30 insertions(+), 18 deletions(-) create mode 100644 docs/administration/CLI_tasks/relay.md diff --git a/docs/administration/CLI_tasks/relay.md b/docs/administration/CLI_tasks/relay.md new file mode 100644 index 000000000..aa44617df --- /dev/null +++ b/docs/administration/CLI_tasks/relay.md @@ -0,0 +1,30 @@ +# Managing relays + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl relay` and in case of source installs it's `mix pleroma.relay`. + +## Follow a relay +```sh +$PREFIX follow +``` + +Example: +```sh +$PREFIX follow https://example.org/relay +``` + +## Unfollow a remote relay + +```sh +$PREFIX unfollow +``` + +Example: +```sh +$PREFIX unfollow https://example.org/relay +``` + +## List relay subscriptions + +```sh +$PREFIX list +``` diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index 200721163..519f2d1b5 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -9,25 +9,7 @@ defmodule Mix.Tasks.Pleroma.Relay do alias Pleroma.Web.ActivityPub.Relay @shortdoc "Manages remote relays" - @moduledoc """ - Manages remote relays - ## Follow a remote relay - - ``mix pleroma.relay follow `` - - Example: ``mix pleroma.relay follow https://example.org/relay`` - - ## Unfollow a remote relay - - ``mix pleroma.relay unfollow `` - - Example: ``mix pleroma.relay unfollow https://example.org/relay`` - - ## List relay subscriptions - - ``mix pleroma.relay list`` - """ def run(["follow", target]) do start_pleroma() From d39ccc2e7ffd019f8fe2438f388c0a0bb8aac34a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 09:35:21 +0300 Subject: [PATCH 119/138] Move uploads task docs to a separate file --- docs/administration/CLI_tasks/uploads.md | 12 ++++++++++++ lib/mix/tasks/pleroma/uploads.ex | 9 --------- 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 docs/administration/CLI_tasks/uploads.md diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md new file mode 100644 index 000000000..a72bbd01f --- /dev/null +++ b/docs/administration/CLI_tasks/uploads.md @@ -0,0 +1,12 @@ +# Managing uploads + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl uploads` and in case of source installs it's `mix pleroma.uploads`. + +## Migrate uploads from local to remote storage +```sh +$PREFIX migrate_local TARGET_UPLOADER [OPTIONS...] +``` +## Options +- `--delete` - delete local uploads after migrating them to the target uploader + +A list of available uploaders can be seen in [Configuration Cheat Sheet](../../configuration/cheatsheet.md#pleromaupload) diff --git a/lib/mix/tasks/pleroma/uploads.ex b/lib/mix/tasks/pleroma/uploads.ex index 95392d81b..bc2248a76 100644 --- a/lib/mix/tasks/pleroma/uploads.ex +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -12,16 +12,7 @@ defmodule Mix.Tasks.Pleroma.Uploads do @log_every 50 @shortdoc "Migrates uploads from local to remote storage" - @moduledoc """ - Manages uploads - ## Migrate uploads from local to remote storage - mix pleroma.uploads migrate_local TARGET_UPLOADER [OPTIONS...] - Options: - - `--delete` - delete local uploads after migrating them to the target uploader - - A list of available uploaders can be seen in config.exs - """ def run(["migrate_local", target_uploader | args]) do delete? = Enum.member?(args, "--delete") start_pleroma() From f5372bfb4a65c8324926965fe34c920bc2449bc5 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 09:36:35 +0300 Subject: [PATCH 120/138] Fix up some headings --- docs/administration/CLI_tasks/instance.md | 2 +- docs/administration/CLI_tasks/uploads.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md index 975ee61d9..ab0b68ad0 100644 --- a/docs/administration/CLI_tasks/instance.md +++ b/docs/administration/CLI_tasks/instance.md @@ -9,7 +9,7 @@ $PREFIX gen [] If any of the options are left unspecified, you will be prompted interactively. -## Options +### Options - `-f`, `--force` - overwrite any output files - `-o `, `--output ` - the output file for the generated configuration - `--output-psql ` - the output file for the generated PostgreSQL setup diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md index a72bbd01f..321ec5e74 100644 --- a/docs/administration/CLI_tasks/uploads.md +++ b/docs/administration/CLI_tasks/uploads.md @@ -6,7 +6,7 @@ Every command should be ran with a prefix, in case of OTP releases it is `./bin/ ```sh $PREFIX migrate_local TARGET_UPLOADER [OPTIONS...] ``` -## Options +### Options - `--delete` - delete local uploads after migrating them to the target uploader A list of available uploaders can be seen in [Configuration Cheat Sheet](../../configuration/cheatsheet.md#pleromaupload) From 8fd47a4a5a9704a523046e7b8d2cdac3f090acea Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 09:38:24 +0300 Subject: [PATCH 121/138] Use consistent command signature --- docs/administration/CLI_tasks/uploads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md index 321ec5e74..71800e341 100644 --- a/docs/administration/CLI_tasks/uploads.md +++ b/docs/administration/CLI_tasks/uploads.md @@ -4,7 +4,7 @@ Every command should be ran with a prefix, in case of OTP releases it is `./bin/ ## Migrate uploads from local to remote storage ```sh -$PREFIX migrate_local TARGET_UPLOADER [OPTIONS...] +$PREFIX migrate_local [] ``` ### Options - `--delete` - delete local uploads after migrating them to the target uploader From b4ca864c6b2e6ee9addea7fbc0b09fca581816ce Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:15:24 +0300 Subject: [PATCH 122/138] Move user tasks docs to a separate file --- docs/administration/CLI_tasks/user.md | 94 +++++++++++++++++++++++++++ lib/mix/tasks/pleroma/user.ex | 79 ---------------------- 2 files changed, 94 insertions(+), 79 deletions(-) create mode 100644 docs/administration/CLI_tasks/user.md diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md new file mode 100644 index 000000000..045730753 --- /dev/null +++ b/docs/administration/CLI_tasks/user.md @@ -0,0 +1,94 @@ +# Managing users + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl user` and in case of source installs it's `mix pleroma.user`. + +## Create a user +```sh +$PREFIX new [] +``` + +### Options +- `--name ` - the user's display name +- `--bio ` - the user's bio +- `--password ` - the user's password +- `--moderator`/`--no-moderator` - whether the user should be a moderator +- `--admin`/`--no-admin` - whether the user should be an admin +- `-y`, `--assume-yes`/`--no-assume-yes` - whether to assume yes to all questions + +## Generate an invite link +```sh +$PREFIX invite [] +``` + +### Options +- `--expires-at DATE` - last day on which token is active (e.g. "2019-04-05") +- `--max-use NUMBER` - maximum numbers of token uses + +## List generated invites +```sh +$PREFIX invites +``` + +## Revoke invite +```sh +$PREFIX revoke_invite +``` + +## Delete a user +``` +$PREFIX rm +``` + +## Delete user's posts and interactions +```sh +$PREFIX delete_activities +``` + +## Sign user out from all applications (delete user's OAuth tokens and authorizations) +```sh +$PREFIX sign_out +``` + +## Deactivate or activate a user +```sh +$PREFIX toggle_activated +``` + +## Unsubscribe local users from a user and deactivate the user +```sh +$PREFIX unsubscribe NICKNAME +``` + +## Unsubscribe local users from an instance and deactivate all accounts on it +```sh +$PREFIX unsubscribe_all_from_instance +``` + +## Create a password reset link for user +```sh +$PREFIX reset_password +``` + +## Set the value of the given user's settings +```sh +$PREFIX set [] +``` +### Options +- `--locked`/`--no-locked` - whether the user should be locked +- `--moderator`/`--no-moderator` - whether the user should be a moderator +- `--admin`/`--no-admin` - whether the user should be an admin + +## Add tags to a user +```sh +$PREFIX tag +``` + +## Delete tags from a user +```sh +$PREFIX untag +``` + +## Toggle confirmation status of the user +```sh +$PREFIX toggle_confirmed +``` diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index d93ba8dee..3cf3ad2c6 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -10,86 +10,7 @@ defmodule Mix.Tasks.Pleroma.User do alias Pleroma.Web.OAuth @shortdoc "Manages Pleroma users" - @moduledoc """ - Manages Pleroma users. - ## Create a new user. - - mix pleroma.user new NICKNAME EMAIL [OPTION...] - - Options: - - `--name NAME` - the user's name (i.e., "Lain Iwakura") - - `--bio BIO` - the user's bio - - `--password PASSWORD` - the user's password - - `--moderator`/`--no-moderator` - whether the user is a moderator - - `--admin`/`--no-admin` - whether the user is an admin - - `-y`, `--assume-yes`/`--no-assume-yes` - whether to assume yes to all questions - - ## Generate an invite link. - - mix pleroma.user invite [OPTION...] - - Options: - - `--expires-at DATE` - last day on which token is active (e.g. "2019-04-05") - - `--max-use NUMBER` - maximum numbers of token uses - - ## List generated invites - - mix pleroma.user invites - - ## Revoke invite - - mix pleroma.user revoke_invite TOKEN OR TOKEN_ID - - ## Delete the user's account. - - mix pleroma.user rm NICKNAME - - ## Delete the user's activities. - - mix pleroma.user delete_activities NICKNAME - - ## Sign user out from all applications (delete user's OAuth tokens and authorizations). - - mix pleroma.user sign_out NICKNAME - - ## Deactivate or activate the user's account. - - mix pleroma.user toggle_activated NICKNAME - - ## Unsubscribe local users from user's account and deactivate it - - mix pleroma.user unsubscribe NICKNAME - - ## Unsubscribe local users from an entire instance and deactivate all accounts - - mix pleroma.user unsubscribe_all_from_instance INSTANCE - - ## Create a password reset link. - - mix pleroma.user reset_password NICKNAME - - ## Set the value of the given user's settings. - - mix pleroma.user set NICKNAME [OPTION...] - - Options: - - `--locked`/`--no-locked` - whether the user's account is locked - - `--moderator`/`--no-moderator` - whether the user is a moderator - - `--admin`/`--no-admin` - whether the user is an admin - - ## Add tags to a user. - - mix pleroma.user tag NICKNAME TAGS - - ## Delete tags from a user. - - mix pleroma.user untag NICKNAME TAGS - - ## Toggle confirmation of the user's account. - - mix pleroma.user toggle_confirmed NICKNAME - """ def run(["new", nickname, email | rest]) do {options, [], []} = OptionParser.parse( From 2cbe2dcbde9346fd354de816ea660b3ab085d876 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:31:59 +0300 Subject: [PATCH 123/138] Oops --- docs/configuration/cheatsheet.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 35274c61b..35832e606 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -743,8 +743,6 @@ A keyword list of rate limiters where a key is a limiter name and value is the l It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. -TODO: Add a list of available limiters - Supported rate limiters: * `:search` for the search requests (account & status search etc.) From 66450f861597ac5c5a349f005b7cc061e4e34ded Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:36:01 +0300 Subject: [PATCH 124/138] Cheatsheet: Move the deprecated config warning into a warning block --- docs/configuration/cheatsheet.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 35832e606..f1d41b0c6 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -212,14 +212,15 @@ These settings **need to be complete**, they will override the defaults. NOTE: for versions < 1.0, you need to set [`:fe`](#fe) to false, as shown a few lines below. ## :fe -__THIS IS DEPRECATED__ +!!! warning + __THIS IS DEPRECATED__ -If you are using this method, please change it to the [`frontend_configurations`](#frontend_configurations) method. -Please **set this option to false** in your config like this: + If you are using this method, please change it to the [`frontend_configurations`](#frontend_configurations) method. + Please **set this option to false** in your config like this: -```elixir -config :pleroma, :fe, false -``` + ```elixir + config :pleroma, :fe, false + ``` This section is used to configure Pleroma-FE, unless ``:managed_config`` in ``:instance`` is set to false. From cb162678df877e3f9b299e10516d0ebd29355b80 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:37:34 +0300 Subject: [PATCH 125/138] Add missing language spec --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f1d41b0c6..82367ae0b 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -266,7 +266,7 @@ All criteria are configured as a map of regular expressions to lists of policy m Example: -``` +```elixir config :pleroma, :mrf_subchain, match_actor: %{ ~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy] From 4e70009490365f0439043aa59f9e6cd05f6da723 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:39:53 +0300 Subject: [PATCH 126/138] Move bold text in RemoveIp description into a proper warning --- docs/configuration/cheatsheet.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 82367ae0b..57325dd56 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -764,7 +764,8 @@ Available caches: ## Pleroma.Plugs.RemoteIp -**If your instance is not behind at least one reverse proxy, you should not enable this plug.** +!!! warning + 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. From 25849157aae60e1bac776d395cadb6d15424eb1d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:43:38 +0300 Subject: [PATCH 127/138] Remove fe settings from :instance as they no longer do anything --- docs/configuration/cheatsheet.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 57325dd56..e23bcaf63 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -115,12 +115,6 @@ config :pleroma, Pleroma.Emails.Mailer, * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML) * `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. -* `scope_copy`: Copy the scope (private/unlisted/public) in replies to posts by default. -* `subject_line_behavior`: Allows changing the default behaviour of subject lines in replies. Valid values: - * "email": Copy and preprend re:, as in email. - * "masto": Copy verbatim, as in Mastodon. - * "noop": Don't copy the subject. -* `always_show_subject_input`: When set to false, auto-hide the subject field when it's empty. * `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. From e560d65db3c6e2692a8060b0646d6e8808b864f0 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:45:40 +0300 Subject: [PATCH 128/138] Fix a typo in activity expirations --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index e23bcaf63..e5f68f09b 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -551,7 +551,7 @@ The above example defines a single job which invokes `Pleroma.Web.Websub.refresh ## Pleroma.ActivityExpiration -# `enabled`: whether expired activities will be sent to the job queue to be deleted +* `enabled`: whether expired activities will be sent to the job queue to be deleted ## Pleroma.Web.Auth.Authenticator From aefb4dcff5721aaa20ebb52d4f7da4874cb1b612 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:54:41 +0300 Subject: [PATCH 129/138] Cheatsheet: Use note/warning blocks instead of bold text --- docs/configuration/cheatsheet.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index e5f68f09b..70a475363 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -15,7 +15,8 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. -Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. +!!! warning + `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. ## Pleroma.Uploaders.Local * `uploads`: Which directory to store the user-uploads in, relative to pleroma’s working directory @@ -300,7 +301,10 @@ config :pleroma, :mrf_subchain, * `dstport`: Port advertised in urls (optional, defaults to `port`) ## Pleroma.Web.Endpoint -`Phoenix` endpoint configuration, all configuration options can be viewed [here](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-dynamic-configuration), only common options are listed here + +!!! note + `Phoenix` endpoint configuration, all configuration options can be viewed [here](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-dynamic-configuration), only common options are listed here. + * `http` - a list containing http protocol configuration, all configuration options can be viewed [here](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html#module-options), only common options are listed here. For deployment using docker, you need to set this to `[ip: {0,0,0,0}, port: 4000]` to make pleroma accessible from other containers (such as your nginx server). - `ip` - a tuple consisting of 4 integers - `port` @@ -313,7 +317,8 @@ config :pleroma, :mrf_subchain, -**Important note**: if you modify anything inside these lists, default `config.exs` values will be overwritten, which may result in breakage, to make sure this does not happen please copy the default value for the list from `config.exs` and modify/add only what you need +!!! warning + If you modify anything inside these lists, default `config.exs` values will be overwritten, which may result in breakage, to make sure this does not happen please copy the default value for the list from `config.exs` and modify/add only what you need Example: ```elixir @@ -627,13 +632,14 @@ Email notifications settings. OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.). Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies). -Note: each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`, -e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`. -The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies. +!!! note + Each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`, e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`. The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies. -Note: each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies. +!!! note + Each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies. -Note: make sure that `"SameSite=Lax"` is set in `extra_cookie_attrs` when you have this feature enabled. OAuth consumer mode will not work with `"SameSite=Strict"` +!!! note + Make sure that `"SameSite=Lax"` is set in `extra_cookie_attrs` when you have this feature enabled. OAuth consumer mode will not work with `"SameSite=Strict"` * For Twitter, [register an app](https://developer.twitter.com/en/apps), configure callback URL to https:///oauth/twitter/callback From 2656f418183d0109a1706a78a2517b61e12871c7 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 10:57:27 +0300 Subject: [PATCH 130/138] Remove silent mode note as it's no longer relevant --- docs/configuration/cheatsheet.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 70a475363..9e5368cf1 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -444,11 +444,6 @@ This config contains two queues: `federator_incoming` and `federator_outgoing`. `config :pleroma_job_queue, :queues` is replaced by `config :pleroma, Oban, :queues` and uses the same format (keys are queues' names, values are max concurrent jobs numbers). -### Note on running with PostgreSQL in silent mode - -If you are running PostgreSQL in [`silent_mode`](https://postgresqlco.nf/en/doc/param/silent_mode?version=9.1), it's advised to set [`log_destination`](https://postgresqlco.nf/en/doc/param/log_destination?version=9.1) to `syslog`, -otherwise `postmaster.log` file may grow because of "you don't own a lock of type ShareLock" warnings (see https://github.com/sorentwo/oban/issues/52). - ## :workers Includes custom worker options not interpretable directly by `Oban`. From 8e08d5b2336bdb6108ec5df15b7b642e0bc2acad Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 11:02:07 +0300 Subject: [PATCH 131/138] MkDocs does not like if a paragraph doesn't have a newline after it --- docs/configuration/custom_emoji.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration/custom_emoji.md b/docs/configuration/custom_emoji.md index f72c0edbc..1648840fd 100644 --- a/docs/configuration/custom_emoji.md +++ b/docs/configuration/custom_emoji.md @@ -4,6 +4,7 @@ Before you add your own custom emoji, check if they are available in an existing See `Mix.Tasks.Pleroma.Emoji` for information about emoji packs. To add custom emoji: + * Create the `STATIC-DIR/emoji/` directory if it doesn't exist (`STATIC-DIR` is configurable, `instance/static/` by default) * Create a directory with whatever name you want (custom is a good name to show the purpose of it). From 6baa037903e06c80a5b5f1c34b2cfdd471ba2f8f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 11:17:29 +0300 Subject: [PATCH 132/138] Move database maintenance tasks docs to a separate file --- docs/administration/CLI_tasks/database.md | 48 +++++++++++++++++++++++ lib/mix/tasks/pleroma/database.ex | 27 ------------- 2 files changed, 48 insertions(+), 27 deletions(-) create mode 100644 docs/administration/CLI_tasks/database.md diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md new file mode 100644 index 000000000..484639231 --- /dev/null +++ b/docs/administration/CLI_tasks/database.md @@ -0,0 +1,48 @@ +# Database maintenance tasks + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl database` and in case of source installs it's `mix pleroma.database`. + +## Replace embedded objects with their references + +Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once if the instance was created before Pleroma 1.0.5. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. + +```sh +$PREFIX remove_embedded_objects [] +``` + +### Options +- `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references + +## Prune old remote posts from the database + +This will prune remote posts older than 90 days (configurable with [`config :pleroma, :instance, remote_post_retention_days`](../../configuration/cheatsheet.md#instance)) from the database, they will be refetched from source when accessed. + +!!! note + The disk space will only be reclaimed after `VACUUM FULL` + +```sh +$PREFIX pleroma.database prune_objects [] +``` + +### Options +- `--vacuum` - run `VACUUM FULL` after the objects are pruned + +## Create a conversation for all existing DMs + +Can be safely re-run + +```sh +$PREFIX bump_all_conversations +``` + +## Remove duplicated items from following and update followers count for all users + +```sh +$PREFIX update_users_following_followers_counts +``` + +## Fix the pre-existing "likes" collections for all objects + +```sh +$PREFIX fix_likes_collections +``` diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 890a383df..81e687f64 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -13,34 +13,7 @@ defmodule Mix.Tasks.Pleroma.Database do use Mix.Task @shortdoc "A collection of database related tasks" - @moduledoc """ - A collection of database related tasks - ## Replace embedded objects with their references - - Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. - - mix pleroma.database remove_embedded_objects - - Options: - - `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references - - ## Prune old objects from the database - - mix pleroma.database prune_objects - - ## Create a conversation for all existing DMs. Can be safely re-run. - - mix pleroma.database bump_all_conversations - - ## Remove duplicated items from following and update followers count for all users - - mix pleroma.database update_users_following_followers_counts - - ## Fix the pre-existing "likes" collections for all objects - - mix pleroma.database fix_likes_collections - """ def run(["remove_embedded_objects" | args]) do {options, [], []} = OptionParser.parse( From e00403af232548fdef8ad8f2923a51561b3064f6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 13:59:49 +0300 Subject: [PATCH 133/138] Mix tasks: derive moduledoc from doc files --- lib/mix/tasks/pleroma/database.ex | 1 + lib/mix/tasks/pleroma/digest.ex | 1 + lib/mix/tasks/pleroma/emoji.ex | 1 + lib/mix/tasks/pleroma/instance.ex | 1 + lib/mix/tasks/pleroma/relay.ex | 1 + lib/mix/tasks/pleroma/uploads.ex | 1 + lib/mix/tasks/pleroma/user.ex | 1 + 7 files changed, 7 insertions(+) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 81e687f64..cfd9eeada 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -13,6 +13,7 @@ defmodule Mix.Tasks.Pleroma.Database do use Mix.Task @shortdoc "A collection of database related tasks" + @moduledoc File.read!("docs/administration/CLI_tasks/database.md") def run(["remove_embedded_objects" | args]) do {options, [], []} = diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 100a81060..7d09e70c5 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -2,6 +2,7 @@ defmodule Mix.Tasks.Pleroma.Digest do use Mix.Task @shortdoc "Manages digest emails" + @moduledoc File.read!("docs/administration/CLI_tasks/digest.md") def run(["test", nickname | opts]) do Mix.Pleroma.start_pleroma() diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 32b92e6af..6ef0a635d 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -6,6 +6,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do use Mix.Task @shortdoc "Manages emoji packs" + @moduledoc File.read!("docs/administration/CLI_tasks/emoji.md") def run(["ls-packs" | args]) do Application.ensure_all_started(:hackney) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 25f94eceb..9af6cda30 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -7,6 +7,7 @@ defmodule Mix.Tasks.Pleroma.Instance do import Mix.Pleroma @shortdoc "Manages Pleroma instance" + @moduledoc File.read!("docs/administration/CLI_tasks/instance.md") def run(["gen" | rest]) do {options, [], []} = diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index 519f2d1b5..d7a7b599f 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -9,6 +9,7 @@ defmodule Mix.Tasks.Pleroma.Relay do alias Pleroma.Web.ActivityPub.Relay @shortdoc "Manages remote relays" + @moduledoc File.read!("docs/administration/CLI_tasks/relay.md") def run(["follow", target]) do start_pleroma() diff --git a/lib/mix/tasks/pleroma/uploads.ex b/lib/mix/tasks/pleroma/uploads.ex index bc2248a76..3e6fc7ee0 100644 --- a/lib/mix/tasks/pleroma/uploads.ex +++ b/lib/mix/tasks/pleroma/uploads.ex @@ -12,6 +12,7 @@ defmodule Mix.Tasks.Pleroma.Uploads do @log_every 50 @shortdoc "Migrates uploads from local to remote storage" + @moduledoc File.read!("docs/administration/CLI_tasks/uploads.md") def run(["migrate_local", target_uploader | args]) do delete? = Enum.member?(args, "--delete") diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 3cf3ad2c6..134b5bccc 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -10,6 +10,7 @@ defmodule Mix.Tasks.Pleroma.User do alias Pleroma.Web.OAuth @shortdoc "Manages Pleroma users" + @moduledoc File.read!("docs/administration/CLI_tasks/user.md") def run(["new", nickname, email | rest]) do {options, [], []} = From 1cae564b5d749a23f29a5303a82e27e2952a55ed Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 14:12:57 +0300 Subject: [PATCH 134/138] Move config task docs to a separate file and mark it as WIP --- docs/administration/CLI_tasks/config.md | 19 +++++++++++++++++++ docs/configuration/cheatsheet.md | 6 +++++- lib/mix/tasks/pleroma/config.ex | 13 +------------ 3 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 docs/administration/CLI_tasks/config.md diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md new file mode 100644 index 000000000..ce19e2402 --- /dev/null +++ b/docs/administration/CLI_tasks/config.md @@ -0,0 +1,19 @@ +# Transfering the config to/from the database + +!!! danger + This is a Work In Progress, not usable just yet. + +Every command should be ran with a prefix, in case of OTP releases it is `./bin/pleroma_ctl config` and in case of source installs it's +`mix pleroma.config`. + +## Transfer config from file to DB. + +```sh +$PREFIX migrate_to_db +``` + +## Transfer config from DB to `config/env.exported_from_db.secret.exs` + +```sh +$PREFIX migrate_from_db +``` diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9e5368cf1..8f00915a3 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -131,13 +131,17 @@ config :pleroma, Pleroma.Emails.Mailer, * `user_name_length`: A user name maximum length (default: `100`) * `skip_thread_containment`: Skip filter out broken threads. The default is `false`. * `limit_to_local_content`: Limit unauthenticated users to search for local statutes and users only. Possible values: `:unauthenticated`, `:all` and `false`. The default is `:unauthenticated`. -* `dynamic_configuration`: Allow transferring configuration to DB with the subsequent customization from Admin api. * `max_account_fields`: The maximum number of custom fields in the user profile (default: `10`) * `max_remote_account_fields`: The maximum number of custom fields in the remote user profile (default: `20`) * `account_field_name_length`: An account field name maximum length (default: `512`) * `account_field_value_length`: An account field value maximum length (default: `2048`) * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. +!!! danger + This is a Work In Progress, not usable just yet + +* `dynamic_configuration`: Allow transferring configuration to DB with the subsequent customization from Admin api. + ## :logger diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 462940e7e..11e4fde43 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -8,18 +8,7 @@ defmodule Mix.Tasks.Pleroma.Config do alias Pleroma.Repo alias Pleroma.Web.AdminAPI.Config @shortdoc "Manages the location of the config" - @moduledoc """ - Manages the location of the config. - - ## Transfers config from file to DB. - - mix pleroma.config migrate_to_db - - ## Transfers config from DB to file `config/env.exported_from_db.secret.exs` - - mix pleroma.config migrate_from_db ENV - """ - + @moduledoc File.read!("docs/administration/CLI_tasks/config.md") def run(["migrate_to_db"]) do start_pleroma() From b5a43e301eb885f3f35632804b1cc1c7243edbfb Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 14:27:11 +0300 Subject: [PATCH 135/138] Change docs build/deploy to just trigger a pipeline in the docs repo --- .gitlab-ci.yml | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7bee30e08..e98f23b25 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,23 +28,6 @@ build: - mix deps.get - mix compile --force -docs-build: - stage: build - only: - - master@pleroma/pleroma - - develop@pleroma/pleroma - variables: - MIX_ENV: dev - PLEROMA_BUILD_ENV: prod - script: - - mix deps.get - - mix compile - - mix docs - artifacts: - paths: - - priv/static/doc - - unit-testing: stage: test services: @@ -85,19 +68,15 @@ analysis: docs-deploy: stage: deploy - image: alpine:3.9 + image: alpine:latest only: + - mkdocs-migration-prep@pleroma/pleroma - master@pleroma/pleroma - develop@pleroma/pleroma before_script: - - apk update && apk add openssh-client rsync + - apk add curl script: - - mkdir -p ~/.ssh - - echo "${SSH_HOST_KEY}" > ~/.ssh/known_hosts - - eval $(ssh-agent -s) - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - - rsync -hrvz --delete -e "ssh -p ${SSH_PORT}" priv/static/doc/ "${SSH_USER_HOST_LOCATION}/${CI_COMMIT_REF_NAME}" - + - curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" https://git.pleroma.social/api/v4/projects/673/trigger/pipeline review_app: image: alpine:3.9 stage: deploy From 69784eb75a00fb929765adbeab41022052038cca Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 15:10:04 +0300 Subject: [PATCH 136/138] Add a missing language specification --- docs/administration/CLI_tasks/user.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 045730753..cf120f2c9 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -35,7 +35,7 @@ $PREFIX revoke_invite ``` ## Delete a user -``` +```sh $PREFIX rm ``` From b2f2012a4f34cfe8151e62d045f0eab3d165791a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 3 Oct 2019 18:42:02 +0300 Subject: [PATCH 137/138] Remove a test branch from CI --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e98f23b25..748bec74a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -70,7 +70,6 @@ docs-deploy: stage: deploy image: alpine:latest only: - - mkdocs-migration-prep@pleroma/pleroma - master@pleroma/pleroma - develop@pleroma/pleroma before_script: From e4ab9a05ddc857298f2f0f36e06c2a874e1d6a6b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 4 Oct 2019 00:10:28 +0200 Subject: [PATCH 138/138] cheatsheet.md: link to pleroma-fe docs for :frontend_configurations --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 8f00915a3..b86799ecc 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -189,7 +189,7 @@ See the [Quack Github](https://github.com/azohra/quack) for more details ## :frontend_configurations -This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` and `masto_fe` are configured. +This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` and `masto_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Pleroma-FE configuration and customization for instance administrators](/frontend/CONFIGURATION/#options). Frontends can access these settings at `/api/pleroma/frontend_configurations`