Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
478 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/.woodpecker/changelog.yaml b/.woodpecker/changelog.yaml
index 64062f17e..a3732c240 100644
--- a/.woodpecker/changelog.yaml
+++ b/.woodpecker/changelog.yaml
@@ -1,19 +1,20 @@
when:
- event: pull_request
+ evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"'
labels:
platform: linux/amd64
variables:
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
check-changelog:
image: docker.io/alpine:3.23
entrypoint: *script_file_entrypoint
commands:
- apk add --no-cache git
- sh ./tools/check-changelog
diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml
new file mode 100644
index 000000000..c921a56f9
--- /dev/null
+++ b/.woodpecker/docker-armv7.yaml
@@ -0,0 +1,85 @@
+when:
+ # Temporary PR validation while arm/v7 Docker builds are being brought up.
+ - event: pull_request
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ]
+ - event: push
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ]
+ - event: tag
+ - event: manual
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ - event: manual
+ branch: stable
+
+# Target the CAX21 arm64 host with native arm32 userspace support, not generic arm64 runners.
+labels:
+ platform: linux/arm
+ armv7: native
+
+variables:
+ docker_variables: &docker_variables
+ repo: pleroma/pleroma
+ registry: git.pleroma.social
+ armv7_build_settings: &armv7_build_settings
+ <<: *docker_variables
+ build_args:
+ - ELIXIR_IMG=arm32v7/elixir
+ - ELIXIR_TAG=1.17.3-otp-26-alpine
+ - ALPINE_IMG=arm32v7/alpine
+ - ALPINE_VER=3.20
+ extra_opts: --custom-platform=linux/arm/v7
+ armv7_push_settings: &armv7_push_settings
+ <<: *armv7_build_settings
+ username:
+ from_secret: pleroma-ci-user
+ password:
+ from_secret: pleroma-ci-password
+
+steps:
+ docker-armv7-pr:
+ image: woodpeckerci/plugin-kaniko:2.3.1
+ when:
+ - event: pull_request
+ settings:
+ <<: *armv7_build_settings
+ dry_run: true
+ tags:
+ - armv7-pr-check
+
+ docker-develop-armv7:
+ image: woodpeckerci/plugin-kaniko:2.3.1
+ when:
+ - event: push
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ - event: manual
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ settings:
+ <<: *armv7_push_settings
+ tags:
+ - latest-armv7
+ - develop-armv7
+ - ${CI_COMMIT_SHA:0:8}-armv7
+
+ docker-stable-armv7:
+ image: woodpeckerci/plugin-kaniko:2.3.1
+ when:
+ - evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""'
+ settings:
+ <<: *armv7_push_settings
+ tags:
+ - latest-armv7
+ - stable-armv7
+ - ${CI_COMMIT_SHA:0:8}-armv7
+
+ docker-stable-tag-armv7:
+ image: woodpeckerci/plugin-kaniko:2.3.1
+ when:
+ - event: tag
+ - evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""'
+ settings:
+ <<: *armv7_push_settings
+ tags:
+ - latest-armv7
+ - stable-armv7
+ - ${CI_COMMIT_SHA:0:8}-armv7
+ - ${CI_COMMIT_TAG}-armv7
diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml
index abc6bfa3b..9a5be78e2 100644
--- a/.woodpecker/docker.yaml
+++ b/.woodpecker/docker.yaml
@@ -1,96 +1,108 @@
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ]
- event: tag
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: stable
matrix:
platform:
- linux/amd64
- linux/arm64
# This is needed for the when clauses below.
labels:
platform: ${platform}
memory: 'high'
variables:
docker_variables: &docker_variables
repo: pleroma/pleroma
registry: git.pleroma.social
username:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1
steps:
docker-develop-amd64:
image: woodpeckerci/plugin-kaniko:2.3.1
when:
- evaluate: 'platform == "linux/amd64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"'
settings:
<<: *docker_variables
tags:
- latest-amd64
- develop-amd64
- ${CI_COMMIT_SHA:0:8}-amd64
docker-develop-arm64:
image: woodpeckerci/plugin-kaniko:2.3.1
when:
- evaluate: 'platform == "linux/arm64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"'
settings:
<<: *docker_variables
tags:
- latest-arm64
- develop-arm64
- ${CI_COMMIT_SHA:0:8}-arm64
docker-stable-amd64:
image: *kaniko_image
when:
- evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""'
settings:
<<: *docker_variables
tags: &amd64_tags
- latest-amd64
- stable-amd64
- ${CI_COMMIT_SHA:0:8}-amd64
+ # FE workflow runs only on linux/amd64
+ docker-stable-e2e-amd64:
+ image: *kaniko_image
+ when:
+ - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"'
+ - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"'
+ settings:
+ <<: *docker_variables
+ dockerfile: Dockerfile-e2e
+ tags:
+ - stable-e2e
+
docker-stable-tag-amd64:
image: *kaniko_image
when:
- evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"'
- evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""'
settings:
<<: *docker_variables
tags:
- <<: *amd64_tags
- ${CI_COMMIT_TAG}-amd64
docker-stable-arm64:
image: *kaniko_image
when:
- evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""'
settings:
<<: *docker_variables
tags: &arm64_tags
- latest-arm64
- stable-arm64
- ${CI_COMMIT_SHA:0:8}-arm64
docker-stable-tag-arm64:
image: *kaniko_image
when:
- evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"'
- evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""'
settings:
<<: *docker_variables
tags:
- <<: *arm64_tags
- ${CI_COMMIT_TAG}-arm64
diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml
index 0ab7441a8..8a5718c5d 100644
--- a/.woodpecker/lint.yaml
+++ b/.woodpecker/lint.yaml
@@ -1,77 +1,78 @@
when:
- event: pull_request
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
+ evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
labels:
platform: linux/amd64
variables:
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
mix-format:
image: &elixir-image
docker.io/elixir:1.15-alpine
entrypoint: *script_file_entrypoint
failure: ignore
commands:
- |
if ! mix format --check-formatted; then
touch fail.stamp
exit 1
fi
credo:
image: *elixir-image
entrypoint: *script_file_entrypoint
failure: ignore
environment:
MIX_ENV: test
commands:
- apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
- adduser -D -h /home/testuser testuser
- mkdir -p /home/testuser/.mix /home/testuser/.hex
- chown -R testuser:testuser . /home/testuser
- su testuser -c "HOME=/home/testuser mix local.hex --force"
- su testuser -c "HOME=/home/testuser mix local.rebar --force"
- su testuser -c "HOME=/home/testuser mix deps.get"
- |
if ! su testuser -c "HOME=/home/testuser mix analyze"; then
touch fail.stamp
exit 1
fi
# cycles:
# image: *elixir-image
# failure: ignore
# commands:
# - apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
# - adduser -D -h /home/testuser testuser
# - mkdir -p /home/testuser/.mix /home/testuser/.hex
# - chown -R testuser:testuser . /home/testuser
# - su testuser -c "HOME=/home/testuser mix local.hex --force"
# - su testuser -c "HOME=/home/testuser mix local.rebar --force"
# - su testuser -c "HOME=/home/testuser mix compile"
# - |
# if ! su testuser -c "HOME=/home/testuser mix xref graph --format cycles --label compile | awk '{print $0} END{exit ($0 != \"No cycles found\")}'"; then
# touch fail.stamp
# exit 1
# fi
ensure-status:
image: *elixir-image
entrypoint: *script_file_entrypoint
commands: |
if test -f fail.stamp; then
echo "One or more previous steps fails. Failing workflow..."
exit 1
else
echo "All steps passed"
exit 0
fi
diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml
index a4a8fc266..a5bcc0ef3 100644
--- a/.woodpecker/unit-testing-elixir-1.15.yaml
+++ b/.woodpecker/unit-testing-elixir-1.15.yaml
@@ -1,44 +1,45 @@
when:
- event: pull_request
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
+ evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
depends_on:
- lint
labels:
platform: linux/amd64
variables:
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
unit-testing-elixir-1.15:
image: elixir:1.15-alpine
entrypoint: *script_file_entrypoint
environment:
MIX_ENV: test
DB_HOST: postgres
DB_PORT: 5432
commands:
- apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
- adduser -D -h /home/testuser testuser
- mkdir -p /home/testuser/.mix /home/testuser/.hex
- chown -R testuser:testuser . /home/testuser
- su testuser -c "HOME=/home/testuser mix local.hex --force"
- su testuser -c "HOME=/home/testuser mix local.rebar --force"
- su testuser -c "HOME=/home/testuser mix deps.get"
- su testuser -c "HOME=/home/testuser mix pleroma.test_runner --preload-modules"
services:
postgres:
image: postgres:13-alpine
environment:
POSTGRES_DB: pleroma_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
diff --git a/.woodpecker/unit-testing-elixir-1.19.yaml b/.woodpecker/unit-testing-elixir-1.19.yaml
index 462e2ee5d..efab97223 100644
--- a/.woodpecker/unit-testing-elixir-1.19.yaml
+++ b/.woodpecker/unit-testing-elixir-1.19.yaml
@@ -1,44 +1,45 @@
when:
- event: pull_request
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
+ evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
depends_on:
- lint
labels:
platform: linux/amd64
variables:
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
unit-testing-elixir-1.18:
image: elixir:1.19-otp-28-alpine
entrypoint: *script_file_entrypoint
environment:
MIX_ENV: test
DB_HOST: postgres
DB_PORT: 5432
commands:
- apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
- adduser -D -h /home/testuser testuser
- mkdir -p /home/testuser/.mix /home/testuser/.hex
- chown -R testuser:testuser . /home/testuser
- su testuser -c "HOME=/home/testuser mix local.hex --force"
- su testuser -c "HOME=/home/testuser mix local.rebar --force"
- su testuser -c "HOME=/home/testuser mix deps.get"
- su testuser -c "HOME=/home/testuser mix pleroma.test_runner --preload-modules"
services:
postgres:
image: postgres:13-alpine
environment:
POSTGRES_DB: pleroma_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
diff --git a/Dockerfile b/Dockerfile
index 11a17b7ad..5fd1cc0a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,60 +1,62 @@
# https://hub.docker.com/r/hexpm/elixir/tags
ARG ELIXIR_IMG=hexpm/elixir
ARG ELIXIR_VER=1.17.3
ARG ERLANG_VER=26.2.5.6
ARG ALPINE_VER=3.17.9
+ARG ELIXIR_TAG=${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER}
+ARG ALPINE_IMG=alpine
-FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build
+FROM ${ELIXIR_IMG}:${ELIXIR_TAG} AS build
COPY . .
ENV MIX_ENV=prod
ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS
RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\
echo "import Config" > config/prod.secret.exs &&\
mix local.hex --force &&\
mix local.rebar --force &&\
mix deps.clean --all &&\
mix deps.get --only prod &&\
mkdir release &&\
mix release --path release
-FROM alpine:${ALPINE_VER}
+FROM ${ALPINE_IMG}:${ALPINE_VER}
ARG BUILD_DATE
ARG VCS_REF
LABEL maintainer="ops@pleroma.social" \
org.opencontainers.image.title="pleroma" \
org.opencontainers.image.description="Pleroma for Docker" \
org.opencontainers.image.authors="ops@pleroma.social" \
org.opencontainers.image.vendor="pleroma.social" \
org.opencontainers.image.documentation="https://git.pleroma.social/pleroma/pleroma" \
org.opencontainers.image.licenses="AGPL-3.0" \
org.opencontainers.image.url="https://pleroma.social" \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.created=$BUILD_DATE
ARG HOME=/opt/pleroma
ARG DATA=/var/lib/pleroma
RUN apk update &&\
apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\
adduser --system --shell /bin/false --home ${HOME} pleroma &&\
mkdir -p ${DATA}/uploads &&\
mkdir -p ${DATA}/static &&\
chown -R pleroma ${DATA} &&\
mkdir -p /etc/pleroma &&\
chown -R pleroma /etc/pleroma
USER pleroma
COPY --from=build --chown=pleroma:0 /release ${HOME}
COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs
COPY ./docker-entrypoint.sh ${HOME}
EXPOSE 4000
ENTRYPOINT ["/opt/pleroma/docker-entrypoint.sh"]
diff --git a/Dockerfile b/Dockerfile-e2e
similarity index 80%
copy from Dockerfile
copy to Dockerfile-e2e
index 11a17b7ad..df8bb39aa 100644
--- a/Dockerfile
+++ b/Dockerfile-e2e
@@ -1,60 +1,61 @@
# https://hub.docker.com/r/hexpm/elixir/tags
-ARG ELIXIR_IMG=hexpm/elixir
-ARG ELIXIR_VER=1.17.3
-ARG ERLANG_VER=26.2.5.6
-ARG ALPINE_VER=3.17.9
+ARG ELIXIR_IMG=docker.io/hexpm/elixir
+ARG ALPINE_IMG=docker.io/alpine
+ARG ELIXIR_VER=1.18.4
+ARG ERLANG_VER=27.3.4.11
+ARG ALPINE_VER=3.23.4
FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build
COPY . .
ENV MIX_ENV=prod
ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS
RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\
echo "import Config" > config/prod.secret.exs &&\
mix local.hex --force &&\
mix local.rebar --force &&\
- mix deps.clean --all &&\
+ mix deps.clean --all &&\
mix deps.get --only prod &&\
mkdir release &&\
mix release --path release
-FROM alpine:${ALPINE_VER}
+FROM ${ALPINE_IMG}:${ALPINE_VER}
ARG BUILD_DATE
ARG VCS_REF
LABEL maintainer="ops@pleroma.social" \
org.opencontainers.image.title="pleroma" \
- org.opencontainers.image.description="Pleroma for Docker" \
+ org.opencontainers.image.description="Pleroma FE E2E test image" \
org.opencontainers.image.authors="ops@pleroma.social" \
org.opencontainers.image.vendor="pleroma.social" \
org.opencontainers.image.documentation="https://git.pleroma.social/pleroma/pleroma" \
org.opencontainers.image.licenses="AGPL-3.0" \
org.opencontainers.image.url="https://pleroma.social" \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.created=$BUILD_DATE
ARG HOME=/opt/pleroma
ARG DATA=/var/lib/pleroma
RUN apk update &&\
apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\
adduser --system --shell /bin/false --home ${HOME} pleroma &&\
mkdir -p ${DATA}/uploads &&\
mkdir -p ${DATA}/static &&\
chown -R pleroma ${DATA} &&\
mkdir -p /etc/pleroma &&\
chown -R pleroma /etc/pleroma
USER pleroma
COPY --from=build --chown=pleroma:0 /release ${HOME}
COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs
-COPY ./docker-entrypoint.sh ${HOME}
+COPY ./docker-entrypoint-e2e.sh ${HOME}
EXPOSE 4000
-ENTRYPOINT ["/opt/pleroma/docker-entrypoint.sh"]
+ENTRYPOINT ["/opt/pleroma/docker-entrypoint-e2e.sh"]
diff --git a/changelog.d/docker-e2e-image.skip b/changelog.d/docker-e2e-image.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/featured-collection-page.fix b/changelog.d/featured-collection-page.fix
new file mode 100644
index 000000000..b611a4137
--- /dev/null
+++ b/changelog.d/featured-collection-page.fix
@@ -0,0 +1 @@
+Support fetching featured collection items from the first collection page
diff --git a/changelog.d/grouped-notifications-api.add b/changelog.d/grouped-notifications-api.add
new file mode 100644
index 000000000..8c2cc5397
--- /dev/null
+++ b/changelog.d/grouped-notifications-api.add
@@ -0,0 +1 @@
+Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` for newly-created notifications
diff --git a/changelog.d/grouped_notifications_flag.skip b/changelog.d/grouped_notifications_flag.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/mastodon-websocket-protocol.fix b/changelog.d/mastodon-websocket-protocol.fix
new file mode 100644
index 000000000..66dab16ed
--- /dev/null
+++ b/changelog.d/mastodon-websocket-protocol.fix
@@ -0,0 +1 @@
+Echo Mastodon-style `Sec-WebSocket-Protocol` tokens in streaming WebSocket handshakes.
diff --git a/changelog.d/mfm-extend.skip b/changelog.d/mfm-extend.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/weblate-ci.skip b/changelog.d/weblate-ci.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/wss-necroposts.fix b/changelog.d/wss-necroposts.fix
new file mode 100644
index 000000000..743f9087c
--- /dev/null
+++ b/changelog.d/wss-necroposts.fix
@@ -0,0 +1 @@
+RichMedia: Fix backfill causing old posts to show up on timelines by disabling it in MastoAPI StatusView
diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh
new file mode 100755
index 000000000..d7aa332f7
--- /dev/null
+++ b/docker-entrypoint-e2e.sh
@@ -0,0 +1,70 @@
+#!/bin/ash
+set -eu
+
+SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded
+CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs
+
+echo '-- Waiting for database...'
+while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do
+ sleep 1s
+done
+
+echo '-- Writing E2E config overrides...'
+cat > "$CONFIG_OVERRIDE_PATH" <<EOF
+import Config
+
+config :pleroma, Pleroma.Captcha,
+ enabled: false
+
+config :pleroma, :instance,
+ registrations_open: true,
+ account_activation_required: false,
+ account_approval_required: false
+EOF
+
+echo '-- Running migrations...'
+/opt/pleroma/bin/pleroma_ctl migrate
+
+if [ ! -f "$SEED_SENTINEL_PATH" ]; then
+ if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then
+ echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...'
+ if ! PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then
+ echo '-- User already exists or creation failed, ensuring admin + confirmed...'
+ PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed
+ fi
+ else
+ echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)'
+ fi
+
+ touch "$SEED_SENTINEL_PATH"
+fi
+
+echo '-- Starting!'
+/opt/pleroma/bin/pleroma start &
+PLEROMA_PID=$!
+
+cleanup() {
+ if kill -0 "$PLEROMA_PID" 2>/dev/null; then
+ kill -TERM "$PLEROMA_PID"
+ wait "$PLEROMA_PID" || true
+ fi
+}
+
+trap cleanup INT TERM
+
+echo '-- Waiting for API...'
+api_ok=false
+for _i in $(seq 1 120); do
+ if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then
+ api_ok=true
+ break
+ fi
+ sleep 1s
+done
+
+if [ "$api_ok" != true ]; then
+ echo 'Timed out waiting for Pleroma API to become available'
+ exit 1
+fi
+
+wait "$PLEROMA_PID"
diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex
index b8693c3a8..afd750823 100644
--- a/lib/pleroma/notification.ex
+++ b/lib/pleroma/notification.ex
@@ -1,772 +1,867 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Notification do
use Ecto.Schema
alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.FollowingRelationship
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.ThreadMute
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.Push
alias Pleroma.Web.Streamer
import Ecto.Query
import Ecto.Changeset
require Logger
@type t :: %__MODULE__{}
@include_muted_option :with_muted
schema "notifications" do
field(:seen, :boolean, default: false)
# This is an enum type in the database. If you add a new notification type,
# remember to add a migration to add it to the `notifications_type` enum
# as well.
field(:type, :string)
+ field(:group_key, :string)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
timestamps()
end
def update_notification_type(user, activity) do
with %__MODULE__{} = notification <-
Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do
type =
activity
|> type_from_activity()
notification
- |> changeset(%{type: type})
+ |> changeset(%{type: type, group_key: grouped_notification_key(type, activity)})
|> Repo.update()
end
end
@spec unread_notifications_count(User.t()) :: integer()
def unread_notifications_count(%User{id: user_id}) do
from(q in __MODULE__,
where: q.user_id == ^user_id and q.seen == false
)
|> Repo.aggregate(:count, :id)
end
+ @groupable_notification_types ~w{favourite follow reblog}
+ @group_bucket_seconds 12 * 60 * 60
+
+ def groupable_notification_types, do: @groupable_notification_types
+
+ def group_notifications(notifications, grouped_types \\ nil) do
+ grouped_types = normalize_grouped_types(grouped_types)
+
+ {group_keys, grouped_notifications} =
+ Enum.reduce(notifications, {[], %{}}, fn notification,
+ {group_keys, grouped_notifications} ->
+ group_key = group_key(notification, grouped_types)
+
+ if Map.has_key?(grouped_notifications, group_key) do
+ {group_keys, Map.update!(grouped_notifications, group_key, &[notification | &1])}
+ else
+ {[group_key | group_keys], Map.put(grouped_notifications, group_key, [notification])}
+ end
+ end)
+
+ group_keys
+ |> Enum.reverse()
+ |> Enum.map(fn group_key ->
+ grouped_notifications
+ |> Map.fetch!(group_key)
+ |> Enum.reverse()
+ end)
+ end
+
+ def group_key(notification, grouped_types \\ nil)
+
+ def group_key(%Notification{} = notification, nil) do
+ group_key(notification, @groupable_notification_types)
+ end
+
+ def group_key(%Notification{type: type, group_key: group_key} = notification, grouped_types) do
+ grouped_types = normalize_grouped_types(grouped_types)
+
+ if type in @groupable_notification_types and type in grouped_types and is_binary(group_key) do
+ group_key
+ else
+ ungrouped_group_key(notification)
+ end
+ end
+
+ def normalize_grouped_types(nil), do: @groupable_notification_types
+ def normalize_grouped_types(types) when is_list(types), do: types
+ def normalize_grouped_types(type), do: [type]
+
+ defp ungrouped_group_key(%Notification{id: id}), do: "ungrouped-#{id}"
+
+ defp grouped_notification_key(type, activity) when type in @groupable_notification_types do
+ with target_id when is_binary(target_id) <- group_target_id(type, activity) do
+ # Mastodon uses Redis to reuse a recent bucket for rolling 12h windows. Pleroma keeps keys
+ # deterministic and self-contained; clients must treat group_key as opaque either way.
+ "#{type}-#{target_id}-#{group_time_bucket(activity)}"
+ end
+ end
+
+ defp grouped_notification_key(_type, _activity), do: nil
+
+ defp group_time_bucket(%Activity{inserted_at: inserted_at}) when not is_nil(inserted_at) do
+ inserted_at
+ |> NaiveDateTime.to_erl()
+ |> :calendar.datetime_to_gregorian_seconds()
+ |> div(@group_bucket_seconds)
+ end
+
+ defp group_time_bucket(_),
+ do: group_time_bucket(%Activity{inserted_at: NaiveDateTime.utc_now()})
+
+ defp group_target_id(type, activity) when type in ["favourite", "reblog"] do
+ with object_id when is_binary(object_id) <- object_id_for(activity),
+ %Activity{id: id} <- Activity.get_create_by_object_ap_id(object_id) do
+ to_string(id)
+ else
+ _ -> nil
+ end
+ end
+
+ defp group_target_id("follow", %{data: %{"object" => ap_id}}) do
+ case User.get_cached_by_ap_id(ap_id) do
+ %User{id: id} -> to_string(id)
+ _ -> nil
+ end
+ end
+
+ defp group_target_id(_, _), do: nil
+
+ defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+ defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+ defp object_id_for(_), do: nil
+
@notification_types ~w{
favourite
follow
follow_request
mention
move
pleroma:chat_mention
pleroma:emoji_reaction
pleroma:report
reblog
poll
status
update
}
def changeset(%Notification{} = notification, attrs) do
notification
- |> cast(attrs, [:seen, :type])
+ |> cast(attrs, [:seen, :type, :group_key])
|> validate_inclusion(:type, @notification_types)
end
@spec last_read_query(User.t()) :: Ecto.Queryable.t()
def last_read_query(user) do
from(q in Pleroma.Notification,
where: q.user_id == ^user.id,
where: q.seen == true,
select: type(q.id, :string),
limit: 1,
order_by: fragment("? desc nulls last", q.id)
)
end
defp for_user_query_ap_id_opts(user, opts) do
ap_id_relationships =
[:block] ++
if opts[@include_muted_option], do: [], else: [:notification_mute]
preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships)
exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts)
exclude_notification_muted_opts =
Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts)
{exclude_blocked_opts, exclude_notification_muted_opts}
end
def for_user_query(user, opts \\ %{}) do
{exclude_blocked_opts, exclude_notification_muted_opts} =
for_user_query_ap_id_opts(user, opts)
Notification
|> where(user_id: ^user.id)
|> join(:inner, [n], activity in assoc(n, :activity))
|> join(:left, [n, a], object in Object,
on:
fragment(
"(?->>'id') = associated_object_id(?)",
object.data,
a.data
)
)
|> join(:inner, [_n, a], u in User, on: u.ap_id == a.actor, as: :user_actor)
|> preload([n, a, o], activity: {a, object: o})
|> where([user_actor: user_actor], user_actor.is_active)
|> exclude_notification_muted(user, exclude_notification_muted_opts)
|> exclude_blocked(user, exclude_blocked_opts)
|> exclude_blockers(user)
|> exclude_filtered(user)
|> exclude_visibility(opts)
end
# Excludes blocked users and non-followed domain-blocked users
defp exclude_blocked(query, user, opts) do
blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocked_ap_ids)
|> FollowingRelationship.keep_following_or_not_domain_blocked(user)
end
defp exclude_blockers(query, user) do
if Pleroma.Config.get([:activitypub, :blockers_visible]) == true do
query
else
blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block])
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocker_ap_ids)
end
end
defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do
query
end
defp exclude_notification_muted(query, user, opts) do
notification_muted_ap_ids =
opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^notification_muted_ap_ids)
|> join(:left, [n, a], tm in ThreadMute,
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data),
as: :thread_mute
)
|> where([thread_mute: thread_mute], is_nil(thread_mute.user_id))
end
defp exclude_filtered(query, user) do
case Pleroma.Filter.compose_regex(user) do
nil ->
query
regex ->
from([_n, a, o] in query,
where:
fragment("not(?->>'content' ~* ?)", o.data, ^regex) or
fragment("?->>'content' is null", o.data) or
fragment("?->>'actor' = ?", o.data, ^user.ap_id)
)
end
end
@valid_visibilities ~w[direct unlisted public private]
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when is_list(visibility) do
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
query
|> join(:left, [n, a], mutated_activity in Pleroma.Activity,
on:
fragment(
"associated_object_id(?)",
a.data
) ==
fragment(
"associated_object_id(?)",
mutated_activity.data
) and
fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and
fragment("?->>'type'", mutated_activity.data) == "Create",
as: :mutated_activity
)
|> where(
[n, a, mutated_activity: mutated_activity],
not fragment(
"""
CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce'
THEN (activity_visibility(?, ?, ?) = ANY (?))
ELSE (activity_visibility(?, ?, ?) = ANY (?)) END
""",
a.data,
a.data,
mutated_activity.actor,
mutated_activity.recipients,
mutated_activity.data,
^visibility,
a.actor,
a.recipients,
a.data,
^visibility
)
)
else
Logger.error("Could not exclude visibility to #{visibility}")
query
end
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility in @valid_visibilities do
exclude_visibility(query, [visibility])
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility not in @valid_visibilities do
Logger.error("Could not exclude visibility to #{visibility}")
query
end
defp exclude_visibility(query, _visibility), do: query
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.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33])
[%Pleroma.Notification{}, %Pleroma.Notification{}]
iex> Pleroma.Notification.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 set_read_up_to(%{id: user_id} = user, id) do
query =
from(
n in Notification,
where: n.user_id == ^user_id,
where: n.id <= ^id,
where: n.seen == false,
# Ideally we would preload object and activities here
# but Ecto does not support preloads in update_all
select: n.id
)
{:ok, %{marker: marker}} =
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
Streamer.stream(["user", "user:notification"], marker)
{:ok, %{marker: marker}}
end
@spec read_one(User.t(), String.t()) ::
{:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil
def read_one(%User{} = user, notification_id) do
with {:ok, %Notification{} = notification} <- get(user, notification_id) do
Multi.new()
|> Multi.update(:update, changeset(notification, %{seen: true}))
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
end
end
def get(%{id: user_id} = _user, id) do
query =
from(
n in Notification,
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 Notification, where: n.user_id == ^user.id)
|> Repo.delete_all()
end
def destroy_multiple(%{id: user_id} = _user, ids) do
from(n in Notification,
where: n.id in ^ids,
where: n.user_id == ^user_id
)
|> Repo.delete_all()
end
def dismiss(%Pleroma.Activity{} = activity) do
Notification
|> where([n], n.activity_id == ^activity.id)
|> Repo.delete_all()
|> case do
{_, notifications} -> {:ok, notifications}
_ -> {:error, "Cannot dismiss notification"}
end
end
def dismiss(%{id: user_id} = _user, id) do
notification = Repo.get(Notification, id)
case notification do
%{user_id: ^user_id} ->
Repo.delete(notification)
_ ->
{:error, "Cannot dismiss notification"}
end
end
@spec create_notifications(Activity.t()) :: {:ok, [Notification.t()] | []}
def create_notifications(activity)
def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do
object = Object.normalize(activity, fetch: false)
if object && object.data["type"] == "Answer" do
{:ok, []}
else
do_create_notifications(activity)
end
end
def create_notifications(%Activity{data: %{"type" => type}} = activity)
when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
do_create_notifications(activity)
end
def create_notifications(_), do: {:ok, []}
defp do_create_notifications(%Activity{} = activity) do
enabled_receivers = get_notified_from_activity(activity)
enabled_subscribers = get_notified_subscribers_from_activity(activity)
notifications =
(Enum.map(enabled_receivers, fn user ->
create_notification(activity, user)
end) ++
Enum.map(enabled_subscribers -- enabled_receivers, fn user ->
create_notification(activity, user, type: "status")
end))
|> Enum.reject(&is_nil/1)
{:ok, notifications}
end
defp type_from_activity(%{data: %{"type" => type}} = activity) do
case type do
"Follow" ->
if Activity.follow_accepted?(activity) do
"follow"
else
"follow_request"
end
"Announce" ->
"reblog"
"Like" ->
"favourite"
"Move" ->
"move"
"EmojiReact" ->
"pleroma:emoji_reaction"
"Flag" ->
"pleroma:report"
# Compatibility with old reactions
"EmojiReaction" ->
"pleroma:emoji_reaction"
"Create" ->
activity
|> type_from_activity_object()
"Update" ->
"update"
t ->
raise "No notification type for activity type #{t}"
end
end
defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention"
defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do
object = Object.get_by_ap_id(activity.data["object"])
case object && object.data["type"] do
"ChatMessage" -> "pleroma:chat_mention"
_ -> "mention"
end
end
# TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
type = Keyword.get(opts, :type, type_from_activity(activity))
unless skip?(activity, user, opts) do
{:ok, %{notification: notification}} =
Multi.new()
|> Multi.insert(:notification, %Notification{
user_id: user.id,
activity: activity,
seen: mark_as_read?(activity, user),
- type: type
+ type: type,
+ group_key: grouped_notification_key(type, activity)
})
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
notification
end
end
def create_poll_notifications(%Activity{} = activity) do
with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
Object.normalize(activity) do
voters =
case data do
%{"voters" => voters} when is_list(voters) -> voters
_ -> []
end
notifications =
Enum.reduce([actor | voters], [], fn ap_id, acc ->
with %User{local: true} = user <- User.get_by_ap_id(ap_id) do
[create_notification(activity, user, type: "poll") | acc]
else
_ -> acc
end
end)
{:ok, notifications}
end
end
@doc """
Returns a tuple with 2 elements:
{notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1
"""
@spec get_notified_from_activity(Activity.t(), boolean()) :: list(User.t())
def get_notified_from_activity(activity, local_only \\ true)
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
when type in [
"Create",
"Like",
"Announce",
"Follow",
"Move",
"EmojiReact",
"Flag",
"Update"
] do
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
potential_receivers =
User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only)
notification_enabled_ap_ids =
potential_receiver_ap_ids
|> exclude_domain_blocker_ap_ids(activity, potential_receivers)
|> exclude_relationship_restricted_ap_ids(activity)
|> exclude_thread_muter_ap_ids(activity)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_from_activity(_, _local_only), do: []
def get_notified_subscribers_from_activity(activity, local_only \\ true)
def get_notified_subscribers_from_activity(
%Activity{data: %{"type" => "Create"}} = activity,
local_only
) do
notification_enabled_ap_ids = Utils.get_notified_subscribers(activity)
potential_receivers =
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_subscribers_from_activity(_, _), do: []
# For some activities, only notify the author of the object
def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}})
when type in ~w{Like Announce EmojiReact} do
case Object.get_cached_by_ap_id(object_id) do
%Object{data: %{"actor" => actor}} ->
[actor]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => object_id}}) do
[object_id]
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}}) do
(User.all_users_with_privilege(:reports_manage_reports)
|> Enum.map(fn user -> user.ap_id end)) --
[actor]
end
# Update activity: notify all who repeated this
def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
repeaters =
Activity.Queries.by_type("Announce")
|> Activity.Queries.by_object_id(object_id)
|> Activity.with_joined_user_actor()
|> where([a, u], u.local)
|> select([a, u], u.ap_id)
|> Repo.all()
repeaters -- [actor]
end
end
def get_potential_receiver_ap_ids(activity) do
[]
|> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_followers(activity)
|> Enum.uniq()
end
@doc "Filters out AP IDs domain-blocking and not following the activity's actor"
def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ [])
def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: []
def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do
activity_actor_domain = activity.actor && URI.parse(activity.actor).host
users =
ap_ids
|> Enum.map(fn ap_id ->
Enum.find(preloaded_users, &(&1.ap_id == ap_id)) ||
User.get_cached_by_ap_id(ap_id)
end)
|> Enum.filter(& &1)
domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id
domain_blocker_follower_ap_ids =
if Enum.any?(domain_blocker_ap_ids) do
activity
|> Activity.user_actor()
|> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids)
else
[]
end
ap_ids
|> Kernel.--(domain_blocker_ap_ids)
|> Kernel.++(domain_blocker_follower_ap_ids)
end
@doc "Filters out AP IDs of users basing on their relationships with activity actor user"
def exclude_relationship_restricted_ap_ids([], _activity), do: []
def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do
relationship_restricted_ap_ids =
activity
|> Activity.user_actor()
|> User.incoming_relationships_ungrouped_ap_ids([
:block,
:notification_mute
])
Enum.uniq(ap_ids) -- relationship_restricted_ap_ids
end
@doc "Filters out AP IDs of users who mute activity thread"
def exclude_thread_muter_ap_ids([], _activity), do: []
def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do
thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"])
Enum.uniq(ap_ids) -- thread_muter_ap_ids
end
def skip?(activity, user, opts \\ [])
@spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(%Activity{} = activity, %User{} = user, opts) do
[
:self,
:internal,
:invisible,
:block_from_strangers,
:recently_followed,
:filtered
]
|> Enum.find(&skip?(&1, activity, user, opts))
end
def skip?(_activity, _user, _opts), do: false
@spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
cond do
opts[:type] == "poll" -> false
activity.data["actor"] == user.ap_id -> true
true -> false
end
end
def skip?(:internal, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.internal?(user)
end
def skip?(:invisible, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.invisible?(user)
end
def skip?(
:block_from_strangers,
%Activity{} = activity,
%User{notification_settings: %{block_from_strangers: true}} = user,
opts
) do
actor = activity.data["actor"]
follower = User.get_cached_by_ap_id(actor)
cond do
opts[:type] == "poll" -> false
user.ap_id == actor -> false
!User.following?(user, follower) -> true
true -> false
end
end
# To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
def skip?(
:recently_followed,
%Activity{data: %{"type" => "Follow"}} = activity,
%User{} = user,
_opts
) do
actor = activity.data["actor"]
Notification.for_user(user)
|> Enum.any?(fn
%{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true
_ -> false
end)
end
def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
do: false
def skip?(:filtered, activity, user, _opts) do
object = Object.normalize(activity, fetch: false)
cond do
is_nil(object) ->
false
object.data["actor"] == user.ap_id ->
false
not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) ->
Regex.match?(regex, object.data["content"])
true ->
false
end
end
def skip?(_type, _activity, _user, _opts), do: false
def mark_as_read?(activity, target_user) do
user = Activity.user_actor(activity)
User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(activity, target_user)
end
def for_user_and_activity(user, activity) do
from(n in __MODULE__,
where: n.user_id == ^user.id,
where: n.activity_id == ^activity.id
)
|> Repo.one()
end
@spec mark_context_as_read(User.t(), String.t()) :: {integer(), nil | [term()]}
def mark_context_as_read(%User{id: id}, context) do
from(
n in Notification,
join: a in assoc(n, :activity),
where: n.user_id == ^id,
where: n.seen == false,
where: fragment("?->>'context'", a.data) == ^context
)
|> Repo.update_all(set: [seen: true])
end
@doc "Streams a list of notifications over websockets and web push"
@spec stream(list(Notification.t())) :: :ok
def stream(notifications) do
Enum.each(notifications, fn notification ->
Streamer.stream(["user", "user:notification"], notification)
Push.send(notification)
end)
end
end
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 0b513ee16..1e8bfe3dd 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -1,1981 +1,2013 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.Activity
alias Pleroma.Activity.Ir.Topics
alias Pleroma.Config
alias Pleroma.Constants
alias Pleroma.Conversation
alias Pleroma.Conversation.Participation
alias Pleroma.Filter
alias Pleroma.Hashtag
alias Pleroma.Maps
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Object.Containment
alias Pleroma.Object.Fetcher
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.Upload
alias Pleroma.User
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.Streamer
alias Pleroma.Web.WebFinger
alias Pleroma.Workers.BackgroundWorker
alias Pleroma.Workers.PollWorker
import Ecto.Query
import Pleroma.Web.ActivityPub.Utils
import Pleroma.Web.ActivityPub.Visibility
require Logger
require Pleroma.Constants
@behaviour Pleroma.Web.ActivityPub.ActivityPub.Persisting
@behaviour Pleroma.Web.ActivityPub.ActivityPub.Streaming
defp get_recipients(%{"type" => "Create"} = data) do
to = Map.get(data, "to", [])
cc = Map.get(data, "cc", [])
bcc = Map.get(data, "bcc", [])
actor = Map.get(data, "actor", [])
recipients = [to, cc, bcc, [actor]] |> Enum.concat() |> Enum.uniq()
{recipients, to, cc}
end
defp get_recipients(data) do
to = Map.get(data, "to", [])
cc = Map.get(data, "cc", [])
bcc = Map.get(data, "bcc", [])
recipients = Enum.concat([to, cc, bcc])
{recipients, to, cc}
end
defp check_actor_can_insert(%{"type" => "Delete"}), do: true
defp check_actor_can_insert(%{"type" => "Undo"}), do: true
defp check_actor_can_insert(%{"actor" => actor}) when is_binary(actor) do
case User.get_cached_by_ap_id(actor) do
%User{is_active: true} -> true
_ -> false
end
end
defp check_actor_can_insert(_), do: true
defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil(content) do
limit = Config.get([:instance, :remote_limit])
String.length(content) <= limit
end
defp check_remote_limit(_), do: true
def increase_note_count_if_public(actor, object) do
if public?(object), do: User.increase_note_count(actor), else: {:ok, actor}
end
def decrease_note_count_if_public(actor, object) do
if public?(object), do: User.decrease_note_count(actor), else: {:ok, actor}
end
def update_last_status_at_if_public(actor, object) do
if public?(object), do: User.update_last_status_at(actor), else: {:ok, actor}
end
defp increase_replies_count_if_reply(%{
"object" => %{"inReplyTo" => reply_ap_id} = object,
"type" => "Create"
}) do
if public?(object) do
Object.increase_replies_count(reply_ap_id)
end
end
defp increase_replies_count_if_reply(_create_data), do: :noop
defp increase_quotes_count_if_quote(%{
"object" => %{"quoteUrl" => quote_ap_id} = object,
"type" => "Create"
}) do
if public?(object) do
Object.increase_quotes_count(quote_ap_id)
end
end
defp increase_quotes_count_if_quote(_create_data), do: :noop
@object_types ~w[ChatMessage Question Answer Audio Video Image Event Article Note Page]
@impl true
def persist(%{"type" => type} = object, meta) when type in @object_types do
with {:ok, object} <- Object.create(object) do
{:ok, object, meta}
end
end
@impl true
def persist(object, meta) do
with local <- Keyword.fetch!(meta, :local),
{recipients, _, _} <- get_recipients(object),
{:ok, activity} <-
Repo.insert(%Activity{
data: object,
local: local,
recipients: recipients,
actor: object["actor"]
}),
# TODO: add tests for expired activities, when Note type will be supported in new pipeline
{:ok, _} <- maybe_create_activity_expiration(activity) do
{:ok, activity, meta}
end
end
@spec insert(map(), boolean(), boolean(), boolean()) :: {:ok, Activity.t()} | {:error, any()}
def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do
with nil <- Activity.normalize(map),
map <- lazy_put_activity_defaults(map, fake),
{_, true} <- {:actor_check, bypass_actor_check || check_actor_can_insert(map)},
{_, true} <- {:remote_limit_pass, check_remote_limit(map)},
{:ok, map} <- MRF.filter(map),
{recipients, _, _} = get_recipients(map),
{:fake, false, map, recipients} <- {:fake, fake, map, recipients},
{:containment, :ok} <- {:containment, Containment.contain_child(map)},
{:ok, map, object} <- insert_full_object(map),
{:ok, activity} <- insert_activity_with_expiration(map, local, recipients) do
# Splice in the child object if we have one.
activity = Maps.put_if_present(activity, :object, object)
Pleroma.Web.RichMedia.Card.get_by_activity(activity)
# Add local posts to search index
if local, do: Pleroma.Search.add_to_index(activity)
{:ok, activity}
else
%Activity{} = activity ->
{:ok, activity}
{:actor_check, _} ->
{:error, false}
{:containment, _} = error ->
error
{:error, _} = error ->
error
{:fake, true, map, recipients} ->
activity = %Activity{
data: map,
local: local,
actor: map["actor"],
recipients: recipients,
id: "pleroma:fakeid"
}
Pleroma.Web.RichMedia.Card.get_by_activity(activity)
{:ok, activity}
{:remote_limit_pass, _} ->
{:error, :remote_limit}
{:reject, _} = e ->
{:error, e}
end
end
defp insert_activity_with_expiration(data, local, recipients) do
struct = %Activity{
data: data,
local: local,
actor: data["actor"],
recipients: recipients
}
with {:ok, activity} <- Repo.insert(struct) do
maybe_create_activity_expiration(activity)
end
end
def notify_and_stream(activity) do
{:ok, notifications} = Notification.create_notifications(activity)
Notification.stream(notifications)
original_activity =
case activity do
%{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} ->
Activity.get_create_by_object_ap_id_with_object(id)
_ ->
activity
end
conversation = create_or_bump_conversation(original_activity, original_activity.actor)
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
end
defp maybe_create_activity_expiration(
%{data: %{"expires_at" => %DateTime{} = expires_at}} = activity
) do
with {:ok, _job} <-
Pleroma.Workers.PurgeExpiredActivity.enqueue(
%{
activity_id: activity.id
},
scheduled_at: expires_at
) do
{:ok, activity}
end
end
defp maybe_create_activity_expiration(activity), do: {:ok, activity}
defp create_or_bump_conversation(activity, actor) do
with {:ok, conversation} <- Conversation.create_or_bump_for(activity),
%User{} = user <- User.get_cached_by_ap_id(actor) do
Participation.mark_as_read(user, conversation)
{:ok, conversation}
end
end
defp get_participations({:ok, conversation}) do
conversation
|> Repo.preload(:participations, force: true)
|> Map.get(:participations)
end
defp get_participations(_), do: []
def stream_out_participations(participations) do
participations =
participations
|> Repo.preload(:user)
Streamer.stream("participation", participations)
end
@impl true
def stream_out_participations(%Object{data: %{"context" => context}}, user) do
with %Conversation{} = conversation <- Conversation.get_for_ap_id(context) do
conversation = Repo.preload(conversation, :participations)
last_activity_id =
fetch_latest_direct_activity_id_for_context(conversation.ap_id, %{
user: user,
blocking_user: user
})
if last_activity_id do
stream_out_participations(conversation.participations)
end
end
end
@impl true
def stream_out_participations(_, _), do: :noop
@impl true
def stream_out(%Activity{data: %{"type" => data_type}} = activity)
when data_type in ["Create", "Announce", "Delete", "Update"] do
activity
|> Topics.get_activity_topics()
|> Streamer.stream(activity)
end
@impl true
def stream_out(_activity) do
:noop
end
@spec create(map(), boolean()) :: {:ok, Activity.t()} | {:error, any()}
def create(params, fake \\ false) do
with {:ok, result} <- Repo.transaction(fn -> do_create(params, fake) end) do
result
end
end
defp do_create(%{to: to, actor: actor, context: context, object: object} = params, fake) do
additional = params[:additional] || %{}
# only accept false as false value
local = !(params[:local] == false)
published = params[:published]
quick_insert? = Config.get([:env]) == :benchmark
create_data =
make_create_data(
%{to: to, actor: actor, published: published, context: context, object: object},
additional
)
with {:ok, activity} <- insert(create_data, local, fake),
{:fake, false, activity} <- {:fake, fake, activity},
_ <- increase_replies_count_if_reply(create_data),
_ <- increase_quotes_count_if_quote(create_data),
{:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
{:ok, _actor} <- increase_note_count_if_public(actor, activity),
{:ok, _actor} <- update_last_status_at_if_public(actor, activity),
_ <- notify_and_stream(activity),
:ok <- maybe_schedule_poll_notifications(activity),
:ok <- maybe_handle_group_posts(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
{:quick_insert, true, activity} ->
{:ok, activity}
{:fake, true, activity} ->
{:ok, activity}
{:error, message} ->
Repo.rollback(message)
end
end
defp maybe_schedule_poll_notifications(activity) do
PollWorker.schedule_poll_end(activity)
:ok
end
@spec listen(map()) :: {:ok, Activity.t()} | {:error, any()}
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]
listen_data =
make_listen_data(
%{to: to, actor: actor, published: published, context: context, object: object},
additional
)
with {:ok, activity} <- insert(listen_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
end
@spec unfollow(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | nil | {:error, any()}
def unfollow(follower, followed, activity_id \\ nil, local \\ true) do
with {:ok, result} <-
Repo.transaction(fn -> do_unfollow(follower, followed, activity_id, local) end) do
result
end
end
defp do_unfollow(follower, followed, activity_id, local) do
with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
{:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
nil -> nil
{:error, error} -> Repo.rollback(error)
end
end
@spec flag(map()) :: {:ok, Activity.t()} | {:error, any()}
def flag(params) do
with {:ok, result} <- Repo.transaction(fn -> do_flag(params) end) do
result
end
end
defp do_flag(
%{
actor: actor,
context: _context,
account: account,
statuses: statuses,
content: content
} = params
) do
# only accept false as false value
local = !(params[:local] == false)
forward = !(params[:forward] == false)
additional = params[:additional] || %{}
additional =
if forward do
Map.merge(additional, %{"to" => [], "cc" => [account.ap_id]})
else
Map.merge(additional, %{"to" => [], "cc" => []})
end
with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
User.all_users_with_privilege(:reports_manage_reports)
|> Enum.filter(fn user -> user.ap_id != actor end)
|> Enum.filter(fn user -> not is_nil(user.email) end)
|> Enum.each(fn privileged_user ->
privileged_user
|> Pleroma.Emails.AdminEmail.report(actor, account, statuses, content)
|> Pleroma.Emails.Mailer.deliver_async()
end)
{:ok, activity}
else
{:error, error} -> Repo.rollback(error)
end
end
@spec move(User.t(), User.t(), boolean()) :: {:ok, Activity.t()} | {:error, any()}
def move(%User{} = origin, %User{} = target, local \\ true) do
params = %{
"type" => "Move",
"actor" => origin.ap_id,
"object" => origin.ap_id,
"target" => target.ap_id,
"to" => [origin.follower_address]
}
with true <- origin.ap_id in target.also_known_as,
{:ok, activity} <- insert(params, local),
_ <- notify_and_stream(activity) do
maybe_federate(activity)
BackgroundWorker.new(%{
"op" => "move_following",
"origin_id" => origin.id,
"target_id" => target.id
})
|> Oban.insert()
{:ok, activity}
else
false -> {:error, "Target account must have the origin in `alsoKnownAs`"}
err -> err
end
end
def fetch_activities_for_context_query(context, opts) do
public = [Constants.as_public()]
recipients =
if opts[:user],
do: [opts[:user].ap_id | User.following(opts[:user])] ++ public,
else: public
from(activity in Activity)
|> maybe_preload_objects(opts)
|> maybe_preload_bookmarks(opts)
|> maybe_set_thread_muted_field(opts)
|> restrict_unauthenticated(opts[:user])
|> restrict_blocked(opts)
|> restrict_blockers_visibility(opts)
|> restrict_recipients(recipients, opts[:user])
|> restrict_filtered(opts)
|> where(
[activity],
fragment(
"?->>'type' = ? and ?->>'context' = ?",
activity.data,
"Create",
activity.data,
^context
)
)
|> exclude_poll_votes(opts)
|> exclude_id(opts)
|> order_by([activity], desc: activity.id)
end
@spec fetch_activities_for_context(String.t(), keyword() | map()) :: [Activity.t()]
def fetch_activities_for_context(context, opts \\ %{}) do
context
|> fetch_activities_for_context_query(opts)
|> Repo.all()
end
def fetch_objects_for_replies_collection(parent_ap_id, opts \\ %{}) do
opts =
opts
|> Map.put(:order_asc, true)
|> Map.put(:id_type, :integer)
from(o in Object,
where:
fragment("?->>'inReplyTo' = ?", o.data, ^parent_ap_id) and
fragment(
"(?->'to' \\? ?::text OR ?->'cc' \\? ?::text)",
o.data,
^Pleroma.Constants.as_public(),
o.data,
^Pleroma.Constants.as_public()
) and
fragment("?->>'type' <> 'Answer'", o.data),
select: %{id: o.id, ap_id: fragment("?->>'id'", o.data)}
)
|> Pagination.fetch_paginated(opts, :keyset)
end
@spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) ::
Ecto.UUID.t() | nil
def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do
context
|> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts))
|> restrict_visibility(%{visibility: "direct"})
|> limit(1)
|> select([a], a.id)
|> Repo.one()
end
defp fetch_paginated_optimized(query, opts, pagination) do
# Note: tag-filtering funcs may apply "ORDER BY objects.id DESC",
# and extra sorting on "activities.id DESC NULLS LAST" would worse the query plan
opts = Map.put(opts, :skip_extra_order, true)
Pagination.fetch_paginated(query, opts, pagination)
end
def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do
list_memberships = Pleroma.List.memberships(opts[:user])
fetch_activities_query(recipients ++ list_memberships, opts)
|> fetch_paginated_optimized(opts, pagination)
|> Enum.reverse()
|> maybe_update_cc(list_memberships, opts[:user])
end
@spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()]
def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do
includes_local_public = Map.get(opts, :includes_local_public, false)
opts = Map.delete(opts, :user)
intended_recipients =
if includes_local_public do
[Constants.as_public(), as_local_public()]
else
[Constants.as_public()]
end
intended_recipients
|> fetch_activities_query(opts)
|> restrict_unlisted(opts)
|> fetch_paginated_optimized(opts, pagination)
end
@spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()]
def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do
opts
|> Map.put(:restrict_unlisted, true)
|> fetch_public_or_unlisted_activities(pagination)
end
@valid_visibilities ~w[direct unlisted public private]
defp restrict_visibility(query, %{visibility: visibility})
when is_list(visibility) do
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
from(
a in query,
where:
fragment(
"activity_visibility(?, ?, ?) = ANY (?)",
a.actor,
a.recipients,
a.data,
^visibility
)
)
else
Logger.error("Could not restrict visibility to #{visibility}")
end
end
defp restrict_visibility(query, %{visibility: visibility})
when visibility in @valid_visibilities do
from(
a in query,
where:
fragment("activity_visibility(?, ?, ?) = ?", a.actor, a.recipients, a.data, ^visibility)
)
end
defp restrict_visibility(_query, %{visibility: visibility})
when visibility not in @valid_visibilities do
Logger.error("Could not restrict visibility to #{visibility}")
end
defp restrict_visibility(query, _visibility), do: query
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when is_list(visibility) do
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
from(
a in query,
where:
not fragment(
"activity_visibility(?, ?, ?) = ANY (?)",
a.actor,
a.recipients,
a.data,
^visibility
)
)
else
Logger.error("Could not exclude visibility to #{visibility}")
query
end
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility in @valid_visibilities do
from(
a in query,
where:
not fragment(
"activity_visibility(?, ?, ?) = ?",
a.actor,
a.recipients,
a.data,
^visibility
)
)
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility not in [nil | @valid_visibilities] do
Logger.error("Could not exclude visibility to #{visibility}")
query
end
defp exclude_visibility(query, _visibility), do: query
defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _),
do: query
defp restrict_thread_visibility(query, %{user: %User{skip_thread_containment: true}}, _),
do: query
defp restrict_thread_visibility(query, %{user: %User{ap_id: ap_id}}, _) do
local_public = as_local_public()
from(
a in query,
where: fragment("thread_visibility(?, (?)->>'id', ?) = true", ^ap_id, a.data, ^local_public)
)
end
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)
%{
godmode: params[:godmode],
reading_user: reading_user
}
|> user_activities_recipients()
|> fetch_activities(params)
|> Enum.reverse()
end
def fetch_user_activities(user, reading_user, params \\ %{})
def fetch_user_activities(user, reading_user, %{total: true} = params) do
result = fetch_activities_for_user(user, reading_user, params)
Keyword.put(result, :items, Enum.reverse(result[:items]))
end
def fetch_user_activities(user, reading_user, params) do
user
|> fetch_activities_for_user(reading_user, params)
|> Enum.reverse()
end
defp fetch_activities_for_user(user, reading_user, params) do
params =
params
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:user, reading_user)
|> Map.put(:actor_id, user.ap_id)
|> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects))
params =
if User.blocks?(reading_user, user) do
params
else
params
|> Map.put(:blocking_user, reading_user)
|> Map.put(:muting_user, reading_user)
end
pagination_type = Map.get(params, :pagination_type) || :keyset
%{
godmode: params[:godmode],
reading_user: reading_user
}
|> user_activities_recipients()
|> fetch_activities(params, pagination_type)
end
def fetch_statuses(reading_user, %{total: true} = params) do
result = fetch_activities_for_reading_user(reading_user, params)
Keyword.put(result, :items, Enum.reverse(result[:items]))
end
def fetch_statuses(reading_user, params) do
reading_user
|> fetch_activities_for_reading_user(params)
|> Enum.reverse()
end
defp fetch_activities_for_reading_user(reading_user, params) do
params = Map.put(params, :type, ["Create", "Announce"])
%{
godmode: params[:godmode],
reading_user: reading_user
}
|> user_activities_recipients()
|> fetch_activities(params, :offset)
end
defp user_activities_recipients(%{godmode: true}), do: []
defp user_activities_recipients(%{reading_user: reading_user}) do
if not is_nil(reading_user) and reading_user.local do
[
Constants.as_public(),
as_local_public(),
reading_user.ap_id | User.following(reading_user)
]
else
[Constants.as_public()]
end
end
defp restrict_announce_object_actor(_query, %{announce_filtering_user: _, skip_preload: true}) do
raise "Can't use the child object without preloading!"
end
defp restrict_announce_object_actor(query, %{announce_filtering_user: %{ap_id: actor}}) do
from(
[activity, object] in query,
where:
fragment(
"?->>'type' != ? or ?->>'actor' != ?",
activity.data,
"Announce",
object.data,
^actor
)
)
end
defp restrict_announce_object_actor(query, _), do: query
defp restrict_since(query, %{since_id: ""}), do: query
defp restrict_since(query, %{since_id: since_id}) do
from(activity in query, where: activity.id > ^since_id)
end
defp restrict_since(query, _), do: query
defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do
from(
[_activity, object] in query,
where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all)
)
end
defp restrict_embedded_tag_all(query, %{tag_all: tag}) when is_binary(tag) do
restrict_embedded_tag_any(query, %{tag: tag})
end
defp restrict_embedded_tag_all(query, _), do: query
defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag_any}) do
from(
[_activity, object] in query,
where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag_any)
)
end
defp restrict_embedded_tag_any(query, %{tag: tag}) when is_binary(tag) do
restrict_embedded_tag_any(query, %{tag: [tag]})
end
defp restrict_embedded_tag_any(query, _), do: query
defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do
from(
[_activity, object] in query,
where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject)
)
end
defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject})
when is_binary(tag_reject) do
restrict_embedded_tag_reject_any(query, %{tag_reject: [tag_reject]})
end
defp restrict_embedded_tag_reject_any(query, _), do: query
defp object_ids_query_for_tags(tags) do
from(hto in "hashtags_objects")
|> join(:inner, [hto], ht in Pleroma.Hashtag, on: hto.hashtag_id == ht.id)
|> where([hto, ht], ht.name in ^tags)
|> select([hto], hto.object_id)
|> distinct([hto], true)
end
defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_hashtag_all(query, %{tag_all: [single_tag]}) do
restrict_hashtag_any(query, %{tag: single_tag})
end
defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do
from(
[_activity, object] in query,
where:
fragment(
"""
(SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects
ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?)
AND hashtags_objects.object_id = ?) @> ?
""",
^tags,
object.id,
^tags
)
)
end
defp restrict_hashtag_all(query, %{tag_all: tag}) when is_binary(tag) do
restrict_hashtag_all(query, %{tag_all: [tag]})
end
defp restrict_hashtag_all(query, _), do: query
defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do
hashtag_ids =
from(ht in Hashtag, where: ht.name in ^tags, select: ht.id)
|> Repo.all()
# Note: NO extra ordering should be done on "activities.id desc nulls last" for optimal plan
from(
[_activity, object] in query,
join: hto in "hashtags_objects",
on: hto.object_id == object.id,
where: hto.hashtag_id in ^hashtag_ids,
distinct: [desc: object.id],
order_by: [desc: object.id]
)
end
defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do
restrict_hashtag_any(query, %{tag: [tag]})
end
defp restrict_hashtag_any(query, _), do: query
defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do
raise_on_missing_preload()
end
defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do
from(
[_activity, object] in query,
where: object.id not in subquery(object_ids_query_for_tags(tags_reject))
)
end
defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do
restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]})
end
defp restrict_hashtag_reject_any(query, _), do: query
defp raise_on_missing_preload do
raise "Can't use the child object without preloading!"
end
defp restrict_recipients(query, [], _user), do: query
defp restrict_recipients(query, recipients, nil) do
from(activity in query, where: fragment("? && ?", ^recipients, activity.recipients))
end
defp restrict_recipients(query, recipients, user) do
from(
activity in query,
where: fragment("? && ?", ^recipients, activity.recipients),
or_where: activity.actor == ^user.ap_id
)
end
# Essentially, either look for activities addressed to `recipients`, _OR_ ones
# that reference a hashtag that the user follows
# Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't
# follow any
defp restrict_recipients_or_hashtags(query, recipients, user, nil) do
restrict_recipients(query, recipients, user)
end
defp restrict_recipients_or_hashtags(query, recipients, user, []) do
restrict_recipients(query, recipients, user)
end
defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do
from([activity, object] in query)
|> join(:left, [activity, object], hto in "hashtags_objects",
on: hto.object_id == object.id,
as: :hto
)
|> where(
[activity, object, hto: hto],
(hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or
fragment("? && ?", ^recipients, activity.recipients)
)
end
defp restrict_local(query, %{local_only: true}) do
from(activity in query, where: activity.local == true)
end
defp restrict_local(query, _), do: query
defp restrict_remote(query, %{remote: true}) do
from(activity in query, where: activity.local == false)
end
defp restrict_remote(query, _), do: query
defp restrict_actor(query, %{actor_id: actor_id}) do
from(activity in query, where: activity.actor == ^actor_id)
end
defp restrict_actor(query, _), do: query
defp restrict_type(query, %{type: type}) when is_binary(type) do
from(activity in query, where: fragment("?->>'type' = ?", activity.data, ^type))
end
defp restrict_type(query, %{type: type}) do
from(activity in query, where: fragment("?->>'type' = ANY(?)", activity.data, ^type))
end
defp restrict_type(query, _), do: query
defp restrict_state(query, %{state: state}) do
from(activity in query, where: fragment("?->>'state' = ?", activity.data, ^state))
end
defp restrict_state(query, _), do: query
defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do
from(activity in query,
where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account)
)
end
defp restrict_assigned_account(query, _), do: query
defp restrict_favorited_by(query, %{favorited_by: ap_id}) do
from(
[_activity, object] in query,
where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id)
)
end
defp restrict_favorited_by(query, _), do: query
defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do
raise "Can't use the child object without preloading!"
end
defp restrict_media(query, %{only_media: true}) do
from(
[activity, object] in query,
where: fragment("(?)->>'type' = ?", activity.data, "Create"),
where: fragment("not (?)->'attachment' = (?)", object.data, ^[])
)
end
defp restrict_media(query, _), do: query
defp restrict_replies(query, %{exclude_replies: true}) do
from(
[activity, object] in query,
where:
fragment("?->>'inReplyTo' is null or ?->>'type' = 'Announce'", object.data, activity.data)
)
end
defp restrict_replies(query, %{
reply_filtering_user: %User{} = user,
reply_visibility: "self"
}) do
from(
[activity, object] in query,
where:
fragment(
"?->>'inReplyTo' is null OR ? = ANY(?)",
object.data,
^user.ap_id,
activity.recipients
)
)
end
defp restrict_replies(query, %{
reply_filtering_user: %User{} = user,
reply_visibility: "following"
}) do
from(
[activity, object] in query,
where:
fragment(
"""
?->>'type' != 'Create' -- This isn't a Create
OR ?->>'inReplyTo' is null -- this isn't a reply
OR ? && array_remove(?, ?) -- The recipient is us or one of our friends,
-- unless they are the author (because authors
-- are also part of the recipients). This leads
-- to a bug that self-replies by friends won't
-- show up.
OR ? = ? -- The actor is us
""",
activity.data,
object.data,
^[user.ap_id | User.get_cached_user_friends_ap_ids(user)],
activity.recipients,
activity.actor,
activity.actor,
^user.ap_id
)
)
end
defp restrict_replies(query, _), do: query
defp restrict_reblogs(query, %{exclude_reblogs: true}) do
from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data))
end
defp restrict_reblogs(query, %{only_reblogs: true}) do
from(activity in query, where: fragment("?->>'type' = 'Announce'", activity.data))
end
defp restrict_reblogs(query, _), do: query
defp restrict_muted(query, %{with_muted: true}), do: query
defp restrict_muted(query, %{muting_user: %User{} = user} = opts) do
mutes = opts[:muted_users_ap_ids] || User.muted_users_ap_ids(user)
query =
from([activity] in query,
where: fragment("not (? = ANY(?))", activity.actor, ^mutes),
where:
fragment(
"not (?->'to' \\?| ?) or ? = ?",
activity.data,
^mutes,
activity.actor,
^user.ap_id
)
)
unless opts[:skip_preload] do
from([thread_mute: tm] in query, where: is_nil(tm.user_id))
else
query
end
end
defp restrict_muted(query, _), do: query
defp restrict_blocked(query, %{blocking_user: %User{} = user} = opts) do
blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
domain_blocks = user.domain_blocks || []
following_ap_ids = User.get_friends_ap_ids(user)
query =
if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query)
from(
[activity, object: o] in query,
# You don't block the author
where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids),
# You don't block any recipients, and didn't author the post
where:
fragment(
"((not (? && ?)) or ? = ?)",
activity.recipients,
^blocked_ap_ids,
activity.actor,
^user.ap_id
),
# You don't block the domain of any recipients, and didn't author the post
where:
fragment(
"(recipients_contain_blocked_domains(?, ?) = false) or ? = ?",
activity.recipients,
^domain_blocks,
activity.actor,
^user.ap_id
),
# It's not a boost of a user you block
where:
fragment(
"not (?->>'type' = 'Announce' and ?->'to' \\?| ?)",
activity.data,
activity.data,
^blocked_ap_ids
),
# You don't block the author's domain, and also don't follow the author
where:
fragment(
"(not (split_part(?, '/', 3) = ANY(?))) or ? = ANY(?)",
activity.actor,
^domain_blocks,
activity.actor,
^following_ap_ids
),
# Same as above, but checks the Object
where:
fragment(
"(not (split_part(?->>'actor', '/', 3) = ANY(?))) or (?->>'actor') = ANY(?)",
o.data,
^domain_blocks,
o.data,
^following_ap_ids
)
)
end
defp restrict_blocked(query, _), do: query
defp restrict_blockers_visibility(query, %{blocking_user: %User{} = user}) do
if Config.get([:activitypub, :blockers_visible]) == true do
query
else
blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block])
from(
activity in query,
# The author doesn't block you
where: fragment("not (? = ANY(?))", activity.actor, ^blocker_ap_ids),
# It's not a boost of a user that blocks you
where:
fragment(
"not (?->>'type' = 'Announce' and ?->'to' \\?| ?)",
activity.data,
activity.data,
^blocker_ap_ids
)
)
end
end
defp restrict_blockers_visibility(query, _), do: query
defp restrict_unlisted(query, %{restrict_unlisted: true}) do
from(
activity in query,
where:
fragment(
"not (coalesce(?->'cc', '{}'::jsonb) \\?| ?)",
activity.data,
^[Constants.as_public()]
)
)
end
defp restrict_unlisted(query, _), do: query
defp restrict_pinned(query, %{pinned: true, pinned_object_ids: ids}) do
from(
[activity, object: o] in query,
where:
fragment(
"(?)->>'type' = 'Create' and associated_object_id((?)) = any (?)",
activity.data,
activity.data,
^ids
)
)
end
defp restrict_pinned(query, _), do: query
defp restrict_muted_reblogs(query, %{muting_user: %User{} = user} = opts) do
muted_reblogs = opts[:reblog_muted_users_ap_ids] || User.reblog_muted_users_ap_ids(user)
from(
activity in query,
where:
fragment(
"not ( ?->>'type' = 'Announce' and ? = ANY(?))",
activity.data,
activity.actor,
^muted_reblogs
)
)
end
defp restrict_muted_reblogs(query, _), do: query
defp restrict_instance(query, %{instance: instance}) when is_binary(instance) do
from(
activity in query,
where: fragment("split_part(actor::text, '/'::text, 3) = ?", ^instance)
)
end
defp restrict_instance(query, _), do: query
defp restrict_filtered(query, %{user: %User{} = user}) do
case Filter.compose_regex(user) do
nil ->
query
regex ->
from([activity, object] in query,
where:
fragment("not(?->>'content' ~* ?)", object.data, ^regex) or
activity.actor == ^user.ap_id
)
end
end
defp restrict_filtered(query, %{blocking_user: %User{} = user}) do
restrict_filtered(query, %{user: user})
end
defp restrict_filtered(query, _), do: query
defp restrict_unauthenticated(query, nil) do
local = Config.restrict_unauthenticated_access?(:activities, :local)
remote = Config.restrict_unauthenticated_access?(:activities, :remote)
cond do
local and remote ->
from(activity in query, where: false)
local ->
from(activity in query, where: activity.local == false)
remote ->
from(activity in query, where: activity.local == true)
true ->
query
end
end
defp restrict_unauthenticated(query, _), do: query
defp restrict_quote_url(query, %{quote_url: quote_url}) do
from([_activity, object] in query,
where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url)
)
end
defp restrict_quote_url(query, _), do: query
defp restrict_rule(query, %{rule_id: rule_id}) do
from(
activity in query,
where: fragment("(?)->'rules' \\? (?)", activity.data, ^rule_id)
)
end
defp restrict_rule(query, _), do: query
defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query
defp exclude_poll_votes(query, _) do
if has_named_binding?(query, :object) do
from([activity, object: o] in query,
where: fragment("not(?->>'type' = ?)", o.data, "Answer")
)
else
query
end
end
defp exclude_chat_messages(query, %{include_chat_messages: true}), do: query
defp exclude_chat_messages(query, _) do
if has_named_binding?(query, :object) do
from([activity, object: o] in query,
where: fragment("not(?->>'type' = ?)", o.data, "ChatMessage")
)
else
query
end
end
defp exclude_invisible_actors(query, %{type: "Flag"}), do: query
defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query
defp exclude_invisible_actors(query, _opts) do
query
|> join(:inner, [activity], u in User,
as: :u,
on: activity.actor == u.ap_id and u.invisible == false
)
end
defp exclude_id(query, %{exclude_id: id}) when is_binary(id) do
from(activity in query, where: activity.id != ^id)
end
defp exclude_id(query, _), do: query
defp maybe_preload_objects(query, %{skip_preload: true}), do: query
defp maybe_preload_objects(query, _) do
query
|> Activity.with_preloaded_object()
end
defp maybe_preload_bookmarks(query, %{skip_preload: true}), do: query
defp maybe_preload_bookmarks(query, opts) do
query
|> Activity.with_preloaded_bookmark(opts[:user])
end
defp maybe_preload_report_notes(query, %{preload_report_notes: true}) do
query
|> Activity.with_preloaded_report_notes()
end
defp maybe_preload_report_notes(query, _), do: query
defp maybe_set_thread_muted_field(query, %{skip_preload: true}), do: query
defp maybe_set_thread_muted_field(query, opts) do
query
|> Activity.with_set_thread_muted_field(opts[:muting_user] || opts[:user])
end
defp maybe_order(query, %{order: :desc}) do
query
|> order_by(desc: :id)
end
defp maybe_order(query, %{order: :asc}) do
query
|> order_by(asc: :id)
end
defp maybe_order(query, _), do: query
defp normalize_fetch_activities_query_opts(opts) do
Enum.reduce([:tag, :tag_all, :tag_reject], opts, fn key, opts ->
case opts[key] do
value when is_bitstring(value) ->
Map.put(opts, key, Hashtag.normalize_name(value))
value when is_list(value) ->
normalized_value =
value
|> Enum.map(&Hashtag.normalize_name/1)
|> Enum.uniq()
Map.put(opts, key, normalized_value)
_ ->
opts
end
end)
end
defp fetch_activities_query_ap_ids_ops(opts) do
source_user = opts[:muting_user]
ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: []
ap_id_relationships =
if opts[:blocking_user] && opts[:blocking_user] == source_user do
[:block | ap_id_relationships]
else
ap_id_relationships
end
preloaded_ap_ids = User.outgoing_relationships_ap_ids(source_user, ap_id_relationships)
restrict_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts)
restrict_muted_opts = Map.merge(%{muted_users_ap_ids: preloaded_ap_ids[:mute]}, opts)
restrict_muted_reblogs_opts =
Map.merge(%{reblog_muted_users_ap_ids: preloaded_ap_ids[:reblog_mute]}, opts)
{restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts}
end
def fetch_activities_query(recipients, opts \\ %{}) do
opts = normalize_fetch_activities_query_opts(opts)
{restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} =
fetch_activities_query_ap_ids_ops(opts)
config = %{
skip_thread_containment: Config.get([:instance, :skip_thread_containment])
}
query =
Activity
|> maybe_preload_objects(opts)
|> maybe_preload_bookmarks(opts)
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts)
|> maybe_order(opts)
|> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
|> restrict_replies(opts)
|> restrict_since(opts)
|> restrict_local(opts)
|> restrict_remote(opts)
|> restrict_actor(opts)
|> restrict_type(opts)
|> restrict_state(opts)
|> restrict_assigned_account(opts)
|> restrict_favorited_by(opts)
|> restrict_blocked(restrict_blocked_opts)
|> restrict_blockers_visibility(opts)
|> restrict_muted(restrict_muted_opts)
|> restrict_filtered(opts)
|> restrict_media(opts)
|> restrict_visibility(opts)
|> restrict_thread_visibility(opts, config)
|> restrict_reblogs(opts)
|> restrict_pinned(opts)
|> restrict_muted_reblogs(restrict_muted_reblogs_opts)
|> restrict_instance(opts)
|> restrict_announce_object_actor(opts)
|> restrict_filtered(opts)
|> restrict_rule(opts)
|> restrict_quote_url(opts)
|> maybe_restrict_deactivated_users(opts)
|> exclude_poll_votes(opts)
|> exclude_chat_messages(opts)
|> exclude_invisible_actors(opts)
|> exclude_visibility(opts)
if Config.feature_enabled?(:improved_hashtag_timeline) do
query
|> restrict_hashtag_any(opts)
|> restrict_hashtag_all(opts)
|> restrict_hashtag_reject_any(opts)
else
query
|> restrict_embedded_tag_any(opts)
|> restrict_embedded_tag_all(opts)
|> restrict_embedded_tag_reject_any(opts)
end
end
@doc """
Fetch favorites activities of user with order by sort adds to favorites
"""
@spec fetch_favourites(User.t(), map(), Pagination.type()) :: list(Activity.t())
def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do
user.ap_id
|> Activity.Queries.by_actor()
|> Activity.Queries.by_type("Like")
|> Activity.with_joined_object()
|> Object.with_joined_activity()
|> select([like, object, activity], %{activity | object: object, pagination_id: like.id})
|> order_by([like, _, _], desc_nulls_last: like.id)
|> Pagination.fetch_paginated(
Map.merge(params, %{skip_order: true}),
pagination
)
end
defp maybe_update_cc(activities, [_ | _] = list_memberships, %User{ap_id: user_ap_id}) do
Enum.map(activities, fn
%{data: %{"bcc" => [_ | _] = bcc}} = activity ->
if Enum.any?(bcc, &(&1 in list_memberships)) do
update_in(activity.data["cc"], &[user_ap_id | &1])
else
activity
end
activity ->
activity
end)
end
defp maybe_update_cc(activities, _, _), do: activities
defp fetch_activities_bounded_query(query, recipients, recipients_with_public) do
from(activity in query,
where:
fragment("? && ?", activity.recipients, ^recipients) or
(fragment("? && ?", activity.recipients, ^recipients_with_public) and
^Constants.as_public() in activity.recipients)
)
end
def fetch_activities_bounded(
recipients,
recipients_with_public,
opts \\ %{},
pagination \\ :keyset
) do
fetch_activities_query([], opts)
|> fetch_activities_bounded_query(recipients, recipients_with_public)
|> Pagination.fetch_paginated(opts, pagination)
|> Enum.reverse()
end
@spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()}
def upload(file, opts \\ []) do
with {:ok, data} <- Upload.store(sanitize_upload_file(file), opts) do
obj_data = Maps.put_if_present(data, "actor", opts[:actor])
Repo.insert(%Object{data: obj_data})
end
end
defp sanitize_upload_file(%Plug.Upload{filename: filename} = upload) when is_binary(filename) do
%Plug.Upload{
upload
| filename: Path.basename(filename)
}
end
defp sanitize_upload_file(upload), do: upload
@spec get_actor_url(any()) :: binary() | nil
defp get_actor_url(url) when is_binary(url), do: url
defp get_actor_url(%{"href" => href}) when is_binary(href), do: href
defp get_actor_url(url) when is_list(url) do
url
|> List.first()
|> get_actor_url()
end
defp get_actor_url(_url), do: nil
defp normalize_image(%{"url" => url} = data) when is_binary(url) do
%{
"type" => "Image",
"url" => [%{"href" => url}]
}
|> maybe_put_description(data)
end
defp normalize_image(%{"url" => urls}) when is_list(urls) do
url = urls |> List.first()
%{"url" => url}
|> normalize_image()
end
defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image()
defp normalize_image(_), do: nil
defp normalize_also_known_as(urls) when is_list(urls), do: urls
defp normalize_also_known_as(url) when is_binary(url), do: [url]
defp normalize_also_known_as(nil), do: []
defp maybe_put_description(map, %{"name" => description}) when is_binary(description) do
Map.put(map, "name", description)
end
defp maybe_put_description(map, _), do: map
defp object_to_user_data(data, additional) do
fields =
data
|> Map.get("attachment", [])
|> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end)
|> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end)
emojis =
data
|> Map.get("tag", [])
|> Enum.filter(fn
%{"type" => "Emoji"} -> true
_ -> false
end)
|> Map.new(fn %{"icon" => %{"url" => url}, "name" => name} ->
{String.trim(name, ":"), url}
end)
is_locked = data["manuallyApprovesFollowers"] || false
capabilities = data["capabilities"] || %{}
accepts_chat_messages = capabilities["acceptsChatMessages"]
data = Transmogrifier.maybe_fix_user_object(data)
is_discoverable = data["discoverable"] || false
invisible = data["invisible"] || false
actor_type = data["type"] || "Person"
featured_address = data["featured"]
{:ok, pinned_objects} = fetch_and_prepare_featured_from_ap_id(featured_address)
public_key =
if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do
data["publicKey"]["publicKeyPem"]
end
shared_inbox =
if is_map(data["endpoints"]) && is_binary(data["endpoints"]["sharedInbox"]) do
data["endpoints"]["sharedInbox"]
end
birthday =
if is_binary(data["vcard:bday"]) do
case Date.from_iso8601(data["vcard:bday"]) do
{:ok, date} -> date
{:error, _} -> nil
end
end
show_birthday = !!birthday
with {:ok, nickname} <- nickname_from_actor(data, additional) do
{:ok,
%{
ap_id: data["id"],
uri: get_actor_url(data["url"]),
banner: normalize_image(data["image"]),
fields: fields,
emoji: emojis,
is_locked: is_locked,
is_discoverable: is_discoverable,
invisible: invisible,
avatar: normalize_image(data["icon"]),
name: data["name"],
follower_address: data["followers"],
following_address: data["following"],
featured_address: featured_address,
bio: data["summary"] || "",
actor_type: actor_type,
also_known_as: normalize_also_known_as(data["alsoKnownAs"]),
public_key: public_key,
inbox: data["inbox"],
shared_inbox: shared_inbox,
accepts_chat_messages: accepts_chat_messages,
birthday: birthday,
show_birthday: show_birthday,
pinned_objects: pinned_objects,
nickname: nickname
}}
end
end
defp nickname_from_actor(data, additional) do
generated = generated_nickname(data)
case additional[:nickname_from_acct] do
^generated when is_binary(generated) ->
{:ok, generated}
acct when is_binary(acct) ->
with ^acct <- webfinger_nickname(data) do
{:ok, acct}
else
_ -> {:error, {:webfinger_actor_mismatch, acct, data["id"]}}
end
_ ->
{:ok, generate_nickname(data)}
end
end
defp generated_nickname(%{"preferredUsername" => username, "id" => ap_id})
when is_binary(username) and is_binary(ap_id) do
case URI.parse(ap_id) do
%URI{host: host} when is_binary(host) -> "#{username}@#{host}"
_ -> nil
end
end
defp generated_nickname(_), do: nil
defp webfinger_nickname(data) do
with generated when is_binary(generated) <- generated_nickname(data),
{:ok, %{"subject" => "acct:" <> acct, "ap_id" => ap_id}} <- WebFinger.finger(generated),
true <- ap_id == data["id"] do
acct
end
end
defp generate_nickname(%{"preferredUsername" => username} = data) when is_binary(username) do
generated = generated_nickname(data)
if Config.get([WebFinger, :update_nickname_on_user_fetch]) do
case webfinger_nickname(data) do
acct when is_binary(acct) -> acct
_ -> generated
end
else
generated
end
end
# nickname can be nil because of virtual actors
defp generate_nickname(_), do: nil
def fetch_follow_information_for_user(user) do
with {:ok, following_data} <-
Fetcher.fetch_and_contain_remote_object_from_id(user.following_address),
{:ok, hide_follows} <- collection_private(following_data),
{:ok, followers_data} <-
Fetcher.fetch_and_contain_remote_object_from_id(user.follower_address),
{:ok, hide_followers} <- collection_private(followers_data) do
{:ok,
%{
hide_follows: hide_follows,
follower_count: normalize_counter(followers_data["totalItems"]),
following_count: normalize_counter(following_data["totalItems"]),
hide_followers: hide_followers
}}
else
{:error, _} = e -> e
end
end
defp normalize_counter(counter) when is_integer(counter), do: counter
defp normalize_counter(_), do: 0
def maybe_update_follow_information(user_data) do
with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])},
{_, true} <- {:user_type_check, user_data[:type] in ["Person", "Service"]},
{_, true} <-
{:collections_available,
!!(user_data[:following_address] && user_data[:follower_address])},
{:ok, info} <-
fetch_follow_information_for_user(user_data) do
info = Map.merge(user_data[:info] || %{}, info)
user_data
|> Map.put(:info, info)
else
{:user_type_check, false} ->
user_data
{:collections_available, false} ->
user_data
{:enabled, false} ->
user_data
e ->
Logger.error(
"Follower/Following counter update for #{user_data.ap_id} failed.\n" <> inspect(e)
)
user_data
end
end
defp collection_private(%{"first" => %{"type" => type}})
when type in ["CollectionPage", "OrderedCollectionPage"],
do: {:ok, false}
defp collection_private(%{"first" => first}) do
with {:ok, %{"type" => type}} when type in ["CollectionPage", "OrderedCollectionPage"] <-
Fetcher.fetch_and_contain_remote_object_from_id(first) do
{:ok, false}
else
{:error, _} -> {:ok, true}
end
end
defp collection_private(_data), do: {:ok, true}
def user_data_from_user_object(data, additional \\ []) do
with {:ok, data} <- MRF.filter(data),
{:ok, data} <- object_to_user_data(data, additional) do
{:ok, data}
else
{:error, _} = e -> e
e -> {:error, e}
end
end
defp fetch_and_prepare_user_from_ap_id(ap_id, additional) do
with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id),
{:ok, data} <- user_data_from_user_object(data, additional) do
{:ok, maybe_update_follow_information(data)}
else
# If this has been deleted, only log a debug and not an error
{:error, "Object has been deleted" = e} ->
Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}")
{:error, e}
{:error, {:reject, reason} = e} ->
Logger.info("Rejected user #{ap_id}: #{inspect(reason)}")
{:error, e}
{:error, e} ->
Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}")
{:error, e}
end
end
def maybe_handle_clashing_nickname(data) do
with nickname when is_binary(nickname) <- data[:nickname],
%User{} = old_user <- User.get_by_nickname(nickname),
{_, false} <- {:ap_id_comparison, data[:ap_id] == old_user.ap_id} do
Logger.info(
"Found an old user for #{nickname}, the old ap id is #{old_user.ap_id}, new one is #{data[:ap_id]}, renaming."
)
old_user
|> User.remote_user_changeset(%{nickname: "#{old_user.id}.#{old_user.nickname}"})
|> User.update_and_set_cache()
else
{:ap_id_comparison, true} ->
Logger.info(
"Found an old user for #{data[:nickname]}, but the ap id #{data[:ap_id]} is the same as the new user. Race condition? Not changing anything."
)
_ ->
nil
end
end
+ @featured_collection_types ["OrderedCollection", "Collection"]
+ @featured_collection_page_types ["OrderedCollectionPage", "CollectionPage"]
+ @featured_collection_item_types @featured_collection_types ++ @featured_collection_page_types
+
def pin_data_from_featured_collection(%{
"type" => type,
"orderedItems" => objects
})
- when type in ["OrderedCollection", "Collection"] do
+ when type in @featured_collection_item_types do
Map.new(objects, fn
%{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()}
object_ap_id when is_binary(object_ap_id) -> {object_ap_id, NaiveDateTime.utc_now()}
end)
end
def pin_data_from_featured_collection(obj) do
Logger.error("Could not parse featured collection #{inspect(obj)}")
%{}
end
def fetch_and_prepare_featured_from_ap_id(nil) do
{:ok, %{}}
end
def fetch_and_prepare_featured_from_ap_id(ap_id) do
with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do
- {:ok, pin_data_from_featured_collection(data)}
+ {:ok, prepare_featured_collection(data)}
else
e ->
Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}")
{:ok, %{}}
end
end
+ defp prepare_featured_collection(%{"orderedItems" => objects} = data) when is_list(objects) do
+ pin_data_from_featured_collection(data)
+ end
+
+ defp prepare_featured_collection(%{
+ "type" => type,
+ "first" => %{"type" => page_type} = first
+ })
+ when type in @featured_collection_types and page_type in @featured_collection_page_types do
+ pin_data_from_featured_collection(first)
+ end
+
+ defp prepare_featured_collection(%{"type" => type, "first" => first})
+ when type in @featured_collection_types and is_binary(first) do
+ case Fetcher.fetch_and_contain_remote_object_from_id(first) do
+ {:ok, data} ->
+ pin_data_from_featured_collection(data)
+
+ e ->
+ Logger.error("Could not decode featured collection page at fetch #{first}, #{inspect(e)}")
+ %{}
+ end
+ end
+
+ defp prepare_featured_collection(data) do
+ pin_data_from_featured_collection(data)
+ end
+
def enqueue_pin_fetches(%{pinned_objects: pins}) do
# enqueue a task to fetch all pinned objects
Enum.each(pins, fn {ap_id, _} ->
if is_nil(Object.get_cached_by_ap_id(ap_id)) do
Pleroma.Workers.RemoteFetcherWorker.new(%{
"op" => "fetch_remote",
"id" => ap_id,
"depth" => 1
})
|> Oban.insert()
end
end)
end
def enqueue_pin_fetches(_), do: nil
def make_user_from_ap_id(ap_id, additional \\ []) do
user = User.get_cached_by_ap_id(ap_id)
with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do
enqueue_pin_fetches(data)
if user do
user
|> User.remote_user_changeset(data)
|> User.update_and_set_cache()
else
maybe_handle_clashing_nickname(data)
data
|> User.remote_user_changeset()
|> Repo.insert()
|> User.set_cache()
end
end
end
def make_user_from_nickname(nickname) do
with {:ok, %{"ap_id" => ap_id, "subject" => "acct:" <> acct}} when not is_nil(ap_id) <-
WebFinger.finger(nickname) do
make_user_from_ap_id(ap_id, nickname_from_acct: acct)
else
_e -> {:error, "No AP id in WebFinger"}
end
end
# filter out broken threads
defp contain_broken_threads(%Activity{} = activity, %User{} = user) do
entire_thread_visible_for_user?(activity, user)
end
# do post-processing on a specific activity
def contain_activity(%Activity{} = activity, %User{} = user) do
contain_broken_threads(activity, user)
end
def fetch_direct_messages_query do
Activity
|> restrict_type(%{type: "Create"})
|> restrict_visibility(%{visibility: "direct"})
|> order_by([activity], asc: activity.id)
end
defp maybe_restrict_deactivated_users(activity, %{type: "Flag"}), do: activity
defp maybe_restrict_deactivated_users(activity, _opts),
do: Activity.restrict_deactivated_users(activity)
end
diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex
index 94d1f6b82..6c8f7d5e9 100644
--- a/lib/pleroma/web/api_spec/operations/notification_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex
@@ -1,242 +1,400 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.NotificationOperation do
- alias OpenApiSpex.Operation
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Notifications"],
summary: "Retrieve a list of notifications",
description:
"Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.",
operationId: "NotificationController.index",
security: [%{"oAuth" => ["read:notifications"]}],
parameters:
[
Operation.parameter(
:exclude_types,
:query,
%Schema{type: :array, items: notification_type()},
"Array of types to exclude"
),
Operation.parameter(
:account_id,
:query,
%Schema{type: :string},
"Return only notifications received from this account"
),
Operation.parameter(
:exclude_visibilities,
:query,
%Schema{type: :array, items: VisibilityScope},
"Exclude the notifications for activities with the given visibilities"
),
Operation.parameter(
:include_types,
:query,
%Schema{type: :array, items: notification_type()},
"Deprecated, use `types` instead"
),
Operation.parameter(
:types,
:query,
%Schema{type: :array, items: notification_type()},
"Include the notifications for activities with the given types"
),
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include the notifications from muted users"
)
] ++ pagination_params(),
responses: %{
200 =>
Operation.response("Array of notifications", "application/json", %Schema{
type: :array,
items: notification()
}),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["Notifications"],
summary: "Retrieve a notification",
description: "View information about a notification with a given ID.",
operationId: "NotificationController.show",
security: [%{"oAuth" => ["read:notifications"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Notification", "application/json", notification())
}
}
end
+ def grouped_index_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve grouped notifications",
+ description: "Notifications concerning the user, grouped by supported notification types.",
+ operationId: "NotificationController.grouped_index",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: grouped_notification_params() ++ pagination_params(),
+ responses: %{
+ 200 => Operation.response("Grouped notifications", "application/json", grouped_result()),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
+ def show_group_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve a notification group",
+ operationId: "NotificationController.show_group",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: [group_key_param()],
+ responses: %{
+ 200 => Operation.response("Grouped notifications", "application/json", grouped_result()),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
+ def group_accounts_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve notification group accounts",
+ operationId: "NotificationController.group_accounts",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: [group_key_param()],
+ responses: %{
+ 200 =>
+ Operation.response("Accounts", "application/json", %Schema{
+ type: :array,
+ items: Account
+ })
+ }
+ }
+ end
+
+ def unread_count_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve unread grouped notification count",
+ operationId: "NotificationController.unread_count",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters:
+ grouped_notification_params() ++
+ [
+ Operation.parameter(
+ :limit,
+ :query,
+ %Schema{type: :integer},
+ "Maximum number of notifications to count"
+ )
+ ],
+ responses: %{
+ 200 =>
+ Operation.response("Unread notification group count", "application/json", %Schema{
+ type: :object,
+ properties: %{count: %Schema{type: :integer}}
+ }),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
def clear_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss all notifications",
description: "Clear all notifications from the server.",
operationId: "NotificationController.clear",
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
def dismiss_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss a notification",
description: "Clear a single notification from the server.",
operationId: "NotificationController.dismiss",
parameters: [id_param()],
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
+ def dismiss_group_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Dismiss a notification group",
+ description: "Clear all notifications in a notification group from the server.",
+ operationId: "NotificationController.dismiss_group",
+ parameters: [group_key_param()],
+ security: [%{"oAuth" => ["write:notifications"]}],
+ responses: %{200 => empty_object_response()}
+ }
+ end
+
def dismiss_via_body_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss a single notification",
deprecated: true,
description: "Clear a single notification from the server.",
operationId: "NotificationController.dismiss_via_body",
requestBody:
request_body(
"Parameters",
%Schema{type: :object, properties: %{id: %Schema{type: :string}}},
required: true
),
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
def destroy_multiple_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss multiple notifications",
operationId: "NotificationController.destroy_multiple",
security: [%{"oAuth" => ["write:notifications"]}],
parameters: [
Operation.parameter(
:ids,
:query,
%Schema{type: :array, items: %Schema{type: :string}},
"Array of notification IDs to dismiss",
required: true
)
],
responses: %{200 => empty_object_response()}
}
end
def notification do
%Schema{
title: "Notification",
description: "Response schema for a notification",
type: :object,
properties: %{
id: %Schema{type: :string},
group_key: %Schema{
type: :string,
description: "Group key shared by similar notifications"
},
type: notification_type(),
created_at: %Schema{type: :string, format: :"date-time"},
account: %Schema{
allOf: [Account],
description: "The account that performed the action that generated the notification."
},
status: %Schema{
allOf: [Status],
description:
"Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.",
nullable: true
},
pleroma: %Schema{
type: :object,
properties: %{
is_seen: %Schema{type: :boolean},
is_muted: %Schema{type: :boolean}
}
}
},
example: %{
"id" => "34975861",
- "group-key" => "ungrouped-34975861",
+ "group_key" => "ungrouped-34975861",
"type" => "mention",
"created_at" => "2019-11-23T07:49:02.064Z",
"account" => Account.schema().example,
"status" => Status.schema().example,
"pleroma" => %{"is_seen" => false, "is_muted" => false}
}
}
end
+ defp grouped_result do
+ %Schema{
+ title: "GroupedNotificationsResults",
+ type: :object,
+ properties: %{
+ accounts: %Schema{type: :array, items: Account},
+ statuses: %Schema{type: :array, items: Status},
+ notification_groups: %Schema{type: :array, items: notification_group()}
+ },
+ required: [:accounts, :statuses, :notification_groups]
+ }
+ end
+
+ defp notification_group do
+ %Schema{
+ title: "NotificationGroup",
+ type: :object,
+ properties: %{
+ group_key: %Schema{type: :string},
+ notifications_count: %Schema{type: :integer},
+ type: notification_type(),
+ most_recent_notification_id: %Schema{type: :string},
+ page_min_id: %Schema{type: :string, nullable: true},
+ page_max_id: %Schema{type: :string, nullable: true},
+ latest_page_notification_at: %Schema{type: :string, format: :"date-time", nullable: true},
+ sample_account_ids: %Schema{type: :array, items: %Schema{type: :string}},
+ status_id: %Schema{type: :string, nullable: true}
+ },
+ required: [
+ :group_key,
+ :notifications_count,
+ :type,
+ :most_recent_notification_id,
+ :sample_account_ids
+ ]
+ }
+ end
+
+ defp grouped_notification_params do
+ [
+ Operation.parameter(
+ :exclude_types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Array of types to exclude"
+ ),
+ Operation.parameter(
+ :account_id,
+ :query,
+ %Schema{type: :string},
+ "Return only notifications received from this account"
+ ),
+ Operation.parameter(
+ :types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Include the notifications for activities with the given types"
+ ),
+ Operation.parameter(
+ :grouped_types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Notification types that may be grouped"
+ )
+ ]
+ end
+
defp notification_type do
%Schema{
type: :string,
enum: [
"follow",
"favourite",
"reblog",
"mention",
"pleroma:emoji_reaction",
"pleroma:chat_mention",
"pleroma:report",
"move",
"follow_request",
"poll",
"status",
"update",
"admin.sign_up",
"admin.report"
],
description: """
The type of event that resulted in the notification.
- `follow` - Someone followed you
- `mention` - Someone mentioned you in their status
- `reblog` - Someone boosted one of your statuses
- `favourite` - Someone favourited one of your statuses
- `poll` - A poll you have voted in or created has ended
- `move` - Someone moved their account
- `pleroma:emoji_reaction` - Someone reacted with emoji to your status
- `pleroma:chat_mention` - Someone mentioned you in a chat message
- `pleroma:report` - Someone was reported
- `status` - Someone you are subscribed to created a status
- `update` - A status you boosted has been edited
- `admin.sign_up` - Someone signed up (optionally sent to admins)
- `admin.report` - A new report has been filed
"""
}
end
defp id_param do
Operation.parameter(:id, :path, :string, "Notification ID",
example: "123",
required: true
)
end
+
+ defp group_key_param do
+ Operation.parameter(:group_key, :path, :string, "Notification group key",
+ example: "favourite-123",
+ required: true
+ )
+ end
end
diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex
index b15b0ea81..0c85e79f9 100644
--- a/lib/pleroma/web/controller_helper.ex
+++ b/lib/pleroma/web/controller_helper.ex
@@ -1,143 +1,155 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ControllerHelper do
use Pleroma.Web, :controller
alias Pleroma.Pagination
alias Pleroma.Web.Utils.Params
def json_response(conn, status, _) when status in [204, :no_content] do
conn
|> put_resp_header("content-type", "application/json")
|> send_resp(status, "")
end
def json_response(conn, status, json) do
conn
|> put_status(status)
|> json(json)
end
@spec fetch_integer_param(map(), String.t() | atom(), integer() | nil) :: integer() | nil
def fetch_integer_param(params, name, default \\ nil) do
params
|> Map.get(name, default)
|> param_to_integer(default)
end
defp param_to_integer(val, _) when is_integer(val), do: val
defp param_to_integer(val, default) when is_binary(val) do
case Integer.parse(val) do
{res, _} -> res
_ -> default
end
end
defp param_to_integer(_, default), do: default
def add_link_headers(conn, entries, extra_params \\ %{})
def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _entries, _extra_params),
do: conn
def add_link_headers(conn, entries, extra_params) do
case get_pagination_fields(conn, entries, extra_params) do
%{"next" => next_url, "prev" => prev_url} ->
put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
_ ->
conn
end
end
# TODO: Only fetch the params from open_api_spex when everything is converted
@id_keys Pagination.page_keys() -- ["limit", "order"]
+ @id_key_atoms Enum.map(@id_keys, &String.to_atom/1)
+ @drop_id_params_key :drop_id_params
defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do
+ drop_id_params? = Map.get(extra_params, @drop_id_params_key, false)
+ extra_params = Map.delete(extra_params, @drop_id_params_key)
+
+ path_param_keys =
+ conn.path_params
+ |> Map.keys()
+ |> Enum.flat_map(&[&1, String.to_existing_atom(&1)])
+
params =
if Map.has_key?(conn.private, :open_api_spex) do
get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)])
else
conn.params
end
- |> Map.drop(Map.keys(conn.path_params) |> Enum.map(&String.to_existing_atom/1))
+ |> Map.drop(path_param_keys)
|> Map.merge(extra_params)
- |> Map.drop(@id_keys)
+ # Some OpenApiSpex routes cast cursor params to atoms. Keep the historical default for
+ # existing endpoints, but allow grouped notifications to opt out of stale request cursors.
+ |> Map.drop(@id_keys ++ if(drop_id_params?, do: @id_key_atoms, else: []))
{{next_id, nid}, {prev_id, pid}} =
if order == :desc,
do: {{:max_id, max_id}, {:min_id, min_id}},
else: {{:min_id, min_id}, {:max_id, max_id}}
id = Phoenix.Controller.current_url(conn)
base_id = %{URI.parse(id) | query: nil} |> URI.to_string()
%{
"next" => current_url(conn, Map.put(params, next_id, nid)),
"prev" => current_url(conn, Map.put(params, prev_id, pid)),
"id" => id,
"partOf" => base_id
}
end
defp get_first_last_pagination_id(entries) do
case List.last(entries) do
%{pagination_id: last_id} when not is_nil(last_id) ->
%{pagination_id: first_id} = List.first(entries)
{first_id, last_id}
%{id: last_id} ->
%{id: first_id} = List.first(entries)
{first_id, last_id}
_ ->
nil
end
end
def get_pagination_fields(conn, entries, extra_params \\ %{}, order \\ :desc)
def get_pagination_fields(conn, entries, extra_params, :desc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{min_id, max_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :desc)
end
end
def get_pagination_fields(conn, entries, extra_params, :asc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{max_id, min_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :asc)
end
end
def assign_account_by_id(%{private: %{open_api_spex: %{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
def try_render(conn, target, params) when is_binary(target) do
render(conn, target, params)
end
def try_render(conn, _, _) do
render_error(conn, :not_implemented, "Can't display this activity")
end
@doc """
Returns true if request specifies to include embedded relationships in account objects.
May only be used in selected account-related endpoints; has no effect for status- or
notification-related endpoints.
"""
# Intended for PleromaFE: https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838
def embed_relationships?(params) do
# To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])`
params
|> Map.get(:with_relationships, params["with_relationships"])
|> Params.truthy_param?()
end
end
diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex
index 36e78e1e2..07b33f866 100644
--- a/lib/pleroma/web/endpoint.ex
+++ b/lib/pleroma/web/endpoint.ex
@@ -1,207 +1,206 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Endpoint do
use Phoenix.Endpoint, otp_app: :pleroma
require Pleroma.Constants
alias Pleroma.Config
- socket("/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler,
- longpoll: false,
+ plug(Pleroma.Web.MastodonAPI.WebsocketPlug,
+ path: "/api/v1/streaming",
websocket: [
path: "/",
compress: false,
connect_info: [:sec_websocket_headers],
error_handler: {Pleroma.Web.MastodonAPI.WebsocketHandler, :handle_error, []},
fullsweep_after: 20
]
)
socket("/socket", Pleroma.Web.UserSocket,
websocket: [
path: "/websocket",
serializer: [
{Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"},
{Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"}
],
timeout: 60_000,
transport_log: false,
compress: false,
fullsweep_after: 20
],
longpoll: false
)
socket("/live", Phoenix.LiveView.Socket)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
plug(Pleroma.Web.Plugs.LoggerMetadataPath)
plug(Pleroma.Web.Plugs.SetLocalePlug)
plug(CORSPlug)
plug(Pleroma.Web.Plugs.HTTPSecurityPlug)
plug(Pleroma.Web.Plugs.UploadedMedia)
@static_cache_control "public, max-age=1209600, immutable"
@static_cache_disabled "public, no-cache"
# cache for a day
@favicon_cache_control "public, max=age=86400, immutable"
# InstanceStatic needs to be before Plug.Static to be able to override shipped-static files
# If you're adding new paths to `only:` you'll need to configure them in InstanceStatic as well
# Cache-control headers are duplicated in case we turn off etags in the future
plug(
Pleroma.Web.Plugs.InstanceStatic,
at: "/",
from: :pleroma,
only: ["emoji", "images"],
gzip: true,
cache_control_for_etags: @static_cache_control,
headers: %{
"cache-control" => @static_cache_control
}
)
plug(Pleroma.Web.Plugs.FaviconPlug,
at: "/",
only: ["favicon.png"],
cache_control_for_etags: @favicon_cache_control,
headers: %{
"cache-control" => @favicon_cache_control
}
)
plug(Pleroma.Web.Plugs.InstanceStatic,
at: "/",
gzip: true,
cache_control_for_etags: @static_cache_disabled,
headers: %{
"cache-control" => @static_cache_disabled
}
)
plug(Pleroma.Web.Plugs.FrontendStatic,
at: "/",
frontend_type: :primary,
only: ["index.html"],
gzip: true,
cache_control_for_etags: @static_cache_disabled,
headers: %{
"cache-control" => @static_cache_disabled
}
)
plug(Pleroma.Web.Plugs.FrontendStatic,
at: "/",
frontend_type: :primary,
gzip: true,
cache_control_for_etags: @static_cache_control,
headers: %{
"cache-control" => @static_cache_control
}
)
plug(Plug.Static.IndexHtml, at: "/pleroma/admin/")
plug(Pleroma.Web.Plugs.FrontendStatic,
at: "/pleroma/admin",
frontend_type: :admin,
gzip: true,
cache_control_for_etags: @static_cache_disabled,
headers: %{
"cache-control" => @static_cache_disabled
}
)
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(
Plug.Static,
at: "/",
from: :pleroma,
only: Pleroma.Constants.static_only_files(),
# credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength
gzip: true,
cache_control_for_etags: @static_cache_disabled,
headers: %{
"cache-control" => @static_cache_disabled
}
)
plug(Plug.Static,
at: "/pleroma/admin/",
from: {:pleroma, "priv/static/adminfe/"}
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug(Phoenix.CodeReloader)
end
plug(Pleroma.Web.Plugs.TrailingFormatPlug)
plug(Plug.RequestId)
plug(Plug.Logger, log: :debug)
plug(Plug.Parsers,
parsers: [:urlencoded, Pleroma.Web.Multipart, :json],
pass: ["*/*"],
json_decoder: Jason,
# Note: this is compile-time only, won't work for database-config
length: Config.get([:instance, :upload_limit]),
body_reader: {Pleroma.Web.Plugs.DigestPlug, :read_body, []}
)
plug(Plug.MethodOverride)
plug(Plug.Head)
secure_cookies = Config.get([__MODULE__, :secure_cookie_flag])
cookie_name =
if secure_cookies,
do: "__Host-pleroma_key",
else: "pleroma_key"
extra =
- Config.get([__MODULE__, :extra_cookie_attrs])
- |> Enum.join(";")
+ Enum.join(Config.get([__MODULE__, :extra_cookie_attrs]), ";")
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
plug(
Plug.Session,
store: :cookie,
key: cookie_name,
signing_salt: Config.get([__MODULE__, :signing_salt], "CqaoopA2"),
http_only: true,
secure: secure_cookies,
extra: extra
)
plug(Pleroma.Web.Plugs.RemoteIp)
plug(Pleroma.Web.Router)
@doc """
Dynamically loads configuration from the system environment
on startup.
It receives the endpoint configuration from the config files
and must return the updated configuration.
"""
def load_from_system_env(config) do
port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
{:ok, Keyword.put(config, :http, [:inet6, port: port])}
end
def websocket_url do
String.replace_leading(url(), "http", "ws")
end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
index afd83b785..8a5f8e7b6 100644
--- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
@@ -1,128 +1,259 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationController do
use Pleroma.Web, :controller
- import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+ import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, add_link_headers: 3]
alias Pleroma.Notification
+ alias Pleroma.User
+ alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.MastodonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
- @oauth_read_actions [:show, :index]
+ # Mastodon's docs currently list write:notifications for group accounts, but the endpoint is
+ # read-only and Mastodon's implementation accepts read:notifications. Prefer least privilege.
+ @oauth_read_actions [:show, :index, :grouped_index, :show_group, :group_accounts, :unread_count]
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(
OAuthScopesPlug,
%{scopes: ["read:notifications"]} when action in @oauth_read_actions
)
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation
@default_notification_types ~w{
mention
follow
follow_request
reblog
favourite
move
pleroma:emoji_reaction
poll
update
status
}
# GET /api/v1/notifications
def index(%{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, _) do
- case Pleroma.User.get_cached_by_id(account_id) do
+ case User.get_cached_by_id(account_id) do
%{ap_id: account_ap_id} ->
params =
params
|> Map.delete(:account_id)
|> Map.put(:account_ap_id, account_ap_id)
do_get_notifications(conn, params)
_ ->
conn
|> put_status(:not_found)
|> json(%{"error" => "Account is not found"})
end
end
def index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
do_get_notifications(conn, params)
end
+ # GET /api/v2/notifications
+ def grouped_index(
+ %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn,
+ _
+ ) do
+ case User.get_cached_by_id(account_id) do
+ %{ap_id: account_ap_id} ->
+ params =
+ params
+ |> Map.delete(:account_id)
+ |> Map.put(:account_ap_id, account_ap_id)
+
+ do_get_grouped_notifications(conn, params)
+
+ _ ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Account is not found"})
+ end
+ end
+
+ def grouped_index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
+ do_get_grouped_notifications(conn, params)
+ end
+
+ # GET /api/v2/notifications/:group_key
+ def show_group(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ {notifications, notification_group_counts, notification_group_bounds} =
+ MastodonAPI.get_notification_group_result(user, group_key, %{})
+
+ if Enum.empty?(notifications) do
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Notification group is not found"})
+ else
+ grouped_types = if String.starts_with?(group_key, "ungrouped-"), do: [], else: nil
+
+ render(conn, "grouped_index.json",
+ notification_groups: [notifications],
+ notification_group_counts: notification_group_counts,
+ notification_group_bounds: notification_group_bounds,
+ for: user,
+ grouped_types: grouped_types,
+ include_page_metadata: false
+ )
+ end
+ end
+
+ # GET /api/v2/notifications/:group_key/accounts
+ def group_accounts(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ # Mastodon paginates this endpoint in code, but the public docs say it returns accounts of all
+ # notifications in the group and do not document cursor params here. Follow the documented API.
+ users = MastodonAPI.get_notification_group_accounts(user, group_key)
+
+ json(conn, AccountView.render("index.json", %{users: users, for: user}))
+ end
+
+ # GET /api/v2/notifications/unread_count
+ def unread_count(
+ %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn,
+ _
+ ) do
+ case User.get_cached_by_id(account_id) do
+ %{ap_id: account_ap_id} ->
+ params =
+ params
+ |> Map.delete(:account_id)
+ |> Map.put(:account_ap_id, account_ap_id)
+
+ do_get_unread_group_count(conn, params)
+
+ _ ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Account is not found"})
+ end
+ end
+
+ def unread_count(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
+ do_get_unread_group_count(conn, params)
+ end
+
+ # POST /api/v2/notifications/:group_key/dismiss
+ def dismiss_group(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ MastodonAPI.dismiss_notification_group(user, group_key)
+ json(conn, %{})
+ end
+
defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do
- params =
- Map.new(params, fn {k, v} -> {to_string(k), v} end)
- |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
+ params = normalize_notification_params(params)
notifications = MastodonAPI.get_notifications(user, params)
conn
|> add_link_headers(notifications)
|> render("index.json",
notifications: notifications,
for: user
)
end
+ defp do_get_grouped_notifications(%{assigns: %{user: user}} = conn, params) do
+ params = normalize_notification_params(params)
+
+ {notification_groups, page_notifications, notification_group_counts,
+ notification_group_bounds} =
+ MastodonAPI.get_grouped_notification_page(user, params)
+
+ conn
+ |> add_link_headers(page_notifications, %{drop_id_params: true})
+ |> render("grouped_index.json",
+ notification_groups: notification_groups,
+ notification_group_counts: notification_group_counts,
+ notification_group_bounds: notification_group_bounds,
+ for: user,
+ grouped_types: params["grouped_types"]
+ )
+ end
+
+ defp do_get_unread_group_count(%{assigns: %{user: user}} = conn, params) do
+ params = normalize_notification_params(params)
+ json(conn, %{count: MastodonAPI.unread_notification_group_count(user, params)})
+ end
+
+ defp normalize_notification_params(params) do
+ params
+ |> Map.new(fn {k, v} -> {to_string(k), v} end)
+ |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
+ end
+
# GET /api/v1/notifications/:id
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, notification} <- Notification.get(user, id) do
render(conn, "show.json", notification: notification, for: user)
else
{:error, reason} ->
conn
|> put_status(:forbidden)
|> json(%{"error" => reason})
end
end
# POST /api/v1/notifications/clear
def clear(%{assigns: %{user: user}} = conn, _params) do
Notification.clear(user)
json(conn, %{})
end
# POST /api/v1/notifications/:id/dismiss
def dismiss(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
do_dismiss(conn, id)
end
# POST /api/v1/notifications/dismiss (deprecated)
def dismiss_via_body(
%{private: %{open_api_spex: %{body_params: %{id: id}}}} = conn,
_
) do
do_dismiss(conn, id)
end
defp do_dismiss(%{assigns: %{user: user}} = conn, notification_id) do
with {:ok, _notif} <- Notification.dismiss(user, notification_id) do
json(conn, %{})
else
{:error, reason} ->
conn
|> put_status(:forbidden)
|> json(%{"error" => reason})
end
end
# DELETE /api/v1/notifications/destroy_multiple
def destroy_multiple(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{ids: ids}}}} = conn,
_
) do
Notification.destroy_multiple(user, ids)
json(conn, %{})
end
end
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex
index c9e045d23..d6f2dd3a3 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex
@@ -1,121 +1,454 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
import Ecto.Query
import Ecto.Changeset
+ alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Pagination
+ alias Pleroma.Repo
alias Pleroma.ScheduledActivity
alias Pleroma.User
alias Pleroma.Web.CommonAPI
+ @notification_group_sample_limit 8
+
@spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()}
def follow(follower, followed, params \\ %{}) do
result =
if not User.following?(follower, followed) do
CommonAPI.follow(followed, follower)
else
{:ok, followed, follower, nil}
end
with {:ok, _followed, follower, _} <- result do
options = cast_params(params)
set_reblogs_visibility(options[:reblogs], result)
set_subscription(options[:notify], result)
{:ok, follower}
end
end
defp set_reblogs_visibility(false, {:ok, followed, follower, _}) do
CommonAPI.hide_reblogs(followed, follower)
end
defp set_reblogs_visibility(_, {:ok, followed, follower, _}) do
CommonAPI.show_reblogs(followed, follower)
end
defp set_subscription(true, {:ok, followed, follower, _}) do
User.subscribe(follower, followed)
end
defp set_subscription(false, {:ok, followed, follower, _}) do
User.unsubscribe(follower, followed)
end
defp set_subscription(_, _), do: {:ok, nil}
@spec get_followers(User.t(), map()) :: list(User.t())
def get_followers(user, params \\ %{}) do
user
|> User.get_followers_query()
|> Pagination.fetch_paginated(params)
end
def get_friends(user, params \\ %{}) do
user
|> User.get_friends_query()
|> Pagination.fetch_paginated(params)
end
def get_notifications(user, params \\ %{}) do
- options =
- cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end)
+ user
+ |> notifications_query(params)
+ |> Pagination.fetch_paginated(params)
+ end
- options =
- if ("pleroma:report" not in options.include_types and
- User.privileged?(user, :reports_manage_reports)) or
- User.privileged?(user, :reports_manage_reports) do
- options
- else
- options
- |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types ->
- current_exclude_types ++ ["pleroma:report"]
- end)
- end
+ def get_grouped_notification_page(user, params \\ %{}) do
+ grouped_types =
+ params
+ |> Map.get("grouped_types")
+ |> Notification.normalize_grouped_types()
+
+ query = notifications_query(user, params)
+ {order, cursor_filters} = group_pagination(params)
+
+ group_rows =
+ query
+ |> notification_group_rows(grouped_types, grouped_limit(params), order, cursor_filters)
+
+ group_rows = if order == :asc, do: Enum.reverse(group_rows), else: group_rows
+
+ page_notifications = representative_notifications(query, group_rows)
+
+ notification_groups =
+ Enum.map(group_rows, &notification_group_sample(user, params, &1.group_key))
+
+ {
+ notification_groups,
+ page_notifications,
+ notification_group_counts(group_rows),
+ notification_group_bounds(group_rows)
+ }
+ end
+
+ def get_notification_group_result(user, group_key, params \\ %{}) do
+ notifications = notification_group_sample(user, params, group_key)
+ metadata = notification_group_metadata(user, group_key, params)
+
+ if Enum.empty?(notifications) or is_nil(metadata) do
+ {[], %{}, %{}}
+ else
+ {
+ notifications,
+ %{group_key => metadata.notifications_count},
+ %{group_key => Map.drop(metadata, [:notifications_count])}
+ }
+ end
+ end
+
+ def get_notification_group(user, group_key, params \\ %{})
+
+ def get_notification_group(user, "ungrouped-" <> notification_id, _params) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} -> [notification]
+ _ -> []
+ end
+ end
+
+ def get_notification_group(user, group_key, params) do
+ notification_group_sample(user, params, group_key)
+ end
+
+ def get_notification_group_accounts(user, "ungrouped-" <> notification_id) do
+ with {:ok, notification} <- Notification.get(user, notification_id),
+ %User{} = actor <- User.get_cached_by_ap_id(notification.activity.data["actor"]) do
+ [actor]
+ else
+ _ -> []
+ end
+ end
+
+ def get_notification_group_accounts(user, group_key) do
+ user
+ |> notification_group_query(group_key, %{})
+ |> exclude(:preload)
+ |> distinct(true)
+ |> select([user_actor: user_actor], user_actor)
+ |> Repo.all()
+ end
+
+ def dismiss_notification_group(user, "ungrouped-" <> notification_id) do
+ Notification.destroy_multiple(user, [notification_id])
+ end
+
+ def dismiss_notification_group(%User{id: user_id}, group_key) do
+ Notification
+ |> where([n], n.user_id == ^user_id and n.group_key == ^group_key)
+ |> Repo.delete_all()
+ end
+
+ defp group_pagination(params) do
+ cond do
+ min_id = Map.get(params, "min_id") ->
+ cursor_filters = [{:gt, min_id}]
+
+ cursor_filters =
+ case Map.get(params, "max_id") do
+ nil -> cursor_filters
+ max_id -> [{:lt, max_id} | cursor_filters]
+ end
+
+ {:asc, cursor_filters}
+
+ since_id = Map.get(params, "since_id") ->
+ {:desc, [{:gt, since_id}]}
+
+ max_id = Map.get(params, "max_id") ->
+ {:desc, [{:lt, max_id}]}
+
+ true ->
+ {:desc, []}
+ end
+ end
+
+ defp grouped_limit(params) do
+ params
+ |> Map.get("limit", 40)
+ |> parse_limit(40)
+ |> min(80)
+ end
+
+ def unread_notification_group_count(user, params \\ %{}) do
+ grouped_types =
+ params
+ |> Map.get("grouped_types")
+ |> Notification.normalize_grouped_types()
+
+ limit = unread_count_limit(params)
+
+ user
+ |> notifications_query(params)
+ # The grouped API docs define unread by the notifications marker, not by Pleroma's per-row
+ # seen flag used by the v1 unread count. Keep this marker-based for Mastodon clients.
+ |> restrict_after_marker(notification_marker_last_read_id(user))
+ |> notification_group_rows(grouped_types, limit, :desc, [])
+ |> length()
+ end
+
+ defp notification_group_rows(query, grouped_types, group_limit, :desc, cursor_filters) do
+ query
+ |> notification_group_keyed_query(grouped_types)
+ |> group_by([n], n.group_key)
+ |> apply_group_cursor_filters(cursor_filters)
+ |> select([n], %{
+ group_key: n.group_key,
+ representative_id: max(n.id),
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> order_by([n], desc: max(n.id))
+ |> limit(^group_limit)
+ |> Repo.all()
+ end
+
+ defp notification_group_rows(query, grouped_types, group_limit, :asc, cursor_filters) do
+ query
+ |> notification_group_keyed_query(grouped_types)
+ |> group_by([n], n.group_key)
+ |> apply_group_cursor_filters(cursor_filters)
+ |> select([n], %{
+ group_key: n.group_key,
+ representative_id: max(n.id),
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> order_by([n], asc: max(n.id))
+ |> limit(^group_limit)
+ |> Repo.all()
+ end
+
+ defp apply_group_cursor_filters(query, []), do: query
+
+ defp apply_group_cursor_filters(query, [{:gt, id} | rest]) do
+ query
+ |> having([n], max(n.id) > ^id)
+ |> apply_group_cursor_filters(rest)
+ end
+
+ defp apply_group_cursor_filters(query, [{:lt, id} | rest]) do
+ query
+ |> having([n], max(n.id) < ^id)
+ |> apply_group_cursor_filters(rest)
+ end
+
+ defp notification_group_keyed_query(query, grouped_types) do
+ query
+ |> exclude(:preload)
+ |> select([n], %{
+ id: n.id,
+ inserted_at: n.inserted_at,
+ group_key:
+ fragment(
+ "CASE WHEN ? IS NOT NULL AND ?::text = ANY(?) THEN ? ELSE 'ungrouped-' || ?::text END",
+ n.group_key,
+ n.type,
+ type(^grouped_types, {:array, :string}),
+ n.group_key,
+ n.id
+ )
+ })
+ |> subquery()
+ end
+
+ defp representative_notifications(_query, []), do: []
+
+ defp representative_notifications(query, group_rows) do
+ representative_ids = Enum.map(group_rows, & &1.representative_id)
+
+ notifications_by_id =
+ query
+ |> where([n], n.id in ^representative_ids)
+ |> Repo.all()
+ |> Map.new(&{to_string(&1.id), &1})
+
+ group_rows
+ |> Enum.map(&Map.get(notifications_by_id, to_string(&1.representative_id)))
+ |> Enum.filter(& &1)
+ end
+
+ defp notification_group_counts(group_rows) do
+ Map.new(group_rows, &{&1.group_key, &1.notifications_count})
+ end
+
+ defp notification_group_bounds(group_rows) do
+ Map.new(group_rows, fn row ->
+ {row.group_key,
+ %{
+ page_min_id: row.page_min_id,
+ page_max_id: row.page_max_id,
+ latest_page_notification_at: row.latest_page_notification_at
+ }}
+ end)
+ end
+
+ defp notification_group_sample(user, _params, "ungrouped-" <> notification_id) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} -> [notification]
+ _ -> []
+ end
+ end
+
+ defp notification_group_sample(user, params, group_key) do
+ user
+ |> notification_group_query(group_key, params)
+ |> order_by([n], desc: n.id)
+ |> limit(^@notification_group_sample_limit)
+ |> Repo.all()
+ end
+
+ defp notification_group_metadata(user, "ungrouped-" <> notification_id, _params) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} ->
+ %{
+ notifications_count: 1,
+ page_min_id: notification.id,
+ page_max_id: notification.id,
+ latest_page_notification_at: notification.inserted_at
+ }
+
+ _ ->
+ nil
+ end
+ end
+
+ defp notification_group_metadata(user, group_key, params) do
+ user
+ |> notification_group_query(group_key, params)
+ |> exclude(:preload)
+ |> select([n], %{
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> Repo.one()
+ |> case do
+ %{notifications_count: 0} -> nil
+ metadata -> metadata
+ end
+ end
+
+ defp notification_group_query(user, group_key, params) do
+ user
+ |> notifications_query(params)
+ |> where([n], n.group_key == ^group_key)
+ end
+
+ defp notification_marker_last_read_id(user) do
+ Marker
+ |> where([m], m.user_id == ^user.id and m.timeline == "notifications")
+ |> select([m], m.last_read_id)
+ |> Repo.one()
+ end
+
+ defp restrict_after_marker(query, last_read_id)
+ when is_binary(last_read_id) and last_read_id != "" do
+ where(query, [n], n.id > ^last_read_id)
+ end
+
+ defp restrict_after_marker(query, _last_read_id), do: query
+
+ defp notifications_query(user, params) do
+ options = notification_options(user, params)
user
|> Notification.for_user_query(options)
|> restrict(:types, options)
|> restrict(:exclude_types, options)
|> restrict(:account_ap_id, options)
- |> Pagination.fetch_paginated(params)
end
+ defp notification_options(user, params) do
+ options =
+ params
+ |> cast_params()
+ |> Map.update(:include_types, [], fn include_types -> include_types end)
+
+ if ("pleroma:report" not in options.include_types and
+ User.privileged?(user, :reports_manage_reports)) or
+ User.privileged?(user, :reports_manage_reports) do
+ options
+ else
+ options
+ |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types ->
+ current_exclude_types ++ ["pleroma:report"]
+ end)
+ end
+ end
+
+ defp unread_count_limit(params) do
+ params
+ |> Map.get("limit", 100)
+ |> parse_limit(100)
+ |> min(1000)
+ end
+
+ defp parse_limit(limit, _default) when is_integer(limit) and limit > 0, do: limit
+
+ defp parse_limit(limit, default) when is_binary(limit) do
+ case Integer.parse(limit) do
+ {limit, _} when limit > 0 -> limit
+ _ -> default
+ end
+ end
+
+ defp parse_limit(_, default), do: default
+
def get_scheduled_activities(user, params \\ %{}) do
user
|> ScheduledActivity.for_user_query()
|> Pagination.fetch_paginated(params)
end
defp cast_params(params) do
param_types = %{
exclude_types: {:array, :string},
types: {:array, :string},
exclude_visibilities: {:array, :string},
+ grouped_types: {:array, :string},
+ limit: :integer,
reblogs: :boolean,
with_muted: :boolean,
account_ap_id: :string,
notify: :boolean
}
changeset = cast({%{}, param_types}, params, Map.keys(param_types))
changeset.changes
end
defp restrict(query, :types, %{types: mastodon_types = [_ | _]}) do
where(query, [n], n.type in ^mastodon_types)
end
defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do
where(query, [n], n.type not in ^mastodon_types)
end
defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do
where(query, [n, a], a.actor == ^account_ap_id)
end
defp restrict(query, _, _), do: query
end
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
index d64ce4fb5..1a0f63ffa 100644
--- a/lib/pleroma/web/mastodon_api/views/instance_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -1,438 +1,441 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.InstanceView do
use Pleroma.Web, :view
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
alias Pleroma.Config
alias Pleroma.Web.ActivityPub.MRF
@mastodon_api_level "2.7.2"
@block_severities %{
federated_timeline_removal: "silence",
reject: "suspend"
}
+ def mastodon_api_level, do: @mastodon_api_level
+
def render("show.json", _) do
instance = Config.get(:instance)
common_information(instance)
|> Map.merge(%{
uri: Pleroma.Web.WebFinger.host(),
description: Keyword.get(instance, :description),
short_description: Keyword.get(instance, :short_description),
email: Keyword.get(instance, :email),
urls: %{
streaming_api: Pleroma.Web.Endpoint.websocket_url()
},
stats: Pleroma.Stats.get_stats(),
thumbnail:
URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail))
|> to_string,
registrations: Keyword.get(instance, :registrations_open),
approval_required: Keyword.get(instance, :account_approval_required),
contact_account: contact_account(Keyword.get(instance, :contact_username)),
configuration: configuration(),
# Extra (not present in Mastodon):
max_toot_chars: Keyword.get(instance, :limit),
max_media_attachments: Keyword.get(instance, :max_media_attachments),
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),
background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image),
shout_limit: Config.get([:shout, :limit]),
description_limit: Keyword.get(instance, :description_limit),
chat_limit: Keyword.get(instance, :chat_limit),
pleroma: pleroma_configuration(instance)
})
end
def render("show2.json", _) do
instance = Config.get(:instance)
common_information(instance)
|> Map.merge(%{
domain: Pleroma.Web.WebFinger.host(),
source_url: Pleroma.Application.repository(),
description: Keyword.get(instance, :short_description),
usage: %{users: %{active_month: Pleroma.User.active_user_count()}},
thumbnail: %{
url:
URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail))
|> to_string
},
configuration: configuration2(),
registrations: %{
enabled: Keyword.get(instance, :registrations_open),
approval_required: Keyword.get(instance, :account_approval_required),
message: nil,
url: nil
},
contact: %{
email: Keyword.get(instance, :email),
account: contact_account(Keyword.get(instance, :contact_username))
},
# Extra (not present in Mastodon):
pleroma: pleroma_configuration2(instance)
})
end
def render("rules.json", _) do
Pleroma.Rule.query()
|> Pleroma.Repo.all()
|> render_many(__MODULE__, "rule.json", as: :rule)
end
def render("rule.json", %{rule: rule}) do
%{
id: to_string(rule.id),
text: rule.text,
hint: rule.hint || ""
}
end
def render("domain_blocks.json", _) do
if Config.get([:mrf, :transparency]) do
exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
domain_blocks =
Config.get(:mrf_simple)
|> Enum.map(fn {rule, instances} ->
instances
|> Enum.map(fn
{host, reason} when not_empty_string(host) and not_empty_string(reason) ->
{host, reason}
{host, _reason} when not_empty_string(host) ->
{host, ""}
host when not_empty_string(host) ->
{host, ""}
_ ->
nil
end)
|> Enum.reject(&is_nil/1)
|> Enum.reject(fn {host, _} ->
host in exclusions or not Map.has_key?(@block_severities, rule)
end)
|> Enum.map(fn {host, reason} ->
domain_block = %{
domain: host,
digest: :crypto.hash(:sha256, host) |> Base.encode16(case: :lower),
severity: Map.get(@block_severities, rule)
}
if not_empty_string(reason) do
Map.put(domain_block, :comment, reason)
else
domain_block
end
end)
end)
|> List.flatten()
domain_blocks
else
[]
end
end
def render("translation_languages.json", _) do
with true <- Pleroma.Language.Translation.configured?(),
{:ok, languages} <- Pleroma.Language.Translation.languages_matrix() do
languages
else
_ -> %{}
end
end
defp common_information(instance) do
%{
languages: Keyword.get(instance, :languages, ["en"]),
rules: render(__MODULE__, "rules.json"),
title: Keyword.get(instance, :name),
version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})"
}
end
def features do
[
"pleroma_api",
"mastodon_api",
+ "mastodon_api_grouped_notifications",
"mastodon_api_streaming",
"polls",
"v2_suggestions",
"pleroma_explicit_addressing",
"shareable_emoji_packs",
"multifetch",
"pleroma:api/v1/notifications:include_types_filter",
"editing",
"quote_posting",
if Config.get([:activitypub, :blockers_visible]) do
"blockers_visible"
end,
if Config.get([:media_proxy, :enabled]) do
"media_proxy"
end,
if Config.get([:gopher, :enabled]) do
"gopher"
end,
# backwards compat
if Config.get([:shout, :enabled]) do
"chat"
end,
if Config.get([:shout, :enabled]) do
"shout"
end,
if Config.get([:instance, :allow_relay]) do
"relay"
end,
if Config.get([:instance, :safe_dm_mentions]) do
"safe_dm_mentions"
end,
"pleroma_emoji_reactions",
"pleroma_custom_emoji_reactions",
"pleroma_chat_messages",
"pleroma:pin_chats",
if Config.get([:instance, :show_reactions]) do
"exposable_reactions"
end,
if Config.get([:instance, :profile_directory]) do
"profile_directory"
end,
"pleroma:get:main/ostatus",
"pleroma:group_actors",
"pleroma:bookmark_folders",
if Pleroma.Language.LanguageDetector.configured?() do
"pleroma:language_detection"
end,
"pleroma:block_expiration"
]
|> Enum.filter(& &1)
end
def federation do
quarantined = Config.get([:instance, :quarantined_instances], [])
rejected = Config.get([:instance, :rejected_instances], [])
if Config.get([:mrf, :transparency]) do
{:ok, data} = MRF.describe()
data
|> Map.put(
:quarantined_instances,
Enum.map(quarantined, fn {instance, _reason} -> instance end)
)
# This is for backwards compatibility. We originally didn't sent
# extra info like a reason why an instance was rejected/quarantined/etc.
# Because we didn't want to break backwards compatibility it was decided
# to add an extra "info" key.
|> Map.put(:quarantined_instances_info, %{
"quarantined_instances" =>
quarantined
|> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end)
|> Map.new()
})
|> Map.put(
:rejected_instances,
rejected
|> Enum.map(fn {instance, reason} -> {instance, %{"reason" => reason}} end)
|> Map.new()
)
else
%{}
end
|> Map.put(:enabled, Config.get([:instance, :federating]))
end
defp fields_limits do
%{
max_fields: Config.get([:instance, :max_account_fields]),
max_remote_fields: Config.get([:instance, :max_remote_account_fields]),
name_length: Config.get([:instance, :account_field_name_length]),
value_length: Config.get([:instance, :account_field_value_length])
}
end
defp contact_account(nil), do: nil
defp contact_account("@" <> username) do
contact_account(username)
end
defp contact_account(username) do
user = Pleroma.User.get_cached_by_nickname(username)
if user do
Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil})
else
nil
end
end
defp configuration do
%{
accounts: %{
max_featured_tags: 0
},
statuses: %{
max_characters: Config.get([:instance, :limit]),
max_media_attachments: Config.get([:instance, :max_media_attachments])
},
media_attachments: %{
image_size_limit: Config.get([:instance, :upload_limit]),
video_size_limit: Config.get([:instance, :upload_limit]),
supported_mime_types: ["application/octet-stream"]
},
polls: %{
max_options: Config.get([:instance, :poll_limits, :max_options]),
max_characters_per_option: Config.get([:instance, :poll_limits, :max_option_chars]),
min_expiration: Config.get([:instance, :poll_limits, :min_expiration]),
max_expiration: Config.get([:instance, :poll_limits, :max_expiration])
}
}
end
defp configuration2 do
configuration()
|> put_in([:accounts, :max_pinned_statuses], Config.get([:instance, :max_pinned_statuses], 0))
|> put_in([:accounts, :max_profile_fields], Config.get([:instance, :max_account_fields]))
|> put_in(
[:accounts, :profile_field_name_limit],
Config.get([:instance, :account_field_name_length])
)
|> put_in(
[:accounts, :profile_field_value_limit],
Config.get([:instance, :account_field_value_length])
)
|> put_in([:statuses, :characters_reserved_per_url], 0)
|> Map.merge(%{
urls: %{
streaming: Pleroma.Web.Endpoint.websocket_url(),
status: Config.get([:instance, :status_page])
},
vapid: %{
public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
},
translation: %{enabled: Pleroma.Language.Translation.configured?()},
timelines_access: %{
live_feeds: timelines_access(),
hashtag_feeds: timelines_access(),
# not implemented in Pleroma
trending_link_feeds: %{
local: "disabled",
remote: "disabled"
}
}
})
end
defp timelines_access do
%{
local: timeline_access(:local),
remote: timeline_access(:federated)
}
end
defp timeline_access(kind) do
if Config.restrict_unauthenticated_access?(:timelines, kind) do
"authenticated"
else
"public"
end
end
defp pleroma_configuration(instance) do
base_urls = %{}
base_urls =
if Config.get([:media_proxy, :enabled]) do
Map.put(base_urls, :media_proxy, Config.get([:media_proxy, :base_url]))
else
base_urls
end
base_urls =
case Config.get([Pleroma.Upload, :base_url]) do
nil -> base_urls
url -> Map.put(base_urls, :upload, url)
end
%{
metadata: %{
account_activation_required: Keyword.get(instance, :account_activation_required),
features: features(),
federation: federation(),
fields_limits: fields_limits(),
post_formats: Config.get([:instance, :allowed_post_formats]),
birthday_required: Config.get([:instance, :birthday_required]),
birthday_min_age: Config.get([:instance, :birthday_min_age]),
translation: supported_languages(),
base_urls: base_urls,
markup: markup()
},
stats: %{mau: Pleroma.User.active_user_count()},
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
}
end
defp pleroma_configuration2(instance) do
configuration = pleroma_configuration(instance)
configuration
|> Map.merge(%{
metadata:
configuration.metadata
|> Map.merge(%{
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),
background_image:
Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image),
chat_limit: Keyword.get(instance, :chat_limit),
description_limit: Keyword.get(instance, :description_limit),
shout_limit: Config.get([:shout, :limit])
})
})
end
defp supported_languages do
enabled = Pleroma.Language.Translation.configured?()
source_languages =
with true <- enabled,
{:ok, languages} <- Pleroma.Language.Translation.supported_languages(:source) do
languages
else
_ -> nil
end
target_languages =
with true <- enabled,
{:ok, languages} <- Pleroma.Language.Translation.supported_languages(:target) do
languages
else
_ -> nil
end
%{
source_languages: source_languages,
target_languages: target_languages
}
end
defp markup do
%{
allow_inline_images: Config.get([:markup, :allow_inline_images]),
allow_headings: Config.get([:markup, :allow_headings]),
allow_tables: Config.get([:markup, :allow_tables])
}
end
end
diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex
index 27532c42a..33d4b2924 100644
--- a/lib/pleroma/web/mastodon_api/views/notification_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex
@@ -1,171 +1,321 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationView do
use Pleroma.Web, :view
alias Pleroma.Activity
alias Pleroma.Chat.MessageReference
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.UserRelationship
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.AdminAPI.ReportView
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.MediaProxy
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
@parent_types ~w{Like Announce EmojiReact Update}
def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
activities = Enum.map(notifications, & &1.activity)
parent_activities =
activities
|> Enum.filter(fn
%{data: %{"type" => type}} ->
type in @parent_types
end)
|> Enum.map(&object_id_for/1)
|> Activity.create_by_object_ap_id()
|> Activity.with_preloaded_object(:left)
|> Pleroma.Repo.all()
relationships_opt =
cond do
Map.has_key?(opts, :relationships) ->
opts[:relationships]
is_nil(reading_user) ->
UserRelationship.view_relationships_option(nil, [])
true ->
move_activities_targets =
activities
|> Enum.filter(&(&1.data["type"] == "Move"))
|> Enum.map(&User.get_cached_by_ap_id(&1.data["target"]))
|> Enum.filter(& &1)
actors =
activities
|> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end)
|> Enum.filter(& &1)
|> Kernel.++(move_activities_targets)
UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
end
opts =
opts
|> Map.put(:parent_activities, parent_activities)
|> Map.put(:relationships, relationships_opt)
safe_render_many(notifications, NotificationView, "show.json", opts)
end
+ def render("grouped_index.json", %{notifications: notifications} = opts) do
+ grouped_types = Notification.normalize_grouped_types(opts[:grouped_types])
+
+ opts
+ |> Map.delete(:notifications)
+ |> Map.put(
+ :notification_groups,
+ Notification.group_notifications(notifications, grouped_types)
+ )
+ |> then(&render("grouped_index.json", &1))
+ end
+
+ def render(
+ "grouped_index.json",
+ %{notification_groups: notification_groups, for: reading_user} = opts
+ ) do
+ grouped_types = Notification.normalize_grouped_types(opts[:grouped_types])
+ notification_group_counts = Map.get(opts, :notification_group_counts, %{})
+ notification_group_bounds = Map.get(opts, :notification_group_bounds, %{})
+ include_page_metadata = Map.get(opts, :include_page_metadata, true)
+
+ statuses =
+ notification_groups
+ |> Enum.map(&List.first/1)
+ |> Enum.map(&render("show.json", %{notification: &1, for: reading_user}))
+ |> Enum.map(& &1[:status])
+ |> Enum.filter(& &1)
+ |> Enum.uniq_by(& &1[:id])
+
+ actors =
+ notification_groups
+ |> List.flatten()
+ |> notification_actors()
+
+ %{
+ accounts: AccountView.render("index.json", %{users: actors, for: reading_user}),
+ statuses: statuses,
+ notification_groups:
+ Enum.map(
+ notification_groups,
+ &render_group(
+ &1,
+ reading_user,
+ grouped_types,
+ notification_group_counts,
+ notification_group_bounds,
+ include_page_metadata
+ )
+ )
+ }
+ end
+
def render(
"show.json",
%{
notification: %Notification{activity: activity} = notification,
for: reading_user
} = opts
) do
actor = User.get_cached_by_ap_id(activity.data["actor"])
parent_activity_fn = fn ->
if opts[:parent_activities] do
Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
else
Activity.get_create_by_object_ap_id(object_id_for(activity))
end
end
# Note: :relationships contain user mutes (needed for :muted flag in :status)
status_render_opts = %{relationships: opts[:relationships]}
account = AccountView.render("show.json", %{user: actor, for: reading_user})
response = %{
id: to_string(notification.id),
- group_key: "ungrouped-" <> to_string(notification.id),
+ group_key: Notification.group_key(notification),
type: notification.type,
created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at),
account: account,
pleroma: %{
is_muted: User.mutes?(reading_user, actor),
is_seen: notification.seen
}
}
case notification.type do
type when type in ["mention", "status", "poll"] ->
put_status(response, activity, reading_user, status_render_opts)
type when type in ["favourite", "reblog", "update"] ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
"move" ->
put_target(response, activity, reading_user, %{})
"pleroma:emoji_reaction" ->
response
|> put_status(parent_activity_fn.(), reading_user, status_render_opts)
|> put_emoji(activity)
"pleroma:chat_mention" ->
put_chat_message(response, activity, reading_user, status_render_opts)
"pleroma:report" ->
put_report(response, activity)
type when type in ["follow", "follow_request"] ->
response
end
end
defp put_report(response, activity) do
report_render = ReportView.render("show.json", Report.extract_report_info(activity))
Map.put(response, :report, report_render)
end
+ defp render_group(
+ [%Notification{} = notification | _] = notifications,
+ _reading_user,
+ grouped_types,
+ notification_group_counts,
+ notification_group_bounds,
+ include_page_metadata
+ ) do
+ latest_notification = List.first(notifications)
+ oldest_notification = List.last(notifications)
+ status_activity = status_activity_for_group(notifications, grouped_types)
+ group_key = Notification.group_key(notification, grouped_types)
+ bounds = Map.get(notification_group_bounds, group_key, %{})
+
+ response = %{
+ group_key: group_key,
+ notifications_count: Map.get(notification_group_counts, group_key, length(notifications)),
+ type: notification.type,
+ most_recent_notification_id:
+ bounds
+ |> Map.get(:page_max_id, latest_notification.id)
+ |> to_string(),
+ sample_account_ids:
+ notifications
+ |> notification_actors()
+ |> Enum.map(&to_string(&1.id))
+ }
+
+ response =
+ if include_page_metadata do
+ Map.merge(response, %{
+ page_min_id:
+ bounds
+ |> Map.get(:page_min_id, oldest_notification.id)
+ |> to_string(),
+ page_max_id:
+ bounds
+ |> Map.get(:page_max_id, latest_notification.id)
+ |> to_string(),
+ latest_page_notification_at:
+ bounds
+ |> Map.get(:latest_page_notification_at, latest_notification.inserted_at)
+ |> CommonAPI.Utils.to_masto_date()
+ })
+ else
+ response
+ end
+
+ if status_activity do
+ Map.put(response, :status_id, to_string(status_activity.id))
+ else
+ response
+ end
+ end
+
+ defp status_activity_for_group([%Notification{} = notification | _], grouped_types) do
+ status_activity_for(notification, grouped_types)
+ end
+
+ defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types)
+ when type in ["mention", "status", "poll"] do
+ activity
+ end
+
+ defp status_activity_for(%Notification{type: type} = notification, grouped_types)
+ when type in ["favourite", "reblog"] do
+ group_key = Notification.group_key(notification, grouped_types)
+
+ case String.split(group_key, "-", parts: 3) do
+ [^type, activity_id, _bucket] ->
+ case Activity.create_by_id_with_object(activity_id) do
+ %Activity{} = activity -> activity
+ _ -> parent_status_activity(notification.activity)
+ end
+
+ _ ->
+ parent_status_activity(notification.activity)
+ end
+ end
+
+ defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types)
+ when type in ["update", "pleroma:emoji_reaction"] do
+ parent_status_activity(activity)
+ end
+
+ defp status_activity_for(_, _grouped_types), do: nil
+
+ defp parent_status_activity(activity) do
+ Activity.get_create_by_object_ap_id(object_id_for(activity))
+ end
+
+ defp notification_actors(notifications) do
+ notifications
+ |> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["actor"]))
+ |> Enum.filter(& &1)
+ |> Enum.uniq_by(& &1.id)
+ end
+
defp put_emoji(response, activity) do
response
|> Map.put(:emoji, activity.data["content"])
|> Map.put(:emoji_url, MediaProxy.url(Pleroma.Emoji.emoji_url(activity.data)))
end
defp put_chat_message(response, activity, reading_user, opts) do
object = Object.normalize(activity, fetch: false)
author = User.get_cached_by_ap_id(object.data["actor"])
chat = Pleroma.Chat.get(reading_user.id, author.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref})
chat_message_render = MessageReferenceView.render("show.json", render_opts)
Map.put(response, :chat_message, chat_message_render)
end
defp put_status(response, activity, reading_user, opts) do
status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user})
status_render = StatusView.render("show.json", status_render_opts)
Map.put(response, :status, status_render)
end
defp put_target(response, activity, reading_user, opts) do
target_user = User.get_cached_by_ap_id(activity.data["target"])
target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user})
target_render = AccountView.render("show.json", target_render_opts)
Map.put(response, :target, target_render)
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 09af60865..6949c5f2d 100644
--- a/lib/pleroma/web/mastodon_api/views/status_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -1,863 +1,870 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.StatusView do
use Pleroma.Web, :view
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.HTML
alias Pleroma.Maps
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.UserRelationship
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
alias Pleroma.Web.PleromaAPI.EmojiReactionController
alias Pleroma.Web.RichMedia.Card
import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
# This is a naive way to do this, just spawning a process per activity
# to fetch the preview. However it should be fine considering
# pagination is restricted to 40 activities at a time
- defp fetch_rich_media_for_activities(activities) do
+ # Force disable Websockets streaming for backfill jobs,
+ # otherwise old posts can show up on timelines.
+ defp fetch_rich_media_for_activities(activities, opts) do
+ opts = Map.put(opts, :stream, false)
+
Enum.each(activities, fn activity ->
- Card.get_by_activity(activity)
+ Card.get_by_activity(activity, opts)
end)
end
# TODO: Add cached version.
defp get_replied_to_activities([]), do: %{}
defp get_replied_to_activities(activities) do
activities
|> Enum.map(fn
%{data: %{"type" => "Create"}} = activity ->
object = Object.normalize(activity, fetch: false)
object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
_ ->
nil
end)
|> Enum.filter(& &1)
|> Activity.create_by_object_ap_id_with_object()
|> Repo.all()
|> Enum.reduce(%{}, fn activity, acc ->
object = Object.normalize(activity, fetch: false)
if object, do: Map.put(acc, object.data["id"], activity), else: acc
end)
end
defp get_quoted_activities([]), do: %{}
defp get_quoted_activities(activities) do
activities
|> Enum.map(fn
%{data: %{"type" => "Create"}} = activity ->
object = Object.normalize(activity, fetch: false)
object && object.data["quoteUrl"] != "" && object.data["quoteUrl"]
_ ->
nil
end)
|> Enum.filter(& &1)
|> Activity.create_by_object_ap_id_with_object()
|> Repo.all()
|> Enum.reduce(%{}, fn activity, acc ->
object = Object.normalize(activity, fetch: false)
if object, do: Map.put(acc, object.data["id"], activity), else: acc
end)
end
# DEPRECATED This field seems to be a left-over from the StatusNet era.
# If your application uses `pleroma.conversation_id`: this field is deprecated.
# It is currently stubbed instead by doing a CRC32 of the context, and
# clearing the MSB to avoid overflow exceptions with signed integers on the
# different clients using this field (Java/Kotlin code, mostly; see Husky.)
# This should be removed in a future version of Pleroma. Pleroma-FE currently
# depends on this field, as well.
defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do
import Bitwise
:erlang.crc32(context)
|> band(bnot(0x8000_0000))
end
defp get_context_id(_), do: nil
# Check if the user reblogged this status
defp reblogged?(activity, %User{ap_id: ap_id}) do
with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
Object.normalize(activity, fetch: false) do
ap_id in announcements
else
_ -> false
end
end
# False if the user is logged out
defp reblogged?(_activity, _user), do: false
def render("index.json", opts) do
reading_user = opts[:for]
# To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
activities = Enum.filter(opts.activities, & &1)
# Start prefetching rich media before doing anything else
- fetch_rich_media_for_activities(activities)
+ fetch_rich_media_for_activities(activities, opts)
+
replied_to_activities = get_replied_to_activities(activities)
quoted_activities = get_quoted_activities(activities)
parent_activities =
activities
|> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
|> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
|> Activity.create_by_object_ap_id()
|> Activity.with_preloaded_object(:left)
|> Activity.with_preloaded_bookmark(reading_user)
|> Activity.with_set_thread_muted_field(reading_user)
|> Repo.all()
relationships_opt =
cond do
Map.has_key?(opts, :relationships) ->
opts[:relationships]
is_nil(reading_user) ->
UserRelationship.view_relationships_option(nil, [])
true ->
# Note: unresolved users are filtered out
actors =
(activities ++ parent_activities)
|> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
|> Enum.filter(& &1)
UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
end
opts =
opts
|> Map.put(:replied_to_activities, replied_to_activities)
|> Map.put(:quoted_activities, quoted_activities)
|> Map.put(:parent_activities, parent_activities)
|> Map.put(:relationships, relationships_opt)
safe_render_many(activities, StatusView, "show.json", opts)
end
def render(
"show.json",
%{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
) do
user = CommonAPI.get_user(activity.data["actor"])
created_at = Utils.to_masto_date(activity.data["published"])
object = Object.normalize(activity, fetch: false)
reblogged_parent_activity =
if opts[:parent_activities] do
Activity.Queries.find_by_object_ap_id(
opts[:parent_activities],
object.data["id"]
)
else
Activity.create_by_object_ap_id(object.data["id"])
|> Activity.with_preloaded_bookmark(opts[:for])
|> Activity.with_set_thread_muted_field(opts[:for])
|> Repo.one()
end
reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
reblogged = render("show.json", reblog_rendering_opts)
favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
bookmark = Activity.get_bookmark(reblogged_parent_activity, opts[:for])
bookmark_folder =
if bookmark != nil do
bookmark.folder_id
else
nil
end
mentions =
activity.recipients
|> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
|> Enum.filter(& &1)
|> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
{pinned?, pinned_at} = pin_data(object, user)
%{
id: to_string(activity.id),
uri: object.data["id"],
url: object.data["id"],
account:
AccountView.render("show.json", %{
user: user,
for: opts[:for]
}),
in_reply_to_id: nil,
in_reply_to_account_id: nil,
reblog: reblogged,
content: reblogged[:content] || "",
created_at: created_at,
reblogs_count: 0,
replies_count: 0,
favourites_count: 0,
reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
favourited: present?(favorited),
bookmarked: present?(bookmark),
muted: false,
pinned: pinned?,
sensitive: false,
spoiler_text: "",
visibility: get_visibility(activity),
media_attachments: reblogged[:media_attachments] || [],
mentions: mentions,
tags: reblogged[:tags] || [],
application: build_application(object.data["generator"]),
language: get_language(object),
emojis: [],
pleroma: %{
local: activity.local,
pinned_at: pinned_at,
bookmark_folder: bookmark_folder
}
}
end
def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
object = Object.normalize(activity, fetch: false)
user = CommonAPI.get_user(object.data["actor"])
user_follower_address = user.follower_address
like_count = object.data["like_count"] || 0
announcement_count = object.data["announcement_count"] || 0
hashtags = Object.hashtags(object)
sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
tags = Object.tags(object)
tag_mentions =
tags
|> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
|> Enum.map(fn tag -> tag["href"] end)
mentions =
(object.data["to"] ++ tag_mentions)
|> Enum.uniq()
|> Enum.map(fn
Pleroma.Constants.as_public() -> nil
^user_follower_address -> nil
ap_id -> User.get_cached_by_ap_id(ap_id)
end)
|> Enum.filter(& &1)
|> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
bookmark = Activity.get_bookmark(activity, opts[:for])
bookmark_folder =
if bookmark != nil do
bookmark.folder_id
else
nil
end
client_posted_this_activity = opts[:for] && user.id == opts[:for].id
expires_at =
with true <- client_posted_this_activity,
%Oban.Job{scheduled_at: scheduled_at} <-
Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
scheduled_at
else
_ -> nil
end
thread_muted? =
cond do
is_nil(opts[:for]) -> false
is_boolean(activity.thread_muted?) -> activity.thread_muted?
true -> CommonAPI.thread_muted?(activity, opts[:for])
end
attachment_data = object.data["attachment"] || []
attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
created_at = Utils.to_masto_date(object.data["published"])
edited_at =
with %{"updated" => updated} <- object.data,
date <- Utils.to_masto_date(updated),
true <- date != "" do
date
else
_ ->
nil
end
reply_to = get_reply_to(activity, opts)
reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
history_len =
1 +
(Object.Updater.history_for(object.data)
|> Map.get("orderedItems")
|> length())
# See render("history.json", ...) for more details
# Here the implicit index of the current content is 0
chrono_order = history_len - 1
quote_activity = get_quote(activity, opts)
quote_id =
case quote_activity do
%Activity{id: id} -> id
_ -> nil
end
quote_post =
if visible_for_user?(quote_activity, opts[:for]) and opts[:show_quote] != false do
quote_rendering_opts = Map.merge(opts, %{activity: quote_activity, show_quote: false})
render("show.json", quote_rendering_opts)
else
nil
end
content =
object
|> render_content()
content_html =
content
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
User.html_filter_policy(opts[:for]),
activity,
"mastoapi:content:#{chrono_order}"
)
content_plaintext =
content
|> Activity.HTML.get_cached_stripped_html_for_activity(
activity,
"mastoapi:content:#{chrono_order}"
)
summary = object.data["summary"] || ""
+ # Force disable Websockets streaming for backfill jobs which the below call will create,
+ # otherwise old posts can show up on timelines.
card =
- case Card.get_by_activity(activity) do
+ case Card.get_by_activity(activity, Map.put(opts, :stream, false)) do
%Card{} = result -> render("card.json", result)
_ -> nil
end
url =
if user.local do
Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
else
object.data["url"] || object.data["external_url"] || object.data["id"]
end
direct_conversation_id =
with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
{_, true} <- {:include_id, opts[:with_direct_conversation_id]},
{_, %User{} = for_user} <- {:for_user, opts[:for]} do
Activity.direct_conversation_id(activity, for_user)
else
{:direct_conversation_id, participation_id} when is_integer(participation_id) ->
participation_id
_e ->
nil
end
emoji_reactions =
object
|> Object.get_emoji_reactions()
|> EmojiReactionController.filter_allowed_users(
opts[:for],
Map.get(opts, :with_muted, false)
)
|> Stream.map(fn {emoji, users, url} ->
build_emoji_map(emoji, users, url, opts[:for])
end)
|> Enum.to_list()
# Status muted state (would do 1 request per status unless user mutes are preloaded)
muted =
thread_muted? ||
UserRelationship.exists?(
get_in(opts, [:relationships, :user_relationships]),
:mute,
opts[:for],
user,
fn for_user, user -> User.mutes?(for_user, user) end
)
{pinned?, pinned_at} = pin_data(object, user)
%{
id: to_string(activity.id),
uri: object.data["id"],
url: url,
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,
card: card,
content: content_html,
text: opts[:with_source] && get_source_text(object.data["source"]),
created_at: created_at,
edited_at: edited_at,
reblogs_count: announcement_count,
replies_count: object.data["repliesCount"] || 0,
favourites_count: like_count,
reblogged: reblogged?(activity, opts[:for]),
favourited: present?(favorited),
bookmarked: present?(bookmark),
muted: muted,
pinned: pinned?,
sensitive: sensitive,
spoiler_text: summary,
visibility: get_visibility(object),
media_attachments: attachments,
poll: render(PollView, "show.json", object: object, for: opts[:for]),
mentions: mentions,
tags: build_tags(tags),
application: build_application(object.data["generator"]),
language: get_language(object),
emojis: build_emojis(object.data["emoji"]),
quotes_count: object.data["quotesCount"] || 0,
pleroma: %{
local: activity.local,
conversation_id: get_context_id(activity),
context: object.data["context"],
in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
quote: quote_post,
quote_id: quote_id,
quote_url: object.data["quoteUrl"],
quote_visible: visible_for_user?(quote_activity, opts[:for]),
content: %{"text/plain" => content_plaintext},
spoiler_text: %{"text/plain" => summary},
expires_at: expires_at,
direct_conversation_id: direct_conversation_id,
thread_muted: thread_muted?,
emoji_reactions: emoji_reactions,
parent_visible: visible_for_user?(reply_to, opts[:for]),
pinned_at: pinned_at,
quotes_count: object.data["quotesCount"] || 0,
bookmark_folder: bookmark_folder,
list_id: get_list_id(object, client_posted_this_activity)
}
}
end
def render("show.json", _) do
nil
end
def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
object = Object.normalize(activity, fetch: false)
hashtags = Object.hashtags(object)
user = CommonAPI.get_user(activity.data["actor"])
past_history =
Object.Updater.history_for(object.data)
|> Map.get("orderedItems")
|> Enum.map(&Map.put(&1, "id", object.data["id"]))
|> Enum.map(&%Object{data: &1, id: object.id})
history =
[object | past_history]
# Mastodon expects the original to be at the first
|> Enum.reverse()
|> Enum.with_index()
|> Enum.map(fn {object, chrono_order} ->
%{
# The history is prepended every time there is a new edit.
# In chrono_order, the oldest item is always at 0, and so on.
# The chrono_order is an invariant kept between edits.
chrono_order: chrono_order,
object: object
}
end)
individual_opts =
opts
|> Map.put(:as, :item)
|> Map.put(:user, user)
|> Map.put(:hashtags, hashtags)
render_many(history, StatusView, "history_item.json", individual_opts)
end
def render(
"history_item.json",
%{
activity: activity,
user: user,
item: %{object: object, chrono_order: chrono_order},
hashtags: hashtags
} = opts
) do
sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
attachment_data = object.data["attachment"] || []
attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
content =
object
|> render_content()
content_html =
content
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
User.html_filter_policy(opts[:for]),
activity,
"mastoapi:content:#{chrono_order}"
)
summary = object.data["summary"] || ""
%{
account:
AccountView.render("show.json", %{
user: user,
for: opts[:for]
}),
content: content_html,
sensitive: sensitive,
spoiler_text: summary,
created_at: created_at,
media_attachments: attachments,
emojis: build_emojis(object.data["emoji"]),
poll: render(PollView, "show.json", object: object, for: opts[:for])
}
end
def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
object = Object.normalize(activity, fetch: false)
%{
id: activity.id,
text: get_source_text(Map.get(object.data, "source", "")),
spoiler_text: Map.get(object.data, "summary", ""),
content_type: get_source_content_type(object.data["source"])
}
end
def render("card.json", %Card{fields: rich_media}) do
page_url_data = URI.parse(rich_media["url"])
page_url = page_url_data |> to_string
image_url = proxied_url(rich_media["image"], page_url_data)
audio_url = proxied_url(rich_media["audio"], page_url_data)
video_url = proxied_url(rich_media["video"], page_url_data)
%{
type: "link",
provider_name: page_url_data.host,
provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
url: page_url,
image: image_url,
image_description: rich_media["image:alt"] || "",
title: rich_media["title"] || "",
description: rich_media["description"] || "",
pleroma: %{
opengraph:
rich_media
|> Maps.put_if_present("image", image_url)
|> Maps.put_if_present("audio", audio_url)
|> Maps.put_if_present("video", video_url)
}
}
end
def render("card.json", _), do: nil
def render("attachment.json", %{attachment: attachment}) do
[attachment_url | _] = attachment["url"]
media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
href_remote = attachment_url["href"]
href = href_remote |> MediaProxy.url()
href_preview = attachment_url["href"] |> MediaProxy.preview_url()
meta = render("attachment_meta.json", %{attachment: attachment})
type =
cond do
String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio"
true -> "unknown"
end
attachment_id =
with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
{_, %Object{data: _object_data, id: object_id}} <-
{:object, Object.get_by_ap_id(ap_id)} do
to_string(object_id)
else
_ ->
<<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
to_string(attachment["id"] || hash_id)
end
description =
if attachment["summary"] do
HTML.strip_tags(attachment["summary"])
else
attachment["name"]
end
name = if attachment["summary"], do: attachment["name"]
pleroma =
%{mime_type: media_type}
|> Maps.put_if_present(:name, name)
%{
id: attachment_id,
url: href,
remote_url: href_remote,
preview_url: href_preview,
text_url: href,
type: type,
description: description,
pleroma: pleroma,
blurhash: attachment["blurhash"]
}
|> Maps.put_if_present(:meta, meta)
end
def render("attachment_meta.json", %{
attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
})
when is_integer(width) and is_integer(height) do
%{
original: %{
width: width,
height: height,
aspect: width / height
}
}
end
def render("attachment_meta.json", _), do: nil
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 render("translation.json", %{
content: content,
detected_source_language: detected_source_language,
provider: provider
}) do
%{content: content, detected_source_language: detected_source_language, provider: provider}
end
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
object = Object.normalize(activity, fetch: false)
with nil <- replied_to_activities[object.data["inReplyTo"]] do
# If user didn't participate in the thread
Activity.get_in_reply_to_activity(activity)
end
end
def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
object = Object.normalize(activity, fetch: false)
if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
else
nil
end
end
def get_quote(activity, %{quoted_activities: quoted_activities}) do
object = Object.normalize(activity, fetch: false)
with nil <- quoted_activities[object.data["quoteUrl"]] do
# For when a quote post is inside an Announce
Activity.get_create_by_object_ap_id_with_object(object.data["quoteUrl"])
end
end
def get_quote(%{data: %{"object" => _object}} = activity, _) do
object = Object.normalize(activity, fetch: false)
if object.data["quoteUrl"] && object.data["quoteUrl"] != "" do
Activity.get_create_by_object_ap_id(object.data["quoteUrl"])
else
nil
end
end
def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
url = object.data["url"] || object.data["id"]
"<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
end
def render_content(object), do: object.data["content"] || ""
@doc """
Builds a dictionary tags.
## Examples
iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
[{"name": "fediverse", "url": "/tag/fediverse"},
{"name": "nextcloud", "url": "/tag/nextcloud"}]
"""
@spec build_tags(list(any())) :: list(map())
def build_tags(object_tags) when is_list(object_tags) do
object_tags
|> Enum.filter(&is_binary/1)
|> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
end
def build_tags(_), do: []
@doc """
Builds list emojis.
Arguments: `nil` or list tuple of name and url.
Returns list emojis.
## Examples
iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
[%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
"""
@spec build_emojis(nil | list(tuple())) :: list(map())
def build_emojis(nil), do: []
def build_emojis(emojis) do
emojis
|> Enum.map(fn {name, url} ->
name = HTML.strip_tags(name)
url =
url
|> HTML.strip_tags()
|> MediaProxy.url()
%{shortcode: name, url: url, static_url: url, visible_in_picker: false}
end)
end
defp present?(nil), do: false
defp present?(false), do: false
defp present?(_), do: true
defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
if pinned_at = pinned_objects[object_id] do
{true, Utils.to_masto_date(pinned_at)}
else
{false, nil}
end
end
defp build_emoji_map(emoji, users, url, current_user) do
%{
name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
count: length(users),
url: MediaProxy.url(url),
me: !!(current_user && current_user.ap_id in users),
account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
}
end
@spec build_application(map() | nil) :: map() | nil
defp build_application(%{"type" => _type, "name" => name, "url" => url}),
do: %{name: name, website: url}
defp build_application(_), do: nil
@spec build_image_url(URI.t(), URI.t()) :: String.t()
defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
URI.merge(page_url_data, image_url_data) |> to_string
end
defp get_source_text(%{"content" => content} = _source) do
content
end
defp get_source_text(source) when is_binary(source) do
source
end
defp get_source_text(_) do
""
end
defp get_source_content_type(%{"mediaType" => type} = _source) do
type
end
defp get_source_content_type(_source) do
Utils.get_content_type(nil)
end
defp get_language(%{data: %{"language" => "und"}}), do: nil
defp get_language(object), do: object.data["language"]
defp proxied_url(url, page_url_data) do
if is_binary(url) do
build_image_url(URI.parse(url), page_url_data) |> MediaProxy.url()
else
nil
end
end
defp get_list_id(object, client_posted_this_activity) do
with true <- client_posted_this_activity,
%{data: %{"listMessage" => list_ap_id}} when is_binary(list_ap_id) <- object,
%{id: list_id} <- Pleroma.List.get_by_ap_id(list_ap_id) do
list_id
else
_ -> nil
end
end
end
diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex
index 2b698bd5d..3dc862a5a 100644
--- a/lib/pleroma/web/mastodon_api/websocket_handler.ex
+++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex
@@ -1,264 +1,265 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
require Logger
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Streamer
alias Pleroma.Web.StreamerView
@behaviour Phoenix.Socket.Transport
# Client ping period.
@tick :timer.seconds(30)
@impl Phoenix.Socket.Transport
def child_spec(_opts), do: :ignore
# This only prepares the connection and is not in the process yet
@impl Phoenix.Socket.Transport
def connect(%{params: params} = transport_info) do
with access_token <- find_access_token(transport_info),
{:ok, user, oauth_token} <- authenticate_request(access_token),
{:ok, topic} <-
Streamer.get_topic(params["stream"], user, oauth_token, params) do
topics =
if topic do
[topic]
else
[]
end
state = %{
user: user,
topics: topics,
oauth_token: oauth_token,
count: 0,
timer: nil
}
{:ok, state}
else
{:error, :bad_topic} ->
Logger.debug("#{__MODULE__} bad topic #{inspect(transport_info)}")
{:error, :bad_topic}
{:error, :unauthorized} ->
Logger.debug("#{__MODULE__} authentication error: #{inspect(transport_info)}")
{:error, :unauthorized}
end
end
# All subscriptions/links and messages cannot be created
# until the processed is launched with init/1
@impl Phoenix.Socket.Transport
def init(state) do
Enum.each(state.topics, fn topic -> Streamer.add_socket(topic, state.oauth_token) end)
Process.send_after(self(), :ping, @tick)
{:ok, state}
end
@impl Phoenix.Socket.Transport
def handle_in({text, [opcode: :text]}, state) do
- with {:ok, %{} = event} <- Jason.decode(text) do
- handle_client_event(event, state)
- else
+ case Jason.decode(text) do
+ {:ok, %{} = event} ->
+ handle_client_event(event, state)
+
_ ->
Logger.error("#{__MODULE__} received non-JSON event: #{inspect(text)}")
{:ok, state}
end
end
def handle_in(frame, state) do
Logger.error("#{__MODULE__} received frame: #{inspect(frame)}")
{:ok, state}
end
@impl Phoenix.Socket.Transport
def handle_info({:render_with_user, view, template, item, topic}, state) do
user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
- unless Streamer.filtered_by_user?(user, item) do
+ if Streamer.filtered_by_user?(user, item) do
+ {:ok, state}
+ else
message = view.render(template, item, user, topic)
{:push, {:text, message}, %{state | user: user}}
- else
- {:ok, state}
end
end
def handle_info({:text, text}, state) do
{:push, {:text, text}, state}
end
def handle_info(:ping, state) do
Process.send_after(self(), :ping, @tick)
{:push, {:ping, ""}, state}
end
def handle_info(:close, state) do
{:stop, {:closed, ~c"connection closed by server"}, state}
end
def handle_info(msg, state) do
Logger.debug("#{__MODULE__} received info: #{inspect(msg)}")
{:ok, state}
end
@impl Phoenix.Socket.Transport
def terminate(reason, state) do
Logger.debug(
"#{__MODULE__} terminating websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topics #{state.topics || "?"}: #{inspect(reason)})"
)
Enum.each(state.topics, fn topic -> Streamer.remove_socket(topic) end)
:ok
end
# Public streams without authentication.
defp authenticate_request(nil) do
{:ok, nil, nil}
end
# Authenticated streams.
defp authenticate_request(access_token) do
with oauth_token = %Token{user_id: user_id} <- Repo.get_by(Token, token: access_token),
user = %User{} <- User.get_cached_by_id(user_id) do
{:ok, user, oauth_token}
else
_ -> {:error, :unauthorized}
end
end
defp handle_client_event(%{"type" => "subscribe", "stream" => _topic} = params, state) do
with {_, {:ok, topic}} <-
{:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)},
{_, false} <- {:subscribed, topic in state.topics} do
Streamer.add_socket(topic, state.oauth_token)
message =
StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "success"})
{:reply, :ok, {:text, message}, %{state | topics: [topic | state.topics]}}
else
{:subscribed, true} ->
message =
StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "ignored"})
{:reply, :error, {:text, message}, state}
{:topic, {:error, error}} ->
message =
StreamerView.render("pleroma_respond.json", %{
type: "subscribe",
result: "error",
error: error
})
{:reply, :error, {:text, message}, state}
end
end
defp handle_client_event(%{"type" => "unsubscribe", "stream" => _topic} = params, state) do
with {_, {:ok, topic}} <-
{:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)},
{_, true} <- {:subscribed, topic in state.topics} do
Streamer.remove_socket(topic)
message =
StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "success"})
{:reply, :ok, {:text, message}, %{state | topics: List.delete(state.topics, topic)}}
else
{:subscribed, false} ->
message =
StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "ignored"})
{:reply, :error, {:text, message}, state}
{:topic, {:error, error}} ->
message =
StreamerView.render("pleroma_respond.json", %{
type: "unsubscribe",
result: "error",
error: error
})
{:reply, :error, {:text, message}, state}
end
end
defp handle_client_event(
%{"type" => "pleroma:authenticate", "token" => access_token} = _params,
state
) do
with {:auth, nil, nil} <- {:auth, state.user, state.oauth_token},
{:ok, user, oauth_token} <- authenticate_request(access_token) do
message =
StreamerView.render("pleroma_respond.json", %{
type: "pleroma:authenticate",
result: "success"
})
{:reply, :ok, {:text, message}, %{state | user: user, oauth_token: oauth_token}}
else
{:auth, _, _} ->
message =
StreamerView.render("pleroma_respond.json", %{
type: "pleroma:authenticate",
result: "error",
error: :already_authenticated
})
{:reply, :error, {:text, message}, state}
_ ->
message =
StreamerView.render("pleroma_respond.json", %{
type: "pleroma:authenticate",
result: "error",
error: :unauthorized
})
{:reply, :error, {:text, message}, state}
end
end
defp handle_client_event(params, state) do
Logger.error("#{__MODULE__} received unknown event: #{inspect(params)}")
{:ok, state}
end
def handle_error(conn, :unauthorized) do
Plug.Conn.send_resp(conn, 401, "Unauthorized")
end
def handle_error(conn, _reason) do
Plug.Conn.send_resp(conn, 404, "Not Found")
end
defp find_access_token(%{connect_info: %{sec_websocket_headers: sec_headers}} = transport_info) do
find_sec_websocket_protocol(sec_headers) || find_access_token_from_params(transport_info)
end
defp find_access_token(transport_info), do: find_access_token_from_params(transport_info)
defp find_sec_websocket_protocol(sec_headers) do
Enum.find_value(sec_headers, fn
- {"sec-websocket-protocol", token} -> token
+ {"sec-websocket-protocol", protocols} -> protocols |> Plug.Conn.Utils.list() |> List.first()
_ -> nil
end)
end
defp find_access_token_from_params(%{params: %{"access_token" => token}}), do: token
defp find_access_token_from_params(_), do: nil
end
diff --git a/lib/pleroma/web/mastodon_api/websocket_plug.ex b/lib/pleroma/web/mastodon_api/websocket_plug.ex
new file mode 100644
index 000000000..c5fb5b748
--- /dev/null
+++ b/lib/pleroma/web/mastodon_api/websocket_plug.ex
@@ -0,0 +1,104 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.WebsocketPlug do
+ @moduledoc """
+ A Phoenix 1.8 compatible WebSocket transport for Mastodon streaming.
+
+ It mirrors Phoenix.Transports.WebSocket, but echoes a successfully authenticated
+ Mastodon-style Sec-WebSocket-Protocol token so browser clients accept the handshake.
+ """
+
+ @behaviour Plug
+
+ import Plug.Conn
+
+ alias Phoenix.Socket.Transport
+ alias Pleroma.Web.Endpoint
+ alias Pleroma.Web.MastodonAPI.WebsocketHandler
+
+ @connect_info_opts [:check_csrf]
+
+ @impl Plug
+ def init(opts) do
+ path = String.split(Keyword.fetch!(opts, :path), "/", trim: true)
+ websocket = Keyword.fetch!(opts, :websocket)
+ config = Transport.load_config(websocket, Phoenix.Transports.WebSocket)
+
+ {path, config}
+ end
+
+ @impl Plug
+ def call(%{method: "GET", path_info: path} = conn, {path, opts}) do
+ conn
+ |> fetch_query_params()
+ |> Transport.code_reload(Endpoint, opts)
+ |> Transport.transport_log(opts[:transport_log])
+ |> Transport.check_origin(WebsocketHandler, Endpoint, opts)
+ |> connect(opts)
+ end
+
+ def call(%{path_info: path} = conn, {path, _opts}) do
+ conn
+ |> send_resp(400, "")
+ |> halt()
+ end
+
+ def call(conn, _opts), do: conn
+
+ defp connect(%{halted: true} = conn, _opts), do: conn
+
+ defp connect(%{params: params} = conn, opts) do
+ keys = Keyword.get(opts, :connect_info, [])
+
+ connect_info =
+ Transport.connect_info(conn, Endpoint, keys, Keyword.take(opts, @connect_info_opts))
+
+ config = %{
+ endpoint: Endpoint,
+ transport: :websocket,
+ options: opts,
+ params: params,
+ connect_info: connect_info
+ }
+
+ case WebsocketHandler.connect(config) do
+ {:ok, arg} ->
+ try do
+ conn
+ |> echo_sec_websocket_protocol()
+ |> WebSockAdapter.upgrade(WebsocketHandler, arg, opts)
+ |> halt()
+ rescue
+ e in WebSockAdapter.UpgradeError ->
+ conn
+ |> send_resp(400, e.message)
+ |> halt()
+ end
+
+ :error ->
+ conn
+ |> send_resp(403, "")
+ |> halt()
+
+ {:error, reason} ->
+ {m, f, args} = opts[:error_handler]
+
+ halt(apply(m, f, [conn, reason | args]))
+ end
+ end
+
+ defp echo_sec_websocket_protocol(conn) do
+ case get_req_header(conn, "sec-websocket-protocol") do
+ [protocols | _] ->
+ case Plug.Conn.Utils.list(protocols) do
+ [protocol | _] -> put_resp_header(conn, "sec-websocket-protocol", protocol)
+ [] -> conn
+ end
+
+ [] ->
+ conn
+ end
+ end
+end
diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex
index 1cd90629f..a100b39ab 100644
--- a/lib/pleroma/web/rich_media/backfill.ex
+++ b/lib/pleroma/web/rich_media/backfill.ex
@@ -1,68 +1,71 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.Backfill do
alias Pleroma.Web.RichMedia.Card
alias Pleroma.Web.RichMedia.Helpers
alias Pleroma.Web.RichMedia.Parser
alias Pleroma.Web.RichMedia.Parser.TTL
alias Pleroma.Workers.RichMediaWorker
require Logger
+ @callback run(map()) :: :ok | Parser.parse_errors() | Helpers.get_errors()
+
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@stream_out_impl Pleroma.Config.get(
[__MODULE__, :stream_out],
Pleroma.Web.ActivityPub.ActivityPub
)
@spec run(map()) :: :ok | Parser.parse_errors() | Helpers.get_errors()
def run(%{"url" => url} = args) do
url_hash = Card.url_to_hash(url)
case Parser.parse(url) do
{:ok, fields} ->
{:ok, card} = Card.create(url, fields)
maybe_schedule_expiration(url, fields)
-
- with %{"activity_id" => activity_id} <- args,
- false <- is_nil(activity_id) do
- stream_update(args)
- end
+ maybe_update_stream(args)
warm_cache(url_hash, card)
:ok
{:error, type} = error
when type in [:invalid_metadata, :body_too_large, :content_type, :validate, :get, :head] ->
negative_cache(url_hash)
error
end
end
defp maybe_schedule_expiration(url, fields) do
case TTL.process(fields, url) do
{:ok, ttl} when is_number(ttl) ->
timestamp = DateTime.from_unix!(ttl)
RichMediaWorker.new(%{"op" => "expire", "url" => url}, scheduled_at: timestamp)
|> Oban.insert()
_ ->
:ok
end
end
- defp stream_update(%{"activity_id" => activity_id}) do
+ defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true})
+ when is_binary(activity_id) do
Pleroma.Activity.get_by_id(activity_id)
|> Pleroma.Activity.normalize()
|> @stream_out_impl.stream_out()
end
+ # Streamer.stream_out returns noop when unsupported activity type is requested to be streamed.
+ # Do the same here for unwanted streaming
+ defp maybe_update_stream(_), do: :noop
+
defp warm_cache(key, val), do: @cachex.put(:rich_media_cache, key, val)
defp negative_cache(key, ttl \\ :timer.minutes(15)),
do: @cachex.put(:rich_media_cache, key, :error, ttl: ttl)
end
diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex
index 6b4bb9555..1e9e66ec1 100644
--- a/lib/pleroma/web/rich_media/card.ex
+++ b/lib/pleroma/web/rich_media/card.ex
@@ -1,169 +1,182 @@
defmodule Pleroma.Web.RichMedia.Card do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.HTML
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Web.RichMedia.Parser
alias Pleroma.Workers.RichMediaWorker
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config)
@type t :: %__MODULE__{}
schema "rich_media_card" do
field(:url_hash, :binary)
field(:fields, :map)
timestamps()
end
@doc false
def changeset(card, attrs) do
card
|> cast(attrs, [:url_hash, :fields])
|> validate_required([:url_hash, :fields])
|> unique_constraint(:url_hash)
end
@spec create(String.t(), map()) :: {:ok, t()}
def create(url, fields) do
url_hash = url_to_hash(url)
fields = Map.put_new(fields, "url", url)
%__MODULE__{}
|> changeset(%{url_hash: url_hash, fields: fields})
|> Repo.insert(on_conflict: {:replace, [:fields]}, conflict_target: :url_hash)
end
@spec delete(String.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} | :ok
def delete(url) do
url_hash = url_to_hash(url)
@cachex.del(:rich_media_cache, url_hash)
case get_by_url(url) do
%__MODULE__{} = card -> Repo.delete(card)
nil -> :ok
end
end
@spec get_by_url(String.t() | nil) :: t() | nil | :error
def get_by_url(url) when is_binary(url) do
host = URI.parse(url).host
with true <- @config_impl.get([:rich_media, :enabled]),
true <- host not in @config_impl.get([:rich_media, :ignore_hosts], []) do
url_hash = url_to_hash(url)
@cachex.fetch!(:rich_media_cache, url_hash, fn _ ->
result =
__MODULE__
|> where(url_hash: ^url_hash)
|> Repo.one()
case result do
%__MODULE__{} = card -> {:commit, card}
_ -> {:ignore, nil}
end
end)
else
false -> :error
end
end
def get_by_url(nil), do: nil
@spec get_or_backfill_by_url(String.t(), keyword()) :: t() | nil
def get_or_backfill_by_url(url, opts \\ []) do
host = URI.parse(url).host
with true <- @config_impl.get([:rich_media, :enabled]),
true <- host not in @config_impl.get([:rich_media, :ignore_hosts], []) do
case get_by_url(url) do
%__MODULE__{} = card ->
card
nil ->
activity_id = Keyword.get(opts, :activity_id, nil)
- RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id})
+ # Nested opts, first layer comes from get_by_activity/2 as Keyword,
+ # second from API views/Federation as Map.
+ # Provide default Map when called directly.
+ opts = Keyword.get(opts, :opts, %{})
+ stream = Map.get(opts, :stream, true)
+
+ RichMediaWorker.new(%{
+ "op" => "backfill",
+ "url" => url,
+ "activity_id" => activity_id,
+ "stream" => stream
+ })
|> Oban.insert()
nil
:error ->
nil
end
else
false -> nil
end
end
@spec get_by_object(Object.t()) :: t() | nil | :error
def get_by_object(object) do
case HTML.extract_first_external_url_from_object(object) do
nil -> nil
url -> get_or_backfill_by_url(url)
end
end
- @spec get_by_activity(Activity.t()) :: t() | nil | :error
+ @spec get_by_activity(Activity.t(), %{}) :: t() | nil | :error
+ def get_by_activity(activity, opts \\ %{})
+
# Fake/Draft activity
- def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity) do
+ def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity, _opts) do
with {_, true} <- {:config, @config_impl.get([:rich_media, :enabled])},
%Object{} = object <- Object.normalize(activity, fetch: false),
url when not is_nil(url) <- HTML.extract_first_external_url_from_object(object) do
case get_by_url(url) do
# Cache hit
%__MODULE__{} = card ->
card
# Cache miss, but fetch for rendering the Draft
_ ->
with {:ok, fields} <- Parser.parse(url),
{:ok, card} <- create(url, fields) do
card
else
_ -> nil
end
end
else
_ ->
nil
end
end
- def get_by_activity(activity) do
+ def get_by_activity(activity, opts) do
with %Object{} = object <- Object.normalize(activity, fetch: false),
{_, nil} <- {:cached, get_cached_url(object, activity.id)} do
nil
else
{:cached, url} ->
- get_or_backfill_by_url(url, activity_id: activity.id)
+ get_or_backfill_by_url(url, activity_id: activity.id, opts: opts)
_ ->
:error
end
end
@spec url_to_hash(String.t()) :: String.t()
def url_to_hash(url) do
:crypto.hash(:sha256, url) |> Base.encode16(case: :lower)
end
defp get_cached_url(object, activity_id) do
key = "URL|#{activity_id}"
@cachex.fetch!(:scrubber_cache, key, fn _ ->
url = HTML.extract_first_external_url_from_object(object)
Activity.HTML.add_cache_key_for(activity_id, key)
{:commit, url}
end)
end
end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 813c5c482..5657bcad7 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -1,1120 +1,1130 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Router do
use Pleroma.Web, :router
import Phoenix.LiveDashboard.Router
import Oban.Web.Router
pipeline :accepts_html do
plug(:accepts, ["html"])
end
pipeline :accepts_html_xml do
plug(:accepts, ["html", "xml", "rss", "atom"])
end
pipeline :accepts_html_json do
plug(:accepts, ["html", "activity+json", "json"])
end
pipeline :accepts_html_xml_json do
plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"])
end
pipeline :accepts_xml_rss_atom do
plug(:accepts, ["xml", "rss", "atom"])
end
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :oauth do
plug(:fetch_session)
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
end
# Note: expects _user_ authentication (user-unbound app-bound tokens don't qualify)
pipeline :expect_user_authentication do
plug(Pleroma.Web.Plugs.ExpectAuthenticatedCheckPlug)
end
# Note: expects public instance or _user_ authentication (user-unbound tokens don't qualify)
pipeline :expect_public_instance_or_user_authentication do
plug(Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug)
end
pipeline :authenticate do
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Web.Plugs.UserFetcherPlug)
plug(Pleroma.Web.Plugs.AuthenticationPlug)
end
pipeline :after_auth do
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.UserTrackingPlug)
end
pipeline :base_api do
plug(:accepts, ["json"])
plug(:fetch_session)
plug(:authenticate)
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :no_auth_or_privacy_expectations_api do
plug(:base_api)
plug(:after_auth)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
# Pipeline for app-related endpoints (no user auth checks — app-bound tokens must be supported)
pipeline :app_api do
plug(:no_auth_or_privacy_expectations_api)
end
pipeline :api do
plug(:expect_public_instance_or_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :authenticated_api do
plug(:expect_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :admin_api do
plug(:expect_user_authentication)
plug(:base_api)
plug(Pleroma.Web.Plugs.AdminSecretAuthenticationPlug)
plug(:after_auth)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.UserIsStaffPlug)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :require_admin do
plug(Pleroma.Web.Plugs.UserIsAdminPlug)
end
pipeline :require_privileged_role_users_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_delete)
end
pipeline :require_privileged_role_users_manage_credentials do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_credentials)
end
pipeline :require_privileged_role_messages_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_read)
end
pipeline :require_privileged_role_users_manage_tags do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_tags)
end
pipeline :require_privileged_role_users_manage_activation_state do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
end
pipeline :require_privileged_role_reports_manage_reports do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_read)
end
pipeline :require_privileged_role_messages_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_delete)
end
pipeline :require_privileged_role_emoji_manage_emoji do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :emoji_manage_emoji)
end
pipeline :require_privileged_role_instances_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :instances_delete)
end
pipeline :require_privileged_role_moderation_log_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :moderation_log_read)
end
pipeline :require_privileged_role_statistics_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :statistics_read)
end
pipeline :require_privileged_role_announcements_manage_announcements do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :announcements_manage_announcements)
end
pipeline :pleroma_html do
plug(:browser)
plug(:authenticate)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :well_known do
plug(:accepts, ["activity+json", "json", "jrd", "jrd+json", "xml", "xrd+xml", "html"])
end
pipeline :config do
plug(:accepts, ["json", "xml"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
end
pipeline :pleroma_api do
plug(:accepts, ["html", "json"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :mailbox_preview do
plug(:accepts, ["html"])
plug(:put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' 'unsafe-eval'"
})
end
pipeline :http_signature do
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
plug(Pleroma.Web.Plugs.EnsureHostMatchesPlug)
end
pipeline :inbox_guard do
plug(Pleroma.Web.Plugs.InboxGuardPlug)
end
pipeline :static_fe do
plug(Pleroma.Web.Plugs.StaticFEPlug)
end
scope "/api/v1/pleroma", Pleroma.Web.OAuth do
pipe_through(:pleroma_api)
get("/password_reset/:token", PasswordController, :reset, as: :reset_password)
post("/password_reset", PasswordController, :do_reset, as: :reset_password)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:pleroma_api)
get("/emoji", UtilController, :emoji)
get("/captcha", UtilController, :captcha)
get("/healthcheck", UtilController, :healthcheck)
get("/federation_status", InstancesController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_api)
post("/remote_interaction", RemoteInteractionController, :remote_interaction)
end
scope "/api/v1/pleroma", Pleroma.Web do
pipe_through(:pleroma_api)
post("/uploader_callback/:upload_path", UploaderController, :callback)
end
# AdminAPI: only admins can perform these actions
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through([:admin_api, :require_admin])
get("/users/:nickname/permission_group", AdminAPIController, :right_get)
get("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_get)
post("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_add)
delete(
"/users/:nickname/permission_group/:permission_group",
AdminAPIController,
:right_delete
)
post("/users/permission_group/:permission_group", AdminAPIController, :right_add_multiple)
delete(
"/users/permission_group/:permission_group",
AdminAPIController,
:right_delete_multiple
)
post("/users/follow", UserController, :follow)
post("/users/unfollow", UserController, :unfollow)
post("/users", UserController, :create)
patch("/users/suggest", UserController, :suggest)
patch("/users/unsuggest", UserController, :unsuggest)
get("/relay", RelayController, :index)
post("/relay", RelayController, :follow)
delete("/relay", RelayController, :unfollow)
get("/instance_document/:name", InstanceDocumentController, :show)
patch("/instance_document/:name", InstanceDocumentController, :update)
delete("/instance_document/:name", InstanceDocumentController, :delete)
get("/config", ConfigController, :show)
post("/config", ConfigController, :update)
get("/config/descriptions", ConfigController, :descriptions)
get("/need_reboot", AdminAPIController, :need_reboot)
get("/restart", AdminAPIController, :restart)
get("/oauth_app", OAuthAppController, :index)
post("/oauth_app", OAuthAppController, :create)
patch("/oauth_app/:id", OAuthAppController, :update)
delete("/oauth_app/:id", OAuthAppController, :delete)
get("/media_proxy_caches", MediaProxyCacheController, :index)
post("/media_proxy_caches/delete", MediaProxyCacheController, :delete)
post("/media_proxy_caches/purge", MediaProxyCacheController, :purge)
get("/frontends", FrontendController, :index)
post("/frontends/install", FrontendController, :install)
post("/backups", AdminAPIController, :create_backup)
get("/rules", RuleController, :index)
post("/rules", RuleController, :create)
patch("/rules/:id", RuleController, :update)
delete("/rules/:id", RuleController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_announcements_manage_announcements)
get("/announcements", AnnouncementController, :index)
post("/announcements", AnnouncementController, :create)
get("/announcements/:id", AnnouncementController, :show)
patch("/announcements/:id", AnnouncementController, :change)
delete("/announcements/:id", AnnouncementController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_delete)
delete("/users", UserController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_credentials)
get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset)
get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials)
patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials)
put("/users/disable_mfa", AdminAPIController, :disable_mfa)
patch("/users/force_password_reset", AdminAPIController, :force_password_reset)
patch("/users/confirm_email", AdminAPIController, :confirm_email)
patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_read)
get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
get("/users/:nickname/chats", AdminAPIController, :list_user_chats)
get("/statuses", StatusController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
get("/instances/:instance/statuses", InstanceController, :list_statuses)
get("/statuses/:id", StatusController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_tags)
put("/users/tag", AdminAPIController, :tag_users)
delete("/users/tag", AdminAPIController, :untag_users)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_activation_state)
patch("/users/:nickname/toggle_activation", UserController, :toggle_activation)
patch("/users/activate", UserController, :activate)
patch("/users/deactivate", UserController, :deactivate)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_invites)
patch("/users/approve", UserController, :approve)
post("/users/invite_token", InviteController, :create)
get("/users/invites", InviteController, :index)
post("/users/revoke_invite", InviteController, :revoke)
post("/users/email_invite", InviteController, :email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_reports_manage_reports)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
patch("/reports", ReportController, :update)
post("/reports/assign_account", ReportController, :assign_account)
post("/reports/:id/notes", ReportController, :notes_create)
delete("/reports/:report_id/notes/:id", ReportController, :notes_delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_read)
get("/users", UserController, :index)
get("/users/:nickname", UserController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_delete)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/reload_emoji", AdminAPIController, :reload_emoji)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_instances_delete)
delete("/instances/:instance", InstanceController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_moderation_log_read)
get("/moderation_log", AdminAPIController, :list_log)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_statistics_read)
get("/stats", AdminAPIController, :stats)
end
scope "/api/v1/pleroma/emoji", Pleroma.Web.PleromaAPI do
scope "/pack" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/", EmojiPackController, :create)
patch("/", EmojiPackController, :update)
delete("/", EmojiPackController, :delete)
end
scope "/pack" do
pipe_through(:api)
get("/", EmojiPackController, :show)
end
# Modifying packs
scope "/packs" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
get("/import", EmojiPackController, :import_from_filesystem)
get("/remote", EmojiPackController, :remote)
post("/download", EmojiPackController, :download)
post("/download_zip", EmojiPackController, :download_zip)
post("/files", EmojiFileController, :create)
patch("/files", EmojiFileController, :update)
delete("/files", EmojiFileController, :delete)
end
# Pack info / downloading
scope "/packs" do
pipe_through(:api)
get("/", EmojiPackController, :index)
get("/archive", EmojiPackController, :archive)
end
end
scope "/", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_html)
post("/main/ostatus", RemoteInteractionController, :remote_subscribe)
get("/main/ostatus", RemoteInteractionController, :show_subscribe_form)
get("/ostatus_subscribe", RemoteInteractionController, :follow)
post("/ostatus_subscribe", RemoteInteractionController, :do_follow)
get("/authorize_interaction", RemoteInteractionController, :authorize_interaction)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/change_email", UtilController, :change_email)
post("/change_password", UtilController, :change_password)
post("/delete_account", UtilController, :delete_account)
put("/notification_settings", UtilController, :update_notification_settings)
post("/disable_account", UtilController, :disable_account)
post("/move_account", UtilController, :move_account)
put("/aliases", UtilController, :add_alias)
get("/aliases", UtilController, :list_aliases)
delete("/aliases", UtilController, :delete_alias)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/mutes_import", UserImportController, :mutes)
post("/blocks_import", UserImportController, :blocks)
post("/follow_import", UserImportController, :follow)
get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
end
scope "/oauth", Pleroma.Web.OAuth do
# Note: use /api/v1/accounts/verify_credentials for userinfo of signed-in user
get("/registration_details", OAuthController, :registration_details)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show)
scope [] do
pipe_through(:oauth)
get("/authorize", OAuthController, :authorize)
post("/authorize", OAuthController, :create_authorization)
end
scope [] do
pipe_through(:fetch_session)
post("/token", OAuthController, :token_exchange)
post("/revoke", OAuthController, :token_revoke)
post("/mfa/challenge", MFAController, :challenge)
end
scope [] do
pipe_through(:browser)
get("/prepare_request", OAuthController, :prepare_request)
get("/:provider", OAuthController, :request)
get("/:provider/callback", OAuthController, :callback)
post("/register", OAuthController, :register)
end
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/apps", AppController, :index)
get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index)
get("/statuses/:id/reactions", EmojiReactionController, :index)
get(
"/preferred_frontend/available",
FrontendSettingsController,
:available_frontends
)
put(
"/preferred_frontend",
FrontendSettingsController,
:update_preferred_frontend
)
end
scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
post("/chats/by-account-id/:id", ChatController, :create)
get("/chats", ChatController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
post("/chats/:id/messages", ChatController, :post_chat_message)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
post("/chats/:id/read", ChatController, :mark_as_read)
post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read)
post("/chats/:id/pin", ChatController, :pin)
post("/chats/:id/unpin", ChatController, :unpin)
get("/conversations/:id/statuses", ConversationController, :statuses)
get("/conversations/:id", ConversationController, :show)
post("/conversations/read", ConversationController, :mark_as_read)
patch("/conversations/:id", ConversationController, :update)
put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create)
delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete)
post("/notifications/read", NotificationController, :mark_as_read)
get("/mascot", MascotController, :show)
put("/mascot", MascotController, :update)
post("/scrobble", ScrobbleController, :create)
get("/backups", BackupController, :index)
post("/backups", BackupController, :create)
get("/bookmark_folders", BookmarkFolderController, :index)
post("/bookmark_folders", BookmarkFolderController, :create)
patch("/bookmark_folders/:id", BookmarkFolderController, :update)
delete("/bookmark_folders/:id", BookmarkFolderController, :delete)
get("/outgoing_follow_requests", FollowRequestController, :outgoing)
end
scope [] do
pipe_through(:api)
get("/accounts/:id/favourites", AccountController, :favourites)
get("/statuses/:id/quotes", StatusController, :quotes)
end
scope [] do
pipe_through(:authenticated_api)
post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
get("/birthdays", AccountController, :birthdays)
end
scope [] do
pipe_through(:authenticated_api)
get("/settings/:app", SettingsController, :show)
patch("/settings/:app", SettingsController, :update)
end
post("/accounts/confirmation_resend", AccountController, :confirmation_resend)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/accounts/:id/scrobbles", ScrobbleController, :index)
end
scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/:id/endorsements", AccountController, :endorsements)
end
scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
get("/chats", ChatController, :index2)
end
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
get("/accounts/verify_credentials", AccountController, :verify_credentials)
patch("/accounts/update_credentials", AccountController, :update_credentials)
get("/accounts/relationships", AccountController, :relationships)
get("/accounts/familiar_followers", AccountController, :familiar_followers)
get("/accounts/:id/lists", AccountController, :lists)
get("/endorsements", AccountController, :own_endorsements)
get("/blocks", AccountController, :blocks)
get("/mutes", AccountController, :mutes)
post("/follows", AccountController, :follow_by_uri)
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("/accounts/:id/note", AccountController, :note)
post("/accounts/:id/pin", AccountController, :endorse)
post("/accounts/:id/unpin", AccountController, :unendorse)
post("/accounts/:id/endorse", AccountController, :endorse)
post("/accounts/:id/unendorse", AccountController, :unendorse)
post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers)
get("/conversations", ConversationController, :index)
post("/conversations/:id/read", ConversationController, :mark_as_read)
delete("/conversations/:id", ConversationController, :delete)
get("/domain_blocks", DomainBlockController, :index)
post("/domain_blocks", DomainBlockController, :create)
delete("/domain_blocks", DomainBlockController, :delete)
get("/filters", FilterController, :index)
post("/filters", FilterController, :create)
get("/filters/:id", FilterController, :show)
put("/filters/:id", FilterController, :update)
delete("/filters/:id", FilterController, :delete)
get("/follow_requests", FollowRequestController, :index)
post("/follow_requests/:id/authorize", FollowRequestController, :authorize)
post("/follow_requests/:id/reject", FollowRequestController, :reject)
get("/lists", ListController, :index)
get("/lists/:id", ListController, :show)
get("/lists/:id/accounts", ListController, :list_accounts)
delete("/lists/:id", ListController, :delete)
post("/lists", ListController, :create)
put("/lists/:id", ListController, :update)
post("/lists/:id/accounts", ListController, :add_to_list)
delete("/lists/:id/accounts", ListController, :remove_from_list)
get("/markers", MarkerController, :index)
post("/markers", MarkerController, :upsert)
post("/media", MediaController, :create)
get("/media/:id", MediaController, :show)
put("/media/:id", MediaController, :update)
get("/notifications", NotificationController, :index)
get("/notifications/:id", NotificationController, :show)
post("/notifications/:id/dismiss", NotificationController, :dismiss)
post("/notifications/clear", NotificationController, :clear)
delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple)
# Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead
post("/notifications/dismiss", NotificationController, :dismiss_via_body)
post("/polls/:id/votes", PollController, :vote)
post("/reports", ReportController, :create)
get("/scheduled_statuses", ScheduledActivityController, :index)
get("/scheduled_statuses/:id", ScheduledActivityController, :show)
put("/scheduled_statuses/:id", ScheduledActivityController, :update)
delete("/scheduled_statuses/:id", ScheduledActivityController, :delete)
# Unlike `GET /api/v1/accounts/:id/favourites`, demands authentication
get("/favourites", StatusController, :favourites)
get("/bookmarks", StatusController, :bookmarks)
post("/statuses", StatusController, :create)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
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)
post("/statuses/:id/translate", StatusController, :translate)
get("/statuses/:id/quotes", StatusController, :quotes)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
put("/push/subscription", SubscriptionController, :update)
delete("/push/subscription", SubscriptionController, :delete)
get("/suggestions", SuggestionController, :index)
delete("/suggestions/:account_id", SuggestionController, :dismiss)
get("/timelines/home", TimelineController, :home)
get("/timelines/direct", TimelineController, :direct)
get("/timelines/list/:list_id", TimelineController, :list)
get("/announcements", AnnouncementController, :index)
post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
get("/tags/:id", TagController, :show)
post("/tags/:id/follow", TagController, :follow)
post("/tags/:id/unfollow", TagController, :unfollow)
get("/followed_tags", TagController, :show_followed)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:app_api)
post("/apps", AppController, :create)
get("/apps/verify_credentials", AppController, :verify_credentials)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/search", SearchController, :account_search)
get("/search", SearchController, :search)
get("/accounts/lookup", AccountController, :lookup)
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id/endorsements", AccountController, :endorsements)
get("/accounts/:id", AccountController, :show)
post("/accounts", AccountController, :create)
get("/instance", InstanceController, :show)
get("/instance/peers", InstanceController, :peers)
get("/instance/rules", InstanceController, :rules)
get("/instance/domain_blocks", InstanceController, :domain_blocks)
get("/instance/translation_languages", InstanceController, :translation_languages)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
get("/statuses/:id/history", StatusController, :show_history)
get("/statuses/:id/source", StatusController, :show_source)
get("/custom_emojis", CustomEmojiController, :index)
get("/trends", MastodonAPIController, :empty_array)
get("/timelines/public", TimelineController, :public)
get("/timelines/tag/:tag", TimelineController, :hashtag)
get("/polls/:id", PollController, :show)
get("/directory", DirectoryController, :index)
end
+ scope "/api/v2", Pleroma.Web.MastodonAPI do
+ pipe_through(:authenticated_api)
+
+ get("/notifications", NotificationController, :grouped_index)
+ get("/notifications/unread_count", NotificationController, :unread_count)
+ get("/notifications/:group_key/accounts", NotificationController, :group_accounts)
+ get("/notifications/:group_key", NotificationController, :show_group)
+ post("/notifications/:group_key/dismiss", NotificationController, :dismiss_group)
+ end
+
scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/search", SearchController, :search2)
post("/media", MediaController, :create2)
get("/suggestions", SuggestionController, :index2)
get("/instance", InstanceController, :show2)
end
scope "/api", Pleroma.Web do
pipe_through(:config)
get("/pleroma/frontend_configurations", PleromaAPI.UtilController, :frontend_configurations)
end
scope "/api", Pleroma.Web do
pipe_through(:api)
get(
"/account/confirm_email/:user_id/:token",
OAuth.TokenController,
:confirm_email,
as: :confirm_email
)
end
scope "/api" do
pipe_through(:base_api)
get("/openapi", OpenApiSpex.Plug.RenderSpec, [])
end
scope "/api", Pleroma.Web, as: :authenticated_pleroma_api do
pipe_through(:authenticated_api)
get("/oauth_tokens", OAuth.TokenController, :oauth_tokens)
delete("/oauth_tokens/:id", OAuth.TokenController, :revoke_token)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_json, :http_signature, :static_fe])
get("/objects/:uuid", OStatus.OStatusController, :object)
get("/activities/:uuid", OStatus.OStatusController, :activity)
get("/notice/:id", OStatus.OStatusController, :notice)
# Mastodon compatibility routes
get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object)
get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_xml_json, :http_signature, :static_fe])
# Note: returns user _profile_ for json requests, redirects to user _feed_ for non-json ones
get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through([:accepts_html_xml])
get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_html)
get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_xml_rss_atom)
get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed)
end
scope "/", Pleroma.Web do
pipe_through(:browser)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch)
post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch)
end
pipeline :ap_service_actor do
plug(:accepts, ["activity+json", "json"])
end
# Server to Server (S2S) AP interactions
pipeline :activitypub do
plug(:ap_service_actor)
plug(:http_signature)
end
# Client to Server (C2S) AP interactions
pipeline :activitypub_client do
plug(:ap_service_actor)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug)
plug(:fetch_session)
plug(:authenticate)
plug(:after_auth)
end
# AP interactions used by both S2S and C2S
pipeline :activitypub_server_or_client do
plug(:ap_service_actor)
plug(:fetch_session)
plug(:authenticate)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug, allow_server: true)
plug(:after_auth)
plug(:http_signature)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_client])
get("/api/ap/whoami", ActivityPubController, :whoami)
get("/users/:nickname/inbox", ActivityPubController, :read_inbox)
post("/users/:nickname/outbox", ActivityPubController, :update_outbox)
post("/api/ap/upload_media", ActivityPubController, :upload_media)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_server_or_client])
get("/users/:nickname/outbox", ActivityPubController, :outbox)
get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
get("/objects/:uuid/replies", ActivityPubController, :object_replies)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub, :inbox_guard])
post("/inbox", ActivityPubController, :inbox)
post("/users/:nickname/inbox", ActivityPubController, :inbox)
end
scope "/relay", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :relay)
scope [] do
pipe_through(:http_signature)
post("/inbox", ActivityPubController, :inbox)
end
get("/following", ActivityPubController, :relay_following)
get("/followers", ActivityPubController, :relay_followers)
end
scope "/internal/fetch", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :internal_fetch)
post("/inbox", ActivityPubController, :inbox)
end
scope "/.well-known", Pleroma.Web do
pipe_through(:well_known)
get("/host-meta", WebFinger.WebFingerController, :host_meta)
get("/webfinger", WebFinger.WebFingerController, :webfinger)
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
end
scope "/nodeinfo", Pleroma.Web do
get("/:version", Nodeinfo.NodeinfoController, :nodeinfo)
end
scope "/", Pleroma.Web do
pipe_through(:api)
get("/manifest.json", ManifestController, :show)
end
scope "/", Pleroma.Web do
pipe_through(:pleroma_html)
post("/auth/password", OAuth.PasswordController, :request)
get("/embed/:id", EmbedController, :show)
end
scope "/proxy/", Pleroma.Web do
get("/preview/:sig/:url", MediaProxy.MediaProxyController, :preview)
get("/preview/:sig/:url/:filename", MediaProxy.MediaProxyController, :preview)
get("/:sig/:url", MediaProxy.MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxy.MediaProxyController, :remote)
end
if Pleroma.Config.get(:env) == :dev do
scope "/dev" do
pipe_through([:mailbox_preview])
forward("/mailbox", Plug.Swoosh.MailboxPreview, base_path: "/dev/mailbox")
end
end
scope "/" do
pipe_through([:pleroma_html, :authenticate, :require_admin])
live_dashboard("/pleroma/live_dashboard", additional_pages: [oban: Oban.LiveDashboard])
oban_dashboard("/pleroma/oban")
end
# Test-only routes needed to test action dispatching and plug chain execution
if Pleroma.Config.get(:env) == :test do
@test_actions [
:do_oauth_check,
:fallback_oauth_check,
:skip_oauth_check,
:fallback_oauth_skip_publicity_check,
:skip_oauth_skip_publicity_check,
:missing_oauth_check_definition
]
scope "/test/api", Pleroma.Tests do
pipe_through(:api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
scope "/test/authenticated_api", Pleroma.Tests do
pipe_through(:authenticated_api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
end
scope "/", Pleroma.Web.MongooseIM do
get("/user_exists", MongooseIMController, :user_exists)
get("/check_password", MongooseIMController, :check_password)
end
scope "/", Pleroma.Web.Fallback do
get("/registration/:token", RedirectController, :registration_page)
get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta)
match(:*, "/api/pleroma/*path", LegacyPleromaApiRerouterPlug, [])
get("/api/*path", RedirectController, :api_not_implemented)
get("/phoenix/live_dashboard/*path", RedirectController, :live_dashboard)
get("/*path", RedirectController, :redirector_with_preload)
options("/*path", RedirectController, :empty)
end
# /pleroma/{phoenix,oban}/* need to get filtered out from api routes for frontend configuration
# to not drop admin overrides for /pleroma/admin.
@non_api_routes ["/pleroma/live_dashboard", "/pleroma/oban"]
def get_api_routes do
Phoenix.Router.routes(__MODULE__)
|> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end)
|> Enum.reject(fn r -> String.starts_with?(r.path, @non_api_routes) end)
|> Enum.map(fn r ->
r.path
|> String.split("/", trim: true)
|> List.first()
end)
|> Enum.uniq()
end
end
diff --git a/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs b/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs
new file mode 100644
index 000000000..7c7d529e0
--- /dev/null
+++ b/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs
@@ -0,0 +1,15 @@
+defmodule Pleroma.Repo.Migrations.AddGroupKeyToNotifications do
+ use Ecto.Migration
+
+ def change do
+ alter table(:notifications) do
+ add(:group_key, :string)
+ end
+
+ create_if_not_exists(
+ index(:notifications, [:user_id, :group_key, "id desc nulls last"],
+ where: "group_key IS NOT NULL"
+ )
+ )
+ end
+end
diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex
index 342ef9944..8e2d16650 100644
--- a/priv/scrubbers/default.ex
+++ b/priv/scrubbers/default.ex
@@ -1,171 +1,181 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HTML.Scrubber.Default do
@doc "The default HTML scrubbing policy: no "
require FastSanitize.Sanitizer.Meta
alias FastSanitize.Sanitizer.Meta
# credo:disable-for-previous-line
# No idea how to fix this one…
@valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], [])
Meta.strip_comments()
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values(:a, "class", [
"hashtag",
"u-url",
"mention",
"u-url mention",
"mention u-url",
"mention hashtag"
])
Meta.allow_tag_with_this_attribute_values(:a, "rel", [
"tag",
"nofollow",
"noopener",
"noreferrer",
"ugc"
])
Meta.allow_tag_with_these_attributes(:a, ["name", "title", "lang"])
Meta.allow_tag_with_these_attributes(:abbr, ["title", "lang"])
Meta.allow_tag_with_these_attributes(:acronym, ["title", "lang"])
# sort(1)-ed list
Meta.allow_tag_with_these_attributes(:bdi, [])
Meta.allow_tag_with_these_attributes(:bdo, ["dir"])
Meta.allow_tag_with_these_attributes(:big, ["lang"])
Meta.allow_tag_with_these_attributes(:b, ["lang"])
Meta.allow_tag_with_these_attributes(:blockquote, ["lang"])
Meta.allow_tag_with_these_attributes(:br, ["lang"])
+ Meta.allow_tag_with_these_attributes(:center, [])
Meta.allow_tag_with_these_attributes(:cite, ["lang"])
Meta.allow_tag_with_these_attributes(:code, ["lang"])
Meta.allow_tag_with_these_attributes(:del, ["lang"])
Meta.allow_tag_with_these_attributes(:dfn, ["lang"])
Meta.allow_tag_with_these_attributes(:em, ["lang"])
Meta.allow_tag_with_these_attributes(:hr, ["lang"])
Meta.allow_tag_with_these_attributes(:i, ["lang"])
Meta.allow_tag_with_these_attributes(:ins, ["lang"])
Meta.allow_tag_with_these_attributes(:kbd, ["lang"])
Meta.allow_tag_with_these_attributes(:li, ["lang"])
Meta.allow_tag_with_these_attributes(:ol, ["lang"])
Meta.allow_tag_with_these_attributes(:p, ["lang"])
Meta.allow_tag_with_these_attributes(:pre, ["lang"])
Meta.allow_tag_with_these_attributes(:q, ["lang"])
Meta.allow_tag_with_these_attributes(:rb, ["lang"])
Meta.allow_tag_with_these_attributes(:rp, ["lang"])
Meta.allow_tag_with_these_attributes(:rtc, ["lang"])
Meta.allow_tag_with_these_attributes(:rt, ["lang"])
Meta.allow_tag_with_these_attributes(:ruby, ["lang"])
Meta.allow_tag_with_these_attributes(:samp, ["lang"])
Meta.allow_tag_with_these_attributes(:s, ["lang"])
Meta.allow_tag_with_these_attributes(:small, ["lang"])
Meta.allow_tag_with_these_attributes(:strong, ["lang"])
Meta.allow_tag_with_these_attributes(:sub, ["lang"])
Meta.allow_tag_with_these_attributes(:sup, ["lang"])
Meta.allow_tag_with_these_attributes(:tt, ["lang"])
Meta.allow_tag_with_these_attributes(:u, ["lang"])
Meta.allow_tag_with_these_attributes(:ul, ["lang"])
Meta.allow_tag_with_these_attributes(:var, ["lang"])
Meta.allow_tag_with_these_attributes(:wbr, ["lang"])
Meta.allow_tag_with_this_attribute_values(:span, "class", [
"h-card",
"recipients-inline",
"quote-inline",
"invisible",
"ellipsis",
- "mfm-center",
+ "mfm-tada",
+ "mfm-jelly",
+ "mfm-twitch",
+ "mfm-shake",
+ "mfm-spin",
+ "mfm-jump",
+ "mfm-bounce",
"mfm-flip",
- "mfm-font",
- "mfm-blur",
- "mfm-rotate",
"mfm-x2",
"mfm-x3",
"mfm-x4",
- "mfm-position",
"mfm-scale",
+ "mfm-position",
"mfm-fg",
"mfm-bg",
- "mfm-jelly",
- "mfm-twitch",
- "mfm-shake",
- "mfm-spin",
- "mfm-jump",
- "mfm-bounce",
+ "mfm-border",
+ "mfm-font",
+ "mfm-blur",
"mfm-rainbow",
- "mfm-tada",
- "mfm-sparkle"
+ "mfm-sparkle",
+ "mfm-rotate",
+ "mfm-ruby",
+ "mfm-unixtime",
+ # Exists in Akkoma but not Misskey?
+ "mfm-center"
])
Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"])
Meta.allow_tag_with_these_attributes(:span, [
"lang",
- "data-mfm-h",
- "data-mfm-v",
- "data-mfm-x",
- "data-mfm-y",
- "data-mfm-alternate",
"data-mfm-speed",
- "data-mfm-deg",
+ "data-mfm-delay",
"data-mfm-left",
+ "data-mfm-alternate",
+ "data-mfm-x",
+ "data-mfm-y",
+ "data-mfm-h",
+ "data-mfm-v",
+ "data-mfm-color",
+ "data-mfm-width",
+ "data-mfm-style",
+ "data-mfm-radius",
+ "data-mfm-noclip",
"data-mfm-serif",
"data-mfm-monospace",
"data-mfm-cursive",
"data-mfm-fantasy",
"data-mfm-emoji",
"data-mfm-math",
- "data-mfm-color"
+ "data-mfm-deg"
])
Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"])
@allow_inline_images Pleroma.Config.get([:markup, :allow_inline_images])
if @allow_inline_images do
Meta.allow_tag_with_this_attribute_values(:img, "class", ["emoji"])
# restrict img tags to http/https only, because of MediaProxy.
Meta.allow_tag_with_uri_attributes(:img, ["src"], ["http", "https"])
Meta.allow_tag_with_these_attributes(:img, [
"width",
"height",
"title",
"alt",
"lang"
])
end
if Pleroma.Config.get([:markup, :allow_tables]) do
Meta.allow_tag_with_these_attributes(:table, ["lang"])
Meta.allow_tag_with_these_attributes(:tbody, ["lang"])
Meta.allow_tag_with_these_attributes(:td, ["lang"])
Meta.allow_tag_with_these_attributes(:th, ["lang"])
Meta.allow_tag_with_these_attributes(:thead, ["lang"])
Meta.allow_tag_with_these_attributes(:tr, ["lang"])
end
if Pleroma.Config.get([:markup, :allow_headings]) do
Meta.allow_tag_with_these_attributes(:h1, ["lang"])
Meta.allow_tag_with_these_attributes(:h2, ["lang"])
Meta.allow_tag_with_these_attributes(:h3, ["lang"])
Meta.allow_tag_with_these_attributes(:h4, ["lang"])
Meta.allow_tag_with_these_attributes(:h5, ["lang"])
end
if Pleroma.Config.get([:markup, :allow_fonts]) do
Meta.allow_tag_with_these_attributes(:font, ["face", "lang"])
end
Meta.strip_everything_not_covered()
end
diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs
index 078bb643c..de88e5002 100644
--- a/test/pleroma/integration/mastodon_websocket_test.exs
+++ b/test/pleroma/integration/mastodon_websocket_test.exs
@@ -1,516 +1,582 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Integration.MastodonWebsocketTest do
# Needs a streamer, needs to stay synchronous
use Pleroma.DataCase
import ExUnit.CaptureLog
import Pleroma.Factory
alias Pleroma.Integration.WebsocketClient
alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.OAuth
@moduletag needs_streamer: true, capture_log: true
@path Pleroma.Web.Endpoint.url()
|> URI.parse()
|> Map.put(:scheme, "ws")
|> Map.put(:path, "/api/v1/streaming")
|> URI.to_string()
def start_socket(qs \\ nil, headers \\ []) do
path =
case qs do
nil -> @path
qs -> @path <> qs
end
WebsocketClient.start_link(self(), path, headers)
end
+ defp raw_websocket_handshake(qs, headers) do
+ uri = URI.parse(@path <> qs)
+ port = uri.port || 80
+ path = uri.path <> if(uri.query, do: "?" <> uri.query, else: "")
+
+ default_headers = [
+ {"host", "#{uri.host}:#{port}"},
+ {"upgrade", "websocket"},
+ {"connection", "Upgrade"},
+ {"sec-websocket-key", Base.encode64(:crypto.strong_rand_bytes(16))},
+ {"sec-websocket-version", "13"}
+ ]
+
+ request = [
+ "GET #{path} HTTP/1.1\r\n",
+ Enum.map(default_headers ++ headers, fn {name, value} -> "#{name}: #{value}\r\n" end),
+ "\r\n"
+ ]
+
+ with {:ok, socket} <-
+ :gen_tcp.connect(String.to_charlist(uri.host), port, [:binary, active: false], 1_000),
+ :ok <- :gen_tcp.send(socket, request),
+ {:ok, response} <- :gen_tcp.recv(socket, 0, 1_000) do
+ :gen_tcp.close(socket)
+ {:ok, parse_http_response(response)}
+ end
+ end
+
+ defp parse_http_response(response) do
+ [headers | _] = String.split(response, "\r\n\r\n", parts: 2)
+ [status_line | header_lines] = String.split(headers, "\r\n")
+ [_, status | _] = String.split(status_line, " ")
+
+ headers =
+ Enum.map(header_lines, fn line ->
+ [name, value] = String.split(line, ":", parts: 2)
+ {String.downcase(name), String.trim(value)}
+ end)
+
+ %{status: String.to_integer(status), headers: headers}
+ end
+
defp decode_json(json) do
with {:ok, %{"event" => event, "payload" => payload_text}} <- Jason.decode(json),
{:ok, payload} <- Jason.decode(payload_text) do
{:ok, %{"event" => event, "payload" => payload}}
end
end
# Turns atom keys to strings
defp atom_key_to_string(json) do
json
|> Jason.encode!()
|> Jason.decode!()
end
test "refuses invalid requests" do
capture_log(fn ->
assert {:error, %WebSockex.RequestError{code: 404}} = start_socket("?stream=ncjdk")
Process.sleep(30)
end)
end
test "requires authentication and a valid token for protected streams" do
capture_log(fn ->
assert {:error, %WebSockex.RequestError{code: 401}} =
start_socket("?stream=user&access_token=aaaaaaaaaaaa")
assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
Process.sleep(30)
end)
end
test "allows unified stream" do
assert {:ok, _} = start_socket()
end
test "allows public streams without authentication" do
assert {:ok, _} = start_socket("?stream=public")
assert {:ok, _} = start_socket("?stream=public:local")
assert {:ok, _} = start_socket("?stream=public:remote&instance=lain.com")
assert {:ok, _} = start_socket("?stream=hashtag&tag=lain")
end
test "receives well formatted events" do
user = insert(:user)
{:ok, _} = start_socket("?stream=public")
{:ok, activity} = CommonAPI.post(user, %{status: "nice echo chamber"})
assert_receive {:text, raw_json}, 1_000
assert {:ok, json} = Jason.decode(raw_json)
assert "update" == json["event"]
assert json["payload"]
assert {:ok, json} = Jason.decode(json["payload"])
- view_json =
- Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil)
- |> atom_key_to_string()
+ view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil))
assert json == view_json
end
describe "subscribing via WebSocket" do
test "can subscribe" do
user = insert(:user)
{:ok, pid} = start_socket()
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
{:ok, activity} = CommonAPI.post(user, %{status: "nice echo chamber"})
assert_receive {:text, raw_json}, 1_000
assert {:ok, json} = Jason.decode(raw_json)
assert "update" == json["event"]
assert json["payload"]
assert {:ok, json} = Jason.decode(json["payload"])
- view_json =
- Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil)
- |> Jason.encode!()
- |> Jason.decode!()
+ view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil))
assert json == view_json
end
test "can subscribe to multiple streams" do
user = insert(:user)
{:ok, pid} = start_socket()
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(
pid,
%{type: "subscribe", stream: "hashtag", tag: "mew"} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
{:ok, _activity} = CommonAPI.post(user, %{status: "nice echo chamber #mew"})
assert_receive {:text, raw_json}, 1_000
assert {:ok, %{"stream" => stream1}} = Jason.decode(raw_json)
assert_receive {:text, raw_json}, 1_000
assert {:ok, %{"stream" => stream2}} = Jason.decode(raw_json)
streams = [stream1, stream2]
assert ["hashtag", "mew"] in streams
assert ["public"] in streams
end
test "won't double subscribe" do
user = insert(:user)
{:ok, pid} = start_socket()
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "ignored"}
}} = decode_json(raw_json)
{:ok, _activity} = CommonAPI.post(user, %{status: "nice echo chamber"})
assert_receive {:text, _}, 1_000
refute_receive {:text, _}, 1_000
end
test "rejects invalid streams" do
{:ok, pid} = start_socket()
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "nonsense"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "error", "error" => "bad_topic"}
}} = decode_json(raw_json)
end
test "can unsubscribe" do
user = insert(:user)
{:ok, pid} = start_socket()
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(pid, %{type: "unsubscribe", stream: "public"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "unsubscribe", "result" => "success"}
}} = decode_json(raw_json)
{:ok, _activity} = CommonAPI.post(user, %{status: "nice echo chamber"})
refute_receive {:text, _}, 1_000
end
end
describe "with a valid user token" do
setup do
{:ok, app} =
Pleroma.Repo.insert(
OAuth.App.register_changeset(%OAuth.App{}, %{
client_name: "client",
scopes: ["read"],
redirect_uris: "url"
})
)
user = insert(:user)
{:ok, auth} = OAuth.Authorization.create_authorization(app, user)
{:ok, token} = OAuth.Token.exchange_token(app, auth)
%{app: app, user: user, token: token}
end
test "accepts valid tokens", state do
assert {:ok, _} = start_socket("?stream=user&access_token=#{state.token.token}")
end
test "accepts the 'user' stream", %{token: token} = _state do
assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
capture_log(fn ->
assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
Process.sleep(30)
end)
end
test "accepts the 'user:notification' stream", %{token: token} = _state do
assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
capture_log(fn ->
assert {:error, %WebSockex.RequestError{code: 401}} =
start_socket("?stream=user:notification")
Process.sleep(30)
end)
end
test "accepts valid token on Sec-WebSocket-Protocol header", %{token: token} do
assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}])
capture_log(fn ->
assert {:error, %WebSockex.RequestError{code: 401}} =
start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}])
Process.sleep(30)
end)
end
+ test "echoes the Sec-WebSocket-Protocol token in the handshake", %{token: token} do
+ assert {:ok, %{status: 101, headers: headers}} =
+ raw_websocket_handshake("?stream=user", [
+ {"sec-websocket-protocol", token.token}
+ ])
+
+ assert {"sec-websocket-protocol", token.token} in headers
+ end
+
+ test "echoes the selected Sec-WebSocket-Protocol token", %{token: token} do
+ assert {:ok, %{status: 101, headers: headers}} =
+ raw_websocket_handshake("?stream=user", [
+ {"sec-websocket-protocol", "#{token.token}, phoenix"}
+ ])
+
+ assert {"sec-websocket-protocol", token.token} in headers
+ end
+
+ test "does not echo an invalid Sec-WebSocket-Protocol token", %{token: token} do
+ assert {:ok, %{status: 401, headers: headers}} =
+ raw_websocket_handshake("?stream=user", [
+ {"sec-websocket-protocol", "invalid"}
+ ])
+
+ refute {"sec-websocket-protocol", token.token} in headers
+ refute List.keymember?(headers, "sec-websocket-protocol", 0)
+ end
+
test "prefers sec-websocket-protocol token over query access_token", %{
token: token,
user: user
} do
assert {:ok, state} =
Pleroma.Web.MastodonAPI.WebsocketHandler.connect(%{
params: %{"stream" => "user", "access_token" => "invalid"},
connect_info: %{
sec_websocket_headers: [
{"sec-websocket-version", "13"},
{"sec-websocket-protocol", token.token}
]
}
})
assert state.user.id == user.id
assert state.oauth_token.id == token.id
assert state.topics != []
end
test "accepts valid token on client-sent event", %{token: token} do
assert {:ok, pid} = start_socket()
WebsocketClient.send_text(
pid,
%{type: "pleroma:authenticate", token: token.token} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "pleroma:authenticate", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(pid, %{type: "subscribe", stream: "user"} |> Jason.encode!())
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
end
test "rejects invalid token on client-sent event" do
assert {:ok, pid} = start_socket()
WebsocketClient.send_text(
pid,
%{type: "pleroma:authenticate", token: "Something else"} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{
"type" => "pleroma:authenticate",
"result" => "error",
"error" => "unauthorized"
}
}} = decode_json(raw_json)
end
test "rejects new authenticate request if already logged-in", %{token: token} do
assert {:ok, pid} = start_socket()
WebsocketClient.send_text(
pid,
%{type: "pleroma:authenticate", token: token.token} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "pleroma:authenticate", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(
pid,
%{type: "pleroma:authenticate", token: "Something else"} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{
"type" => "pleroma:authenticate",
"result" => "error",
"error" => "already_authenticated"
}
}} = decode_json(raw_json)
end
test "accepts the 'list' stream", %{token: token, user: user} do
posting_user = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "test"}, user)
Pleroma.List.follow(list, posting_user)
assert {:ok, _} = start_socket("?stream=list&access_token=#{token.token}&list=#{list.id}")
assert {:ok, pid} = start_socket("?access_token=#{token.token}")
WebsocketClient.send_text(
pid,
%{type: "subscribe", stream: "list", list: list.id} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "success"}
}} = decode_json(raw_json)
WebsocketClient.send_text(
pid,
%{type: "subscribe", stream: "list", list: to_string(list.id)} |> Jason.encode!()
)
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "pleroma:respond",
"payload" => %{"type" => "subscribe", "result" => "ignored"}
}} = decode_json(raw_json)
end
test "disconnect when token is revoked", %{app: app, user: user, token: token} do
assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
{:ok, auth} = OAuth.Authorization.create_authorization(app, user)
{:ok, token2} = OAuth.Token.exchange_token(app, auth)
assert {:ok, _} = start_socket("?stream=user&access_token=#{token2.token}")
OAuth.Token.Strategy.Revoke.revoke(token)
assert_receive {:close, _}
assert_receive {:close, _}
refute_receive {:close, _}
end
test "receives private statuses", %{user: reading_user, token: token} do
user = insert(:user)
CommonAPI.follow(user, reading_user)
{:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
{:ok, activity} =
CommonAPI.post(user, %{status: "nice echo chamber", visibility: "private"})
assert_receive {:text, raw_json}, 1_000
assert {:ok, json} = Jason.decode(raw_json)
assert "update" == json["event"]
assert json["payload"]
assert {:ok, json} = Jason.decode(json["payload"])
view_json =
- Pleroma.Web.MastodonAPI.StatusView.render("show.json",
- activity: activity,
- for: reading_user
+ atom_key_to_string(
+ StatusView.render("show.json",
+ activity: activity,
+ for: reading_user
+ )
)
- |> Jason.encode!()
- |> Jason.decode!()
assert json == view_json
end
test "receives edits", %{user: reading_user, token: token} do
user = insert(:user)
CommonAPI.follow(user, reading_user)
{:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
{:ok, activity} =
CommonAPI.post(user, %{status: "nice echo chamber", visibility: "private"})
assert_receive {:text, _raw_json}, 1_000
{:ok, _} = CommonAPI.update(activity, user, %{status: "mew mew", visibility: "private"})
assert_receive {:text, raw_json}, 1_000
activity = Pleroma.Activity.normalize(activity)
view_json =
- Pleroma.Web.MastodonAPI.StatusView.render("show.json",
- activity: activity,
- for: reading_user
+ atom_key_to_string(
+ StatusView.render("show.json",
+ activity: activity,
+ for: reading_user
+ )
)
- |> Jason.encode!()
- |> Jason.decode!()
assert {:ok, %{"event" => "status.update", "payload" => ^view_json}} = decode_json(raw_json)
end
test "receives notifications", %{user: reading_user, token: token} do
user = insert(:user)
CommonAPI.follow(user, reading_user)
{:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
{:ok, %Pleroma.Activity{id: activity_id} = _activity} =
CommonAPI.post(user, %{
status: "nice echo chamber @#{reading_user.nickname}",
visibility: "private"
})
assert_receive {:text, raw_json}, 1_000
assert {:ok,
%{
"event" => "notification",
"payload" => %{
"status" => %{
"id" => ^activity_id
}
}
}} = decode_json(raw_json)
end
end
end
diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs
index 9aafc41a5..dd7a82a55 100644
--- a/test/pleroma/web/activity_pub/activity_pub_test.exs
+++ b/test/pleroma/web/activity_pub/activity_pub_test.exs
@@ -1,2821 +1,2954 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo
alias Pleroma.Activity
alias Pleroma.Builders.ActivityBuilder
alias Pleroma.Config
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI
import ExUnit.CaptureLog
import Mock
import Mox
import Pleroma.Factory
import Tesla.Mock
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
:ok
end
setup do: clear_config([:instance, :federating])
describe "streaming out participations" do
test "it streams them out" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, conversation} = Pleroma.Conversation.create_or_bump_for(activity)
participations =
conversation.participations
|> Repo.preload(:user)
with_mock Pleroma.Web.Streamer,
stream: fn _, _ -> nil end do
ActivityPub.stream_out_participations(conversation.participations)
assert called(Pleroma.Web.Streamer.stream("participation", participations))
end
end
test "streams them out on activity creation" do
user_one = insert(:user)
user_two = insert(:user)
with_mock Pleroma.Web.Streamer,
stream: fn _, _ -> nil end do
{:ok, activity} =
CommonAPI.post(user_one, %{
status: "@#{user_two.nickname}",
visibility: "direct"
})
conversation =
activity.data["context"]
|> Pleroma.Conversation.get_for_ap_id()
|> Repo.preload(participations: :user)
assert called(Pleroma.Web.Streamer.stream("participation", conversation.participations))
end
end
end
describe "fetching restricted by visibility" do
test "it restricts by the appropriate visibility" do
user = insert(:user)
{:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities = ActivityPub.fetch_activities([], %{visibility: "direct", actor_id: user.ap_id})
assert activities == [direct_activity]
activities =
ActivityPub.fetch_activities([], %{visibility: "unlisted", actor_id: user.ap_id})
assert activities == [unlisted_activity]
activities =
ActivityPub.fetch_activities([], %{visibility: "private", actor_id: user.ap_id})
assert activities == [private_activity]
activities = ActivityPub.fetch_activities([], %{visibility: "public", actor_id: user.ap_id})
assert activities == [public_activity]
activities =
ActivityPub.fetch_activities([], %{
visibility: ~w[private public],
actor_id: user.ap_id
})
assert activities == [public_activity, private_activity]
end
end
describe "fetching excluded by visibility" do
test "it excludes by the appropriate visibility" do
user = insert(:user)
{:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"})
{:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"})
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "direct",
actor_id: user.ap_id
})
assert public_activity in activities
assert unlisted_activity in activities
assert private_activity in activities
refute direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "unlisted",
actor_id: user.ap_id
})
assert public_activity in activities
refute unlisted_activity in activities
assert private_activity in activities
assert direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "private",
actor_id: user.ap_id
})
assert public_activity in activities
assert unlisted_activity in activities
refute private_activity in activities
assert direct_activity in activities
activities =
ActivityPub.fetch_activities([], %{
exclude_visibilities: "public",
actor_id: user.ap_id
})
refute public_activity in activities
assert unlisted_activity in activities
assert private_activity in activities
assert direct_activity in activities
end
end
describe "building a user from his ap id" do
test "it returns a user" do
user_id = "http://mastodon.example.org/users/admin"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.ap_id == user_id
assert user.nickname == "admin@mastodon.example.org"
assert user.follower_address == "http://mastodon.example.org/users/admin/followers"
end
test "it returns a user that is invisible" do
user_id = "http://mastodon.example.org/users/relay"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert User.invisible?(user)
end
test "it returns a user that accepts chat messages" do
user_id = "http://mastodon.example.org/users/admin"
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.accepts_chat_messages
end
test "works for guppe actors" do
user_id = "https://gup.pe/u/bernie2020"
Tesla.Mock.mock(fn
%{method: :get, url: ^user_id} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/guppe-actor.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.name == "Bernie2020 group"
assert user.actor_type == "Group"
end
test "works for bridgy actors" do
user_id = "https://fed.brid.gy/jk.nipponalba.scot"
Tesla.Mock.mock(fn
%{method: :get, url: ^user_id} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/bridgy/actor.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.actor_type == "Person"
assert user.avatar == %{
"type" => "Image",
"url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}],
"name" => "profile picture"
}
assert user.banner == %{
"type" => "Image",
"url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}],
"name" => "profile picture"
}
end
test "fetches user featured collection" do
ap_id = "https://example.com/users/lain"
featured_url = "https://example.com/users/lain/collections/featured"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
|> Map.put("featured", featured_url)
|> Jason.encode!()
object_id = Ecto.UUID.generate()
featured_data =
"test/fixtures/mastodon/collections/featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{object_id}}", object_id)
object_url = "https://example.com/objects/#{object_id}"
object_data =
"test/fixtures/statuses/note.json"
|> File.read!()
|> String.replace("{{object_id}}", object_id)
|> String.replace("{{nickname}}", "lain")
Tesla.Mock.mock(fn
%{
method: :get,
url: ^ap_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^featured_url
} ->
%Tesla.Env{
status: 200,
body: featured_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^object_url
} ->
%Tesla.Env{
status: 200,
body: object_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(ap_id)
assert_enqueued(
worker: Pleroma.Workers.RemoteFetcherWorker,
args: %{
"op" => "fetch_remote",
"id" => object_url,
"depth" => 1
}
)
# wait for oban
Pleroma.Tests.ObanHelpers.perform_all()
assert user.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
in_db = Pleroma.User.get_by_ap_id(ap_id)
assert in_db.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url)
end
test "fetches user featured collection without embedded object" do
ap_id = "https://example.com/users/lain"
featured_url = "https://example.com/users/lain/collections/featured"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "lain")
|> Jason.decode!()
|> Map.put("featured", featured_url)
|> Jason.encode!()
object_id = Ecto.UUID.generate()
featured_data =
"test/fixtures/mastodon/collections/external_featured.json"
|> File.read!()
|> String.replace("{{domain}}", "example.com")
|> String.replace("{{nickname}}", "lain")
|> String.replace("{{object_id}}", object_id)
object_url = "https://example.com/objects/#{object_id}"
object_data =
"test/fixtures/statuses/note.json"
|> File.read!()
|> String.replace("{{object_id}}", object_id)
|> String.replace("{{nickname}}", "lain")
Tesla.Mock.mock(fn
%{
method: :get,
url: ^ap_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^featured_url
} ->
%Tesla.Env{
status: 200,
body: featured_data,
headers: [{"content-type", "application/activity+json"}]
}
%{
method: :get,
url: ^object_url
} ->
%Tesla.Env{
status: 200,
body: object_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(ap_id)
assert_enqueued(
worker: Pleroma.Workers.RemoteFetcherWorker,
args: %{
"op" => "fetch_remote",
"id" => object_url,
"depth" => 1
}
)
# wait for oban
Pleroma.Tests.ObanHelpers.perform_all()
assert user.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
in_db = Pleroma.User.get_by_ap_id(ap_id)
assert in_db.featured_address == featured_url
assert Map.has_key?(user.pinned_objects, object_url)
assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url)
end
+ test "fetches user featured collection from the first collection page" do
+ ap_id = "https://example.com/users/lain"
+ featured_url = "https://example.com/users/lain/collections/featured"
+ first_page_url = "#{featured_url}?page=true"
+
+ user_data =
+ "test/fixtures/users_mock/user.json"
+ |> File.read!()
+ |> String.replace("{{nickname}}", "lain")
+ |> Jason.decode!()
+ |> Map.put("featured", featured_url)
+ |> Jason.encode!()
+
+ object_id = Ecto.UUID.generate()
+ object_url = "https://example.com/objects/#{object_id}"
+
+ featured_data =
+ Jason.encode!(%{
+ "id" => featured_url,
+ "type" => "OrderedCollection",
+ "first" => first_page_url
+ })
+
+ first_page_data =
+ Jason.encode!(%{
+ "id" => first_page_url,
+ "type" => "OrderedCollectionPage",
+ "partOf" => featured_url,
+ "orderedItems" => [object_url]
+ })
+
+ object_data =
+ "test/fixtures/statuses/note.json"
+ |> File.read!()
+ |> String.replace("{{object_id}}", object_id)
+ |> String.replace("{{nickname}}", "lain")
+
+ Tesla.Mock.mock(fn
+ %{
+ method: :get,
+ url: ^ap_id
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: user_data,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+
+ %{
+ method: :get,
+ url: ^featured_url
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: featured_data,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+
+ %{
+ method: :get,
+ url: ^first_page_url
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: first_page_data,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+
+ %{
+ method: :get,
+ url: ^object_url
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: object_data,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+ end)
+
+ refute capture_log(fn ->
+ {:ok, user} = ActivityPub.make_user_from_ap_id(ap_id)
+
+ assert_enqueued(
+ worker: Pleroma.Workers.RemoteFetcherWorker,
+ args: %{
+ "op" => "fetch_remote",
+ "id" => object_url,
+ "depth" => 1
+ }
+ )
+
+ Pleroma.Tests.ObanHelpers.perform_all()
+
+ assert user.featured_address == featured_url
+ assert Map.has_key?(user.pinned_objects, object_url)
+ assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url)
+ end) =~ "Could not parse featured collection"
+ end
+
test "fetches user birthday information from misskey" do
user_id = "https://misskey.io/@mkljczk"
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: File.read!("test/fixtures/birthdays/misskey-user.json"),
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.birthday == ~D[2001-02-12]
end
test "fetches avatar description" do
user_id = "https://example.com/users/marcin"
user_data =
"test/fixtures/users_mock/user.json"
|> File.read!()
|> String.replace("{{nickname}}", "marcin")
|> Jason.decode!()
|> Map.delete("featured")
|> Map.update("icon", %{}, fn image -> Map.put(image, "name", "image description") end)
|> Jason.encode!()
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert user.avatar["name"] == "image description"
end
end
test "works with avatar/banner href as list" do
user_id = "https://queef.in/cute_cat"
user_data =
"test/fixtures/users_mock/href_as_array.json"
|> File.read!()
|> Jason.decode!()
|> Map.delete("featured")
|> Jason.encode!()
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert length(user.avatar["url"]) == 1
assert length(user.banner["url"]) == 1
assert user.avatar["url"] |> List.first() |> Map.fetch!("href") ==
"https://queef.in/storage/profile.webp"
assert user.banner["url"] |> List.first() |> Map.fetch!("href") ==
"https://queef.in/storage/banner.gif"
end
test "works with alsoKnownAs as string" do
user_id = "https://hub.netzgemeinde.eu/channel/jupiter_rowland"
user_data =
"test/fixtures/users_mock/hubzilla-actor-alsoknownas-string.json"
|> File.read!()
user_data_decoded =
user_data
|> Jason.decode!()
Tesla.Mock.mock(fn
%{
method: :get,
url: ^user_id
} ->
%Tesla.Env{
status: 200,
body: user_data,
headers: [{"content-type", "application/activity+json"}]
}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id(user_id)
assert is_list(user.also_known_as)
assert user.also_known_as == [user_data_decoded["alsoKnownAs"]]
end
test "it fetches the appropriate tag-restricted posts" do
user = insert(:user)
{:ok, status_one} = CommonAPI.post(user, %{status: ". #TEST"})
{:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"})
{:ok, status_three} = CommonAPI.post(user, %{status: ". #test #Reject"})
{:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"})
{:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"})
for hashtag_timeline_strategy <- [:enabled, :disabled] do
clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_strategy)
fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"})
fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["TEST", "essais"]})
fetch_three =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["test", "Essais"],
tag_reject: ["reject"]
})
fetch_four =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["test"],
tag_all: ["test", "REJECT"]
})
# Testing that deduplication (if needed) is done on DB (not Ecto) level; :limit is important
fetch_five =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["ANY1", "any2"],
limit: 2
})
fetch_six =
ActivityPub.fetch_activities([], %{
type: "Create",
tag: ["any1", "Any2"],
tag_all: [],
tag_reject: []
})
# Regression test: passing empty lists as filter options shouldn't affect the results
assert fetch_five == fetch_six
[fetch_one, fetch_two, fetch_three, fetch_four, fetch_five] =
Enum.map([fetch_one, fetch_two, fetch_three, fetch_four, fetch_five], fn statuses ->
Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end)
end)
assert fetch_one == [status_one, status_three]
assert fetch_two == [status_one, status_two, status_three]
assert fetch_three == [status_one, status_two]
assert fetch_four == [status_three]
assert fetch_five == [status_four, status_five]
end
end
describe "insertion" do
test "drops activities beyond a certain limit" do
limit = Config.get([:instance, :remote_limit])
random_text =
:crypto.strong_rand_bytes(limit + 1)
|> Base.encode64()
|> binary_part(0, limit + 1)
data = %{
"ok" => true,
"object" => %{
"content" => random_text
}
}
assert {:error, :remote_limit} = ActivityPub.insert(data)
end
test "doesn't drop activities with content being null" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => nil
}
}
assert {:ok, _} = ActivityPub.insert(data)
end
test "returns the activity if one with the same id is already in" do
activity = insert(:note_activity)
{:ok, new_activity} = ActivityPub.insert(activity.data)
assert activity.id == new_activity.id
end
test "inserts a given map into the activity database, giving it an id if it has none." do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert activity.data["ok"] == data["ok"]
assert is_binary(activity.data["id"])
given_id = "bla"
data = %{
"id" => given_id,
"actor" => user.ap_id,
"to" => [],
"context" => "blabla",
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert activity.data["ok"] == data["ok"]
assert activity.data["id"] == given_id
assert activity.data["context"] == "blabla"
end
test "adds a context when none is there" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
object = Pleroma.Object.normalize(activity, fetch: false)
assert is_binary(activity.data["context"])
assert is_binary(object.data["context"])
end
test "adds an id to a given object if it lacks one and is a note and inserts it to the object database" do
user = insert(:user)
data = %{
"actor" => user.ap_id,
"to" => [],
"object" => %{
"actor" => user.ap_id,
"to" => [],
"type" => "Note",
"content" => "hey"
}
}
{:ok, %Activity{} = activity} = ActivityPub.insert(data)
assert object = Object.normalize(activity, fetch: false)
assert is_binary(object.data["id"])
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.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
setup do
[user: insert(:user)]
end
test "it reverts create", %{user: user} do
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
end
assert Repo.aggregate(Activity, :count, :id) == 0
assert Repo.aggregate(Object, :count, :id) == 0
end
test "creates activity if expiration is not configured and expires_at is not passed", %{
user: user
} do
clear_config([Pleroma.Workers.PurgeExpiredActivity, :enabled], false)
assert {:ok, _} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
end
test "rejects activity if expires_at present but expiration is not configured", %{user: user} do
clear_config([Pleroma.Workers.PurgeExpiredActivity, :enabled], false)
assert {:error, :expired_activities_disabled} =
ActivityPub.create(%{
to: ["user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user2"],
"type" => "Note",
"content" => "testing"
},
additional: %{
"expires_at" => DateTime.utc_now()
}
})
assert Repo.aggregate(Activity, :count, :id) == 0
assert Repo.aggregate(Object, :count, :id) == 0
end
test "removes doubled 'to' recipients", %{user: user} do
{:ok, activity} =
ActivityPub.create(%{
to: ["user1", "user1", "user2"],
actor: user,
context: "",
object: %{
"to" => ["user1", "user1", "user2"],
"type" => "Note",
"content" => "testing"
}
})
assert activity.data["to"] == ["user1", "user2"]
assert activity.actor == user.ap_id
assert activity.recipients == ["user1", "user2", user.ap_id]
end
test "increases user note count only for public activities", %{user: user} do
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "1",
visibility: "public"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "2",
visibility: "unlisted"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "2",
visibility: "private"
})
{:ok, _} =
CommonAPI.post(User.get_cached_by_id(user.id), %{
status: "3",
visibility: "direct"
})
user = User.get_cached_by_id(user.id)
assert user.note_count == 2
end
test "increases replies count", %{user: user} do
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"})
ap_id = activity.data["id"]
reply_data = %{status: "1", in_reply_to_status_id: activity.id}
# public
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "public"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 1
# unlisted
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "unlisted"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# private
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "private"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
# direct
{:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "direct"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["repliesCount"] == 2
end
test "increases quotes count", %{user: user} do
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"})
ap_id = activity.data["id"]
quote_data = %{status: "1", quoted_status_id: activity.id}
# public
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "public"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 1
# unlisted
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "unlisted"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
# private
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "private"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
# direct
{:ok, _} = CommonAPI.post(user2, Map.put(quote_data, :visibility, "direct"))
assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id)
assert object.data["quotesCount"] == 2
end
end
describe "fetch activities for recipients" do
test "retrieve the activities for certain recipients" do
{:ok, activity_one} = ActivityBuilder.insert(%{"to" => ["someone"]})
{:ok, activity_two} = ActivityBuilder.insert(%{"to" => ["someone_else"]})
{:ok, _activity_three} = ActivityBuilder.insert(%{"to" => ["noone"]})
activities = ActivityPub.fetch_activities(["someone", "someone_else"])
assert length(activities) == 2
assert activities == [activity_one, activity_two]
end
end
describe "fetch activities for followed hashtags" do
test "it should return public activities that reference a given hashtag" do
hashtag = insert(:hashtag, name: "tenshi")
user = insert(:user)
other_user = insert(:user)
{:ok, normally_visible} =
CommonAPI.post(other_user, %{status: "hello :)", visibility: "public"})
{:ok, public} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "public"})
{:ok, _unrelated} = CommonAPI.post(user, %{status: "dai #tensh", visibility: "public"})
{:ok, unlisted} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "unlisted"})
{:ok, _private} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "private"})
activities =
ActivityPub.fetch_activities([other_user.follower_address], %{
followed_hashtags: [hashtag.id]
})
assert length(activities) == 3
normal_id = normally_visible.id
public_id = public.id
unlisted_id = unlisted.id
assert [%{id: ^normal_id}, %{id: ^public_id}, %{id: ^unlisted_id}] = activities
end
end
describe "fetch activities in context" do
test "retrieves activities that have a given context" do
{:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
{:ok, activity_two} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
{:ok, _activity_three} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"})
{:ok, _activity_four} = ActivityBuilder.insert(%{"type" => "Announce", "context" => "2hu"})
activity_five = insert(:note_activity)
user = insert(:user)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_five.data["actor"]})
activities = ActivityPub.fetch_activities_for_context("2hu", %{blocking_user: user})
assert activities == [activity_two, activity]
end
test "doesn't return activities with filtered words" do
user = insert(:user)
user_two = insert(:user)
insert(:filter, user: user, phrase: "test", hide: true)
{:ok, %{id: id1, data: %{"context" => context}}} = CommonAPI.post(user, %{status: "1"})
{:ok, %{id: id2}} = CommonAPI.post(user_two, %{status: "2", in_reply_to_status_id: id1})
{:ok, %{id: id3} = user_activity} =
CommonAPI.post(user, %{status: "3 test?", in_reply_to_status_id: id2})
{:ok, %{id: id4} = filtered_activity} =
CommonAPI.post(user_two, %{status: "4 test!", in_reply_to_status_id: id3})
{:ok, _} = CommonAPI.post(user, %{status: "5", in_reply_to_status_id: id4})
activities =
context
|> ActivityPub.fetch_activities_for_context(%{user: user})
|> Enum.map(& &1.id)
assert length(activities) == 4
assert user_activity.id in activities
refute filtered_activity.id in activities
end
end
test "doesn't return blocked activities" do
activity_one = insert(:note_activity)
activity_two = insert(:note_activity)
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_one.data["actor"]})
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_one)
{:ok, _user_block} = User.unblock(user, %{ap_id: activity_one.data["actor"]})
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
{:ok, _user_relationship} = User.block(user, %{ap_id: activity_three.data["actor"]})
{:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
activities = ActivityPub.fetch_activities([], %{blocking_user: nil, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
end
test "doesn't return activities from deactivated users" do
_user = insert(:user)
deactivated = insert(:user)
active = insert(:user)
{:ok, activity_one} = CommonAPI.post(deactivated, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(active, %{status: "yay!"})
{:ok, _updated_user} = User.set_activation(deactivated, false)
activities = ActivityPub.fetch_activities([], %{})
refute Enum.member?(activities, activity_one)
assert Enum.member?(activities, activity_two)
end
test "always see your own posts even when they address people you block" do
user = insert(:user)
blockee = insert(:user)
{:ok, _} = User.block(user, blockee)
{:ok, activity} = CommonAPI.post(user, %{status: "hey! @#{blockee.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: user})
assert Enum.member?(activities, activity)
end
test "doesn't return transitive interactions concerning blocked users" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
{:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
{:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker})
assert Enum.member?(activities, activity_one)
refute Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_four)
end
test "doesn't return announce activities with blocked users in 'to'" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
{:ok, activity_three} = CommonAPI.repeat(activity_two.id, friend)
activities =
ActivityPub.fetch_activities([], %{blocking_user: blocker})
|> Enum.map(fn act -> act.id end)
assert Enum.member?(activities, activity_one.id)
refute Enum.member?(activities, activity_two.id)
refute Enum.member?(activities, activity_three.id)
end
test "doesn't return announce activities with blocked users in 'cc'" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
assert object = Pleroma.Object.normalize(activity_two, fetch: false)
data = %{
"actor" => friend.ap_id,
"object" => object.data["id"],
"context" => object.data["context"],
"type" => "Announce",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [blockee.ap_id]
}
assert {:ok, activity_three} = ActivityPub.insert(data)
activities =
ActivityPub.fetch_activities([], %{blocking_user: blocker})
|> Enum.map(fn act -> act.id end)
assert Enum.member?(activities, activity_one.id)
refute Enum.member?(activities, activity_two.id)
refute Enum.member?(activities, activity_three.id)
end
test "doesn't return activities from blocked domains" do
domain = "dogwhistle.zone"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
note = insert(:note, %{data: %{"actor" => domain_user.ap_id}})
activity = insert(:note_activity, %{note: note})
user = insert(:user)
{:ok, user} = User.block_domain(user, domain)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
refute activity in activities
followed_user = insert(:user)
CommonAPI.follow(followed_user, user)
{:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user)
activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true})
refute repeat_activity in activities
end
test "see your own posts even when they address actors from blocked domains" do
user = insert(:user)
domain = "dogwhistle.zone"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
{:ok, user} = User.block_domain(user, domain)
{:ok, activity} = CommonAPI.post(user, %{status: "hey! @#{domain_user.nickname}"})
activities = ActivityPub.fetch_activities([], %{blocking_user: user})
assert Enum.member?(activities, activity)
end
test "does return activities from followed users on blocked domains" do
domain = "meanies.social"
domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"})
blocker = insert(:user)
{:ok, blocker, domain_user} = User.follow(blocker, domain_user)
{:ok, blocker} = User.block_domain(blocker, domain)
assert User.following?(blocker, domain_user)
assert User.blocks_domain?(blocker, domain_user)
refute User.blocks?(blocker, domain_user)
note = insert(:note, %{data: %{"actor" => domain_user.ap_id}})
activity = insert(:note_activity, %{note: note})
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true})
assert activity in activities
# And check that if the guy we DO follow boosts someone else from their domain,
# that should be hidden
another_user = insert(:user, %{ap_id: "https://#{domain}/@meanie2"})
bad_note = insert(:note, %{data: %{"actor" => another_user.ap_id}})
bad_activity = insert(:note_activity, %{note: bad_note})
{:ok, repeat_activity} = CommonAPI.repeat(bad_activity.id, domain_user)
activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true})
refute repeat_activity in activities
end
test "returns your own posts regardless of mute" do
user = insert(:user)
muted = insert(:user)
{:ok, muted_post} = CommonAPI.post(muted, %{status: "Im stupid"})
{:ok, reply} =
CommonAPI.post(user, %{status: "I'm muting you", in_reply_to_status_id: muted_post.id})
{:ok, _} = User.mute(user, muted)
[activity] = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert activity.id == reply.id
end
test "doesn't return muted activities" do
activity_one = insert(:note_activity)
activity_two = insert(:note_activity)
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
activity_one_actor = User.get_by_ap_id(activity_one.data["actor"])
{:ok, _user_relationships} = User.mute(user, activity_one_actor)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
refute Enum.member?(activities, activity_one)
# Calling with 'with_muted' will deliver muted activities, too.
activities =
ActivityPub.fetch_activities([], %{
muting_user: user,
with_muted: true,
skip_preload: true
})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
{:ok, _user_mute} = User.unmute(user, activity_one_actor)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, activity_one)
activity_three_actor = User.get_by_ap_id(activity_three.data["actor"])
{:ok, _user_relationships} = User.mute(user, activity_three_actor)
{:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster)
%Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id)
activity_three = Activity.get_by_id(activity_three.id)
activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true})
assert Enum.member?(activities, activity_two)
refute Enum.member?(activities, activity_three)
refute Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
activities = ActivityPub.fetch_activities([], %{muting_user: nil, skip_preload: true})
assert Enum.member?(activities, activity_two)
assert Enum.member?(activities, activity_three)
assert Enum.member?(activities, boost_activity)
assert Enum.member?(activities, activity_one)
end
test "doesn't return thread muted activities" do
user = insert(:user)
_activity_one = insert(:note_activity)
note_two = insert(:note, data: %{"context" => "suya.."})
activity_two = insert(:note_activity, note: note_two)
{:ok, _activity_two} = CommonAPI.add_mute(activity_two, user)
assert [_activity_one] = ActivityPub.fetch_activities([], %{muting_user: user})
end
test "returns thread muted activities when with_muted is set" do
user = insert(:user)
_activity_one = insert(:note_activity)
note_two = insert(:note, data: %{"context" => "suya.."})
activity_two = insert(:note_activity, note: note_two)
{:ok, _activity_two} = CommonAPI.add_mute(activity_two, user)
assert [_activity_two, _activity_one] =
ActivityPub.fetch_activities([], %{muting_user: user, with_muted: true})
end
test "does include announces on request" do
activity_three = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, user, booster} = User.follow(user, booster)
{:ok, announce} = CommonAPI.repeat(activity_three.id, booster)
[announce_activity] = ActivityPub.fetch_activities([user.ap_id | User.following(user)])
assert announce_activity.id == announce.id
end
test "excludes reblogs on request" do
user = insert(:user)
{:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user})
{:ok, _} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user})
[activity] = ActivityPub.fetch_user_activities(user, nil, %{exclude_reblogs: true})
assert activity == expected_activity
end
test "includes only reblogs on request" do
user = insert(:user)
{:ok, _} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user})
{:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user})
[activity] = ActivityPub.fetch_user_activities(user, nil, %{only_reblogs: true})
assert activity == expected_activity
end
describe "irreversible filters" do
setup do
user = insert(:user)
user_two = insert(:user)
insert(:filter, user: user_two, phrase: "cofe", hide: true)
insert(:filter, user: user_two, phrase: "ok boomer", hide: true)
insert(:filter, user: user_two, phrase: "test", hide: false)
params = %{
type: ["Create", "Announce"],
user: user_two
}
{:ok, %{user: user, user_two: user_two, params: params}}
end
test "it returns statuses if they don't contain exact filter words", %{
user: user,
params: params
} do
{:ok, _} = CommonAPI.post(user, %{status: "hey"})
{:ok, _} = CommonAPI.post(user, %{status: "got cofefe?"})
{:ok, _} = CommonAPI.post(user, %{status: "I am not a boomer"})
{:ok, _} = CommonAPI.post(user, %{status: "ok boomers"})
{:ok, _} = CommonAPI.post(user, %{status: "ccofee is not a word"})
{:ok, _} = CommonAPI.post(user, %{status: "this is a test"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.count(activities) == 6
end
test "it does not filter user's own statuses", %{user_two: user_two, params: params} do
{:ok, _} = CommonAPI.post(user_two, %{status: "Give me some cofe!"})
{:ok, _} = CommonAPI.post(user_two, %{status: "ok boomer"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.count(activities) == 2
end
test "it excludes statuses with filter words", %{user: user, params: params} do
{:ok, _} = CommonAPI.post(user, %{status: "Give me some cofe!"})
{:ok, _} = CommonAPI.post(user, %{status: "ok boomer"})
{:ok, _} = CommonAPI.post(user, %{status: "is it a cOfE?"})
{:ok, _} = CommonAPI.post(user, %{status: "cofe is all I need"})
{:ok, _} = CommonAPI.post(user, %{status: "— ok BOOMER\n"})
activities = ActivityPub.fetch_activities([], params)
assert Enum.empty?(activities)
end
test "it returns all statuses if user does not have any filters" do
another_user = insert(:user)
{:ok, _} = CommonAPI.post(another_user, %{status: "got cofe?"})
{:ok, _} = CommonAPI.post(another_user, %{status: "test!"})
activities =
ActivityPub.fetch_activities([], %{
type: ["Create", "Announce"],
user: another_user
})
assert Enum.count(activities) == 2
end
end
describe "public fetch activities" do
test "doesn't retrieve unlisted activities" do
user = insert(:user)
{:ok, _unlisted_activity} = CommonAPI.post(user, %{status: "yeah", visibility: "unlisted"})
{:ok, listed_activity} = CommonAPI.post(user, %{status: "yeah"})
[activity] = ActivityPub.fetch_public_activities()
assert activity == listed_activity
end
test "retrieves public activities" do
_activities = ActivityPub.fetch_public_activities()
%{public: public} = ActivityBuilder.public_and_non_public()
activities = ActivityPub.fetch_public_activities()
assert length(activities) == 1
assert Enum.at(activities, 0) == public
end
test "retrieves a maximum of 20 activities" do
ActivityBuilder.insert_list(10)
expected_activities = ActivityBuilder.insert_list(20)
activities = ActivityPub.fetch_public_activities()
assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 20
end
test "retrieves ids starting from a since_id" do
activities = ActivityBuilder.insert_list(30)
expected_activities = ActivityBuilder.insert_list(10)
since_id = List.last(activities).id
activities = ActivityPub.fetch_public_activities(%{since_id: since_id})
assert collect_ids(activities) == collect_ids(expected_activities)
assert length(activities) == 10
end
test "retrieves ids up to max_id" do
ActivityBuilder.insert_list(10)
expected_activities = ActivityBuilder.insert_list(20)
%{id: max_id} =
10
|> ActivityBuilder.insert_list()
|> List.first()
activities = ActivityPub.fetch_public_activities(%{max_id: max_id})
assert length(activities) == 20
assert collect_ids(activities) == collect_ids(expected_activities)
end
test "paginates via offset/limit" do
_first_part_activities = ActivityBuilder.insert_list(10)
second_part_activities = ActivityBuilder.insert_list(10)
later_activities = ActivityBuilder.insert_list(10)
activities = ActivityPub.fetch_public_activities(%{page: "2", page_size: "20"}, :offset)
assert length(activities) == 20
assert collect_ids(activities) ==
collect_ids(second_part_activities) ++ collect_ids(later_activities)
end
test "doesn't return reblogs for users for whom reblogs have been muted" do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(booster, user)
{:ok, activity} = CommonAPI.repeat(activity.id, booster)
activities = ActivityPub.fetch_activities([], %{muting_user: user})
refute Enum.any?(activities, fn %{id: id} -> id == activity.id end)
end
test "returns reblogs for users for whom reblogs have not been muted" do
activity = insert(:note_activity)
user = insert(:user)
booster = insert(:user)
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(booster, user)
{:ok, _reblog_mute} = CommonAPI.show_reblogs(booster, user)
{:ok, activity} = CommonAPI.repeat(activity.id, booster)
activities = ActivityPub.fetch_activities([], %{muting_user: user})
assert Enum.any?(activities, fn %{id: id} -> id == activity.id end)
end
end
describe "uploading files" do
setup do
test_file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
%{test_file: test_file}
end
test "strips / from filename", %{test_file: %Plug.Upload{} = file} do
file = %{file | filename: "../../../../../nested/bad.jpg"}
{:ok, %Object{} = object} = ActivityPub.upload(file)
[%{"href" => href}] = object.data["url"]
assert Regex.match?(~r"/bad.jpg$", href)
refute Regex.match?(~r"/nested/", href)
end
test "sets a description if given", %{test_file: file} do
{:ok, %Object{} = object} = ActivityPub.upload(file, description: "a cool file")
assert object.data["name"] == "a cool file"
end
test "it sets the default description depending on the configuration", %{test_file: file} do
clear_config([Pleroma.Upload, :default_description])
clear_config([Pleroma.Upload, :default_description], nil)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == ""
clear_config([Pleroma.Upload, :default_description], :filename)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "an_image.jpg"
clear_config([Pleroma.Upload, :default_description], "unnamed attachment")
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "unnamed attachment"
end
test "copies the file to the configured folder", %{test_file: file} do
clear_config([Pleroma.Upload, :default_description], :filename)
{:ok, %Object{} = object} = ActivityPub.upload(file)
assert object.data["name"] == "an_image.jpg"
end
test "works with base64 encoded images" do
file = %{
img: data_uri()
}
{:ok, %Object{}} = ActivityPub.upload(file)
end
end
describe "fetch the latest Follow" do
test "fetches the latest Follow activity" do
%Activity{data: %{"type" => "Follow"}} = activity = insert(:follow_activity)
follower = Repo.get_by(User, ap_id: activity.data["actor"])
followed = Repo.get_by(User, ap_id: activity.data["object"])
assert activity == Utils.fetch_latest_follow(follower, followed)
end
end
describe "unfollowing" do
test "it reverts unfollow activity" do
follower = insert(:user)
followed = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do
assert {:error, :reverted} = ActivityPub.unfollow(follower, followed)
end
activity = Activity.get_by_id(follow_activity.id)
assert activity.data["type"] == "Follow"
assert activity.data["actor"] == follower.ap_id
assert activity.data["object"] == followed.ap_id
end
test "creates an undo activity for the last follow" do
follower = insert(:user)
followed = insert(:user)
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
{:ok, activity} = ActivityPub.unfollow(follower, followed)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == follower.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
assert embedded_object["type"] == "Follow"
assert embedded_object["object"] == followed.ap_id
assert embedded_object["id"] == follow_activity.data["id"]
end
test "creates an undo activity for a pending follow request" do
follower = insert(:user)
followed = insert(:user, %{is_locked: true})
{:ok, _, _, follow_activity} = CommonAPI.follow(followed, follower)
{:ok, activity} = ActivityPub.unfollow(follower, followed)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == follower.ap_id
embedded_object = activity.data["object"]
assert is_map(embedded_object)
assert embedded_object["type"] == "Follow"
assert embedded_object["object"] == followed.ap_id
assert embedded_object["id"] == follow_activity.data["id"]
end
end
describe "timeline post-processing" do
test "it filters broken threads" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
{:ok, user1, user3} = User.follow(user1, user3)
assert User.following?(user1, user3)
{:ok, user2, user3} = User.follow(user2, user3)
assert User.following?(user2, user3)
{:ok, user3, user2} = User.follow(user3, user2)
assert User.following?(user3, user2)
{:ok, public_activity} = CommonAPI.post(user3, %{status: "hi 1"})
{:ok, private_activity_1} = CommonAPI.post(user3, %{status: "hi 2", visibility: "private"})
{:ok, private_activity_2} =
CommonAPI.post(user2, %{
status: "hi 3",
visibility: "private",
in_reply_to_status_id: private_activity_1.id
})
{:ok, private_activity_3} =
CommonAPI.post(user3, %{
status: "hi 4",
visibility: "private",
in_reply_to_status_id: private_activity_2.id
})
activities =
ActivityPub.fetch_activities([user1.ap_id | User.following(user1)])
|> Enum.map(fn a -> a.id end)
private_activity_1 = Activity.get_by_ap_id_with_object(private_activity_1.data["id"])
assert [public_activity.id, private_activity_1.id, private_activity_3.id] == activities
assert length(activities) == 3
activities =
ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{user: user1})
|> Enum.map(fn a -> a.id end)
assert [public_activity.id, private_activity_1.id] == activities
assert length(activities) == 2
end
end
describe "flag/1" do
setup do
reporter = insert(:user)
target_account = insert(:user)
content = "foobar"
{:ok, activity} = CommonAPI.post(target_account, %{status: content})
context = Utils.generate_context_id()
reporter_ap_id = reporter.ap_id
target_ap_id = target_account.ap_id
activity_ap_id = activity.data["id"]
object_ap_id = activity.object.data["id"]
activity_with_object = Activity.get_by_ap_id_with_object(activity_ap_id)
{:ok,
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: activity,
content: content,
activity_ap_id: activity_ap_id,
object_ap_id: object_ap_id,
activity_with_object: activity_with_object,
reporter_ap_id: reporter_ap_id,
target_ap_id: target_ap_id
}}
end
test "it can create a Flag activity",
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: reported_activity,
content: content,
object_ap_id: object_ap_id,
activity_with_object: activity_with_object,
reporter_ap_id: reporter_ap_id,
target_ap_id: target_ap_id
} do
assert {:ok, activity} =
ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
note_obj = %{
"type" => "Note",
"id" => object_ap_id,
"content" => content,
"published" => activity_with_object.object.data["published"],
"actor" =>
AccountView.render("show.json", %{user: target_account, skip_visibility_check: true})
}
assert %Activity{
actor: ^reporter_ap_id,
data: %{
"type" => "Flag",
"content" => ^content,
"context" => ^context,
"object" => [^target_ap_id, ^note_obj]
}
} = activity
end
test_with_mock "reverts on error",
%{
reporter: reporter,
context: context,
target_account: target_account,
reported_activity: reported_activity,
content: content
},
Utils,
[:passthrough],
maybe_federate: fn _ -> {:error, :reverted} end do
assert {:error, :reverted} =
ActivityPub.flag(%{
actor: reporter,
context: context,
account: target_account,
statuses: [reported_activity],
content: content
})
assert Repo.aggregate(Activity, :count, :id) == 1
assert Repo.aggregate(Object, :count, :id) == 1
assert Repo.aggregate(Notification, :count, :id) == 0
end
end
test "fetch_activities/2 returns activities addressed to a list " do
user = insert(:user)
member = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "foo"}, user)
{:ok, list} = Pleroma.List.follow(list, member)
{:ok, %Activity{} = activity} =
CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"})
activity = Repo.preload(activity, :bookmark)
activity = %{activity | thread_muted?: !!activity.thread_muted?}
assert ActivityPub.fetch_activities([], %{user: user}) == [activity]
end
def data_uri do
File.read!("test/fixtures/avatar_data_uri")
end
describe "fetch_activities_bounded" do
test "fetches private posts for followed users" do
user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{
status: "thought I looked cute might delete later :3",
visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([user.follower_address], [])
assert result.id == activity.id
end
test "fetches only public posts for other users" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#cofe", visibility: "public"})
{:ok, _private_activity} =
CommonAPI.post(user, %{
status: "why is tenshi eating a corndog so cute?",
visibility: "private"
})
[result] = ActivityPub.fetch_activities_bounded([], [user.follower_address])
assert result.id == activity.id
end
end
describe "fetch_follow_information_for_user" do
test "synchronizes following/followers counters" do
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/fuser2/followers",
following_address: "https://remote.org/users/fuser2/following"
)
{:ok, info} = ActivityPub.fetch_follow_information_for_user(user)
assert info.follower_count == 527
assert info.following_count == 267
end
test "detects hidden followers" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_closed/followers?page=1" ->
%Tesla.Env{status: 403, body: ""}
_ ->
apply(HttpRequestMock, :request, [env])
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.hide_follows == false
end
test "detects hidden follows" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_closed/following?page=1" ->
%Tesla.Env{status: 403, body: ""}
_ ->
apply(HttpRequestMock, :request, [env])
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == false
assert follow_info.hide_follows == true
end
test "detects hidden follows/followers for friendica" do
user =
insert(:user,
local: false,
follower_address: "https://remote.org/followers/fuser3",
following_address: "https://remote.org/following/fuser3"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.follower_count == 296
assert follow_info.following_count == 32
assert follow_info.hide_follows == true
end
test "doesn't crash when follower and following counters are hidden" do
mock(fn env ->
case env.url do
"https://remote.org/users/masto_hidden_counters/following" ->
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/followers"
},
headers: HttpRequestMock.activitypub_object_headers()
)
"https://remote.org/users/masto_hidden_counters/following?page=1" ->
%Tesla.Env{status: 403, body: ""}
"https://remote.org/users/masto_hidden_counters/followers" ->
json(
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.org/users/masto_hidden_counters/following"
},
headers: HttpRequestMock.activitypub_object_headers()
)
"https://remote.org/users/masto_hidden_counters/followers?page=1" ->
%Tesla.Env{status: 403, body: ""}
end
end)
user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_hidden_counters/followers",
following_address: "https://remote.org/users/masto_hidden_counters/following"
)
{:ok, follow_info} = ActivityPub.fetch_follow_information_for_user(user)
assert follow_info.hide_followers == true
assert follow_info.follower_count == 0
assert follow_info.hide_follows == true
assert follow_info.following_count == 0
end
end
describe "fetch_favourites/3" do
test "returns a favourite activities sorted by adds to favorite" do
user = insert(:user)
other_user = insert(:user)
user1 = insert(:user)
user2 = insert(:user)
{:ok, a1} = CommonAPI.post(user1, %{status: "bla"})
{:ok, _a2} = CommonAPI.post(user2, %{status: "traps are happy"})
{:ok, a3} = CommonAPI.post(user2, %{status: "Trees Are "})
{:ok, a4} = CommonAPI.post(user2, %{status: "Agent Smith "})
{:ok, a5} = CommonAPI.post(user1, %{status: "Red or Blue "})
{:ok, _} = CommonAPI.favorite(a4.id, user)
{:ok, _} = CommonAPI.favorite(a3.id, other_user)
{:ok, _} = CommonAPI.favorite(a3.id, user)
{:ok, _} = CommonAPI.favorite(a5.id, other_user)
{:ok, _} = CommonAPI.favorite(a5.id, user)
{:ok, _} = CommonAPI.favorite(a4.id, other_user)
{:ok, _} = CommonAPI.favorite(a1.id, user)
{:ok, _} = CommonAPI.favorite(a1.id, other_user)
result = ActivityPub.fetch_favourites(user)
assert Enum.map(result, & &1.id) == [a1.id, a5.id, a3.id, a4.id]
result = ActivityPub.fetch_favourites(user, %{limit: 2})
assert Enum.map(result, & &1.id) == [a1.id, a5.id]
end
end
describe "Move activity" do
test "create" do
%{ap_id: old_ap_id} = old_user = insert(:user)
%{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
follower = insert(:user)
follower_move_opted_out = insert(:user, allow_following_move: false)
User.follow(follower, old_user)
User.follow(follower_move_opted_out, old_user)
assert User.following?(follower, old_user)
assert User.following?(follower_move_opted_out, old_user)
assert {:ok, %Activity{} = activity} = ActivityPub.move(old_user, new_user)
assert %Activity{
actor: ^old_ap_id,
data: %{
"actor" => ^old_ap_id,
"object" => ^old_ap_id,
"target" => ^new_ap_id,
"type" => "Move"
},
local: true,
recipients: recipients
} = activity
assert old_user.follower_address in recipients
params = %{
"op" => "move_following",
"origin_id" => old_user.id,
"target_id" => new_user.id
}
assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
refute User.following?(follower, old_user)
assert User.following?(follower, new_user)
assert User.following?(follower_move_opted_out, old_user)
refute User.following?(follower_move_opted_out, new_user)
activity = %{activity | object: nil}
assert [%Notification{activity: ^activity}] = Notification.for_user(follower)
assert [%Notification{activity: ^activity}] = Notification.for_user(follower_move_opted_out)
end
test "old user must be in the new user's `also_known_as` list" do
old_user = insert(:user)
new_user = insert(:user)
assert {:error, "Target account must have the origin in `alsoKnownAs`"} =
ActivityPub.move(old_user, new_user)
end
test "do not move remote user following relationships" do
%{ap_id: old_ap_id} = old_user = insert(:user)
%{ap_id: new_ap_id} = new_user = insert(:user, also_known_as: [old_ap_id])
follower_remote = insert(:user, local: false)
User.follow(follower_remote, old_user)
assert User.following?(follower_remote, old_user)
assert {:ok, activity} = ActivityPub.move(old_user, new_user)
assert %Activity{
actor: ^old_ap_id,
data: %{
"actor" => ^old_ap_id,
"object" => ^old_ap_id,
"target" => ^new_ap_id,
"type" => "Move"
},
local: true
} = activity
params = %{
"op" => "move_following",
"origin_id" => old_user.id,
"target_id" => new_user.id
}
assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params)
Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params})
assert User.following?(follower_remote, old_user)
refute User.following?(follower_remote, new_user)
end
end
test "doesn't retrieve replies activities with exclude_replies" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "yeah"})
{:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id})
[result] = ActivityPub.fetch_public_activities(%{exclude_replies: true})
assert result.id == activity.id
assert length(ActivityPub.fetch_public_activities()) == 2
end
describe "replies filtering with public messages" do
setup :public_messages
test "public timeline", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 16
end
test "public timeline with reply_visibility `following`", %{
users: %{u1: user},
u1: u1,
u2: u2,
u3: u3,
u4: u4,
activities: activities
} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 14
visible_ids =
Map.values(u1) ++ Map.values(u2) ++ Map.values(u4) ++ Map.values(activities) ++ [u3[:r1]]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "public timeline with reply_visibility `self`", %{
users: %{u1: user},
u1: u1,
u2: u2,
u3: u3,
u4: u4,
activities: activities
} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert length(activities_ids) == 10
visible_ids = Map.values(u1) ++ [u2[:r1], u3[:r1], u4[:r1]] ++ Map.values(activities)
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 13
visible_ids =
Map.values(u1) ++
Map.values(u3) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u2[:r3],
u4[:r1],
u4[:r2]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline with reply_visibility `following`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 11
visible_ids =
Map.values(u1) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u2[:r3],
u3[:r1],
u4[:r1],
u4[:r2]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "home timeline with reply_visibility `self`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 9
visible_ids =
Map.values(u1) ++
[
activities[:a1],
activities[:a2],
activities[:a4],
u2[:r1],
u3[:r1],
u4[:r1]
]
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
test "filtering out announces where the user is the actor of the announced message" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
User.follow(user, other_user)
{:ok, post} = CommonAPI.post(user, %{status: "yo"})
{:ok, other_post} = CommonAPI.post(third_user, %{status: "yo"})
{:ok, _announce} = CommonAPI.repeat(post.id, other_user)
{:ok, _announce} = CommonAPI.repeat(post.id, third_user)
{:ok, announce} = CommonAPI.repeat(other_post.id, other_user)
params = %{
type: ["Announce"]
}
results =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert length(results) == 3
params = %{
type: ["Announce"],
announce_filtering_user: user
}
[result] =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert result.id == announce.id
end
end
describe "replies filtering with private messages" do
setup :private_messages
test "public timeline", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
end
test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
end
test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do
activities_ids =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:local_only, false)
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
|> Map.put(:user, user)
|> ActivityPub.fetch_public_activities()
|> Enum.map(& &1.id)
assert activities_ids == []
activities_ids =
%{}
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, nil)
|> ActivityPub.fetch_public_activities()
assert activities_ids == []
end
test "home timeline", %{users: %{u1: user}} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 12
end
test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 12
end
test "home timeline with default reply_visibility `self`", %{
users: %{u1: user},
activities: activities,
u1: u1,
u2: u2,
u3: u3,
u4: u4
} do
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:user, user)
|> Map.put(:reply_visibility, "self")
|> Map.put(:reply_filtering_user, user)
activities_ids =
ActivityPub.fetch_activities([user.ap_id | User.following(user)], params)
|> Enum.map(& &1.id)
assert length(activities_ids) == 10
visible_ids =
Map.values(u1) ++ Map.values(u4) ++ [u2[:r1], u3[:r1]] ++ Map.values(activities)
assert Enum.all?(visible_ids, &(&1 in activities_ids))
end
end
defp public_messages(_) do
[u1, u2, u3, u4] = insert_list(4, :user)
{:ok, u1, u2} = User.follow(u1, u2)
{:ok, u2, u1} = User.follow(u2, u1)
{:ok, u1, u4} = User.follow(u1, u4)
{:ok, u4, u1} = User.follow(u4, u1)
{:ok, u2, u3} = User.follow(u2, u3)
{:ok, u3, u2} = User.follow(u3, u2)
{:ok, a1} = CommonAPI.post(u1, %{status: "Status"})
{:ok, r1_1} =
CommonAPI.post(u2, %{
status: "@#{u1.nickname} reply from u2 to u1",
in_reply_to_status_id: a1.id
})
{:ok, r1_2} =
CommonAPI.post(u3, %{
status: "@#{u1.nickname} reply from u3 to u1",
in_reply_to_status_id: a1.id
})
{:ok, r1_3} =
CommonAPI.post(u4, %{
status: "@#{u1.nickname} reply from u4 to u1",
in_reply_to_status_id: a1.id
})
{:ok, a2} = CommonAPI.post(u2, %{status: "Status"})
{:ok, r2_1} =
CommonAPI.post(u1, %{
status: "@#{u2.nickname} reply from u1 to u2",
in_reply_to_status_id: a2.id
})
{:ok, r2_2} =
CommonAPI.post(u3, %{
status: "@#{u2.nickname} reply from u3 to u2",
in_reply_to_status_id: a2.id
})
{:ok, r2_3} =
CommonAPI.post(u4, %{
status: "@#{u2.nickname} reply from u4 to u2",
in_reply_to_status_id: a2.id
})
{:ok, a3} = CommonAPI.post(u3, %{status: "Status"})
{:ok, r3_1} =
CommonAPI.post(u1, %{
status: "@#{u3.nickname} reply from u1 to u3",
in_reply_to_status_id: a3.id
})
{:ok, r3_2} =
CommonAPI.post(u2, %{
status: "@#{u3.nickname} reply from u2 to u3",
in_reply_to_status_id: a3.id
})
{:ok, r3_3} =
CommonAPI.post(u4, %{
status: "@#{u3.nickname} reply from u4 to u3",
in_reply_to_status_id: a3.id
})
{:ok, a4} = CommonAPI.post(u4, %{status: "Status"})
{:ok, r4_1} =
CommonAPI.post(u1, %{
status: "@#{u4.nickname} reply from u1 to u4",
in_reply_to_status_id: a4.id
})
{:ok, r4_2} =
CommonAPI.post(u2, %{
status: "@#{u4.nickname} reply from u2 to u4",
in_reply_to_status_id: a4.id
})
{:ok, r4_3} =
CommonAPI.post(u3, %{
status: "@#{u4.nickname} reply from u3 to u4",
in_reply_to_status_id: a4.id
})
{:ok,
users: %{u1: u1, u2: u2, u3: u3, u4: u4},
activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
u2: %{r1: r2_1.id, r2: r2_2.id, r3: r2_3.id},
u3: %{r1: r3_1.id, r2: r3_2.id, r3: r3_3.id},
u4: %{r1: r4_1.id, r2: r4_2.id, r3: r4_3.id}}
end
defp private_messages(_) do
[u1, u2, u3, u4] = insert_list(4, :user)
{:ok, u1, u2} = User.follow(u1, u2)
{:ok, u2, u1} = User.follow(u2, u1)
{:ok, u1, u3} = User.follow(u1, u3)
{:ok, u3, u1} = User.follow(u3, u1)
{:ok, u1, u4} = User.follow(u1, u4)
{:ok, u4, u1} = User.follow(u4, u1)
{:ok, u2, u3} = User.follow(u2, u3)
{:ok, u3, u2} = User.follow(u3, u2)
{:ok, a1} = CommonAPI.post(u1, %{status: "Status", visibility: "private"})
{:ok, r1_1} =
CommonAPI.post(u2, %{
status: "@#{u1.nickname} reply from u2 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, r1_2} =
CommonAPI.post(u3, %{
status: "@#{u1.nickname} reply from u3 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, r1_3} =
CommonAPI.post(u4, %{
status: "@#{u1.nickname} reply from u4 to u1",
in_reply_to_status_id: a1.id,
visibility: "private"
})
{:ok, a2} = CommonAPI.post(u2, %{status: "Status", visibility: "private"})
{:ok, r2_1} =
CommonAPI.post(u1, %{
status: "@#{u2.nickname} reply from u1 to u2",
in_reply_to_status_id: a2.id,
visibility: "private"
})
{:ok, r2_2} =
CommonAPI.post(u3, %{
status: "@#{u2.nickname} reply from u3 to u2",
in_reply_to_status_id: a2.id,
visibility: "private"
})
{:ok, a3} = CommonAPI.post(u3, %{status: "Status", visibility: "private"})
{:ok, r3_1} =
CommonAPI.post(u1, %{
status: "@#{u3.nickname} reply from u1 to u3",
in_reply_to_status_id: a3.id,
visibility: "private"
})
{:ok, r3_2} =
CommonAPI.post(u2, %{
status: "@#{u3.nickname} reply from u2 to u3",
in_reply_to_status_id: a3.id,
visibility: "private"
})
{:ok, a4} = CommonAPI.post(u4, %{status: "Status", visibility: "private"})
{:ok, r4_1} =
CommonAPI.post(u1, %{
status: "@#{u4.nickname} reply from u1 to u4",
in_reply_to_status_id: a4.id,
visibility: "private"
})
{:ok,
users: %{u1: u1, u2: u2, u3: u3, u4: u4},
activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id},
u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id},
u2: %{r1: r2_1.id, r2: r2_2.id},
u3: %{r1: r3_1.id, r2: r3_2.id},
u4: %{r1: r4_1.id}}
end
describe "maybe_update_follow_information/1" do
setup do
clear_config([:instance, :external_user_synchronization], true)
user = %{
local: false,
ap_id: "https://gensokyo.2hu/users/raymoo",
following_address: "https://gensokyo.2hu/users/following",
follower_address: "https://gensokyo.2hu/users/followers",
type: "Person"
}
%{user: user}
end
test "logs an error when it can't fetch the info", %{user: user} do
assert capture_log(fn ->
ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
test "just returns the input if the user type is Application", %{
user: user
} do
user =
user
|> Map.put(:type, "Application")
refute capture_log(fn ->
assert ^user = ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
test "it just returns the input if the user has no following/follower addresses", %{
user: user
} do
user =
user
|> Map.put(:following_address, nil)
|> Map.put(:follower_address, nil)
refute capture_log(fn ->
assert ^user = ActivityPub.maybe_update_follow_information(user)
end) =~ "Follower/Following counter update for #{user.ap_id} failed"
end
end
describe "global activity expiration" do
test "creates an activity expiration for local Create activities" do
clear_config([:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy)
{:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"})
{:ok, follow} = ActivityBuilder.insert(%{"type" => "Follow", "context" => "3hu"})
assert_enqueued(
worker: Pleroma.Workers.PurgeExpiredActivity,
args: %{activity_id: activity.id},
scheduled_at:
activity.inserted_at
|> DateTime.from_naive!("Etc/UTC")
|> Timex.shift(days: 365)
)
refute_enqueued(
worker: Pleroma.Workers.PurgeExpiredActivity,
args: %{activity_id: follow.id}
)
end
end
describe "handling of clashing nicknames" do
test "renames an existing user with a clashing nickname and a different ap id" do
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/harinezumigari"
)
%{
nickname: orig_user.nickname,
ap_id: orig_user.ap_id <> "part_2"
}
|> ActivityPub.maybe_handle_clashing_nickname()
user = User.get_by_id(orig_user.id)
assert user.nickname == "#{orig_user.id}.admin@mastodon.example.org"
end
test "does nothing with a clashing nickname and the same ap id" do
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/harinezumigari"
)
%{
nickname: orig_user.nickname,
ap_id: orig_user.ap_id
}
|> ActivityPub.maybe_handle_clashing_nickname()
user = User.get_by_id(orig_user.id)
assert user.nickname == orig_user.nickname
end
end
describe "reply filtering" do
test "`following` still contains announcements by friends" do
user = insert(:user)
followed = insert(:user)
not_followed = insert(:user)
User.follow(user, followed)
{:ok, followed_post} = CommonAPI.post(followed, %{status: "Hello"})
{:ok, not_followed_to_followed} =
CommonAPI.post(not_followed, %{
status: "Also hello",
in_reply_to_status_id: followed_post.id
})
{:ok, retoot} = CommonAPI.repeat(not_followed_to_followed.id, followed)
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
activities =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
followed_post_id = followed_post.id
retoot_id = retoot.id
assert [%{id: ^followed_post_id}, %{id: ^retoot_id}] = activities
assert length(activities) == 2
end
# This test is skipped because, while this is the desired behavior,
# there seems to be no good way to achieve it with the method that
# we currently use for detecting to who a reply is directed.
# This is a TODO and should be fixed by a later rewrite of the code
# in question.
@tag skip: true
test "`following` still contains self-replies by friends" do
user = insert(:user)
followed = insert(:user)
not_followed = insert(:user)
User.follow(user, followed)
{:ok, followed_post} = CommonAPI.post(followed, %{status: "Hello"})
{:ok, not_followed_post} = CommonAPI.post(not_followed, %{status: "Also hello"})
{:ok, _followed_to_not_followed} =
CommonAPI.post(followed, %{status: "sup", in_reply_to_status_id: not_followed_post.id})
{:ok, _followed_self_reply} =
CommonAPI.post(followed, %{status: "Also cofe", in_reply_to_status_id: followed_post.id})
params =
%{}
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:blocking_user, user)
|> Map.put(:muting_user, user)
|> Map.put(:reply_filtering_user, user)
|> Map.put(:reply_visibility, "following")
|> Map.put(:announce_filtering_user, user)
|> Map.put(:user, user)
activities =
[user.ap_id | User.following(user)]
|> ActivityPub.fetch_activities(params)
assert length(activities) == 2
end
end
test "allow fetching of accounts with an empty string name field" do
Tesla.Mock.mock(fn
%{method: :get, url: "https://princess.cat/users/mewmew"} ->
file = File.read!("test/fixtures/mewmew_no_name.json")
%Tesla.Env{status: 200, body: file, headers: HttpRequestMock.activitypub_object_headers()}
end)
{:ok, user} = ActivityPub.make_user_from_ap_id("https://princess.cat/users/mewmew")
assert user.name == " "
end
test "pin_data_from_featured_collection will ignore unsupported values" do
assert %{} ==
ActivityPub.pin_data_from_featured_collection(%{
"type" => "OrderedCollection",
"first" => "https://social.example/users/alice/collections/featured?page=true"
})
end
+
+ test "fetch_and_prepare_featured_from_ap_id handles embedded first collection pages" do
+ featured_url = "https://social.example/users/alice/collections/featured"
+ first_page_url = "#{featured_url}?page=true"
+ object_url = "https://social.example/objects/1"
+
+ featured_data =
+ Jason.encode!(%{
+ "id" => featured_url,
+ "type" => "OrderedCollection",
+ "first" => %{
+ "id" => first_page_url,
+ "type" => "OrderedCollectionPage",
+ "partOf" => featured_url,
+ "orderedItems" => [object_url]
+ }
+ })
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: ^featured_url} ->
+ %Tesla.Env{
+ status: 200,
+ body: featured_data,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+ end)
+
+ refute capture_log(fn ->
+ assert {:ok, pinned_objects} =
+ ActivityPub.fetch_and_prepare_featured_from_ap_id(featured_url)
+
+ assert Map.has_key?(pinned_objects, object_url)
+ end) =~ "Could not parse featured collection"
+ end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
index dd4b7389e..55df41ec6 100644
--- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
@@ -1,717 +1,1032 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
use Pleroma.Web.ConnCase, async: false
alias Pleroma.Notification
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
test "does NOT render account/pleroma/relationship by default" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
response =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert Enum.all?(response, fn n ->
get_in(n, ["account", "pleroma", "relationship"]) == %{}
end)
end
test "list of notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
expected_response =
"hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{user.ap_id}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
assert [%{"status" => %{"content" => response}} | _rest] =
json_response_and_validate_schema(conn, 200)
assert response == expected_response
end
test "by default, does not contain pleroma:chat_mention" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post_chat_message(other_user, user, "hey")
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert [] == result
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:chat_mention")
|> json_response_and_validate_schema(200)
assert [_] = result
end
test "by default, does not contain pleroma:report" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, user} = user |> User.admin_api_update(%{is_moderator: true})
%{conn: conn} = oauth_access(["read:notifications"], user: user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, _report} =
CommonAPI.report(third_user, %{account_id: other_user.id, status_ids: [activity.id]})
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert [] == result
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [_] = result
end
test "Pleroma:report is hidden for non-privileged users" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, user} = user |> User.admin_api_update(%{is_moderator: true})
%{conn: conn} = oauth_access(["read:notifications"], user: user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, _report} =
CommonAPI.report(third_user, %{account_id: other_user.id, status_ids: [activity.id]})
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [_] = result
clear_config([:instance, :moderator_privileges], [])
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [] == result
end
test "excludes mentions from blockers when blockers_visible is false" do
clear_config([:activitypub, :blockers_visible], false)
%{user: user, conn: conn} = oauth_access(["read:notifications"])
blocker = insert(:user)
{:ok, _} = CommonAPI.block(user, blocker)
{:ok, activity} = CommonAPI.post(blocker, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
assert [] == json_response_and_validate_schema(conn, 200)
end
test "getting a single notification" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn = get(conn, "/api/v1/notifications/#{notification.id}")
expected_response =
"hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{user.ap_id}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
assert %{"status" => %{"content" => response}} = json_response_and_validate_schema(conn, 200)
assert response == expected_response
end
test "dismissing a single notification (deprecated endpoint)" do
%{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/api/v1/notifications/dismiss", %{"id" => to_string(notification.id)})
assert %{} = json_response_and_validate_schema(conn, 200)
end
test "dismissing a single notification" do
%{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> post("/api/v1/notifications/#{notification.id}/dismiss")
assert %{} = json_response_and_validate_schema(conn, 200)
end
test "clearing all notifications" do
%{user: user, conn: conn} = oauth_access(["write:notifications", "read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
ret_conn = post(conn, "/api/v1/notifications/clear")
assert %{} = json_response_and_validate_schema(ret_conn, 200)
ret_conn = get(conn, "/api/v1/notifications")
assert all = json_response_and_validate_schema(ret_conn, 200)
assert all == []
end
test "paginates notifications using min_id, since_id, max_id, and limit" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity3} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity4} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
notification3_id = get_notification_id_by_activity(activity3)
notification4_id = get_notification_id_by_activity(activity4)
conn = assign(conn, :user, user)
# min_id
result =
conn
|> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
# since_id
result =
conn
|> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
# max_id
result =
conn
|> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
end
describe "exclude_visibilities" do
test "filters notifications for mentions" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, public_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "public"})
{:ok, direct_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "unlisted"})
{:ok, private_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "private"})
query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "private"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == direct_activity.id
query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == private_activity.id
query = params_to_query(%{exclude_visibilities: ["public", "private", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == unlisted_activity.id
query = params_to_query(%{exclude_visibilities: ["unlisted", "private", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == public_activity.id
end
test "filters notifications for Like activities" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(other_user, user)
{:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(user, other_user)
{:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
{:ok, direct_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "private"})
{:ok, _} = CommonAPI.favorite(public_activity.id, user)
{:ok, _} = CommonAPI.favorite(direct_activity.id, user)
{:ok, _} = CommonAPI.favorite(unlisted_activity.id, user)
{:ok, _} = CommonAPI.favorite(private_activity.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=direct")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
refute direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
refute unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
assert direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=private")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
refute private_activity.id in activity_ids
assert direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=public")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
refute public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
assert direct_activity.id in activity_ids
end
test "filters notifications for Announce activities" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
{:ok, _} = CommonAPI.repeat(public_activity.id, user)
{:ok, _} = CommonAPI.repeat(unlisted_activity.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
refute unlisted_activity.id in activity_ids
end
test "doesn't return less than the requested amount of records when the user's reply is liked" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, mention} =
CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "public"})
{:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, reply} =
CommonAPI.post(other_user, %{
status: ".",
visibility: "public",
in_reply_to_status_id: activity.id
})
{:ok, _favorite} = CommonAPI.favorite(reply.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=direct&limit=2")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert [reply.id, mention.id] == activity_ids
end
end
test "filters notifications using exclude_types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
mention_notification_id = get_notification_id_by_activity(mention_activity)
favorite_notification_id = get_notification_id_by_activity(favorite_activity)
reblog_notification_id = get_notification_id_by_activity(reblog_activity)
follow_notification_id = get_notification_id_by_activity(follow_activity)
query = params_to_query(%{exclude_types: ["mention", "favourite", "reblog"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["favourite", "reblog", "follow"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^mention_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["reblog", "follow", "mention"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^favorite_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["follow", "mention", "favourite"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
end
test "filters notifications using types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
mention_notification_id = get_notification_id_by_activity(mention_activity)
favorite_notification_id = get_notification_id_by_activity(favorite_activity)
reblog_notification_id = get_notification_id_by_activity(reblog_activity)
follow_notification_id = get_notification_id_by_activity(follow_activity)
conn_res = get(conn, "/api/v1/notifications?types[]=follow")
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=mention")
assert [%{"id" => ^mention_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=favourite")
assert [%{"id" => ^favorite_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=reblog")
assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
result = conn |> get("/api/v1/notifications") |> json_response_and_validate_schema(200)
assert length(result) == 4
query = params_to_query(%{types: ["follow", "mention", "favourite", "reblog"]})
result =
conn
|> get("/api/v1/notifications?" <> query)
|> json_response_and_validate_schema(200)
assert length(result) == 4
end
test "filtering falls back to include_types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, _activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, _activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
follow_notification_id = get_notification_id_by_activity(follow_activity)
conn_res = get(conn, "/api/v1/notifications?include_types[]=follow")
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
end
test "destroy multiple" do
%{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
other_user = insert(:user)
{:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity3} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
{:ok, activity4} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
notification3_id = get_notification_id_by_activity(activity3)
notification4_id = get_notification_id_by_activity(activity4)
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result
conn2 =
conn
|> assign(:user, other_user)
|> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:notifications"]))
result =
conn2
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
query = params_to_query(%{ids: [notification1_id, notification2_id]})
conn_destroy = delete(conn, "/api/v1/notifications/destroy_multiple?" <> query)
assert json_response_and_validate_schema(conn_destroy, 200) == %{}
result =
conn2
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
end
+ describe "GET /api/v2/notifications" do
+ test "groups favourite notifications for the same status" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(status.id, other_user2)
+
+ notifications = Repo.all(Notification)
+ notification_ids = Enum.map(notifications, &to_string(&1.id))
+ assert [persisted_group_key] = notifications |> Enum.map(& &1.group_key) |> Enum.uniq()
+ assert is_binary(persisted_group_key)
+
+ result =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => account_id1}, %{"id" => account_id2}] = result["accounts"]
+ assert account_id1 in [other_user1.id, other_user2.id]
+ assert account_id2 in [other_user1.id, other_user2.id]
+ assert [%{"id" => status_id}] = result["statuses"]
+ assert status_id == status.id
+
+ assert [group] = result["notification_groups"]
+ assert group["type"] == "favourite"
+ assert group["notifications_count"] == 2
+ assert group["status_id"] == status.id
+ assert group["most_recent_notification_id"] in notification_ids
+ assert group["page_min_id"] in notification_ids
+ assert group["page_max_id"] in notification_ids
+ assert group["latest_page_notification_at"]
+ assert Enum.sort(group["sample_account_ids"]) == Enum.sort([other_user1.id, other_user2.id])
+ group_key = group["group_key"]
+ assert group_key == persisted_group_key
+
+ assert [%{"group_key" => ^group_key}, %{"group_key" => ^group_key}] =
+ conn
+ |> get("/api/v1/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert %{"notification_groups" => [shown_group]} =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+
+ assert shown_group["group_key"] == group_key
+ assert shown_group["notifications_count"] == 2
+ refute Map.has_key?(shown_group, "page_min_id")
+ refute Map.has_key?(shown_group, "page_max_id")
+ refute Map.has_key?(shown_group, "latest_page_notification_at")
+ end
+
+ test "round-trips ungrouped group keys when grouped_types excludes a type" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user)
+
+ %{"notification_groups" => [%{"group_key" => "ungrouped-" <> _ = group_key}]} =
+ conn
+ |> get("/api/v2/notifications?grouped_types[]=reblog")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}]
+ } =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "keeps legacy notifications without persisted group keys ungrouped" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, favorite1} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, favorite2} = CommonAPI.favorite(status.id, other_user2)
+
+ notification_ids =
+ [favorite1, favorite2]
+ |> Enum.map(&get_notification_id_by_activity/1)
+
+ Repo.update_all(Notification, set: [group_key: nil])
+
+ expected_group_keys = Enum.map(notification_ids, &"ungrouped-#{&1}")
+
+ assert [%{"group_key" => group_key1}, %{"group_key" => group_key2}] =
+ conn
+ |> get("/api/v1/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.sort([group_key1, group_key2]) == Enum.sort(expected_group_keys)
+
+ %{"notification_groups" => groups} =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.sort(Enum.map(groups, & &1["group_key"])) == Enum.sort(expected_group_keys)
+ assert Enum.all?(groups, &(&1["notifications_count"] == 1))
+
+ group_key = List.first(expected_group_keys)
+
+ assert %{
+ "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}]
+ } =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "paginates notification groups instead of raw notifications" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, older_status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, _} = CommonAPI.favorite(older_status.id, other_user3)
+
+ {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"})
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2)
+ older_status_id = older_status.id
+ newer_status_id = newer_status.id
+
+ %{"notification_groups" => groups} =
+ conn
+ |> get("/api/v2/notifications?limit=2")
+ |> json_response_and_validate_schema(200)
+
+ assert [
+ %{"status_id" => ^newer_status_id, "notifications_count" => 2},
+ %{"status_id" => ^older_status_id, "notifications_count" => 1}
+ ] = groups
+ end
+
+ test "uses group-level cursors when paginating notification groups" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, older_status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, _} = CommonAPI.favorite(older_status.id, other_user3)
+
+ {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"})
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2)
+ older_status_id = older_status.id
+ newer_status_id = newer_status.id
+
+ assert %{
+ "notification_groups" => [
+ %{
+ "status_id" => ^newer_status_id,
+ "notifications_count" => 2,
+ "most_recent_notification_id" => cursor
+ }
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "notification_groups" => [
+ %{"status_id" => ^older_status_id, "notifications_count" => 1}
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1&max_id=#{cursor}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "returns total notification count for a partially represented group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, grouped_status} = CommonAPI.post(user, %{status: "grouped"})
+ {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user1)
+
+ {:ok, other_status} = CommonAPI.post(user, %{status: "other"})
+ {:ok, _} = CommonAPI.favorite(other_status.id, other_user3)
+ {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user2)
+ grouped_status_id = grouped_status.id
+
+ assert %{
+ "notification_groups" => [
+ %{"status_id" => ^grouped_status_id, "notifications_count" => 2}
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "counts unread notification groups" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ mentioner = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(status.id, other_user2)
+ {:ok, _} = CommonAPI.post(mentioner, %{status: "hi @#{user.nickname}"})
+
+ assert %{"count" => 2} =
+ conn
+ |> get("/api/v2/notifications/unread_count")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "counts unread notification groups newer than the notification marker" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+ mentioner = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, favorite_activity} = CommonAPI.favorite(status.id, other_user)
+ marker_id = get_notification_id_by_activity(favorite_activity)
+
+ {:ok, _} = CommonAPI.post(mentioner, %{status: "newer @#{user.nickname}"})
+
+ {:ok, _} =
+ Pleroma.Marker.upsert(user, %{"notifications" => %{"last_read_id" => marker_id}})
+
+ assert %{"count" => 1} =
+ conn
+ |> get("/api/v2/notifications/unread_count")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "does not preserve stale cursor params in grouped notification link headers" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, activity1} = CommonAPI.post(other_user, %{status: "one @#{user.nickname}"})
+ {:ok, _activity2} = CommonAPI.post(other_user, %{status: "two @#{user.nickname}"})
+ {:ok, activity3} = CommonAPI.post(other_user, %{status: "three @#{user.nickname}"})
+
+ notification1_id = get_notification_id_by_activity(activity1)
+ notification3_id = get_notification_id_by_activity(activity3)
+
+ conn = get(conn, "/api/v2/notifications?since_id=#{notification1_id}&limit=1")
+
+ assert [link_header] = get_resp_header(conn, "link")
+ assert link_header =~ ~r/max_id=#{notification3_id}/
+ refute link_header =~ "since_id="
+ end
+
+ test "lists accounts from a notification group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+
+ {other_users, %{"group_key" => group_key, "sample_account_ids" => sample_account_ids}} =
+ create_favourite_notification_group(user, conn)
+
+ assert length(sample_account_ids) == 8
+
+ account_ids =
+ conn
+ |> get("/api/v2/notifications/#{group_key}/accounts")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["id"])
+
+ assert Enum.sort(account_ids) == Enum.sort(Enum.map(other_users, & &1.id))
+ end
+
+ test "dismisses a notification group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
+
+ {_other_users, %{"group_key" => group_key}} =
+ create_favourite_notification_group(user, conn)
+
+ assert %{} =
+ conn
+ |> post("/api/v2/notifications/#{group_key}/dismiss")
+ |> json_response_and_validate_schema(200)
+
+ assert [] = Notification.for_user(user)
+ end
+ end
+
test "doesn't see notifications after muting user with notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2)
conn = get(conn, "/api/v1/notifications")
assert json_response_and_validate_schema(conn, 200) == []
end
test "see notifications after muting user without notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2, %{notifications: false})
conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
test "see notifications after muting user with notifications and with_muted parameter" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2)
conn = get(conn, "/api/v1/notifications?with_muted=true")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
test "see move notifications" do
old_user = insert(:user)
new_user = insert(:user, also_known_as: [old_user.ap_id])
%{user: follower, conn: conn} = oauth_access(["read:notifications"])
User.follow(follower, old_user)
Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
Pleroma.Tests.ObanHelpers.perform_all()
conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
describe "link headers" do
test "preserves parameters in link headers" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity1} =
CommonAPI.post(other_user, %{
status: "hi @#{user.nickname}",
visibility: "public"
})
{:ok, activity2} =
CommonAPI.post(other_user, %{
status: "hi @#{user.nickname}",
visibility: "public"
})
notification1 = Repo.get_by(Notification, activity_id: activity1.id)
notification2 = Repo.get_by(Notification, activity_id: activity2.id)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?limit=5")
assert [link_header] = get_resp_header(conn, "link")
assert link_header =~ ~r/limit=5/
assert link_header =~ ~r/min_id=#{notification2.id}/
assert link_header =~ ~r/max_id=#{notification1.id}/
end
end
describe "from specified user" do
test "account_id" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
%{id: account_id} = other_user1 = insert(:user)
other_user2 = insert(:user)
{:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi @#{user.nickname}"})
{:ok, _activity} = CommonAPI.post(other_user2, %{status: "bye @#{user.nickname}"})
assert [%{"account" => %{"id" => ^account_id}}] =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?account_id=#{account_id}")
|> json_response_and_validate_schema(200)
assert %{"error" => "Account is not found"} =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?account_id=cofe")
|> json_response_and_validate_schema(404)
end
end
defp get_notification_id_by_activity(%{id: id}) do
Notification
|> Repo.get_by(activity_id: id)
|> Map.get(:id)
|> to_string()
end
+ defp create_favourite_notification_group(user, conn) do
+ other_users = insert_list(9, :user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+
+ Enum.each(other_users, fn other_user ->
+ {:ok, _} = CommonAPI.favorite(status.id, other_user)
+ end)
+
+ %{
+ "notification_groups" => [
+ %{
+ "notifications_count" => 9
+ } = group
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ {other_users, group}
+ end
+
defp params_to_query(%{} = params) do
Enum.map_join(params, "&", fn
{k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}")
{k, v} -> k <> "=" <> v
end)
end
end
diff --git a/test/pleroma/web/mastodon_api/views/instance_view_test.exs b/test/pleroma/web/mastodon_api/views/instance_view_test.exs
new file mode 100644
index 000000000..66485be0e
--- /dev/null
+++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs
@@ -0,0 +1,675 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.MastodonAPI.InstanceViewTest do
+ use Pleroma.DataCase, async: true
+
+ import Pleroma.Factory
+
+ alias Pleroma.Language.LanguageDetectorMock
+ alias Pleroma.StaticStubbedConfigMock
+ alias Pleroma.Web.MastodonAPI.InstanceView
+
+ import Mox
+
+ @common_information_keys [
+ :languages,
+ :rules,
+ :title,
+ :version
+ ]
+
+ # Used for filtering out values from configuration.json render in configuration2.json render
+ @configuration2_filter [
+ {:accounts,
+ [
+ :max_pinned_statuses,
+ :max_profile_fields,
+ :profile_field_name_limit,
+ :profile_field_value_limit
+ ]},
+ {:statuses, [:characters_reserved_per_url]},
+ {:urls, [:streaming, :status]},
+ {:vapid, [:public_key]},
+ {:translation, [:enabled]},
+ {:timelines_access, [:live_feeds, :hashtag_feeds, :trending_link_feeds]}
+ ]
+
+ @pleroma_configuration_filter [{:metadata, [:features, :federation]}]
+
+ @pleroma_configuration2_filter [
+ {:metadata,
+ [
+ :avatar_upload_limit,
+ :background_upload_limit,
+ :banner_upload_limit,
+ :background_image,
+ :chat_limit,
+ :description_limit,
+ :shout_limit
+ ]}
+ ]
+
+ @show_keys @common_information_keys ++
+ [
+ :uri,
+ :description,
+ :short_description,
+ :email,
+ :urls,
+ :stats,
+ :thumbnail,
+ :registrations,
+ :approval_required,
+ :contact_account,
+ :configuration,
+ # Extra (not present in Mastodon)
+ :max_toot_chars,
+ :max_media_attachments,
+ :poll_limits,
+ :upload_limit,
+ :avatar_upload_limit,
+ :background_upload_limit,
+ :banner_upload_limit,
+ :background_image,
+ :shout_limit,
+ :description_limit,
+ :chat_limit,
+ :pleroma
+ ]
+
+ # Checked in other check_* functions
+ @show_filter @common_information_keys ++ [:contact_account, :configuration, :pleroma]
+
+ @show2_keys @common_information_keys ++
+ [
+ :domain,
+ :source_url,
+ :description,
+ :usage,
+ :thumbnail,
+ :configuration,
+ :registrations,
+ :contact,
+ # Extra (not present in Mastodon)
+ :pleroma
+ ]
+
+ @show2_filter @common_information_keys ++
+ [:thumbnail, :configuration, :registrations, :contact, :pleroma]
+
+ @features [
+ "pleroma_api",
+ "mastodon_api",
+ "mastodon_api_grouped_notifications",
+ "mastodon_api_streaming",
+ "polls",
+ "v2_suggestions",
+ "pleroma_explicit_addressing",
+ "shareable_emoji_packs",
+ "multifetch",
+ "pleroma:api/v1/notifications:include_types_filter",
+ "editing",
+ "quote_posting",
+ "pleroma_emoji_reactions",
+ "pleroma_custom_emoji_reactions",
+ "pleroma_chat_messages",
+ "pleroma:pin_chats",
+ "pleroma:get:main/ostatus",
+ "pleroma:group_actors",
+ "pleroma:bookmark_folders",
+ "pleroma:block_expiration"
+ ]
+
+ @configurable_features [
+ "blockers_visible",
+ "media_proxy",
+ "gopher",
+ "chat",
+ "shout",
+ "relay",
+ "safe_dm_mentions",
+ "exposable_reactions",
+ "profile_directory",
+ "pleroma:language_detection"
+ ]
+ @configurable_features_flags [
+ [:activitypub, :blockers_visible],
+ [:media_proxy, :enabled],
+ [:gopher, :enabled],
+ [:shout, :enabled],
+ [:instance, :allow_relay],
+ [:instance, :safe_dm_mentions],
+ [:instance, :show_reactions],
+ [:instance, :profile_directory]
+ ]
+
+ # Filters out values from one layer deep nested Maps.
+ # Filtering types:
+ # - :inclusive -> filters out everything that is not in the filters
+ # - :discriminative -> filters out what is in the filters
+ defp filter_render(input, filter, type, acc)
+
+ defp filter_render(_input_rest, [], type, acc) when type == :inclusive, do: acc
+
+ defp filter_render(input_rest, [], type, acc) when type == :discriminative,
+ do: Map.merge(input_rest, acc)
+
+ defp filter_render(input, filter, type, acc) do
+ {{filter_key, filters}, filter_rest} = List.pop_at(filter, 0)
+ {config_key_values, input_rest} = Map.pop!(input, filter_key)
+
+ filtered =
+ case type do
+ :inclusive ->
+ Map.reject(config_key_values, fn {key, _} -> key not in filters end)
+
+ :discriminative ->
+ Map.reject(config_key_values, fn {key, _} -> key in filters end)
+ end
+
+ filter_render(input_rest, filter_rest, type, Map.put(acc, filter_key, filtered))
+ end
+
+ defp check_common_information(info) do
+ filtered_info = Map.reject(info, fn {key, _value} -> key not in @common_information_keys end)
+
+ expected = %{
+ languages: ["en"],
+ rules: InstanceView.render("rules.json", %{}),
+ title: "Pleroma",
+ version:
+ InstanceView.mastodon_api_level() <>
+ " (compatible; #{Pleroma.Application.named_version()})"
+ }
+
+ Map.equal?(expected, filtered_info)
+ end
+
+ defp check_thumbnail2(info) do
+ thumbnail = info[:thumbnail]
+
+ expected = %{
+ url:
+ URI.merge(Pleroma.Web.Endpoint.url(), "http://localhost:4001/instance/thumbnail.jpeg")
+ |> to_string
+ }
+
+ expected == thumbnail
+ end
+
+ defp check_contact(info, user) do
+ contact = info[:contact]
+
+ expected = %{
+ email: "admin@example.com",
+ account: Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil})
+ }
+
+ Map.equal?(expected, contact)
+ end
+
+ defp check_contact_account(info, user) do
+ contact = info[:contact_account]
+ view = Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil})
+
+ contact == view
+ end
+
+ defp check_configuration(info) do
+ configuration = info[:configuration]
+
+ expected = %{
+ accounts: %{
+ max_featured_tags: 0
+ },
+ statuses: %{
+ max_characters: 5_000,
+ max_media_attachments: 1_000
+ },
+ media_attachments: %{
+ image_size_limit: 16_000_000,
+ video_size_limit: 16_000_000,
+ supported_mime_types: ["application/octet-stream"]
+ },
+ polls: %{
+ max_options: 20,
+ max_characters_per_option: 200,
+ min_expiration: 0,
+ max_expiration: 365 * 24 * 60 * 60
+ }
+ }
+
+ Map.equal?(expected, configuration)
+ end
+
+ defp check_configuration2(info) do
+ configuration =
+ info[:configuration]
+ |> filter_render(@configuration2_filter, :inclusive, %{})
+
+ expected = %{
+ accounts: %{
+ max_pinned_statuses: 1,
+ max_profile_fields: 10,
+ profile_field_name_limit: 512,
+ profile_field_value_limit: 2048
+ },
+ statuses: %{
+ characters_reserved_per_url: 0
+ },
+ urls: %{
+ streaming: Pleroma.Web.Endpoint.websocket_url(),
+ status: nil
+ },
+ vapid: %{
+ public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
+ },
+ translation: %{enabled: true},
+ timelines_access: %{
+ live_feeds: %{local: "public", remote: "public"},
+ hashtag_feeds: %{local: "public", remote: "public"},
+ # not implemented in Pleroma
+ trending_link_feeds: %{
+ local: "disabled",
+ remote: "disabled"
+ }
+ }
+ }
+
+ Map.equal?(expected, configuration)
+ end
+
+ defp check_registrations2(info) do
+ registrations = info[:registrations]
+
+ expected = %{
+ enabled: true,
+ approval_required: false,
+ message: nil,
+ url: nil
+ }
+
+ Map.equal?(expected, registrations)
+ end
+
+ defp check_pleroma_configuration(info) do
+ configuration =
+ info[:pleroma]
+ |> filter_render(@pleroma_configuration_filter, :discriminative, %{})
+
+ expected = %{
+ metadata: %{
+ account_activation_required: false,
+ fields_limits: %{
+ max_fields: 10,
+ max_remote_fields: 20,
+ name_length: 512,
+ value_length: 2048
+ },
+ post_formats: [
+ "text/plain",
+ "text/html",
+ "text/markdown",
+ "text/bbcode",
+ "text/x.misskeymarkdown"
+ ],
+ birthday_required: false,
+ birthday_min_age: 0,
+ translation: %{
+ source_languages: ["en", "pl"],
+ target_languages: ["en", "pl"]
+ },
+ base_urls: %{},
+ markup: %{
+ allow_inline_images: true,
+ allow_headings: false,
+ allow_tables: false
+ }
+ },
+ stats: %{mau: Pleroma.User.active_user_count()},
+ vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
+ }
+
+ Map.equal?(expected, configuration)
+ end
+
+ defp check_pleroma_configuration2(info) do
+ configuration =
+ info[:pleroma]
+ |> filter_render(@pleroma_configuration2_filter, :inclusive, %{})
+
+ expected = %{
+ metadata: %{
+ avatar_upload_limit: 2_000_000,
+ background_upload_limit: 4_000_000,
+ banner_upload_limit: 4_000_000,
+ background_image: Pleroma.Web.Endpoint.url() <> "/images/city.jpg",
+ chat_limit: 5_000,
+ description_limit: 5_000,
+ shout_limit: 5_000
+ }
+ }
+
+ Map.equal?(expected, configuration)
+ end
+
+ defp check_show(info) do
+ filter = @show_filter
+ filtered_info = Map.reject(info, fn {key, _value} -> key in filter end)
+
+ expected = %{
+ uri: Pleroma.Web.WebFinger.host(),
+ description: "Pleroma: An efficient and flexible fediverse server",
+ short_description: "",
+ email: "admin@example.com",
+ urls: %{
+ streaming_api: Pleroma.Web.Endpoint.websocket_url()
+ },
+ stats: Pleroma.Stats.get_stats(),
+ thumbnail: "http://localhost:4001/instance/thumbnail.jpeg",
+ registrations: true,
+ approval_required: false,
+ max_toot_chars: 5_000,
+ max_media_attachments: 1_000,
+ poll_limits: %{
+ max_options: 20,
+ max_option_chars: 200,
+ min_expiration: 0,
+ max_expiration: 365 * 24 * 60 * 60
+ },
+ upload_limit: 16_000_000,
+ avatar_upload_limit: 2_000_000,
+ background_upload_limit: 4_000_000,
+ banner_upload_limit: 4_000_000,
+ background_image: Pleroma.Web.Endpoint.url() <> "/images/city.jpg",
+ shout_limit: 5_000,
+ description_limit: 5_000,
+ chat_limit: 5_000
+ }
+
+ Map.equal?(expected, filtered_info)
+ end
+
+ defp check_show2(info) do
+ filter = @show2_filter
+ filtered_info = Map.reject(info, fn {key, _value} -> key in filter end)
+
+ expected = %{
+ domain: Pleroma.Web.WebFinger.host(),
+ source_url: Pleroma.Application.repository(),
+ description: "",
+ usage: %{users: %{active_month: Pleroma.User.active_user_count()}}
+ }
+
+ Map.equal?(expected, filtered_info)
+ end
+
+ # When this fails, a new feature flag was probably added. Add it to the macros above.
+ describe "uncofigurable features" do
+ setup do: Enum.each(@configurable_features_flags, &clear_config(&1, false))
+
+ test "returns always enabled features" do
+ features = @features
+
+ assert Enum.sort(InstanceView.features()) == Enum.sort(features)
+ end
+ end
+
+ describe "all features" do
+ setup do
+ Enum.each(@configurable_features_flags, &clear_config(&1, true))
+
+ # Stub the StaticStubbedConfigMock to return our mock for the provider
+ StaticStubbedConfigMock
+ |> stub(:get, fn
+ [Pleroma.Language.LanguageDetector, :provider] -> LanguageDetectorMock
+ _other -> nil
+ end)
+
+ # Stub the LanguageDetectorMock with default implementations
+ LanguageDetectorMock
+ |> stub(:missing_dependencies, fn -> [] end)
+ |> stub(:configured?, fn -> true end)
+
+ :ok
+ end
+
+ test "returns all features including configurable ones" do
+ features = @features ++ @configurable_features
+
+ assert Enum.sort(InstanceView.features()) == Enum.sort(features)
+ end
+ end
+
+ describe "federation/0" do
+ setup do: clear_config([:mrf, :transparency], true)
+
+ test "quarantined instances" do
+ clear_config([:instance, :quarantined_instances], [{"quarantine.example.com", "crimes"}])
+
+ output = InstanceView.federation()
+ assert %{quarantined_instances: ["quarantine.example.com"]} = output
+
+ assert %{
+ quarantined_instances_info: %{
+ "quarantined_instances" => %{"quarantine.example.com" => %{"reason" => "crimes"}}
+ }
+ } = output
+ end
+
+ test "rejected instances" do
+ clear_config([:instance, :rejected_instances], [
+ {"rejected.example.com", "not enough #cofe posting"}
+ ])
+
+ output = InstanceView.federation()
+
+ assert %{
+ rejected_instances: %{
+ "rejected.example.com" => %{"reason" => "not enough #cofe posting"}
+ }
+ } = output
+ end
+
+ test "federating" do
+ clear_config([:instance, :federating], true)
+
+ output = InstanceView.federation()
+ assert %{enabled: true} = output
+ end
+
+ test "transparency" do
+ clear_config([:instance, :quarantined_instances], [{"quarantine.example.com", "crimes"}])
+ clear_config([:instance, :federating], true)
+ clear_config([:mrf, :transparency], false)
+
+ clear_config([:instance, :rejected_instances], [
+ {"rejected.example.com", "not enough #cofe posting"}
+ ])
+
+ output = InstanceView.federation()
+ assert %{enabled: true} == output
+ end
+
+ test "MRFs" do
+ clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
+ clear_config([:mrf_simple, :reject], [{"rejected.example.com", "not enough #cofe posting"}])
+
+ clear_config([:mrf, :transparency_exclusions], [
+ {"media1.example.com", "the Fediverse doesn't need to know"}
+ ])
+
+ clear_config([:mrf_simple, :media_removal], [
+ {"media1.example.com", "NSFW"},
+ {"media2.example.com", "usual suspects"}
+ ])
+
+ output = InstanceView.federation()
+
+ expected = %{
+ mrf_simple: %{
+ reject: ["rejected.example.com"],
+ media_removal: ["media2.example.com"]
+ },
+ mrf_hashtag: %{
+ sensitive: ["nsfw"]
+ },
+ exclusions: true,
+ mrf_policies: ["SimplePolicy", "HashtagPolicy"],
+ mrf_simple_info: %{
+ reject: %{
+ "rejected.example.com" => %{"reason" => "not enough #cofe posting"}
+ },
+ media_removal: %{"media2.example.com" => %{"reason" => "usual suspects"}}
+ }
+ }
+
+ assert expected.mrf_simple.reject == output.mrf_simple.reject
+ assert expected.mrf_simple.media_removal == output.mrf_simple.media_removal
+ assert expected.mrf_simple_info == output.mrf_simple_info
+ assert expected.exclusions == output.exclusions
+ end
+ end
+
+ describe "render show.json/show2.json" do
+ setup do
+ Pleroma.Rule.create(%{text: "rule 1"})
+ Pleroma.Rule.create(%{text: "rule 2"})
+
+ user = insert(:user)
+
+ # contact in show2.json
+ clear_config([:instance, :contact_username], user.nickname)
+
+ # Pleroma.Language.Translation.configured?() -> true
+ clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
+
+ # Deprecated, but still used in view
+ clear_config([:instance, :chat_limit], 5_000)
+
+ %{user: user}
+ end
+
+ test "renders show.json properly", %{user: user} do
+ output = InstanceView.render("show.json", %{})
+ expected_keys = Enum.sort(@show_keys)
+
+ assert check_common_information(output)
+ assert check_show(output)
+ assert check_contact_account(output, user)
+ assert check_configuration(output)
+ assert check_pleroma_configuration(output)
+ assert expected_keys == Enum.sort(Map.keys(output))
+ end
+
+ test "renders show2.json properly", %{user: user} do
+ output = InstanceView.render("show2.json", %{})
+ expected_keys = Enum.sort(@show2_keys)
+
+ assert check_common_information(output)
+ assert check_show2(output)
+ assert check_thumbnail2(output)
+ assert check_configuration2(output)
+ assert check_registrations2(output)
+ assert check_contact(output, user)
+ assert check_pleroma_configuration2(output)
+ assert expected_keys == Enum.sort(Map.keys(output))
+ end
+ end
+
+ describe "render rule.json" do
+ setup do
+ rule = Pleroma.Rule.create(%{text: "hambaga"})
+
+ %{rule: rule}
+ end
+
+ test "renders properly", %{rule: rule} do
+ output = InstanceView.render("rule.json", %{rule: rule})
+
+ assert %{text: "hambaga"} = output
+ end
+ end
+
+ describe "render rules.json" do
+ setup do
+ Pleroma.Rule.create(%{text: "rule 1"})
+ Pleroma.Rule.create(%{text: "rule 2"})
+ Pleroma.Rule.create(%{text: "rule 3"})
+ Pleroma.Rule.create(%{text: "rule 4"})
+
+ :ok
+ end
+
+ test "renders properly" do
+ output = InstanceView.render("rules.json", %{})
+ keys = Enum.map(output, fn %{text: text} -> text end)
+
+ assert length(keys) == 4
+ assert Enum.all?(output, fn %{text: "rule " <> num} -> String.to_integer(num) end)
+ end
+ end
+
+ describe "render domain_blocks.json" do
+ setup do
+ clear_config([:mrf, :transparency], true)
+ clear_config([:mrf_simple, :media_removal], [{"media1.example.com", "NSFW"}])
+
+ clear_config([:mrf, :transparency_exclusions], [
+ {"removed1.example.com", "the Fediverse doesn't need to know"}
+ ])
+
+ clear_config([:mrf_simple, :reject], [
+ {"rejected.example.com", "not enough #cofe posting"},
+ {"rejected-without-reason.example.com", ""}
+ ])
+
+ clear_config([:mrf_simple, :federated_timeline_removal], [
+ {"removed1.example.com", "spam"},
+ {"removed2.example.com", "more spam"}
+ ])
+
+ :ok
+ end
+
+ test "renders properly" do
+ output = InstanceView.render("domain_blocks.json", %{})
+
+ # Media removal is not in @block_severities in the view.
+ # NOTE: Order of these two matters!
+ expected = [
+ %{
+ domain: "removed2.example.com",
+ comment: "more spam",
+ severity: "silence",
+ digest: "5f6649cfc780fac5172dbbcc8a552953f6621a14368d82728b6e8faf51295dc0"
+ },
+ %{
+ domain: "rejected.example.com",
+ comment: "not enough #cofe posting",
+ severity: "suspend",
+ digest: "c15082d347fb7eccd251e199a98dcdce94a8dfc1d33d6ece80889eed374b6f92"
+ },
+ %{
+ domain: "rejected-without-reason.example.com",
+ severity: "suspend",
+ digest: "48297155018c626c8867ca0bba4a2621eccdf51c6f7ac91b7099ed8b2ee9863f"
+ }
+ ]
+
+ assert expected == output
+ end
+ end
+
+ describe "render translation_languages.json" do
+ setup do: clear_config([Pleroma.Language.Translation, :provider], TranslationMock)
+
+ test "returns language matrix" do
+ output = InstanceView.render("translation_languages.json", %{})
+
+ assert %{"en" => ["pl"], "pl" => ["en"]} == output
+ end
+ end
+end
diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs
index c8287bb79..2d0801589 100644
--- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs
@@ -1,374 +1,377 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
use Pleroma.DataCase, async: false
alias Pleroma.Activity
alias Pleroma.Chat
alias Pleroma.Chat.MessageReference
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.AdminAPI.ReportView
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
import Pleroma.Factory
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
defp test_notifications_rendering(notifications, user, expected_result) do
result = NotificationView.render("index.json", %{notifications: notifications, for: user})
assert expected_result == result
result =
NotificationView.render("index.json", %{
notifications: notifications,
for: user,
relationships: nil
})
assert expected_result == result
end
test "ChatMessage notification" do
user = insert(:user)
recipient = insert(:user)
{:ok, activity} = CommonAPI.post_chat_message(user, recipient, "what's up my dude")
{:ok, [notification]} = Notification.create_notifications(activity)
object = Object.normalize(activity, fetch: false)
chat = Chat.get(recipient.id, user.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:chat_mention",
account: AccountView.render("show.json", %{user: user, for: recipient}),
chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], recipient, [expected])
end
test "Mention notification" do
user = insert(:user)
mentioned_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey @#{mentioned_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
user = User.get_cached_by_id(user.id)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "mention",
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)
}
test_notifications_rendering([notification], mentioned_user, [expected])
end
test "Favourite notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "favourite",
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)
}
test_notifications_rendering([notification], user, [expected])
end
test "Reblog notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(reblog_activity)
reblog_activity = Activity.get_by_id(create_activity.id)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "reblog",
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)
}
test_notifications_rendering([notification], user, [expected])
end
test "Follow notification" do
follower = insert(:user)
followed = insert(:user)
{:ok, followed, follower, _activity} = CommonAPI.follow(followed, follower)
notification = Notification |> Repo.one() |> Repo.preload(:activity)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "follow",
account: AccountView.render("show.json", %{user: follower, for: followed}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], followed, [expected])
User.perform(:delete, follower)
refute Repo.one(Notification)
end
test "Move notification" do
old_user = insert(:user)
new_user = insert(:user, also_known_as: [old_user.ap_id])
follower = insert(:user)
User.follow(follower, old_user)
Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
Pleroma.Tests.ObanHelpers.perform_all()
old_user = refresh_record(old_user)
new_user = refresh_record(new_user)
[notification] = Notification.for_user(follower)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "move",
account: AccountView.render("show.json", %{user: old_user, for: follower}),
target: AccountView.render("show.json", %{user: new_user, for: follower}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], follower, [expected])
end
test "EmojiReact notification" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
{:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
activity = Repo.get(Activity, activity.id)
[notification] = Notification.for_user(user)
assert notification
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:emoji_reaction",
emoji: "☕",
account: AccountView.render("show.json", %{user: other_user, for: user}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at),
emoji_url: nil
}
test_notifications_rendering([notification], user, [expected])
end
test "EmojiReact custom emoji notification" do
user = insert(:user)
other_user = insert(:user)
note =
insert(:note,
user: user,
data: %{
"reactions" => [
["👍", [user.ap_id], nil],
["dinosaur", [user.ap_id], "http://localhost:4001/emoji/dino%20walking.gif"]
]
}
)
activity = insert(:note_activity, note: note, user: user)
{:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "dinosaur")
activity = Repo.get(Activity, activity.id)
[notification] = Notification.for_user(user)
assert notification
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:emoji_reaction",
emoji: ":dinosaur:",
account: AccountView.render("show.json", %{user: other_user, for: user}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at),
emoji_url: "http://localhost:4001/emoji/dino%20walking.gif"
}
test_notifications_rendering([notification], user, [expected])
end
test "Poll notification" do
user = insert(:user)
activity = insert(:question_activity, user: user)
{:ok, [notification]} = Notification.create_poll_notifications(activity)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "poll",
account:
AccountView.render("show.json", %{
user: user,
for: user
}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Report notification" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
reporting_user = insert(:user)
reported_user = insert(:user)
moderator_user = insert(:user, is_moderator: true)
{:ok, activity} = CommonAPI.report(reporting_user, %{account_id: reported_user.id})
{:ok, [notification]} = Notification.create_notifications(activity)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:report",
account: AccountView.render("show.json", %{user: reporting_user, for: moderator_user}),
created_at: Utils.to_masto_date(notification.inserted_at),
report: ReportView.render("show.json", Report.extract_report_info(activity))
}
test_notifications_rendering([notification], moderator_user, [expected])
end
test "Edit notification" do
user = insert(:user)
repeat_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "mew"})
{:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
{:ok, update} = CommonAPI.update(activity, user, %{status: "mew mew"})
user = Pleroma.User.get_by_ap_id(user.ap_id)
activity = Pleroma.Activity.normalize(activity)
update = Pleroma.Activity.normalize(update)
{:ok, [notification]} = Notification.create_notifications(update)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "update",
account: AccountView.render("show.json", %{user: user, for: repeat_user}),
created_at: Utils.to_masto_date(notification.inserted_at),
status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
}
test_notifications_rendering([notification], repeat_user, [expected])
end
test "muted notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, _} = Pleroma.UserRelationship.create_mute(user, another_user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: true, is_muted: true},
type: "favourite",
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)
}
test_notifications_rendering([notification], user, [expected])
end
test "Subscribed status notification" do
user = insert(:user)
subscriber = insert(:user)
User.subscribe(subscriber, user)
{:ok, activity} = CommonAPI.post(user, %{status: "hi"})
{:ok, [notification]} = Notification.create_notifications(activity)
user = User.get_cached_by_id(user.id)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "status",
account:
AccountView.render("show.json", %{
user: user,
for: subscriber
}),
status: StatusView.render("show.json", %{activity: activity, for: subscriber}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], subscriber, [expected])
end
end
diff --git a/test/pleroma/web/node_info_test.exs b/test/pleroma/web/node_info_test.exs
index afe4ebb36..e2bf3400a 100644
--- a/test/pleroma/web/node_info_test.exs
+++ b/test/pleroma/web/node_info_test.exs
@@ -1,302 +1,303 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.NodeInfoTest do
use Pleroma.Web.ConnCase, async: false
import Pleroma.Factory
setup do: clear_config([:mrf_simple])
setup do: clear_config(:instance)
test "GET /.well-known/nodeinfo", %{conn: conn} do
links =
conn
|> get("/.well-known/nodeinfo")
|> json_response(200)
|> Map.fetch!("links")
Enum.each(links, fn link ->
href = Map.fetch!(link, "href")
conn
|> get(href)
|> json_response(200)
end)
accept_types = [
"application/activity+json",
"application/json",
"application/jrd+json"
]
for type <- accept_types do
conn
|> put_req_header("accept", type)
|> get("/.well-known/nodeinfo")
|> json_response(200)
end
end
test "nodeinfo shows staff accounts", %{conn: conn} do
moderator = insert(:user, local: true, is_moderator: true)
admin = insert(:user, local: true, is_admin: true)
conn =
conn
|> get("/nodeinfo/2.1.json")
assert result = json_response(conn, 200)
assert moderator.ap_id in result["metadata"]["staffAccounts"]
assert admin.ap_id in result["metadata"]["staffAccounts"]
end
test "nodeinfo shows roles and privileges", %{conn: conn} do
clear_config([:instance, :moderator_privileges], [:cofe])
clear_config([:instance, :admin_privileges], [:suya, :cofe])
conn =
conn
|> get("/nodeinfo/2.1.json")
assert result = json_response(conn, 200)
assert %{"admin" => ["suya", "cofe"], "moderator" => ["cofe"]} == result["metadata"]["roles"]
end
test "nodeinfo shows restricted nicknames", %{conn: conn} do
conn =
conn
|> get("/nodeinfo/2.1.json")
assert result = json_response(conn, 200)
assert Config.get([Pleroma.User, :restricted_nicknames]) ==
result["metadata"]["restrictedNicknames"]
end
test "returns software.repository field in nodeinfo 2.1", %{conn: conn} do
conn
|> get("/.well-known/nodeinfo")
|> json_response(200)
conn =
conn
|> get("/nodeinfo/2.1.json")
assert result = json_response(conn, 200)
assert Pleroma.Application.repository() == result["software"]["repository"]
end
test "returns fieldsLimits field", %{conn: conn} do
clear_config([:instance, :max_account_fields], 10)
clear_config([:instance, :max_remote_account_fields], 15)
clear_config([:instance, :account_field_name_length], 255)
clear_config([:instance, :account_field_value_length], 2048)
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["fieldsLimits"]["maxFields"] == 10
assert response["metadata"]["fieldsLimits"]["maxRemoteFields"] == 15
assert response["metadata"]["fieldsLimits"]["nameLength"] == 255
assert response["metadata"]["fieldsLimits"]["valueLength"] == 2048
end
test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do
clear_config([:instance, :safe_dm_mentions], true)
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert "safe_dm_mentions" in response["metadata"]["features"]
clear_config([:instance, :safe_dm_mentions], false)
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
refute "safe_dm_mentions" in response["metadata"]["features"]
end
describe "`metadata/federation/enabled`" do
setup do: clear_config([:instance, :federating])
test "it shows if federation is enabled/disabled", %{conn: conn} do
clear_config([:instance, :federating], true)
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["enabled"] == true
clear_config([:instance, :federating], false)
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["enabled"] == false
end
end
test "it shows default features flags", %{conn: conn} do
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
default_features = [
"pleroma_api",
"mastodon_api",
+ "mastodon_api_grouped_notifications",
"mastodon_api_streaming",
"polls",
"pleroma_explicit_addressing",
"shareable_emoji_packs",
"multifetch",
"pleroma_emoji_reactions",
"pleroma:api/v1/notifications:include_types_filter",
"pleroma_chat_messages"
]
assert MapSet.subset?(
MapSet.new(default_features),
MapSet.new(response["metadata"]["features"])
)
end
describe "Quarantined instances" do
setup do
clear_config([:mrf, :transparency], true)
quarantined_instances = [{"example.com", "reason to quarantine"}]
clear_config([:instance, :quarantined_instances], quarantined_instances)
end
test "shows quarantined instances data if enabled", %{conn: conn} do
expected_config = ["example.com"]
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["quarantined_instances"] == expected_config
end
test "shows extra information in the quarantined_info field for relevant entries", %{
conn: conn
} do
clear_config([:mrf, :transparency], true)
expected_config = %{
"quarantined_instances" => %{
"example.com" => %{"reason" => "reason to quarantine"}
}
}
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["quarantined_instances_info"] == expected_config
end
end
describe "MRF SimplePolicy" do
setup do
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
clear_config([:mrf, :transparency], true)
end
test "shows MRF transparency data if enabled", %{conn: conn} do
simple_config = %{"reject" => [{"example.com", ""}]}
clear_config(:mrf_simple, simple_config)
expected_config = %{"reject" => ["example.com"]}
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["mrf_simple"] == expected_config
end
test "performs exclusions from MRF transparency data if configured", %{conn: conn} do
clear_config([:mrf, :transparency_exclusions], [
{"other.site", "We don't want them to know"}
])
simple_config = %{"reject" => [{"example.com", ""}, {"other.site", ""}]}
clear_config(:mrf_simple, simple_config)
expected_config = %{"reject" => ["example.com"]}
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["mrf_simple"] == expected_config
assert response["metadata"]["federation"]["exclusions"] == true
end
test "shows extra information in the mrf_simple_info field for relevant entries", %{
conn: conn
} do
simple_config = %{
media_removal: [{"no.media", "LEEWWWDD >//<"}],
media_nsfw: [],
federated_timeline_removal: [{"no.ftl", ""}],
report_removal: [],
reject: [
{"example.instance", "Some reason"},
{"uwu.owo", "awoo to much"},
{"no.reason", ""}
],
followers_only: [],
accept: [],
avatar_removal: [],
banner_removal: [],
reject_deletes: [
{"peak.me", "I want to peak at what they don't want me to see, eheh"}
]
}
clear_config(:mrf_simple, simple_config)
clear_config([:mrf, :transparency_exclusions], [
{"peak.me", "I don't want them to know"}
])
expected_config = %{
"media_removal" => %{
"no.media" => %{"reason" => "LEEWWWDD >//<"}
},
"reject" => %{
"example.instance" => %{"reason" => "Some reason"},
"uwu.owo" => %{"reason" => "awoo to much"}
}
}
response =
conn
|> get("/nodeinfo/2.1.json")
|> json_response(:ok)
assert response["metadata"]["federation"]["mrf_simple_info"] == expected_config
end
end
end
diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs
index 6d221fcf5..071f9b48a 100644
--- a/test/pleroma/web/rich_media/backfill_test.exs
+++ b/test/pleroma/web/rich_media/backfill_test.exs
@@ -1,26 +1,171 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.BackfillTest do
use Pleroma.DataCase
+ alias Pleroma.Tests.ObanHelpers
+ alias Pleroma.Web.CommonAPI
alias Pleroma.Web.RichMedia.Backfill
alias Pleroma.Web.RichMedia.Card
import Mox
+ import Pleroma.Factory
- setup_all do: clear_config([:rich_media, :enabled], true)
+ setup do
+ clear_config([:rich_media, :enabled], true)
+
+ Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
+ Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache)
+
+ :ok
+ end
test "sets a negative cache entry for an error" do
url = "https://bad.example.com/"
url_hash = Card.url_to_hash(url)
Tesla.Mock.mock(fn %{url: ^url} -> :error end)
Pleroma.CachexMock
|> expect(:put, fn :rich_media_cache, ^url_hash, :error, ttl: _ -> {:ok, true} end)
Backfill.run(%{"url" => url})
end
+
+ test "sets a warm_cache entry" do
+ url = "https://good.example.com/"
+ url_hash = Card.url_to_hash(url)
+
+ Tesla.Mock.mock(fn %{url: ^url} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: "<head><meta name=\"twitter:title\" content=\"Cofe\"></head>"
+ }}
+ end)
+
+ Pleroma.CachexMock
+ |> expect(:put, fn :rich_media_cache,
+ ^url_hash,
+ %Pleroma.Web.RichMedia.Card{url_hash: ^url_hash} ->
+ {:ok, true}
+ end)
+
+ Backfill.run(%{"url" => url})
+ end
+
+ test "streams out update when stream == true" do
+ url = "https://example.com"
+ user = insert(:user)
+
+ Tesla.Mock.mock(fn %{url: ^url} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: "<head><meta name=\"twitter:title\" content=\"Cofe\"></head>"
+ }}
+ end)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"})
+
+ Pleroma.CachexMock
+ |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end)
+
+ Pleroma.Web.ActivityPub.ActivityPubMock
+ |> expect(:stream_out, fn %Pleroma.Activity{id: id} ->
+ assert id == activity.id
+ :ok
+ end)
+
+ Backfill.run(%{"url" => url, "activity_id" => activity.id, "stream" => true})
+ end
+
+ test "does not stream out update when stream == false" do
+ url = "https://example.com"
+ user = insert(:user)
+
+ Tesla.Mock.mock(fn %{url: ^url} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: "<head><meta name=\"twitter:title\" content=\"Cofe\"></head>"
+ }}
+ end)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"})
+
+ Pleroma.CachexMock
+ |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end)
+
+ Pleroma.Web.ActivityPub.ActivityPubMock
+ |> deny(:stream_out, 1)
+
+ Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => false})
+ end
+
+ # NOTE: Below two MastoAPI tests cover almost the same code paths.
+ # index.json will always prefetch rich media, while show.json will try to get the card and
+ # fetch it when it isn't cached (both use Card.get_by_activity in the end).
+ # So if index.json doesn't fetch the rich media, show.json will when it renders the post,
+ # hence why index.json test will only call ActivityPub.stream_out twice,
+ # if streaming is re-enabled for in both.
+ test "does not stream out in MastoAPI StatusView index" do
+ url = "https://example.com"
+ user = insert(:user)
+
+ Tesla.Mock.mock(fn %{url: ^url} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: "<head><meta name=\"twitter:title\" content=\"Cofe\"></head>"
+ }}
+ end)
+
+ # CommonAPI federation processing will stream out once as a new post
+ Pleroma.Web.ActivityPub.ActivityPubMock
+ |> expect(:stream_out, 1, fn _ -> :ok end)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"})
+ ObanHelpers.perform_all()
+
+ # Clear cache to force backfill below
+ Pleroma.Activity.HTML.invalidate_cache_for(activity.id)
+ Pleroma.Web.RichMedia.Card.delete(url)
+
+ Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{
+ activities: [activity],
+ as: :activity
+ })
+
+ ObanHelpers.perform_all()
+ end
+
+ test "does not stream out in MastoAPI StatusView show" do
+ url = "https://example.com"
+ user = insert(:user)
+
+ Tesla.Mock.mock(fn %{url: ^url} ->
+ {:ok,
+ %Tesla.Env{
+ status: 200,
+ body: "<head><meta name=\"twitter:title\" content=\"Cofe\"></head>"
+ }}
+ end)
+
+ # CommonAPI federation processing will stream out once as a new post
+ Pleroma.Web.ActivityPub.ActivityPubMock
+ |> expect(:stream_out, 1, fn _ -> :ok end)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"})
+ ObanHelpers.perform_all()
+
+ # Clear cache to force backfill below
+ Pleroma.Activity.HTML.invalidate_cache_for(activity.id)
+ Pleroma.Web.RichMedia.Card.delete(url)
+
+ Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity)
+ ObanHelpers.perform_all()
+ end
end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 12:17 AM (18 h, 20 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800801
Default Alt Text
(478 KB)

Event Timeline