Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
1 MB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml
deleted file mode 100644
index c921a56f9..000000000
--- a/.woodpecker/docker-armv7.yaml
+++ /dev/null
@@ -1,85 +0,0 @@
-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-combine.yaml b/.woodpecker/docker-combine.yaml
index 6c91d26e9..9b9f89fdb 100644
--- a/.woodpecker/docker-combine.yaml
+++ b/.woodpecker/docker-combine.yaml
@@ -1,60 +1,60 @@
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
depends_on:
- docker
skip_clone: true
labels:
platform: linux/amd64
steps:
docker-develop-combine:
image: git.fluffytail.org/phnt/wpc-docker-tagger:latest
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
settings: &docker_settings
registry: "git.pleroma.social"
image: "pleroma/pleroma"
- architectures: [amd64, arm64]
+ architectures: [amd64, arm64, armv7]
tags:
- latest
- develop
- ${CI_COMMIT_SHA:0:8}
username:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
docker-stable-combine:
image: git.fluffytail.org/phnt/wpc-docker-tagger:latest
when:
- evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""'
settings:
<<: *docker_settings
tags: &stable_docker_tags
- latest
- stable
- ${CI_COMMIT_SHA:0:8}
docker-stable-tag-combine:
image: git.fluffytail.org/phnt/wpc-docker-tagger:latest
when:
- event: tag
- evaluate: 'CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""'
settings:
<<: *docker_settings
tags:
- <<: *stable_docker_tags
- ${CI_COMMIT_TAG}
diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml
index 9a5be78e2..1bcf1d2ed 100644
--- a/.woodpecker/docker.yaml
+++ b/.woodpecker/docker.yaml
@@ -1,108 +1,150 @@
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
+ - linux/arm
# 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
+ armv7_docker_variables: &armv7_docker_variables
+ <<: *docker_variables
+ build_args:
+ - ELIXIR_IMG=docker.io/arm32v7/elixir
+ - ELIXIR_TAG=1.17.3-otp-26-alpine
+ - ALPINE_IMG=docker.io/arm32v7/alpine
+ - ALPINE_VER=3.20
+ extra_opts: --custom-platform=linux/arm/v7
+ kaniko_image: &kaniko_image docker.io/woodpeckerci/plugin-kaniko:2.3.2
steps:
docker-develop-amd64:
- image: woodpeckerci/plugin-kaniko:2.3.1
+ image: *kaniko_image
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
+ image: *kaniko_image
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-develop-armv7:
+ image: *kaniko_image
+ when:
+ - evaluate: 'platform == "linux/arm" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"'
+ settings:
+ <<: *armv7_docker_variables
+ tags:
+ - latest-armv7
+ - develop-armv7
+ - ${CI_COMMIT_SHA:0:8}-armv7
+
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
+
+ docker-stable-armv7:
+ image: *kaniko_image
+ when:
+ - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""'
+ settings:
+ <<: *armv7_docker_variables
+ tags: &armv7_tags
+ - latest-armv7
+ - stable-armv7
+ - ${CI_COMMIT_SHA:0:8}-armv7
+
+ docker-stable-tag-armv7:
+ image: *kaniko_image
+ when:
+ - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"'
+ - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""'
+ settings:
+ <<: *armv7_docker_variables
+ tags:
+ - <<: *armv7_tags
+ - ${CI_COMMIT_TAG}-armv7
diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml
index a5bcc0ef3..0f8eac566 100644
--- a/.woodpecker/unit-testing-elixir-1.15.yaml
+++ b/.woodpecker/unit-testing-elixir-1.15.yaml
@@ -1,45 +1,46 @@
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
+ VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS
DB_HOST: postgres
DB_PORT: 5432
commands:
- - apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
+ - apk add --no-cache build-base cmake exiftool ffmpeg vips-dev 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.18.yaml b/.woodpecker/unit-testing-elixir-1.19.yaml
similarity index 85%
rename from .woodpecker/unit-testing-elixir-1.18.yaml
rename to .woodpecker/unit-testing-elixir-1.19.yaml
index 6e576fad9..60b0826a7 100644
--- a/.woodpecker/unit-testing-elixir-1.18.yaml
+++ b/.woodpecker/unit-testing-elixir-1.19.yaml
@@ -1,45 +1,46 @@
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.18-otp-27-alpine
+ unit-testing-elixir-1.19:
+ image: elixir:1.19-otp-28-alpine
entrypoint: *script_file_entrypoint
environment:
MIX_ENV: test
+ VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS
DB_HOST: postgres
DB_PORT: 5432
commands:
- - apk add --no-cache build-base cmake exiftool ffmpeg file-dev git openssl
+ - apk add --no-cache build-base cmake exiftool ffmpeg vips-dev 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 5fd1cc0a1..87c698f93 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,62 +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 ERLANG_VER=27.3.4.14
+ARG ALPINE_VER=3.23.5
ARG ELIXIR_TAG=${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER}
ARG ALPINE_IMG=alpine
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_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/changelog.d/disabling-chats.change b/changelog.d/disabling-chats.change
new file mode 100644
index 000000000..579679a03
--- /dev/null
+++ b/changelog.d/disabling-chats.change
@@ -0,0 +1 @@
+Allow disabling Chats feature
diff --git a/changelog.d/docker-arm32v7-pr-forbid-build.skip b/changelog.d/docker-arm32v7-pr-forbid-build.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/docker-baseimage-ssl.fix b/changelog.d/docker-baseimage-ssl.fix
new file mode 100644
index 000000000..441423f49
--- /dev/null
+++ b/changelog.d/docker-baseimage-ssl.fix
@@ -0,0 +1 @@
+Dockerfile: update base images to fix problems with SSL handshakes
\ No newline at end of file
diff --git a/changelog.d/elixir-1-19.add b/changelog.d/elixir-1-19.add
new file mode 100644
index 000000000..b010b2dfa
--- /dev/null
+++ b/changelog.d/elixir-1-19.add
@@ -0,0 +1 @@
+Implemented ConfigDB configuration blacklist and accompanying Mix task
diff --git a/changelog.d/elixir-1-19.fix b/changelog.d/elixir-1-19.fix
new file mode 100644
index 000000000..53079b0f7
--- /dev/null
+++ b/changelog.d/elixir-1-19.fix
@@ -0,0 +1 @@
+Fixed Elixir 1.19 warnings
diff --git a/changelog.d/fix-preferred-frontend.fix b/changelog.d/fix-preferred-frontend.fix
new file mode 100644
index 000000000..07bf4d0c6
--- /dev/null
+++ b/changelog.d/fix-preferred-frontend.fix
@@ -0,0 +1 @@
+Fix preferred frontend not working due to InstanceStatic plug prematurely returning HTTP 404
diff --git a/changelog.d/hashtag-timeline.fix b/changelog.d/hashtag-timeline.fix
new file mode 100644
index 000000000..c5814d9c9
--- /dev/null
+++ b/changelog.d/hashtag-timeline.fix
@@ -0,0 +1 @@
+Fix hashtag timeline query for additional tag handling
diff --git a/changelog.d/logger-backends.change b/changelog.d/logger-backends.change
new file mode 100644
index 000000000..5f044c2c7
--- /dev/null
+++ b/changelog.d/logger-backends.change
@@ -0,0 +1 @@
+Changed how Logger is configured due to changes in Elixir and added deprecation warnings for the old configuration format
diff --git a/changelog.d/oauth-user-preload.change b/changelog.d/oauth-user-preload.change
new file mode 100644
index 000000000..43f5ea06b
--- /dev/null
+++ b/changelog.d/oauth-user-preload.change
@@ -0,0 +1 @@
+Avoid a duplicate user query during OAuth authentication.
diff --git a/changelog.d/publisher-worker-atoms.fix b/changelog.d/publisher-worker-atoms.fix
new file mode 100644
index 000000000..2df6608ba
--- /dev/null
+++ b/changelog.d/publisher-worker-atoms.fix
@@ -0,0 +1 @@
+Avoid creating atoms from serialized PublisherWorker parameters.
diff --git a/changelog.d/rss.fix b/changelog.d/rss.fix
new file mode 100644
index 000000000..a3455698a
--- /dev/null
+++ b/changelog.d/rss.fix
@@ -0,0 +1 @@
+Fix RSS feeds to be W3C compliant and render better in RSS readers
diff --git a/changelog.d/stream-domain-block-following.fix b/changelog.d/stream-domain-block-following.fix
new file mode 100644
index 000000000..84890c680
--- /dev/null
+++ b/changelog.d/stream-domain-block-following.fix
@@ -0,0 +1 @@
+Keep followed users visible in streams when their domain is blocked.
diff --git a/changelog.d/unit-testing-build-vix.skip b/changelog.d/unit-testing-build-vix.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/user-cache-keys.fix b/changelog.d/user-cache-keys.fix
new file mode 100644
index 000000000..4af21dd1f
--- /dev/null
+++ b/changelog.d/user-cache-keys.fix
@@ -0,0 +1 @@
+Fix inconsistent User cache keys and invalidate numeric ID aliases.
diff --git a/changelog.d/woodpecker-armv7-fixups.fix b/changelog.d/woodpecker-armv7-fixups.fix
new file mode 100644
index 000000000..7bbe7ab3e
--- /dev/null
+++ b/changelog.d/woodpecker-armv7-fixups.fix
@@ -0,0 +1 @@
+Include arm32v7 in multi-arch Docker images
diff --git a/config/config.exs b/config/config.exs
index 3f880af53..f13c12fb9 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,976 +1,986 @@
# .i;;;;i.
# iYcviii;vXY:
# .YXi .i1c.
# .YC. . in7.
# .vc. ...... ;1c.
# i7, .. .;1;
# i7, .. ... .Y1i
# ,7v .6MMM@; .YX,
# .7;. ..IMMMMMM1 :t7.
# .;Y. ;$MMMMMM9. :tc.
# vY. .. .nMMM@MMU. ;1v.
# i7i ... .#MM@M@C. .....:71i
# it: .... $MMM@9;.,i;;;i,;tti
# :t7. ..... 0MMMWv.,iii:::,,;St.
# .nC. ..... IMMMQ..,::::::,.,czX.
# .ct: ....... .ZMMMI..,:::::::,,:76Y.
# c2: ......,i..Y$M@t..:::::::,,..inZY
# vov ......:ii..c$MBc..,,,,,,,,,,..iI9i
# i9Y ......iii:..7@MA,..,,,,,,,,,....;AA:
# iIS. ......:ii::..;@MI....,............;Ez.
# .I9. ......:i::::...8M1..................C0z.
# .z9; ......:i::::,.. .i:...................zWX.
# vbv ......,i::::,,. ................. :AQY
# c6Y. .,...,::::,,..:t0@@QY. ................ :8bi
# :6S. ..,,...,:::,,,..EMMMMMMI. ............... .;bZ,
# :6o, .,,,,..:::,,,..i#MMMMMM#v................. YW2.
# .n8i ..,,,,,,,::,,,,.. tMMMMM@C:.................. .1Wn
# 7Uc. .:::,,,,,::,,,,.. i1t;,..................... .UEi
# 7C...::::::::::::,,,,.. .................... vSi.
# ;1;...,,::::::,......... .................. Yz:
# v97,......... .voC.
# izAotX7777777777777777777777777777777777777777Y7n92:
# .;CoIIIIIUAA666666699999ZZZZZZZZZZZZZZZZZZZZ6ov.
#
# !!! ATTENTION !!!
# DO NOT EDIT THIS FILE! THIS FILE CONTAINS THE DEFAULT VALUES FOR THE CON-
# FIGURATION! EDIT YOUR SECRET FILE (either prod.secret.exs, dev.secret.exs).
#
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
import Config
# General application configuration
config :pleroma, ecto_repos: [Pleroma.Repo]
config :pleroma, Pleroma.Repo,
telemetry_event: [Pleroma.Repo.Instrumenter],
migration_lock: :pg_advisory_lock
config :pleroma, Pleroma.Captcha,
enabled: true,
seconds_valid: 300,
method: Pleroma.Captcha.Native
config :pleroma, Pleroma.Captcha.Kocaptcha, endpoint: "https://captcha.kotobank.ch"
# Upload configuration
config :pleroma, Pleroma.Upload,
uploader: Pleroma.Uploaders.Local,
filters: [Pleroma.Upload.Filter.Dedupe],
link_name: false,
proxy_remote: false,
filename_display_max_length: 30,
default_description: nil,
base_url: nil,
allowed_mime_types: ["image", "audio", "video"]
config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads"
config :pleroma, Pleroma.Uploaders.S3,
bucket: nil,
bucket_namespace: nil,
truncated_namespace: nil,
streaming_enabled: true
config :ex_aws, :s3,
# host: "s3.wasabisys.com", # required if not Amazon AWS
access_key_id: nil,
secret_access_key: nil,
# region: "us-east-1", # may be required for Amazon AWS
scheme: "https://"
config :pleroma, Pleroma.Uploaders.IPFS,
post_gateway_url: "http://localhost:5001",
get_gateway_url: "http://localhost:8080"
config :pleroma, :emoji,
shortcode_globs: ["/emoji/custom/**/*.png"],
pack_extensions: [".png", ".gif"],
groups: [
Custom: ["/emoji/*.png", "/emoji/**/*.png"]
],
default_manifest: "https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json",
shared_pack_cache_seconds_per_file: 60
config :pleroma, :uri_schemes,
valid_schemes: [
"https",
"http",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"xmpp"
]
# Configures the endpoint
config :pleroma, Pleroma.Web.Endpoint,
url: [host: "localhost"],
http: [
ip: {127, 0, 0, 1}
],
protocol: "https",
secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl",
live_view: [signing_salt: "U5ELgdEwTD3n1+D5s0rY0AMg8/y1STxZ3Zvsl3bWh+oBcGrYdil0rXqPMRd3Glcq"],
signing_salt: "CqaoopA2",
render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)],
pubsub_server: Pleroma.PubSub,
secure_cookie_flag: true,
extra_cookie_attrs: [
"SameSite=Lax"
]
# Configures Elixir's Logger
-config :logger, backends: [:console]
+# Primary config
+config :logger, level: :debug
-config :logger, :console,
- level: :debug,
+# Console config
+config :logger, :default_handler, level: :debug
+
+config :logger, :default_formatter,
format: "\n$time $metadata[$level] $message\n",
metadata: [:actor, :path, :type, :user]
+# Syslog config
config :logger, :ex_syslogger,
level: :debug,
ident: "pleroma",
format: "$metadata[$level] $message",
metadata: [:actor, :path, :type, :user]
config :mime, :types, %{
"application/xml" => ["xml"],
"application/xrd+xml" => ["xrd+xml"],
"application/jrd+json" => ["jrd+json"],
"application/activity+json" => ["activity+json"],
"application/ld+json" => ["activity+json"],
# Can be removed when bumping MIME past 2.0.5
# see https://akkoma.dev/AkkomaGang/akkoma/issues/657
"image/apng" => ["apng"]
}
config :tesla, adapter: Tesla.Adapter.Hackney
# Configures http settings, upstream proxy etc.
config :pleroma, :http,
proxy_url: nil,
send_user_agent: true,
user_agent: :default,
adapter: []
config :pleroma, :instance,
name: "Pleroma",
email: "example@example.com",
notify_email: "noreply@example.com",
description: "Pleroma: An efficient and flexible fediverse server",
short_description: "",
background_image: "/images/city.jpg",
instance_thumbnail: "/instance/thumbnail.jpeg",
favicon: "/favicon.png",
limit: 5_000,
description_limit: 5_000,
remote_limit: 100_000,
upload_limit: 16_000_000,
avatar_upload_limit: 2_000_000,
background_upload_limit: 4_000_000,
banner_upload_limit: 4_000_000,
poll_limits: %{
max_options: 20,
max_option_chars: 200,
min_expiration: 0,
max_expiration: 365 * 24 * 60 * 60
},
registrations_open: true,
invites_enabled: false,
account_activation_required: false,
account_approval_required: false,
federating: true,
federation_incoming_replies_max_depth: 100,
allow_relay: true,
public: true,
quarantined_instances: [],
rejected_instances: [],
static_dir: "instance/static/",
allowed_post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
],
autofollowed_nicknames: [],
autofollowing_nicknames: [],
max_pinned_statuses: 1,
attachment_links: false,
max_report_comment_size: 1000,
report_strip_status: true,
safe_dm_mentions: false,
healthcheck: false,
remote_post_retention_days: 90,
skip_thread_containment: true,
limit_to_local_content: :unauthenticated,
user_bio_length: 5000,
user_name_length: 100,
max_account_fields: 10,
max_remote_account_fields: 20,
account_field_name_length: 512,
account_field_value_length: 2048,
registration_reason_length: 500,
external_user_synchronization: true,
extended_nickname_format: true,
cleanup_attachments: false,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 5,
length: 16
]
],
show_reactions: true,
password_reset_token_validity: 60 * 60 * 24,
profile_directory: true,
admin_privileges: [
:users_read,
:users_manage_invites,
:users_manage_activation_state,
:users_manage_tags,
:users_manage_credentials,
:users_delete,
:messages_read,
:messages_delete,
:instances_delete,
:reports_manage_reports,
:moderation_log_read,
:announcements_manage_announcements,
:emoji_manage_emoji,
:statistics_read
],
moderator_privileges: [:messages_delete, :reports_manage_reports],
max_endorsed_users: 20,
birthday_required: false,
birthday_min_age: 0,
max_media_attachments: 1_000
config :pleroma, :welcome,
direct_message: [
enabled: false,
sender_nickname: nil,
message: nil
],
chat_message: [
enabled: false,
sender_nickname: nil,
message: nil
],
email: [
enabled: false,
sender: nil,
subject: "Welcome to <%= instance_name %>",
html: "Welcome to <%= instance_name %>",
text: "Welcome to <%= instance_name %>"
]
config :pleroma, :feed,
post_title: %{
max_length: 100,
omission: "..."
}
config :pleroma, :markup,
# XXX - unfortunately, inline images must be enabled by default right now, because
# of custom emoji. Issue #275 discusses defanging that somehow.
allow_inline_images: true,
allow_headings: false,
allow_tables: false,
allow_fonts: false,
scrub_policy: [
Pleroma.HTML.Scrubber.Default,
Pleroma.HTML.Transform.MediaProxy
]
config :pleroma, :frontend_configurations,
pleroma_fe: %{
alwaysShowSubjectInput: true,
background: "/images/city.jpg",
collapseMessageWithSubject: false,
disableChat: false,
greentext: false,
embeddedToS: true,
hideFilteredStatuses: false,
hideMutedPosts: false,
hidePostStats: false,
hideSitename: false,
hideUserStats: false,
loginMethod: "password",
logo: "/static/logo.svg",
logoMargin: ".1em",
logoMask: true,
minimalScopesMode: false,
noAttachmentLinks: false,
nsfwCensorImage: "",
postContentType: "text/plain",
redirectRootLogin: "/main/friends",
redirectRootNoLogin: "/main/all",
scopeCopy: true,
sidebarRight: false,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
subjectLineBehavior: "email",
theme: "pleroma-dark",
webPushNotifications: false
}
config :pleroma, :assets,
mascots: [
pleroma_fox_tan: %{
url: "/images/pleroma-fox-tan-smol.png",
mime_type: "image/png"
},
pleroma_fox_tan_shy: %{
url: "/images/pleroma-fox-tan-shy.png",
mime_type: "image/png"
}
],
default_mascot: :pleroma_fox_tan
config :pleroma, :manifest,
icons: [
%{
src: "/static/logo.svg",
sizes: "512x512",
purpose: "any",
type: "image/svg+xml"
}
],
theme_color: "#282c37",
background_color: "#191b22"
config :pleroma, :activitypub,
unfollow_blocked: true,
outgoing_blocks: true,
blockers_visible: true,
follow_handshake_timeout: 500,
note_replies_output_limit: 5,
sign_object_fetches: true,
authorized_fetch_mode: false,
client_api_enabled: false,
anonymize_reporter: false,
anonymize_reporter_local_nickname: ""
config :pleroma, :streamer,
workers: 3,
overflow_workers: 2
config :pleroma, :user, deny_follow_blocked: true
config :pleroma, :mrf_normalize_markup, scrub_policy: Pleroma.HTML.Scrubber.Default
config :pleroma, :mrf_rejectnonpublic,
allow_followersonly: false,
allow_direct: false
config :pleroma, :mrf_hellthread,
delist_threshold: 10,
reject_threshold: 20
config :pleroma, :mrf_simple,
media_removal: [],
media_nsfw: [],
federated_timeline_removal: [],
report_removal: [],
reject: [],
followers_only: [],
accept: [],
avatar_removal: [],
banner_removal: [],
reject_deletes: []
config :pleroma, :mrf_keyword,
reject: [],
federated_timeline_removal: [],
replace: []
config :pleroma, :mrf_emoji,
remove_url: [],
remove_shortcode: [],
federated_timeline_removal_url: [],
federated_timeline_removal_shortcode: []
config :pleroma, :mrf_hashtag,
sensitive: ["nsfw"],
reject: [],
federated_timeline_removal: []
config :pleroma, :mrf_subchain, match_actor: %{}
config :pleroma, :mrf_activity_expiration, days: 365
config :pleroma, :mrf_vocabulary,
accept: [],
reject: []
# threshold of 7 days
config :pleroma, :mrf_object_age,
threshold: 604_800,
actions: [:delist, :strip_followers]
config :pleroma, :mrf_nsfw_api,
url: "http://127.0.0.1:5000/",
threshold: 0.7,
mark_sensitive: true,
unlist: false,
reject: false
config :pleroma, :mrf_follow_bot, follower_nickname: nil
config :pleroma, :mrf_inline_quote, template: "<bdi>RT:</bdi> {url}"
config :pleroma, :mrf_remote_report,
reject_all: false,
reject_anonymous: true,
reject_empty_message: true
config :pleroma, :mrf_force_mention,
mention_parent: true,
mention_quoted: true
config :pleroma, :mrf_antimentionspam, user_age_limit: 30_000
config :pleroma, :rich_media,
enabled: true,
ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"],
parsers: [
Pleroma.Web.RichMedia.Parsers.TwitterCard,
Pleroma.Web.RichMedia.Parsers.OEmbed
],
timeout: 5_000,
ttl_setters: [
Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl,
Pleroma.Web.RichMedia.Parser.TTL.Opengraph
],
max_body: 5_000_000
config :pleroma, :media_proxy,
enabled: false,
invalidation: [
enabled: false,
provider: Pleroma.Web.MediaProxy.Invalidation.Script
],
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
# Note: max_read_duration defaults to Pleroma.ReverseProxy.max_read_duration_default/1
max_read_duration: 30_000,
http: [
follow_redirect: true,
pool: :media
]
],
whitelist: []
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http,
method: :purge,
headers: [],
options: []
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script,
script_path: nil,
url_format: nil
# Note: media preview proxy depends on media proxy to be enabled
config :pleroma, :media_preview_proxy,
enabled: false,
thumbnail_max_width: 600,
thumbnail_max_height: 600,
image_quality: 85,
min_content_length: 100 * 1024
config :pleroma, :shout,
enabled: true,
limit: 5_000
config :phoenix, :format_encoders, json: Jason, "activity+json": Jason
config :phoenix, :json_library, Jason
config :phoenix, :filter_parameters, ["password", "confirm"]
config :pleroma, :gopher,
enabled: false,
ip: {0, 0, 0, 0},
port: 9999
config :pleroma, Pleroma.Web.Metadata,
providers: [
Pleroma.Web.Metadata.Providers.OpenGraph,
Pleroma.Web.Metadata.Providers.TwitterCard
],
unfurl_nsfw: false
config :pleroma, Pleroma.Web.Preload,
providers: [
Pleroma.Web.Preload.Providers.Instance
]
config :pleroma, :http_security,
enabled: true,
sts: false,
sts_max_age: 31_536_000,
ct_max_age: 2_592_000,
referrer_policy: "same-origin",
allow_unsafe_eval: false
config :cors_plug,
max_age: 86_400,
methods: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"],
expose: [
"Link",
"X-RateLimit-Reset",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-Request-Id",
"Idempotency-Key"
],
credentials: true,
headers: ["Authorization", "Content-Type", "Idempotency-Key"]
config :pleroma, Pleroma.User,
restricted_nicknames: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"check_password",
"dev",
"friend-requests",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"ostatus_subscribe",
"pleroma",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"user-search",
"user_exists",
"users",
"web",
"verify_credentials",
"update_credentials",
"relationships",
"search",
"confirmation_resend",
"mfa"
],
email_blacklist: []
# The Pruner :max_age must be longer than Worker :unique
# value or it cannot enforce uniqueness.
config :pleroma, Oban,
repo: Pleroma.Repo,
notifier: Oban.Notifiers.PG,
log: false,
queues: [
activity_expiration: 10,
federator_incoming: 5,
federator_outgoing: 25,
web_push: 50,
background: 20,
search_indexing: [limit: 10, paused: true],
slow: 5
],
plugins: [Oban.Plugins.Lazarus, {Oban.Plugins.Pruner, max_age: 900}],
crontab: [
{"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker},
{"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker},
{"*/10 * * * *", Pleroma.Workers.Cron.AppCleanupWorker}
]
config :pleroma, Pleroma.Formatter,
class: false,
rel: "ugc",
new_window: false,
truncate: false,
strip_prefix: false,
extra: true,
validate_tld: :no_scheme
config :pleroma, :ldap,
enabled: System.get_env("LDAP_ENABLED") == "true",
host: System.get_env("LDAP_HOST", "localhost"),
port: String.to_integer(System.get_env("LDAP_PORT", "389")),
ssl: System.get_env("LDAP_SSL") == "true",
sslopts: [],
tls: System.get_env("LDAP_TLS") == "true",
tlsopts: [],
base: System.get_env("LDAP_BASE", "dc=example,dc=com"),
uid: System.get_env("LDAP_UID", "cn"),
# defaults to CAStore's Mozilla roots
cacertfile: System.get_env("LDAP_CACERTFILE", nil),
mail: System.get_env("LDAP_MAIL", "mail")
oauth_consumer_strategies =
System.get_env("OAUTH_CONSUMER_STRATEGIES")
|> to_string()
|> String.split()
|> Enum.map(&hd(String.split(&1, ":")))
ueberauth_providers =
for strategy <- oauth_consumer_strategies do
strategy_module_name = "Elixir.Ueberauth.Strategy.#{String.capitalize(strategy)}"
strategy_module = String.to_atom(strategy_module_name)
{String.to_atom(strategy), {strategy_module, [callback_params: ["state"]]}}
end
config :ueberauth,
Ueberauth,
base_path: "/oauth",
providers: ueberauth_providers
config :pleroma, :auth, oauth_consumer_strategies: oauth_consumer_strategies
config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Sendmail, enabled: false
config :pleroma, Pleroma.Emails.UserEmail,
logo: nil,
styling: %{
link_color: "#d8a070",
background_color: "#2C3645",
content_background_color: "#1B2635",
header_color: "#d8a070",
text_color: "#b9b9ba",
text_muted_color: "#b9b9ba"
}
config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: false
config :pleroma, Pleroma.PromEx,
disabled: false,
manual_metrics_start_delay: :no_delay,
drop_metrics_groups: [],
grafana: [
host: System.get_env("GRAFANA_HOST", "http://localhost:3000"),
auth_token: System.get_env("GRAFANA_TOKEN"),
upload_dashboards_on_start: false,
folder_name: "BEAM",
annotate_app_lifecycle: true
],
metrics_server: [
port: 4021,
path: "/metrics",
protocol: :http,
pool_size: 5,
cowboy_opts: [],
auth_strategy: :none
],
datasource: "Prometheus"
config :pleroma, Pleroma.ScheduledActivity,
daily_user_limit: 25,
total_user_limit: 300,
enabled: true
config :pleroma, :email_notifications,
digest: %{
active: false,
interval: 7,
inactivity_threshold: 7
}
config :pleroma, :oauth2,
token_expires_in: 3600 * 24 * 365 * 100,
issue_new_refresh_token: true,
clean_expired_tokens: false
config :pleroma, :database, rum_enabled: false
config :pleroma, :features, improved_hashtag_timeline: :auto
config :pleroma, :populate_hashtags_table, fault_rate_allowance: 0.01
config :pleroma, :delete_context_objects, fault_rate_allowance: 0.01
config :pleroma, :env, Mix.env()
config :http_signatures,
adapter: Pleroma.Signature
config :pleroma, :rate_limit,
authentication: {60_000, 15},
timeline: {500, 3},
search: [{1000, 10}, {1000, 30}],
app_account_creation: {1_800_000, 25},
oauth_app_creation: {900_000, 5},
relations_actions: {10_000, 10},
relation_id_action: {60_000, 2},
statuses_actions: {10_000, 15},
status_id_action: {60_000, 3},
password_reset: {1_800_000, 5},
account_confirmation_resend: {8_640_000, 5},
ap_routes: {60_000, 15}
config :pleroma, Pleroma.Workers.PurgeExpiredActivity, enabled: true, min_lifetime: 600
config :pleroma, Pleroma.Web.Plugs.RemoteIp,
enabled: true,
headers: ["x-forwarded-for"],
clients: [],
proxies: [],
reserved: [
"127.0.0.0/8",
"::1/128",
"fc00::/7",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16"
]
config :pleroma, :static_fe, enabled: false
# Example of frontend configuration
# This example will make us serve the primary frontend from the
# frontends directory within your `:pleroma, :instance, static_dir`.
# e.g., instance/static/frontends/pleroma/develop/
#
# With no frontend configuration, the bundled files from the `static` directory will
# be used.
#
# config :pleroma, :frontends,
# primary: %{"name" => "pleroma-fe", "ref" => "develop"},
# admin: %{"name" => "admin-fe", "ref" => "stable"},
# available: %{...}
config :pleroma, :frontends,
available: %{
"kenoma" => %{
"name" => "kenoma",
"git" => "https://git.pleroma.social/lambadalambda/kenoma",
"build_url" =>
"https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build",
"ref" => "master"
},
"pleroma-fe" => %{
"name" => "pleroma-fe",
"git" => "https://git.pleroma.social/pleroma/pleroma-fe",
"build_url" =>
"https://git.pleroma.social/api/packages/pleroma/generic/pleroma-fe-builds/${ref}/latest.zip",
"ref" => "develop"
},
"fedi-fe" => %{
"name" => "fedi-fe",
"git" => "https://git.pleroma.social/pleroma/fedi-fe",
"build_url" =>
"https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build_release",
"ref" => "master",
"custom-http-headers" => [
{"service-worker-allowed", "/"}
]
},
"admin-fe" => %{
"name" => "admin-fe",
"git" => "https://git.pleroma.social/pleroma/admin-fe",
"build_url" =>
"https://git.pleroma.social/pleroma/admin-fe/-/jobs/artifacts/${ref}/download?job=build",
"ref" => "develop"
},
"soapbox" => %{
"name" => "soapbox",
"git" => "https://gitlab.com/soapbox-pub/soapbox",
"build_url" =>
"https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/${ref}/download?job=build-production",
"ref" => "v3.0.0-beta.1",
"build_dir" => "static"
},
"glitch-lily" => %{
"name" => "glitch-lily",
"git" => "https://lily-is.land/infra/glitch-lily",
"build_url" =>
"https://lily-is.land/infra/glitch-lily/-/jobs/artifacts/${ref}/download?job=build",
"ref" => "servant",
"build_dir" => "public"
},
"pl-fe" => %{
"name" => "pl-fe",
"git" => "https://github.com/mkljczk/pl-fe",
"build_url" => "https://pl.mkljczk.pl/pl-fe.zip",
"ref" => "develop",
"build_dir" => "."
}
}
config :pleroma, :web_cache_ttl,
activity_pub: nil,
activity_pub_question: 30_000
config :pleroma, :modules, runtime_dir: "instance/modules"
config :pleroma, configurable_from_database: false
config :pleroma, Pleroma.Repo,
parameters: [gin_fuzzy_search_limit: "500", jit: "off"],
prepare: :unnamed
config :pleroma, :connections_pool,
reclaim_multiplier: 0.1,
connection_acquisition_wait: 250,
connection_acquisition_retries: 5,
max_connections: 250,
max_idle_time: 30_000,
retry: 0,
connect_timeout: 5_000
config :pleroma, :pools,
federation: [
size: 75,
max_waiting: 20,
recv_timeout: 10_000
],
media: [
size: 75,
max_waiting: 20,
recv_timeout: 15_000
],
rich_media: [
size: 25,
max_waiting: 20,
recv_timeout: 15_000
],
upload: [
size: 25,
max_waiting: 20,
recv_timeout: 15_000
],
default: [
size: 50,
max_waiting: 2,
recv_timeout: 5_000
]
config :pleroma, :hackney_pools,
federation: [
max_connections: 50,
timeout: 10_000
],
media: [
max_connections: 50,
timeout: 15_000
],
rich_media: [
max_connections: 50,
timeout: 15_000
],
upload: [
max_connections: 25,
timeout: 15_000
]
config :pleroma, :majic_pool, size: 2
private_instance? = :if_instance_is_private
config :pleroma, :restrict_unauthenticated,
timelines: %{local: private_instance?, federated: private_instance?},
profiles: %{local: private_instance?, remote: private_instance?},
activities: %{local: private_instance?, remote: private_instance?}
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false
config :pleroma, :mrf,
policies: [
Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy,
Pleroma.Web.ActivityPub.MRF.TagPolicy,
Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy
],
transparency: true,
transparency_exclusions: []
config :tzdata, :http_client, Pleroma.HTTP.Tzdata
config :ex_aws, http_client: Pleroma.HTTP.ExAws
config :web_push_encryption, http_client: Pleroma.HTTP.WebPush
config :pleroma, :instances_favicons, enabled: false
config :floki, :html_parser, Floki.HTMLParser.FastHtml
config :pleroma, Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator
config :pleroma, Pleroma.User.Backup,
purge_after_days: 30,
limit_days: 7,
dir: nil,
process_chunk_size: 100,
timeout: :timer.minutes(30)
config :pleroma, ConcurrentLimiter, [
{Pleroma.Search, [max_running: 30, max_waiting: 50]}
]
config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fetch: true
config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch
config :pleroma, Pleroma.Search.Meilisearch,
url: "http://127.0.0.1:7700/",
private_key: nil,
initial_indexing_chunk_size: 100_000
config :pleroma, Pleroma.Application,
background_migrators: true,
internal_fetch: true,
load_custom_modules: true,
max_restarts: 3,
streamer_registry: true
config :pleroma, Pleroma.Uploaders.Uploader, timeout: 30_000
config :pleroma, Pleroma.Search.QdrantSearch,
qdrant_url: "http://127.0.0.1:6333/",
qdrant_api_key: "",
openai_url: "http://127.0.0.1:11345",
# The healthcheck url has to be set to nil when used with the real openai
# API, as it doesn't have a healthcheck endpoint.
openai_healthcheck_url: "http://127.0.0.1:11345/health",
openai_model: "snowflake/snowflake-arctic-embed-xs",
openai_api_key: "",
qdrant_index_configuration: %{
vectors: %{size: 384, distance: "Cosine"}
}
+config :pleroma, :database_config_blacklist, [
+ {:pleroma, :logger}
+]
+
config :pleroma, :database_config_whitelist, [
{:pleroma},
{:cors_plug},
{:ex_aws, :s3},
{:mime},
{:prometheus, Pleroma.Web.Endpoint.MetricsExporter},
{:web_push_encryption, :vapid_details}
]
+config :pleroma, Pleroma.Chat, enabled: true
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"
diff --git a/config/description.exs b/config/description.exs
index 7c377588c..1293179b9 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -1,3634 +1,3647 @@
import Config
websocket_config = [
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
]
installed_frontend_options = [
%{
key: "name",
label: "Name",
type: :string,
description:
"Name of the installed frontend. Valid config must include both `Name` and `Reference` values."
},
%{
key: "ref",
label: "Reference",
type: :string,
description:
"Reference of the installed frontend to be used. Valid config must include both `Name` and `Reference` values."
}
]
frontend_options = [
%{
key: "name",
label: "Name",
type: :string,
description: "Name of the frontend."
},
%{
key: "ref",
label: "Reference",
type: :string,
description: "Reference of the frontend to be used."
},
%{
key: "git",
label: "Git Repository URL",
type: :string,
description: "URL of the git repository of the frontend"
},
%{
key: "build_url",
label: "Build URL",
type: :string,
description:
"Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.",
example: "https://some.url/builds/${ref}.zip"
},
%{
key: "build_dir",
label: "Build directory",
type: :string,
description: "The directory inside the zip file "
},
%{
key: "custom-http-headers",
label: "Custom HTTP headers",
type: {:list, :string},
description: "The custom HTTP headers for the frontend"
}
]
config :pleroma, :config_description, [
%{
group: :pleroma,
key: Pleroma.Upload,
type: :group,
description: "Upload general settings",
children: [
%{
key: :uploader,
type: :module,
description: "Module which will be used for uploads",
suggestions: {:list_behaviour_implementations, Pleroma.Uploaders.Uploader}
},
%{
key: :filters,
type: {:list, :module},
description:
"List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom module you need to use full name.",
suggestions: {:list_behaviour_implementations, Pleroma.Upload.Filter}
},
%{
key: :link_name,
type: :boolean,
description:
"If enabled, a name parameter will be added to the URL of the upload. For example `https://instance.tld/media/imagehash.png?name=realname.png`."
},
%{
key: :base_url,
label: "Base URL",
type: :string,
description:
"Base URL for the uploads. Required if you use a CDN or host attachments under a different domain.",
suggestions: [
"https://cdn-host.com"
]
},
%{
key: :proxy_remote,
type: :boolean,
description: """
Proxy requests to the remote uploader.\n
Useful if media upload endpoint is not internet accessible.
"""
},
%{
key: :filename_display_max_length,
type: :integer,
description: "Set max length of a filename to display. 0 = no limit. Default: 30"
},
%{
key: :allowed_mime_types,
label: "Allowed MIME types",
type: {:list, :string},
description:
"List of MIME (main) types uploads are allowed to identify themselves with. Other types may still be uploaded, but will identify as a generic binary to clients. WARNING: Loosening this over the defaults can lead to security issues. Removing types is safe, but only add to the list if you are sure you know what you are doing.",
suggestions: [
"image",
"audio",
"video",
"font"
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Uploaders.Local,
type: :group,
description: "Local uploader-related settings",
children: [
%{
key: :uploads,
type: :string,
description: "Path where user's uploads will be saved",
suggestions: [
"uploads"
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Uploaders.IPFS,
type: :group,
description: "IPFS uploader-related settings",
children: [
%{
key: :get_gateway_url,
type: :string,
description: "GET Gateway URL",
suggestions: [
"https://ipfs.mydomain.com/{CID}",
"https://{CID}.ipfs.mydomain.com/"
]
},
%{
key: :post_gateway_url,
type: :string,
description: "POST Gateway URL",
suggestions: [
"http://localhost:5001/"
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Uploaders.S3,
type: :group,
description: "S3 uploader-related settings",
children: [
%{
key: :bucket,
type: :string,
description: "S3 bucket",
suggestions: [
"bucket"
]
},
%{
key: :bucket_namespace,
type: :string,
description: "S3 bucket namespace",
suggestions: ["pleroma"]
},
%{
key: :truncated_namespace,
type: :string,
description:
"If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or \"\" etc." <>
" For example, when using CDN to S3 virtual host format, set \"\". At this time, write CNAME to CDN in Upload base_url."
},
%{
key: :streaming_enabled,
type: :boolean,
description:
"Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems."
}
]
},
%{
group: :pleroma,
key: Pleroma.Upload.Filter.Mogrify,
type: :group,
description: "Uploads mogrify filter settings",
children: [
%{
key: :args,
type: [:string, {:list, :string}, {:list, :tuple}],
description:
"List of actions for the mogrify command. It's possible to add self-written settings as string. " <>
"For example `auto-orient, strip, {\"resize\", \"3840x1080>\"}` value will be parsed into valid list of the settings.",
suggestions: [
"strip",
"auto-orient",
{"implode", "1"}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Upload.Filter.AnonymizeFilename,
type: :group,
description: "Filter replaces the filename of the upload",
children: [
%{
key: :text,
type: :string,
description:
"Text to replace filenames in links. If no setting, {random}.extension will be used. You can get the original" <>
" filename extension by using {extension}, for example custom-file-name.{extension}.",
suggestions: [
"custom-file-name.{extension}"
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Emails.Mailer,
type: :group,
description: "Mailer-related settings",
children: [
%{
key: :enabled,
label: "Mailer Enabled",
type: :boolean
},
%{
key: :adapter,
type: :module,
description:
"One of the mail adapters listed in [Swoosh documentation](https://hexdocs.pm/swoosh/Swoosh.html#module-adapters)",
suggestions: [
Swoosh.Adapters.AmazonSES,
Swoosh.Adapters.Dyn,
Swoosh.Adapters.Gmail,
Swoosh.Adapters.Mailgun,
Swoosh.Adapters.Mailjet,
Swoosh.Adapters.Mandrill,
Swoosh.Adapters.Postmark,
Swoosh.Adapters.SMTP,
Swoosh.Adapters.Sendgrid,
Swoosh.Adapters.Sendmail,
Swoosh.Adapters.SocketLabs,
Swoosh.Adapters.SparkPost
]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :relay,
type: :string,
description: "Hostname or IP address",
suggestions: ["smtp.example.com"]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :port,
type: :integer,
description: "SMTP port",
suggestions: ["1025"]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :username,
type: :string,
description: "SMTP AUTH username",
suggestions: ["user@example.com"]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :password,
type: :string,
description: "SMTP AUTH password",
suggestions: ["password"]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :ssl,
label: "Use SSL",
type: :boolean,
description: "Use Implicit SSL/TLS. e.g. port 465"
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :tls,
label: "STARTTLS Mode",
type: {:dropdown, :atom},
description: "Explicit TLS (STARTTLS) enforcement mode",
suggestions: [:if_available, :always, :never]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :auth,
label: "AUTH Mode",
type: {:dropdown, :atom},
description: "SMTP AUTH enforcement mode",
suggestions: [:if_available, :always, :never]
},
%{
group: {:subgroup, Swoosh.Adapters.SMTP},
key: :retries,
type: :integer,
description: "SMTP temporary (4xx) error retries",
suggestions: [1]
},
%{
group: {:subgroup, Swoosh.Adapters.Sendgrid},
key: :api_key,
label: "SendGrid API Key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.Sendmail},
key: :cmd_path,
type: :string,
suggestions: ["/usr/bin/sendmail"]
},
%{
group: {:subgroup, Swoosh.Adapters.Sendmail},
key: :cmd_args,
type: :string,
suggestions: ["-N delay,failure,success"]
},
%{
group: {:subgroup, Swoosh.Adapters.Sendmail},
key: :qmail,
label: "Qmail compat mode",
type: :boolean
},
%{
group: {:subgroup, Swoosh.Adapters.Mandrill},
key: :api_key,
label: "Mandrill API Key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.Mailgun},
key: :api_key,
label: "Mailgun API Key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.Mailgun},
key: :domain,
type: :string,
suggestions: ["YOUR_DOMAIN_NAME"]
},
%{
group: {:subgroup, Swoosh.Adapters.Mailjet},
key: :api_key,
label: "MailJet Public API Key",
type: :string,
suggestions: ["MJ_APIKEY_PUBLIC"]
},
%{
group: {:subgroup, Swoosh.Adapters.Mailjet},
key: :secret,
label: "MailJet Private API Key",
type: :string,
suggestions: ["MJ_APIKEY_PRIVATE"]
},
%{
group: {:subgroup, Swoosh.Adapters.Postmark},
key: :api_key,
label: "Postmark API Key",
type: :string,
suggestions: ["X-Postmark-Server-Token"]
},
%{
group: {:subgroup, Swoosh.Adapters.SparkPost},
key: :api_key,
label: "SparkPost API key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.SparkPost},
key: :endpoint,
type: :string,
suggestions: ["https://api.sparkpost.com/api/v1"]
},
%{
group: {:subgroup, Swoosh.Adapters.AmazonSES},
key: :access_key,
label: "AWS Access Key",
type: :string,
suggestions: ["AWS_ACCESS_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.AmazonSES},
key: :secret,
label: "AWS Secret Key",
type: :string,
suggestions: ["AWS_SECRET_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.AmazonSES},
key: :region,
label: "AWS Region",
type: :string,
suggestions: ["us-east-1", "us-east-2"]
},
%{
group: {:subgroup, Swoosh.Adapters.Dyn},
key: :api_key,
label: "Dyn API Key",
type: :string,
suggestions: ["apikey"]
},
%{
group: {:subgroup, Swoosh.Adapters.SocketLabs},
key: :api_key,
label: "SocketLabs API Key",
type: :string,
suggestions: ["INJECTION_API_KEY"]
},
%{
group: {:subgroup, Swoosh.Adapters.SocketLabs},
key: :server_id,
label: "Server ID",
type: :string,
suggestions: ["SERVER_ID"]
},
%{
group: {:subgroup, Swoosh.Adapters.Gmail},
key: :access_token,
label: "GMail API Access Token",
type: :string,
suggestions: ["GMAIL_API_ACCESS_TOKEN"]
}
]
},
%{
group: :pleroma,
key: :uri_schemes,
label: "URI Schemes",
type: :group,
description: "URI schemes related settings",
children: [
%{
key: :valid_schemes,
type: {:list, :string},
description: "List of the scheme part that is considered valid to be an URL",
suggestions: [
"https",
"http",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"xmpp"
]
}
]
},
%{
group: :pleroma,
key: :features,
type: :group,
description: "Customizable features",
children: [
%{
key: :improved_hashtag_timeline,
type: {:dropdown, :atom},
description:
"Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).",
suggestions: [:auto, :enabled, :disabled]
}
]
},
%{
group: :pleroma,
key: :populate_hashtags_table,
type: :group,
description: "`populate_hashtags_table` background migration settings",
children: [
%{
key: :fault_rate_allowance,
type: :float,
description:
"Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records.",
suggestions: [0.01]
},
%{
key: :sleep_interval_ms,
type: :integer,
description:
"Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)."
}
]
},
%{
group: :pleroma,
key: :delete_context_objects,
type: :group,
description: "`delete_context_objects` background migration settings",
children: [
%{
key: :fault_rate_allowance,
type: :float,
description:
"Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if context object deletion failed for all records.",
suggestions: [0.01]
},
%{
key: :sleep_interval_ms,
type: :integer,
description:
"Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)."
}
]
},
%{
group: :pleroma,
key: :instance,
type: :group,
description: "Instance-related settings",
children: [
%{
key: :name,
type: :string,
description: "Name of the instance",
suggestions: [
"Pleroma"
]
},
%{
key: :email,
label: "Admin Email Address",
type: :string,
description: "Email used to reach an Administrator/Moderator of the instance",
suggestions: [
"email@example.com"
]
},
%{
key: :notify_email,
label: "Sender Email Address",
type: :string,
description: "Envelope FROM address for mail sent via Pleroma",
suggestions: [
"notify@example.com"
]
},
%{
key: :description,
type: :string,
description:
"The instance's description. It can be seen in nodeinfo and `/api/v1/instance`",
suggestions: [
"Very cool instance"
]
},
%{
key: :short_description,
type: :string,
description:
"Shorter version of instance description. It can be seen on `/api/v1/instance`",
suggestions: [
"Cool instance"
]
},
%{
key: :status_page,
type: :string,
description: "A page where people can see the status of the server during an outage",
suggestions: [
"https://status.pleroma.example.org"
]
},
%{
key: :contact_username,
type: :string,
description: "Instance owner username",
suggestions: ["admin"]
},
%{
key: :limit,
type: :integer,
description: "Posts character limit (CW/Subject included in the counter)",
suggestions: [
5_000
]
},
%{
key: :remote_limit,
type: :integer,
description: "Hard character limit beyond which remote posts will be dropped",
suggestions: [
100_000
]
},
%{
key: :max_media_attachments,
type: :integer,
description: "Maximum number of post media attachments",
suggestions: [
1_000_000
]
},
%{
key: :upload_limit,
type: :integer,
description: "File size limit of uploads (except for avatar, background, banner)",
suggestions: [
16_000_000
]
},
%{
key: :avatar_upload_limit,
type: :integer,
description: "File size limit of user's profile avatars",
suggestions: [
2_000_000
]
},
%{
key: :background_upload_limit,
type: :integer,
description: "File size limit of user's profile backgrounds",
suggestions: [
4_000_000
]
},
%{
key: :banner_upload_limit,
type: :integer,
description: "File size limit of user's profile banners",
suggestions: [
4_000_000
]
},
%{
key: :poll_limits,
type: :map,
description: "A map with poll limits for local polls",
suggestions: [
%{
max_options: 20,
max_option_chars: 200,
min_expiration: 0,
max_expiration: 31_536_000
}
],
children: [
%{
key: :max_options,
type: :integer,
description: "Maximum number of options",
suggestions: [20]
},
%{
key: :max_option_chars,
type: :integer,
description: "Maximum number of characters per option",
suggestions: [200]
},
%{
key: :min_expiration,
type: :integer,
description: "Minimum expiration time (in seconds)",
suggestions: [0]
},
%{
key: :max_expiration,
type: :integer,
description: "Maximum expiration time (in seconds)",
suggestions: [3600]
}
]
},
%{
key: :registrations_open,
type: :boolean,
description:
"Enable registrations for anyone. Invitations require this setting to be disabled."
},
%{
key: :invites_enabled,
type: :boolean,
description:
"Enable user invitations for admins (depends on `registrations_open` being disabled)"
},
%{
key: :account_activation_required,
type: :boolean,
description: "Require users to confirm their emails before signing in"
},
%{
key: :account_approval_required,
type: :boolean,
description: "Require users to be manually approved by an admin before signing in"
},
%{
key: :federating,
type: :boolean,
description: "Enable federation with other instances"
},
%{
key: :federation_incoming_replies_max_depth,
label: "Fed. incoming replies max depth",
type: :integer,
description:
"Max. depth of reply-to and reply activities fetching on incoming federation, to prevent out-of-memory situations while" <>
" fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes.",
suggestions: [
100
]
},
%{
key: :federation_reachability_timeout_days,
label: "Fed. reachability timeout days",
type: :integer,
description:
"Timeout (in days) of each external federation target being unreachable prior to pausing federating to it",
suggestions: [
7
]
},
%{
key: :allow_relay,
type: :boolean,
description:
"Permits remote instances to subscribe to all public posts of your instance. (Important!) This may increase the visibility of your instance."
},
%{
key: :public,
type: :boolean,
description:
"Makes the client API in authenticated mode-only except for user-profiles." <>
" Useful for disabling the Local Timeline and The Whole Known Network. " <>
" Note: when setting to `false`, please also check `:restrict_unauthenticated` setting."
},
%{
key: :quarantined_instances,
type: {:list, :tuple},
key_placeholder: "instance",
value_placeholder: "reason",
description:
"List of ActivityPub instances where private (DMs, followers-only) activities will not be sent and the reason for doing so",
suggestions: [
{"quarantined.com", "Reason"},
{"*.quarantined.com", "Reason"}
]
},
%{
key: :rejected_instances,
type: {:list, :tuple},
key_placeholder: "instance",
value_placeholder: "reason",
description:
"List of ActivityPub instances to reject requests from if authorized_fetch_mode is enabled",
suggestions: [
{"rejected.com", "Reason"},
{"*.rejected.com", "Reason"}
]
},
%{
key: :static_dir,
type: :string,
description: "Instance static directory",
suggestions: [
"instance/static/"
]
},
%{
key: :allowed_post_formats,
type: {:list, :string},
description: "MIME-type list of formats allowed to be posted (transformed into HTML)",
suggestions: [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
]
},
%{
key: :extended_nickname_format,
type: :boolean,
description:
"Enable to use extended local nicknames format (allows underscores/dashes)." <>
" This will break federation with older software for theses nicknames."
},
%{
key: :cleanup_attachments,
type: :boolean,
description: """
Enable to remove associated attachments when status is removed.
This will not affect duplicates and attachments without status.
Enabling this will increase load to database when deleting statuses on larger instances.
"""
},
%{
key: :max_pinned_statuses,
type: :integer,
description: "The maximum number of pinned statuses. 0 will disable the feature.",
suggestions: [
0,
1,
3
]
},
%{
key: :max_endorsed_users,
type: :integer,
description: "The maximum number of recommended accounts. 0 will disable the feature.",
suggestions: [
0,
1,
3
]
},
%{
key: :autofollowed_nicknames,
type: {:list, :string},
description:
"Set to nicknames of (local) users that every new user should automatically follow"
},
%{
key: :autofollowing_nicknames,
type: {:list, :string},
description:
"Set to nicknames of (local) users that automatically follows every newly registered user"
},
%{
key: :attachment_links,
type: :boolean,
description: "Enable to automatically add attachment link text to statuses"
},
%{
key: :max_report_comment_size,
type: :integer,
description: "The maximum size of the report comment. Default: 1000.",
suggestions: [
1_000
]
},
%{
key: :report_strip_status,
label: "Report strip status",
type: :boolean,
description:
"Strip associated statuses in reports to ids when closed/resolved, otherwise keep a copy"
},
%{
key: :safe_dm_mentions,
label: "Safe DM mentions",
type: :boolean,
description:
"If enabled, only mentions at the beginning of a post will be used to address people in direct messages." <>
" This is to prevent accidental mentioning of people when talking about them (e.g. \"@admin please keep an eye on @bad_actor\")." <>
" Default: disabled"
},
%{
key: :healthcheck,
type: :boolean,
description: "If enabled, system data will be shown on `/api/pleroma/healthcheck`"
},
%{
key: :remote_post_retention_days,
type: :integer,
description:
"The default amount of days to retain remote posts when pruning the database",
suggestions: [
90
]
},
%{
key: :user_bio_length,
type: :integer,
description: "A user bio maximum length. Default: 5000.",
suggestions: [
5_000
]
},
%{
key: :user_name_length,
type: :integer,
description: "A user name maximum length. Default: 100.",
suggestions: [
100
]
},
%{
key: :skip_thread_containment,
type: :boolean,
description: "Skip filtering out broken threads. Default: enabled."
},
%{
key: :limit_to_local_content,
type: {:dropdown, :atom},
description:
"Limit unauthenticated users to search for local statutes and users only. Default: `:unauthenticated`.",
suggestions: [
:unauthenticated,
:all,
false
]
},
%{
key: :max_account_fields,
type: :integer,
description: "The maximum number of custom fields in the user profile. Default: 10.",
suggestions: [
10
]
},
%{
key: :max_remote_account_fields,
type: :integer,
description:
"The maximum number of custom fields in the remote user profile. Default: 20.",
suggestions: [
20
]
},
%{
key: :account_field_name_length,
type: :integer,
description: "An account field name maximum length. Default: 512.",
suggestions: [
512
]
},
%{
key: :account_field_value_length,
type: :integer,
description: "An account field value maximum length. Default: 2048.",
suggestions: [
2048
]
},
%{
key: :registration_reason_length,
type: :integer,
description: "Maximum registration reason length. Default: 500.",
suggestions: [
500
]
},
%{
key: :external_user_synchronization,
type: :boolean,
description: "Enabling following/followers counters synchronization for external users"
},
%{
key: :multi_factor_authentication,
type: :keyword,
description: "Multi-factor authentication settings",
suggestions: [
[
totp: [digits: 6, period: 30],
backup_codes: [number: 5, length: 16]
]
],
children: [
%{
key: :totp,
label: "TOTP settings",
type: :keyword,
description: "TOTP settings",
suggestions: [digits: 6, period: 30],
children: [
%{
key: :digits,
type: :integer,
suggestions: [6],
description:
"Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
},
%{
key: :period,
type: :integer,
suggestions: [30],
description:
"A period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
}
]
},
%{
key: :backup_codes,
type: :keyword,
description: "MFA backup codes settings",
suggestions: [number: 5, length: 16],
children: [
%{
key: :number,
type: :integer,
suggestions: [5],
description: "Number of backup codes to generate."
},
%{
key: :length,
type: :integer,
suggestions: [16],
description:
"Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
}
]
}
]
},
%{
key: :instance_thumbnail,
type: {:string, :image},
description:
"The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.",
suggestions: ["/instance/thumbnail.jpeg"]
},
%{
key: :favicon,
type: {:string, :image},
description: "Favicon of the instance",
suggestions: ["/favicon.png"]
},
%{
key: :show_reactions,
type: :boolean,
description: "Let favourites and emoji reactions be viewed through the API."
},
%{
key: :profile_directory,
type: :boolean,
description: "Enable profile directory."
},
%{
key: :admin_privileges,
type: {:list, :atom},
suggestions: [
:users_read,
:users_manage_invites,
:users_manage_activation_state,
:users_manage_tags,
:users_manage_credentials,
:users_delete,
:messages_read,
:messages_delete,
:instances_delete,
:reports_manage_reports,
:moderation_log_read,
:announcements_manage_announcements,
:emoji_manage_emoji,
:statistics_read
],
description:
"What extra privileges to allow admins (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)"
},
%{
key: :moderator_privileges,
type: {:list, :atom},
suggestions: [
:users_read,
:users_manage_invites,
:users_manage_activation_state,
:users_manage_tags,
:users_manage_credentials,
:users_delete,
:messages_read,
:messages_delete,
:instances_delete,
:reports_manage_reports,
:moderation_log_read,
:announcements_manage_announcements,
:emoji_manage_emoji,
:statistics_read
],
description:
"What extra privileges to allow moderators (e.g. updating user credentials, get password reset token, delete users, index and read private statuses and chats)"
},
%{
key: :birthday_required,
type: :boolean,
description: "Require users to enter their birthday."
},
%{
key: :birthday_min_age,
type: :integer,
description:
"Minimum required age (in days) for users to create account. Only used if birthday is required.",
suggestions: [6570]
},
%{
key: :languages,
type: {:list, :string},
description:
"Languages to be exposed in /api/v1/instance. Should be in the format of BCP47 language codes.",
suggestions: [
"en"
]
}
]
},
%{
group: :pleroma,
key: :welcome,
type: :group,
description: "Welcome messages settings",
children: [
%{
key: :direct_message,
type: :keyword,
descpiption: "Direct message settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables sending a direct message to newly registered users"
},
%{
key: :message,
type: :string,
description: "A message that will be sent to newly registered users",
suggestions: [
"Hi, @username! Welcome on board!"
]
},
%{
key: :sender_nickname,
type: :string,
description: "The nickname of the local user that sends a welcome message",
suggestions: [
"lain"
]
}
]
},
%{
key: :chat_message,
type: :keyword,
descpiption: "Chat message settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables sending a chat message to newly registered users"
},
%{
key: :message,
type: :string,
description:
"A message that will be sent to newly registered users as a chat message",
suggestions: [
"Hello, welcome on board!"
]
},
%{
key: :sender_nickname,
type: :string,
description: "The nickname of the local user that sends a welcome chat message",
suggestions: [
"lain"
]
}
]
},
%{
key: :email,
type: :keyword,
descpiption: "Email message settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables sending an email to newly registered users"
},
%{
key: :sender,
type: [:string, :tuple],
description:
"Email address and/or nickname that will be used to send the welcome email.",
suggestions: [
{"Pleroma App", "welcome@pleroma.app"}
]
},
%{
key: :subject,
type: :string,
description:
"Subject of the welcome email. EEX template with user and instance_name variables can be used.",
suggestions: ["Welcome to <%= instance_name%>"]
},
%{
key: :html,
type: :string,
description:
"HTML content of the welcome email. EEX template with user and instance_name variables can be used.",
suggestions: ["<h1>Hello <%= user.name%>. Welcome to <%= instance_name%></h1>"]
},
%{
key: :text,
type: :string,
description:
"Text content of the welcome email. EEX template with user and instance_name variables can be used.",
suggestions: ["Hello <%= user.name%>. \n Welcome to <%= instance_name%>\n"]
}
]
}
]
},
%{
group: :pleroma,
key: :frontend_configurations,
type: :group,
description:
"This form can be used to configure a keyword list that keeps the configuration data for any " <>
"kind of frontend. By default, settings for pleroma_fe are configured. If you want to " <>
"add your own configuration your settings all fields must be complete.",
children: [
%{
key: :pleroma_fe,
label: "Pleroma FE",
type: :map,
description: "Settings for Pleroma FE",
suggestions: [
%{
alwaysShowSubjectInput: true,
background: "/static/aurora_borealis.jpg",
collapseMessageWithSubject: false,
greentext: false,
embeddedToS: true,
hideFilteredStatuses: false,
hideMutedPosts: false,
hidePostStats: false,
hideSitename: false,
hideUserStats: false,
loginMethod: "password",
logo: "/static/logo.svg",
logoMargin: ".1em",
logoMask: true,
minimalScopesMode: false,
noAttachmentLinks: false,
nsfwCensorImage: "/static/img/nsfw.74818f9.png",
postContentType: "text/plain",
redirectRootLogin: "/main/friends",
redirectRootNoLogin: "/main/all",
scopeCopy: true,
sidebarRight: false,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
subjectLineBehavior: "email",
theme: "pleroma-dark",
webPushNotifications: false
}
],
children: [
%{
key: :alwaysShowSubjectInput,
label: "Always show subject input",
type: :boolean,
description: "When disabled, auto-hide the subject field if it's empty"
},
%{
key: :background,
type: {:string, :image},
description:
"URL of the background, unless viewing a user profile with a background that is set",
suggestions: ["/images/city.jpg"]
},
%{
key: :collapseMessageWithSubject,
label: "Collapse message with subject",
type: :boolean,
description:
"When a message has a subject (aka Content Warning), collapse it by default"
},
%{
key: :greentext,
label: "Greentext",
type: :boolean,
description: "Enables green text on lines prefixed with the > character"
},
%{
key: :embeddedToS,
label: "Embedded ToS panel",
type: :boolean,
description: "Hide Terms of Service panel decorations on About and Registration pages"
},
%{
key: :hideFilteredStatuses,
label: "Hide Filtered Statuses",
type: :boolean,
description: "Hides filtered statuses from timelines"
},
%{
key: :hideMutedPosts,
label: "Hide Muted Posts",
type: :boolean,
description: "Hides muted statuses from timelines"
},
%{
key: :hidePostStats,
label: "Hide post stats",
type: :boolean,
description: "Hide notices statistics (repeats, favorites, ...)"
},
%{
key: :hideSitename,
label: "Hide Sitename",
type: :boolean,
description: "Hides instance name from PleromaFE banner"
},
%{
key: :hideUserStats,
label: "Hide user stats",
type: :boolean,
description:
"Hide profile statistics (posts, posts per day, followers, followings, ...)"
},
%{
key: :logo,
type: {:string, :image},
description: "URL of the logo, defaults to Pleroma's logo",
suggestions: ["/static/logo.svg"]
},
%{
key: :logoMargin,
label: "Logo margin",
type: :string,
description:
"Allows you to adjust vertical margins between logo boundary and navbar borders. " <>
"The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.",
suggestions: [".1em"]
},
%{
key: :logoMask,
label: "Logo mask",
type: :boolean,
description:
"By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <>
"If you want a colorful logo you must disable logoMask."
},
%{
key: :minimalScopesMode,
label: "Minimal scopes mode",
type: :boolean,
description:
"Limit scope selection to Direct, User default, and Scope of post replying to. " <>
"Also prevents replying to a DM with a public post from PleromaFE."
},
%{
key: :nsfwCensorImage,
label: "NSFW Censor Image",
type: {:string, :image},
description:
"URL of the image to use for hiding NSFW media attachments in the timeline",
suggestions: ["/static/img/nsfw.74818f9.png"]
},
%{
key: :postContentType,
label: "Post Content Type",
type: {:dropdown, :atom},
description: "Default post formatting option",
suggestions: [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
]
},
%{
key: :redirectRootNoLogin,
label: "Redirect root no login",
type: :string,
description:
"Relative URL which indicates where to redirect when a user isn't logged in",
suggestions: ["/main/all"]
},
%{
key: :redirectRootLogin,
label: "Redirect root login",
type: :string,
description:
"Relative URL which indicates where to redirect when a user is logged in",
suggestions: ["/main/friends"]
},
%{
key: :scopeCopy,
label: "Scope copy",
type: :boolean,
description: "Copy the scope (private/unlisted/public) in replies to posts by default"
},
%{
key: :sidebarRight,
label: "Sidebar on Right",
type: :boolean,
description: "Change alignment of sidebar and panels to the right"
},
%{
key: :showFeaturesPanel,
label: "Show instance features panel",
type: :boolean,
description:
"Enables panel displaying functionality of the instance on the About page"
},
%{
key: :showInstanceSpecificPanel,
label: "Show instance specific panel",
type: :boolean,
description: "Whether to show the instance's custom panel"
},
%{
key: :subjectLineBehavior,
label: "Subject line behavior",
type: :string,
description: "Allows changing the default behaviour of subject lines in replies.
`email`: copy and prepend re:, as in email,
`masto`: copy verbatim, as in Mastodon,
`noop`: don't copy the subject.",
suggestions: ["email", "masto", "noop"]
},
%{
key: :theme,
type: :string,
description: "Which theme to use. Available themes are defined in styles.json",
suggestions: ["pleroma-dark"]
}
]
}
]
},
%{
group: :pleroma,
key: :assets,
type: :group,
description:
"This section configures assets to be used with various frontends. Currently the only option relates to mascots on the mastodon frontend",
children: [
%{
key: :mascots,
type: {:keyword, :map},
description:
"Keyword of mascots, each element must contain both an URL and a mime_type key",
suggestions: [
pleroma_fox_tan: %{
url: "/images/pleroma-fox-tan-smol.png",
mime_type: "image/png"
},
pleroma_fox_tan_shy: %{
url: "/images/pleroma-fox-tan-shy.png",
mime_type: "image/png"
}
]
},
%{
key: :default_mascot,
type: :atom,
description:
"This will be used as the default mascot on MastoFE. Default: `:pleroma_fox_tan`",
suggestions: [
:pleroma_fox_tan
]
},
%{
key: :default_user_avatar,
type: {:string, :image},
description: "URL of the default user avatar",
suggestions: ["/images/avi.png"]
}
]
},
%{
group: :pleroma,
key: :manifest,
type: :group,
description:
"This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE.",
children: [
%{
key: :icons,
type: {:list, :map},
description: "Describe the icons of the app",
suggestion: [
%{
src: "/static/logo.png"
},
%{
src: "/static/icon.png",
type: "image/png"
},
%{
src: "/static/icon.ico",
sizes: "72x72 96x96 128x128 256x256"
}
]
},
%{
key: :theme_color,
type: :string,
description: "Describe the theme color of the app",
suggestions: ["#282c37", "mediumpurple"]
},
%{
key: :background_color,
type: :string,
description: "Describe the background color of the app",
suggestions: ["#191b22", "aliceblue"]
}
]
},
%{
group: :pleroma,
key: :media_proxy,
type: :group,
description: "Media proxy",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables proxying of remote media via the instance's proxy"
},
%{
key: :base_url,
label: "Base URL",
type: :string,
description:
"The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.",
suggestions: ["https://example.com"]
},
%{
key: :invalidation,
type: :keyword,
descpiption: "",
suggestions: [
enabled: true,
provider: Pleroma.Web.MediaProxy.Invalidation.Script
],
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables media cache object invalidation."
},
%{
key: :provider,
type: :module,
description: "Module which will be used to purge objects from the cache.",
suggestions: [
Pleroma.Web.MediaProxy.Invalidation.Script,
Pleroma.Web.MediaProxy.Invalidation.Http
]
}
]
},
%{
key: :proxy_opts,
label: "Advanced MediaProxy Options",
type: :keyword,
description: "Internal Pleroma.ReverseProxy settings",
suggestions: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
max_read_duration: 30_000
],
children: [
%{
key: :redirect_on_failure,
type: :boolean,
description: """
Redirects the client to the origin server upon encountering HTTP errors.\n
Note that files larger than Max Body Length will trigger an error. (e.g., Peertube videos)\n\n
**WARNING:** This setting will allow larger files to be accessed, but exposes the\n
IP addresses of your users to the other servers, bypassing the MediaProxy.
"""
},
%{
key: :max_body_length,
type: :integer,
description:
"Maximum file size (in bytes) allowed through the Pleroma MediaProxy cache."
},
%{
key: :max_read_duration,
type: :integer,
description: "Timeout (in milliseconds) of GET request to the remote URI."
}
]
},
%{
key: :whitelist,
type: {:list, :string},
description: "List of hosts with scheme to bypass the MediaProxy",
suggestions: ["http://example.com"]
}
]
},
%{
group: :pleroma,
key: :media_preview_proxy,
type: :group,
description: "Media preview proxy",
children: [
%{
key: :enabled,
type: :boolean,
description:
"Enables proxying of remote media preview to the instance's proxy. Requires enabled media proxy."
},
%{
key: :thumbnail_max_width,
type: :integer,
description:
"Max width of preview thumbnail for images (video preview always has original dimensions)."
},
%{
key: :thumbnail_max_height,
type: :integer,
description:
"Max height of preview thumbnail for images (video preview always has original dimensions)."
},
%{
key: :image_quality,
type: :integer,
description: "Quality of the output. Ranges from 0 (min quality) to 100 (max quality)."
},
%{
key: :min_content_length,
type: :integer,
description:
"Min content length (in bytes) to perform preview. Media smaller in size will be served without thumbnailing."
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.MediaProxy.Invalidation.Http,
type: :group,
description: "HTTP invalidate settings",
children: [
%{
key: :method,
type: :atom,
description: "HTTP method of request. Default: :purge"
},
%{
key: :headers,
type: {:keyword, :string},
description: "HTTP headers of request",
suggestions: [{"x-refresh", 1}]
},
%{
key: :options,
type: :keyword,
description: "Request options",
children: [
%{
key: :params,
type: {:map, :string}
}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.MediaProxy.Invalidation.Script,
type: :group,
description: "Invalidation script settings",
children: [
%{
key: :script_path,
type: :string,
description: "Path to executable script which will purge cached items.",
suggestions: ["./installation/nginx-cache-purge.sh.example"]
},
%{
key: :url_format,
label: "URL Format",
type: :string,
description:
"Optional URL format preprocessing. Only required for Apache's htcacheclean.",
suggestions: [":htcacheclean"]
}
]
},
%{
group: :pleroma,
key: :gopher,
type: :group,
description: "Gopher settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables the gopher interface"
},
%{
key: :ip,
label: "IP",
type: :tuple,
description: "IP address to bind to",
suggestions: [{0, 0, 0, 0}]
},
%{
key: :port,
type: :integer,
description: "Port to bind to",
suggestions: [9999]
},
%{
key: :dstport,
type: :integer,
description: "Port advertised in URLs (optional, defaults to port)",
suggestions: [9999]
}
]
},
%{
group: :pleroma,
key: :activitypub,
label: "ActivityPub",
type: :group,
description: "ActivityPub-related settings",
children: [
%{
key: :unfollow_blocked,
type: :boolean,
description: "Whether blocks result in people getting unfollowed"
},
%{
key: :outgoing_blocks,
type: :boolean,
description: "Whether to federate blocks to other instances"
},
%{
key: :blockers_visible,
type: :boolean,
description: "Whether a user can see someone who has blocked them"
},
%{
key: :sign_object_fetches,
type: :boolean,
description: "Sign object fetches with HTTP signatures"
},
%{
key: :authorized_fetch_mode,
type: :boolean,
description: "Require HTTP signatures for AP fetches"
},
%{
key: :authorized_fetch_mode_exceptions,
type: {:list, :string},
description:
"List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)"
},
%{
key: :note_replies_output_limit,
type: :integer,
description:
"The number of Note replies' URIs to be included with outgoing federation (`5` to match Mastodon hardcoded value, `0` to disable the output)"
},
%{
key: :follow_handshake_timeout,
type: :integer,
description: "Following handshake timeout",
suggestions: [500]
},
%{
key: :client_api_enabled,
type: :boolean,
description: "Allow client to server ActivityPub interactions"
},
%{
key: :anonymize_reporter,
type: :boolean,
label: "Anonymize local reports",
description:
"If true, replace local reporters with the designated local user for the copy to be sent to remote servers"
},
%{
key: :anonymize_reporter_local_nickname,
type: :string,
label: "Anonymized reporter",
description:
"The nickname of the designated local user that replaces the actual reporter in the copy to be sent to remote servers",
suggestions: [
"lain"
]
}
]
},
%{
group: :pleroma,
key: :http_security,
label: "HTTP security",
type: :group,
description: "HTTP security settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Whether the managed content security policy is enabled"
},
%{
key: :sts,
label: "STS",
type: :boolean,
description: "Whether to additionally send a Strict-Transport-Security header"
},
%{
key: :sts_max_age,
label: "STS max age",
type: :integer,
description: "The maximum age for the Strict-Transport-Security header if sent",
suggestions: [31_536_000]
},
%{
key: :ct_max_age,
label: "CT max age",
type: :integer,
description: "The maximum age for the Expect-CT header if sent",
suggestions: [2_592_000]
},
%{
key: :referrer_policy,
type: :string,
description: "The referrer policy to use, either \"same-origin\" or \"no-referrer\"",
suggestions: ["same-origin", "no-referrer"]
},
%{
key: :report_uri,
label: "Report URI",
type: :string,
description: "Adds the specified URL to report-uri and report-to group in CSP header",
suggestions: ["https://example.com/report-uri"]
}
]
},
%{
group: :web_push_encryption,
key: :vapid_details,
label: "Vapid Details",
type: :group,
description:
"Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it.",
children: [
%{
key: :subject,
type: :string,
description:
"A mailto link for the administrative contact." <>
" It's best if this email is not a personal email address, but rather a group email to the instance moderation team.",
suggestions: ["mailto:moderators@pleroma.com"]
},
%{
key: :public_key,
type: :string,
description: "VAPID public key",
suggestions: ["Public key"]
},
%{
key: :private_key,
type: :string,
description: "VAPID private key",
suggestions: ["Private key"]
}
]
},
%{
group: :pleroma,
key: Pleroma.Captcha,
type: :group,
description: "Captcha-related settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Whether the captcha should be shown on registration"
},
%{
key: :method,
type: :module,
description: "The method/service to use for captcha",
suggestions: [Pleroma.Captcha.Kocaptcha, Pleroma.Captcha.Native]
},
%{
key: :seconds_valid,
type: :integer,
description: "The time in seconds for which the captcha is valid",
suggestions: [60]
}
]
},
%{
group: :pleroma,
key: Pleroma.Captcha.Kocaptcha,
type: :group,
description:
"Kocaptcha is a very simple captcha service with a single API endpoint, the source code is" <>
" here: https://github.com/koto-bank/kocaptcha. The default endpoint (https://captcha.kotobank.ch) is hosted by the developer.",
children: [
%{
key: :endpoint,
type: :string,
description: "The kocaptcha endpoint to use",
suggestions: ["https://captcha.kotobank.ch"]
}
]
},
%{
group: :pleroma,
label: "Pleroma Admin Token",
type: :group,
description:
"Allows setting a token that can be used to authenticate requests with admin privileges without a normal user account token. Append the `admin_token` parameter to requests to utilize it. (Please reconsider using HTTP Basic Auth or OAuth-based authentication if possible)",
children: [
%{
key: :admin_token,
type: :string,
description: "Admin token",
suggestions: [
"Please use a high entropy string or UUID"
]
}
]
},
%{
group: :pleroma,
key: Oban,
type: :group,
description:
"[Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration.",
children: [
%{
key: :log,
type: {:dropdown, :atom},
description: "Logs verbose mode",
suggestions: [false, :error, :warning, :info, :debug]
},
%{
key: :queues,
type: {:keyword, :integer},
description:
"Background jobs queues (keys: queues, values: max numbers of concurrent jobs)",
suggestions: [
activity_expiration: 10,
attachments_cleanup: 5,
background: 5,
federator_incoming: 50,
federator_outgoing: 50,
mailer: 10,
scheduled_activities: 10,
transmogrifier: 20,
web_push: 50
],
children: [
%{
key: :activity_expiration,
type: :integer,
description: "Activity expiration queue",
suggestions: [10]
},
%{
key: :backup,
type: :integer,
description: "Backup queue",
suggestions: [1]
},
%{
key: :attachments_cleanup,
type: :integer,
description: "Attachment deletion queue",
suggestions: [5]
},
%{
key: :background,
type: :integer,
description: "Background queue",
suggestions: [5]
},
%{
key: :federator_incoming,
type: :integer,
description: "Incoming federation queue",
suggestions: [50]
},
%{
key: :federator_outgoing,
type: :integer,
description: "Outgoing federation queue",
suggestions: [50]
},
%{
key: :mailer,
type: :integer,
description: "Email sender queue, see Pleroma.Emails.Mailer",
suggestions: [10]
},
%{
key: :scheduled_activities,
type: :integer,
description: "Scheduled activities queue, see Pleroma.ScheduledActivities",
suggestions: [10]
},
%{
key: :transmogrifier,
type: :integer,
description: "Transmogrifier queue",
suggestions: [20]
},
%{
key: :web_push,
type: :integer,
description: "Web push notifications queue",
suggestions: [50]
}
]
},
%{
key: :crontab,
type: {:list, :tuple},
description: "Settings for cron background jobs",
suggestions: [
{"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker},
{"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.Metadata,
type: :group,
description: "Metadata-related settings",
children: [
%{
key: :providers,
type: {:list, :module},
description: "List of metadata providers to enable",
suggestions: [
Pleroma.Web.Metadata.Providers.OpenGraph,
Pleroma.Web.Metadata.Providers.TwitterCard,
Pleroma.Web.Metadata.Providers.RelMe,
Pleroma.Web.Metadata.Providers.Feed
]
},
%{
key: :unfurl_nsfw,
label: "Unfurl NSFW",
type: :boolean,
description: "When enabled NSFW attachments will be shown in previews"
}
]
},
%{
group: :pleroma,
key: :rich_media,
type: :group,
description:
"If enabled the instance will parse metadata from attached links to generate link previews",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables RichMedia parsing of URLs"
},
%{
key: :ignore_hosts,
type: {:list, :string},
description: "List of hosts which will be ignored by the metadata parser",
suggestions: ["accounts.google.com", "xss.website"]
},
%{
key: :ignore_tld,
label: "Ignore TLD",
type: {:list, :string},
description: "List TLDs (top-level domains) which will ignore for parse metadata",
suggestions: ["local", "localdomain", "lan"]
},
%{
key: :parsers,
type: {:list, :module},
description:
"List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.",
suggestions: [
Pleroma.Web.RichMedia.Parsers.OEmbed,
Pleroma.Web.RichMedia.Parsers.TwitterCard
]
},
%{
key: :ttl_setters,
label: "TTL setters",
type: {:list, :module},
description:
"List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom module you need to use full name.",
suggestions: [
Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl
]
},
%{
key: :timeout,
type: :integer,
description:
"Amount of milliseconds after which the HTTP request is forcibly terminated.",
suggestions: [5_000]
},
%{
key: :user_agent,
type: :string,
description: "Custom User-Agent header to be used when fetching rich media content."
}
]
},
%{
group: :pleroma,
key: Pleroma.Formatter,
label: "Linkify",
type: :group,
description:
"Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.",
children: [
%{
key: :class,
type: [:string, :boolean],
description: "Specify the class to be added to the generated link. Disable to clear.",
suggestions: ["auto-linker", false]
},
%{
key: :rel,
type: [:string, :boolean],
description: "Override the rel attribute. Disable to clear.",
suggestions: ["ugc", "noopener noreferrer", false]
},
%{
key: :new_window,
type: :boolean,
description: "Link URLs will open in a new window/tab."
},
%{
key: :truncate,
type: [:integer, :boolean],
description:
"Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`",
suggestions: [15, false]
},
%{
key: :strip_prefix,
type: :boolean,
description: "Strip the scheme prefix."
},
%{
key: :extra,
type: :boolean,
description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)"
},
%{
key: :validate_tld,
type: [:atom, :boolean],
description:
"Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for URLs without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)",
suggestions: [:no_scheme, true]
}
]
},
%{
group: :pleroma,
key: Pleroma.ScheduledActivity,
type: :group,
description: "Scheduled activities settings",
children: [
%{
key: :daily_user_limit,
type: :integer,
description:
"The number of scheduled activities a user is allowed to create in a single day. Default: 25.",
suggestions: [25]
},
%{
key: :total_user_limit,
type: :integer,
description:
"The number of scheduled activities a user is allowed to create in total. Default: 300.",
suggestions: [300]
},
%{
key: :enabled,
type: :boolean,
description: "Whether scheduled activities are sent to the job queue to be executed"
}
]
},
%{
group: :pleroma,
key: Pleroma.Workers.PurgeExpiredActivity,
type: :group,
description: "Expired activities settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables expired activities addition & deletion"
},
%{
key: :min_lifetime,
type: :integer,
description: "Minimum lifetime for ephemeral activity (in seconds)",
suggestions: [600]
}
]
},
%{
group: :pleroma,
label: "Pleroma Authenticator",
type: :group,
description: "Authenticator",
children: [
%{
key: Pleroma.Web.Auth.Authenticator,
type: :module,
suggestions: [Pleroma.Web.Auth.PleromaAuthenticator, Pleroma.Web.Auth.LDAPAuthenticator]
}
]
},
%{
group: :pleroma,
key: :ldap,
label: "LDAP",
type: :group,
description:
"Use LDAP for user authentication. When a user logs in to the Pleroma instance, the name and password" <>
" will be verified by trying to authenticate (bind) to a LDAP server." <>
" If a user exists in the LDAP directory but there is no account with the same name yet on the" <>
" Pleroma instance then a new Pleroma account will be created with the same name as the LDAP user name.",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables LDAP authentication"
},
%{
key: :host,
type: :string,
description: "LDAP server hostname",
suggestions: ["localhosts"]
},
%{
key: :port,
type: :integer,
description: "LDAP port, e.g. 389 or 636",
suggestions: [389, 636]
},
%{
key: :ssl,
label: "SSL",
type: :boolean,
description: "Enable to use SSL, usually implies the port 636"
},
%{
key: :sslopts,
label: "SSL options",
type: :keyword,
description: "Additional SSL options",
suggestions: [verify: :verify_peer],
children: [
%{
key: :verify,
type: :atom,
description: "Type of cert verification",
suggestions: [:verify_peer]
}
]
},
%{
key: :tls,
label: "TLS",
type: :boolean,
description: "Enable to use STARTTLS, usually implies the port 389"
},
%{
key: :tlsopts,
label: "TLS options",
type: :keyword,
description: "Additional TLS options",
suggestions: [verify: :verify_peer],
children: [
%{
key: :verify,
type: :atom,
description: "Type of cert verification",
suggestions: [:verify_peer]
}
]
},
%{
key: :base,
type: :string,
description: "LDAP base, e.g. \"dc=example,dc=com\"",
suggestions: ["dc=example,dc=com"]
},
%{
key: :uid,
label: "UID Attribute",
type: :string,
description:
"LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"",
suggestions: ["cn"]
},
%{
key: :cacertfile,
label: "CACertfile",
type: :string,
description: "Path to CA certificate file"
},
%{
key: :mail,
label: "Mail Attribute",
type: :string,
description:
"LDAP attribute name to use as the email address when automatically registering the user on first login",
suggestions: ["mail"]
}
]
},
%{
group: :pleroma,
key: :auth,
type: :group,
description: "Authentication / authorization settings",
children: [
%{
key: :enforce_oauth_admin_scope_usage,
label: "Enforce OAuth admin scope usage",
type: :boolean,
description:
"OAuth admin scope requirement toggle. " <>
"If enabled, admin actions explicitly demand admin OAuth scope(s) presence in OAuth token " <>
"(client app must support admin scopes). If disabled and token doesn't have admin scope(s), " <>
"`is_admin` user flag grants access to admin-specific actions."
},
%{
key: :auth_template,
type: :string,
description:
"Authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.ee`.",
suggestions: ["show.html"]
},
%{
key: :oauth_consumer_template,
label: "OAuth consumer template",
type: :string,
description:
"OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to" <>
" `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`.",
suggestions: ["consumer.html"]
},
%{
key: :oauth_consumer_strategies,
label: "OAuth consumer strategies",
type: {:list, :string},
description:
"The list of enabled OAuth consumer strategies. By default it's set by OAUTH_CONSUMER_STRATEGIES environment variable." <>
" Each entry in this space-delimited string should be of format \"strategy\" or \"strategy:dependency\"" <>
" (e.g. twitter or keycloak:ueberauth_keycloak_strategy in case dependency is named differently than ueberauth_<strategy>).",
suggestions: ["twitter", "keycloak:ueberauth_keycloak_strategy"]
}
]
},
%{
group: :pleroma,
key: :email_notifications,
type: :group,
description: "Email notifications settings",
children: [
%{
key: :digest,
type: :map,
description:
"emails of \"what you've missed\" for users who have been inactive for a while",
suggestions: [
%{
active: false,
schedule: "0 0 * * 0",
interval: 7,
inactivity_threshold: 7
}
],
children: [
%{
key: :active,
label: "Enabled",
type: :boolean,
description: "Globally enable or disable digest emails"
},
%{
key: :schedule,
type: :string,
description:
"When to send digest email, in crontab format. \"0 0 0\" is the default, meaning \"once a week at midnight on Sunday morning\".",
suggestions: ["0 0 * * 0"]
},
%{
key: :interval,
type: :integer,
description: "Minimum interval between digest emails to one user",
suggestions: [7]
},
%{
key: :inactivity_threshold,
type: :integer,
description: "Minimum user inactivity threshold",
suggestions: [7]
}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Emails.UserEmail,
type: :group,
description: "Email template settings",
children: [
%{
key: :logo,
type: {:string, :image},
description: "A path to a custom logo. Set it to `nil` to use the default Pleroma logo.",
suggestions: ["some/path/logo.png"]
},
%{
key: :styling,
type: :map,
description: "A map with color settings for email templates.",
suggestions: [
%{
link_color: "#d8a070",
background_color: "#2C3645",
content_background_color: "#1B2635",
header_color: "#d8a070",
text_color: "#b9b9ba",
text_muted_color: "#b9b9ba"
}
],
children: [
%{
key: :link_color,
type: :string,
suggestions: ["#d8a070"]
},
%{
key: :background_color,
type: :string,
suggestions: ["#2C3645"]
},
%{
key: :content_background_color,
type: :string,
suggestions: ["#1B2635"]
},
%{
key: :header_color,
type: :string,
suggestions: ["#d8a070"]
},
%{
key: :text_color,
type: :string,
suggestions: ["#b9b9ba"]
},
%{
key: :text_muted_color,
type: :string,
suggestions: ["#b9b9ba"]
}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Emails.NewUsersDigestEmail,
type: :group,
description: "New users admin email digest",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables new users admin digest email when `true`"
}
]
},
%{
group: :pleroma,
key: :oauth2,
label: "OAuth2",
type: :group,
description: "Configure OAuth 2 provider capabilities",
children: [
%{
key: :token_expires_in,
type: :integer,
description: "The lifetime in seconds of the access token",
suggestions: [2_592_000]
},
%{
key: :issue_new_refresh_token,
type: :boolean,
description:
"Keeps old refresh token or generate new refresh token when to obtain an access token"
},
%{
key: :clean_expired_tokens,
type: :boolean,
description: "Enable a background job to clean expired OAuth tokens. Default: disabled."
}
]
},
%{
group: :pleroma,
key: :emoji,
type: :group,
children: [
%{
key: :shortcode_globs,
type: {:list, :string},
description: "Location of custom emoji files. * can be used as a wildcard.",
suggestions: ["/emoji/custom/**/*.png"]
},
%{
key: :pack_extensions,
type: {:list, :string},
description:
"A list of file extensions for emojis, when no emoji.txt for a pack is present",
suggestions: [".png", ".gif"]
},
%{
key: :groups,
type: {:keyword, {:list, :string}},
description:
"Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the group name" <>
" and the value is the location or array of locations. * can be used as a wildcard.",
suggestions: [
Custom: ["/emoji/*.png", "/emoji/**/*.png"]
]
},
%{
key: :default_manifest,
type: :string,
description:
"Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download." <>
" Currently only one manifest can be added (no arrays).",
suggestions: ["https://git.pleroma.social/pleroma/emoji-index/raw/master/index.json"]
},
%{
key: :shared_pack_cache_seconds_per_file,
label: "Shared pack cache s/file",
type: :integer,
descpiption:
"When an emoji pack is shared, the archive is created and cached in memory" <>
" for this amount of seconds multiplied by the number of files.",
suggestions: [60]
}
]
},
%{
group: :pleroma,
key: :rate_limit,
type: :group,
description:
"Rate limit settings. This is an advanced feature enabled only for :authentication by default.",
children: [
%{
key: :search,
type: [:tuple, {:list, :tuple}],
description: "For the search requests (account & status search etc.)",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :timeline,
type: [:tuple, {:list, :tuple}],
description: "For requests to timelines (each timeline has it's own limiter)",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :app_account_creation,
type: [:tuple, {:list, :tuple}],
description: "For registering user accounts from the same IP address",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :relations_actions,
type: [:tuple, {:list, :tuple}],
description: "For actions on relationships with all users (follow, unfollow)",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :relation_id_action,
label: "Relation ID action",
type: [:tuple, {:list, :tuple}],
description: "For actions on relation with a specific user (follow, unfollow)",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :statuses_actions,
type: [:tuple, {:list, :tuple}],
description:
"For create / delete / fav / unfav / reblog / unreblog actions on any statuses",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :status_id_action,
label: "Status ID action",
type: [:tuple, {:list, :tuple}],
description:
"For fav / unfav or reblog / unreblog actions on the same status by the same user",
suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]]
},
%{
key: :authentication,
type: [:tuple, {:list, :tuple}],
description: "For authentication create / password check / user existence check requests",
suggestions: [{60_000, 15}]
}
]
},
%{
group: :mime,
label: "Mime Types",
type: :group,
description: "Mime Types settings",
children: [
%{
key: :types,
type: :map,
suggestions: [
%{
"application/xml" => ["xml"],
"application/xrd+xml" => ["xrd+xml"],
"application/jrd+json" => ["jrd+json"],
"application/activity+json" => ["activity+json"],
"application/ld+json" => ["activity+json"]
}
],
children: [
%{
key: "application/xml",
type: {:list, :string},
suggestions: ["xml"]
},
%{
key: "application/xrd+xml",
type: {:list, :string},
suggestions: ["xrd+xml"]
},
%{
key: "application/jrd+json",
type: {:list, :string},
suggestions: ["jrd+json"]
},
%{
key: "application/activity+json",
type: {:list, :string},
suggestions: ["activity+json"]
},
%{
key: "application/ld+json",
type: {:list, :string},
suggestions: ["activity+json"]
}
]
}
]
},
%{
group: :pleroma,
key: :shout,
type: :group,
description: "Pleroma shout settings",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables the backend Shoutbox chat feature."
},
%{
key: :limit,
type: :integer,
description: "Shout message character limit.",
suggestions: [
5_000
]
}
]
},
+ %{
+ group: :pleroma,
+ key: Pleroma.Chat,
+ type: :group,
+ description: "Pleroma Chat settings",
+ children: [
+ %{
+ key: :enabled,
+ type: :boolean,
+ description: "Enables the chats API."
+ }
+ ]
+ },
%{
group: :pleroma,
key: :http,
label: "HTTP",
type: :group,
description: "HTTP settings",
children: [
%{
key: :proxy_url,
label: "Proxy URL",
type: [:string, :tuple],
description: "Proxy URL",
suggestions: ["localhost:9020", {:socks5, :localhost, 3090}]
},
%{
key: :send_user_agent,
type: :boolean
},
%{
key: :user_agent,
type: [:string, :atom],
description:
"What user agent to use. Must be a string or an atom `:default`. Default value is `:default`.",
suggestions: ["Pleroma", :default]
},
%{
key: :adapter,
type: :keyword,
description: "Adapter specific options",
suggestions: [],
children: [
%{
key: :ssl_options,
type: :keyword,
label: "SSL Options",
description: "SSL options for HTTP adapter",
children: [
%{
key: :versions,
type: {:list, :atom},
description: "List of TLS version to use",
suggestions: [:tlsv1, ":tlsv1.1", ":tlsv1.2", ":tlsv1.3"]
}
]
}
]
}
]
},
%{
group: :pleroma,
key: :markup,
label: "Markup Settings",
type: :group,
children: [
%{
key: :allow_inline_images,
type: :boolean
},
%{
key: :allow_headings,
type: :boolean
},
%{
key: :allow_tables,
type: :boolean
},
%{
key: :allow_fonts,
type: :boolean
},
%{
key: :scrub_policy,
type: {:list, :module},
description:
"Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom module you need to use full name.",
suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default]
}
]
},
%{
group: :pleroma,
key: :user,
type: :group,
children: [
%{
key: :deny_follow_blocked,
type: :boolean
}
]
},
%{
group: :pleroma,
key: Pleroma.User,
type: :group,
children: [
%{
key: :restricted_nicknames,
type: {:list, :string},
description: "List of nicknames users may not register with.",
suggestions: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"check_password",
"dev",
"friend-requests",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"ostatus_subscribe",
"pleroma",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"user-search",
"user_exists",
"users",
"web"
]
},
%{
key: :email_blacklist,
type: {:list, :string},
description: "List of email domains users may not register with.",
suggestions: ["mailinator.com", "maildrop.cc"]
}
]
},
%{
group: :cors_plug,
label: "CORS plug config",
type: :group,
children: [
%{
key: :max_age,
type: :integer,
suggestions: [86_400]
},
%{
key: :methods,
type: {:list, :string},
suggestions: ["POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"]
},
%{
key: :expose,
type: {:list, :string},
suggestions: [
"Link",
"X-RateLimit-Reset",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-Request-Id",
"Idempotency-Key"
]
},
%{
key: :credentials,
type: :boolean
},
%{
key: :headers,
type: {:list, :string},
suggestions: ["Authorization", "Content-Type", "Idempotency-Key"]
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.Plugs.RemoteIp,
type: :group,
description: """
`Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://hex.pm/packages/remote_ip) but with runtime configuration.
**If your instance is not behind at least one reverse proxy, you should not enable this plug.**
""",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enable/disable the plug. Default: disabled."
},
%{
key: :headers,
type: {:list, :string},
description: """
A list of strings naming the HTTP headers to use when deriving the true client IP. Default: `["x-forwarded-for"]`.
"""
},
%{
key: :clients,
type: {:list, :string},
description:
"A list of client IPs or subnets in CIDR notation. These will not be treated as proxies or reserved ranges. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128."
},
%{
key: :proxies,
type: {:list, :string},
description:
"A list of upstream proxy IP subnets in CIDR notation from which we will parse the content of `headers`. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128."
},
%{
key: :reserved,
type: {:list, :string},
description: """
A list of reserved IP subnets in CIDR notation which should be ignored if found in `headers`. Defaults to `["127.0.0.0/8", "::1/128", "fc00::/7", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]`
"""
}
]
},
%{
group: :pleroma,
key: :web_cache_ttl,
label: "Web cache TTL",
type: :group,
description:
"The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration.",
children: [
%{
key: :activity_pub,
type: :integer,
description:
"Activity pub routes (except question activities). Default: `nil` (no expiration).",
suggestions: [nil]
},
%{
key: :activity_pub_question,
type: :integer,
description: "Activity pub routes (question activities). Default: `30_000` (30 seconds).",
suggestions: [30_000]
}
]
},
%{
group: :pleroma,
key: :static_fe,
label: "Static FE",
type: :group,
description:
"Render profiles and posts using server-generated HTML that is viewable without using JavaScript",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables the rendering of static HTML. Default: disabled."
}
]
},
%{
group: :pleroma,
key: :feed,
type: :group,
description: "Configure feed rendering",
children: [
%{
key: :post_title,
type: :map,
description: "Configure title rendering",
children: [
%{
key: :max_length,
type: :integer,
description: "Maximum number of characters before truncating title",
suggestions: [100]
},
%{
key: :omission,
type: :string,
description: "Replacement which will be used after truncating string",
suggestions: ["..."]
}
]
}
]
},
%{
group: :pleroma,
key: :mrf_follow_bot,
tab: :mrf,
related_policy: "Pleroma.Web.ActivityPub.MRF.FollowBotPolicy",
label: "MRF FollowBot Policy",
type: :group,
description: "Automatically follows newly discovered accounts.",
children: [
%{
key: :follower_nickname,
type: :string,
description: "The name of the bot account to use for following newly discovered users.",
suggestions: ["followbot"]
}
]
},
%{
group: :pleroma,
key: :modules,
type: :group,
description: "Custom Runtime Modules",
children: [
%{
key: :runtime_dir,
type: :string,
description: "A path to custom Elixir modules (such as MRF policies)."
}
]
},
%{
group: :pleroma,
key: :streamer,
type: :group,
description: "Settings for notifications streamer",
children: [
%{
key: :workers,
type: :integer,
description: "Number of workers to send notifications",
suggestions: [3]
},
%{
key: :overflow_workers,
type: :integer,
description: "Maximum number of workers created if pool is empty",
suggestions: [2]
}
]
},
%{
group: :pleroma,
key: :connections_pool,
type: :group,
description: "Advanced settings for `Gun` connections pool",
children: [
%{
key: :connection_acquisition_wait,
type: :integer,
description:
"Timeout to acquire a connection from pool. The total max time is this value multiplied by the number of retries. Default: 250ms.",
suggestions: [250]
},
%{
key: :connection_acquisition_retries,
type: :integer,
description:
"Number of attempts to acquire the connection from the pool if it is overloaded. Default: 5",
suggestions: [5]
},
%{
key: :max_connections,
type: :integer,
description: "Maximum number of connections in the pool. Default: 250 connections.",
suggestions: [250]
},
%{
key: :connect_timeout,
type: :integer,
description: "Timeout while `gun` will wait until connection is up. Default: 5000ms.",
suggestions: [5000]
},
%{
key: :reclaim_multiplier,
type: :integer,
description:
"Multiplier for the number of idle connection to be reclaimed if the pool is full. For example if the pool maxes out at 250 connections and this setting is set to 0.3, the pool will reclaim at most 75 idle connections if it's overloaded. Default: 0.1",
suggestions: [0.1]
}
]
},
%{
group: :pleroma,
key: :pools,
type: :group,
description: "Advanced settings for `Gun` workers pools",
children:
Enum.map([:federation, :media, :upload, :default], fn pool_name ->
%{
key: pool_name,
type: :keyword,
description: "Settings for #{pool_name} pool.",
children: [
%{
key: :size,
type: :integer,
description: "Maximum number of concurrent requests in the pool.",
suggestions: [50]
},
%{
key: :max_waiting,
type: :integer,
description:
"Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errors when a new request is made",
suggestions: [10]
},
%{
key: :recv_timeout,
type: :integer,
description: "Timeout for the pool while gun will wait for response",
suggestions: [10_000]
}
]
}
end)
},
%{
group: :pleroma,
key: :hackney_pools,
type: :group,
description: "Advanced settings for `Hackney` connections pools",
children: [
%{
key: :federation,
type: :keyword,
description: "Settings for federation pool.",
children: [
%{
key: :max_connections,
type: :integer,
description: "Number workers in the pool.",
suggestions: [50]
},
%{
key: :timeout,
type: :integer,
description: "Timeout while `hackney` will wait for response.",
suggestions: [150_000]
}
]
},
%{
key: :media,
type: :keyword,
description: "Settings for media pool.",
children: [
%{
key: :max_connections,
type: :integer,
description: "Number workers in the pool.",
suggestions: [50]
},
%{
key: :timeout,
type: :integer,
description: "Timeout while `hackney` will wait for response.",
suggestions: [150_000]
}
]
},
%{
key: :upload,
type: :keyword,
description: "Settings for upload pool.",
children: [
%{
key: :max_connections,
type: :integer,
description: "Number workers in the pool.",
suggestions: [25]
},
%{
key: :timeout,
type: :integer,
description: "Timeout while `hackney` will wait for response.",
suggestions: [300_000]
}
]
}
]
},
%{
group: :pleroma,
key: :restrict_unauthenticated,
label: "Restrict Unauthenticated",
type: :group,
description:
"Disallow viewing timelines, user profiles and statuses for unauthenticated users.",
children: [
%{
key: :timelines,
type: :map,
description: "Settings for public and federated timelines.",
children: [
%{
key: :local,
type: :boolean,
description: "Disallow view public timeline."
},
%{
key: :federated,
type: :boolean,
description: "Disallow view federated timeline."
}
]
},
%{
key: :profiles,
type: :map,
description: "Settings for user profiles.",
children: [
%{
key: :local,
type: :boolean,
description: "Disallow view local user profiles."
},
%{
key: :remote,
type: :boolean,
description: "Disallow view remote user profiles."
}
]
},
%{
key: :activities,
type: :map,
description: "Settings for statuses.",
children: [
%{
key: :local,
type: :boolean,
description: "Disallow view local statuses."
},
%{
key: :remote,
type: :boolean,
description: "Disallow view remote statuses."
}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.ApiSpec.CastAndValidate,
type: :group,
children: [
%{
key: :strict,
type: :boolean,
description:
"Enables strict input validation (useful in development, not recommended in production)"
}
]
},
%{
group: :pleroma,
key: :instances_favicons,
type: :group,
description: "Control favicons for instances",
children: [
%{
key: :enabled,
type: :boolean,
description: "Allow/disallow displaying and getting instances favicons"
}
]
},
%{
group: :ex_aws,
key: :s3,
type: :group,
descriptions: "S3 service related settings",
children: [
%{
key: :access_key_id,
type: :string,
description: "S3 access key ID",
suggestions: ["AKIAQ8UKHTGIYN7DMWWJ"]
},
%{
key: :secret_access_key,
type: :string,
description: "Secret access key",
suggestions: ["JFGt+fgH1UQ7vLUQjpW+WvjTdV/UNzVxcwn7DkaeFKtBS5LvoXvIiME4NQBsT6ZZ"]
},
%{
key: :host,
type: :string,
description: "S3 host",
suggestions: ["s3.eu-central-1.amazonaws.com"]
},
%{
key: :region,
type: :string,
description: "S3 region (for AWS)",
suggestions: ["us-east-1"]
}
]
},
%{
group: :pleroma,
key: :frontends,
type: :group,
description: "Installed frontends management",
children: [
%{
key: :primary,
type: :map,
description: "Primary frontend, the one that is served for all pages by default",
children: installed_frontend_options
},
%{
key: :admin,
type: :map,
description: "Admin frontend",
children: installed_frontend_options
},
%{
key: :available,
type: :map,
description:
"A map containing available frontends and parameters for their installation.",
children: frontend_options
},
%{
key: :pickable,
type: {:list, :string},
description:
"A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable."
}
]
},
%{
group: :pleroma,
key: Pleroma.Web.Preload,
type: :group,
description: "Preload-related settings",
children: [
%{
key: :providers,
type: {:list, :module},
description: "List of preload providers to enable",
suggestions: [
Pleroma.Web.Preload.Providers.Instance,
Pleroma.Web.Preload.Providers.User,
Pleroma.Web.Preload.Providers.Timelines
]
}
]
},
%{
group: :pleroma,
key: :majic_pool,
type: :group,
description: "Majic/libmagic configuration",
children: [
%{
key: :size,
type: :integer,
description: "Number of majic workers to start.",
suggestions: [2]
}
]
},
%{
group: :pleroma,
key: Pleroma.User.Backup,
type: :group,
description: "Account Backup",
children: [
%{
key: :purge_after_days,
type: :integer,
description: "Remove backup archives after N days",
suggestions: [30]
},
%{
key: :limit_days,
type: :integer,
description: "Limit user to export not more often than once per N days",
suggestions: [7]
},
%{
key: :process_chunk_size,
type: :integer,
label: "Process Chunk Size",
description: "The number of activities to fetch in the backup job for each chunk.",
suggestions: [100]
},
%{
key: :timeout,
type: :integer,
label: "Timeout",
description: "The amount of time to wait for backup to complete in seconds.",
suggestions: [1_800]
}
]
},
%{
group: :prometheus,
key: Pleroma.Web.Endpoint.MetricsExporter,
type: :group,
description: "Prometheus app metrics endpoint configuration",
children: [
%{
key: :enabled,
type: :boolean,
description: "[Pleroma extension] Enables app metrics endpoint."
},
%{
key: :ip_whitelist,
label: "IP Whitelist",
type: [{:list, :string}, {:list, :charlist}, {:list, :tuple}],
description: "Restrict access of app metrics endpoint to the specified IP addresses."
},
%{
key: :auth,
type: [:boolean, :tuple],
description: "Enables HTTP Basic Auth for app metrics endpoint.",
suggestion: [false, {:basic, "myusername", "mypassword"}]
},
%{
key: :path,
type: :string,
description: "App metrics endpoint URI path.",
suggestions: ["/api/pleroma/app_metrics"]
},
%{
key: :format,
type: :atom,
description: "App metrics endpoint output format.",
suggestions: [:text, :protobuf]
}
]
},
%{
group: :pleroma,
key: ConcurrentLimiter,
type: :group,
description: "Limits configuration for background tasks.",
children: [
%{
key: Pleroma.Web.RichMedia.Helpers,
type: :keyword,
description: "Concurrent limits configuration for getting RichMedia for activities.",
suggestions: [max_running: 5, max_waiting: 5],
children: [
%{
key: :max_running,
type: :integer,
description: "Max running concurrently jobs.",
suggestion: [5]
},
%{
key: :max_waiting,
type: :integer,
description: "Max waiting jobs.",
suggestion: [5]
}
]
},
%{
key: Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy,
type: :keyword,
description: "Concurrent limits configuration for MediaProxyWarmingPolicy.",
suggestions: [max_running: 5, max_waiting: 5],
children: [
%{
key: :max_running,
type: :integer,
description: "Max running concurrently jobs.",
suggestion: [5]
},
%{
key: :max_waiting,
type: :integer,
description: "Max waiting jobs.",
suggestion: [5]
}
]
}
]
},
%{
group: :pleroma,
key: Pleroma.Search,
type: :group,
description: "General search settings.",
children: [
%{
key: :module,
type: :keyword,
description: "Selected search module.",
suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch]
}
]
},
%{
group: :pleroma,
key: Pleroma.Search.Meilisearch,
type: :group,
description: "Meilisearch settings.",
children: [
%{
key: :url,
type: :string,
description: "Meilisearch URL.",
suggestion: ["http://127.0.0.1:7700/"]
},
%{
key: :private_key,
type: :string,
description:
"Private key for meilisearch authentication, or `nil` to disable private key authentication.",
suggestion: [nil]
},
%{
key: :initial_indexing_chunk_size,
type: :integer,
description:
"Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <>
" since there's a limit on maximum insert size",
suggestion: [100_000]
}
]
},
%{
group: :pleroma,
key: Pleroma.Language.LanguageDetector,
type: :group,
description: "Language detection providers",
children: [
%{
key: :provider,
type: :module,
suggestions: {:list_behaviour_implementations, Pleroma.Language.LanguageDetector.Provider}
},
%{
group: {:subgroup, Pleroma.Language.LanguageDetector.Fasttext},
key: :model,
label: "fastText language detection model",
type: :string,
suggestions: ["/usr/share/fasttext/lid.176.bin"]
}
]
},
%{
group: :pleroma,
key: Pleroma.Language.Translation,
type: :group,
description: "Translation providers",
children: [
%{
key: :provider,
type: :module,
suggestions: {:list_behaviour_implementations, Pleroma.Language.Translation.Provider}
},
%{
group: {:subgroup, Pleroma.Language.Translation.Deepl},
key: :base_url,
label: "DeepL base URL",
type: :string,
suggestions: ["https://api-free.deepl.com", "https://api.deepl.com"]
},
%{
group: {:subgroup, Pleroma.Language.Translation.Deepl},
key: :api_key,
label: "DeepL API Key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Pleroma.Language.Translation.Libretranslate},
key: :base_url,
label: "LibreTranslate instance URL",
type: :string,
suggestions: ["https://libretranslate.com"]
},
%{
group: {:subgroup, Pleroma.Language.Translation.Libretranslate},
key: :api_key,
label: "LibreTranslate API Key",
type: :string,
suggestions: ["YOUR_API_KEY"]
},
%{
group: {:subgroup, Pleroma.Language.Translation.TranslateLocally},
key: :intermediary_language,
label:
"translateLocally intermediary language (used when direct source->target model is not available)",
type: :string,
suggestions: ["en"]
},
%{
group: {:subgroup, Pleroma.Language.Translation.Mozhi},
key: :base_url,
label: "Mozhi instance URL",
type: :string
},
%{
group: {:subgroup, Pleroma.Language.Translation.Mozhi},
key: :engine,
label: "Engine used for Mozhi",
type: :string,
suggestions: ["libretranslate"]
}
]
}
]
diff --git a/config/dev.exs b/config/dev.exs
index 14cf4a6dc..e86bdb7fd 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -1,70 +1,70 @@
import Config
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with brunch.io to recompile .js and .css sources.
config :pleroma, Pleroma.Web.Endpoint,
http: [
port: 4000
],
protocol: "http",
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [],
secure_cookie_flag: false
config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Local
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# command from your terminal:
#
# openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem
#
# The `http:` config above can be replaced with:
#
# https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Do not include timestamps in development logs
-config :logger, Logger.Backends.Console, format: "$metadata[$level] $message\n"
+config :logger, :default_formatter, format: "$metadata[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Configure your database
config :pleroma, Pleroma.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "pleroma_dev",
hostname: "localhost",
pool_size: 10
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
# Reduce recompilation time
# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects
config :phoenix, :plug_init_mode, :runtime
if File.exists?("./config/dev.secret.exs") do
import_config "dev.secret.exs"
else
IO.puts(
:stderr,
"!!! RUNNING IN LOCALHOST DEV MODE! !!!\nFEDERATION WON'T WORK UNTIL YOU CONFIGURE A dev.secret.exs"
)
end
if File.exists?("./config/dev.exported_from_db.secret.exs"),
do: import_config("dev.exported_from_db.secret.exs")
diff --git a/config/prod.exs b/config/prod.exs
index 2d252bf02..1dfbbe937 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -1,75 +1,74 @@
import Config
# For production, we often load configuration from external
# sources, such as your system environment. For this reason,
# you won't find the :http configuration below, but set inside
# Pleroma.Web.Endpoint.load_from_system_env/1 dynamically.
# Any dynamic configuration should be moved to such function.
#
# Don't forget to configure the url host to something meaningful,
# Phoenix uses this information when generating URLs.
#
# Finally, we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the mix phoenix.digest task
# which you typically run after static files are built.
config :pleroma, Pleroma.Web.Endpoint,
http: [port: 4000],
protocol: "http"
config :phoenix, serve_endpoints: true
# Do not print debug messages in production
-config :logger, Logger.Backends.Console, level: :info
-config :logger, :console, level: :info
+config :logger, :default_handler, level: :info
config :logger, :ex_syslogger, level: :info
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to the previous section and set your `:url` port to 443:
#
# config :pleroma, Pleroma.Web.Endpoint,
# ...
# url: [host: "example.com", port: 443],
# https: [:inet6,
# port: 443,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")]
#
# Where those two env variables return an absolute path to
# the key and cert in disk or a relative path inside priv,
# for example "priv/ssl/server.key".
#
# We also recommend setting `force_ssl`, ensuring no data is
# ever sent via http, always redirecting to https:
#
# config :pleroma, Pleroma.Web.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Using releases
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start the server for all endpoints:
#
# config :phoenix, :serve_endpoints, true
#
# Alternatively, you can configure exactly which server to
# start per endpoint:
#
# config :pleroma, Pleroma.Web.Endpoint, server: true
#
# Finally import the config/prod.secret.exs
# which should be versioned separately.
if File.exists?("./config/prod.secret.exs") do
import_config "prod.secret.exs"
else
"`config/prod.secret.exs` not found. You may want to create one by running `mix pleroma.instance gen`"
|> IO.warn([])
end
if File.exists?("./config/prod.exported_from_db.secret.exs"),
do: import_config("prod.exported_from_db.secret.exs")
diff --git a/config/test.exs b/config/test.exs
index 9652b7d4b..ed5283a26 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,214 +1,216 @@
import Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :pleroma, Pleroma.Web.Endpoint,
http: [port: 4001],
url: [port: 4001],
server: true
# Disable captha for tests
config :pleroma, Pleroma.Captcha,
# It should not be enabled for automatic tests
enabled: false,
# A fake captcha service for tests
method: Pleroma.Captcha.Mock
# Print only warnings and errors during test
-config :logger, :console,
- level: :warning,
- format: "\n[$level] $message\n"
+config :logger, :default_handler, level: :warning
+
+config :logger, :default_formatter,
+ format: "\n[$level] $message\n",
+ colors: [enabled: false]
config :pleroma, :auth, oauth_consumer_strategies: []
config :pleroma, Pleroma.Upload,
filters: [],
link_name: false,
default_description: :filename
config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads"
config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Test, enabled: true
config :pleroma, :instance,
email: "admin@example.com",
notify_email: "noreply@example.com",
skip_thread_containment: false,
federating: false,
external_user_synchronization: false,
static_dir: "test/instance_static/"
config :pleroma, :activitypub,
sign_object_fetches: false,
follow_handshake_timeout: 0,
client_api_enabled: true
# Configure your database
config :pleroma, Pleroma.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "pleroma_test",
hostname: System.get_env("DB_HOST") || "localhost",
port: System.get_env("DB_PORT") || "5432",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2,
log: false
config :pleroma, :dangerzone, override_repo_pool_size: true
# Reduce hash rounds for testing
config :pleroma, :password, iterations: 1
config :tesla, adapter: Tesla.Mock
config :pleroma, :rich_media,
enabled: false,
ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"],
max_body: 2_000_000
config :pleroma, :instance,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com",
public_key:
"BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4",
private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA"
config :pleroma, Oban, testing: :manual
config :pleroma, Pleroma.ScheduledActivity,
daily_user_limit: 2,
total_user_limit: 3,
enabled: false
config :pleroma, :rate_limit, %{}
config :pleroma, :http_security, report_uri: "https://endpoint.com"
config :pleroma, :http, send_user_agent: false
rum_enabled = System.get_env("RUM_ENABLED") == "true"
config :pleroma, :database, rum_enabled: rum_enabled
config :joken, default_signer: "yU8uHKq+yyAkZ11Hx//jcdacWc8yQ1bxAAGrplzB0Zwwjkp35v0RK9SO8WTPr6QZ"
config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock
config :pleroma, :modules, runtime_dir: "test/fixtures/modules"
config :pleroma, Pleroma.Gun, Pleroma.GunMock
config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true
config :pleroma, Pleroma.Web.Plugs.RemoteIp, enabled: false
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
config :tzdata, :autoupdate, :disabled
config :pleroma, :mrf, policies: []
config :pleroma, :pipeline,
object_validator: Pleroma.Web.ActivityPub.ObjectValidatorMock,
mrf: Pleroma.Web.ActivityPub.MRFMock,
activity_pub: Pleroma.Web.ActivityPub.ActivityPubMock,
side_effects: Pleroma.Web.ActivityPub.SideEffectsMock,
federator: Pleroma.Web.FederatorMock,
config: Pleroma.ConfigMock
config :pleroma, :cachex, provider: Pleroma.CachexMock
config :pleroma, Pleroma.Web.WebFinger, update_nickname_on_user_fetch: false
config :pleroma, :side_effects,
ap_streamer: Pleroma.Web.ActivityPub.ActivityPubMock,
logger: Pleroma.LoggerMock
config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch
config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", private_key: nil
# Reduce recompilation time
# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects
config :phoenix, :plug_init_mode, :runtime
config :pleroma, :config_impl, Pleroma.UnstubbedConfigMock
config :pleroma, :datetime_impl, Pleroma.DateTimeMock
config :pleroma, Pleroma.PromEx, disabled: true
# Mox definitions. Only read during compile time.
config :pleroma, Pleroma.User.Backup, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Uploaders.S3, ex_aws_impl: Pleroma.Uploaders.S3.ExAwsMock
config :pleroma, Pleroma.Uploaders.S3, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Upload, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Language.LanguageDetector, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.ScheduledActivity, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Web.RichMedia.Helpers, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Uploaders.IPFS, config_impl: Pleroma.UnstubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSecurityPlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Web.Plugs.HTTPSignaturePlug, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Upload.Filter.AnonymizeFilename,
config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Upload.Filter.Mogrify, config_impl: Pleroma.StaticStubbedConfigMock
config :pleroma, Pleroma.Upload.Filter.Mogrify, mogrify_impl: Pleroma.MogrifyMock
config :pleroma, Pleroma.Signature, http_signatures_impl: Pleroma.StubbedHTTPSignaturesMock
config :pleroma, Pleroma.Web.ActivityPub.Publisher, signature_impl: Pleroma.SignatureMock
config :pleroma, Pleroma.Web.ActivityPub.Publisher,
transmogrifier_impl: Pleroma.Web.ActivityPub.TransmogrifierMock
peer_module =
if String.to_integer(System.otp_release()) >= 25 do
:peer
else
:slave
end
config :pleroma, Pleroma.Cluster, peer_module: peer_module
config :pleroma, Pleroma.Application,
background_migrators: false,
internal_fetch: false,
load_custom_modules: false,
max_restarts: 100,
streamer_registry: false,
test_http_pools: true
config :pleroma, Pleroma.Web.Streamer, sync_streaming: true
config :pleroma, Pleroma.Uploaders.Uploader, timeout: 1_000
config :pleroma, Pleroma.Emoji.Loader, test_emoji: true
config :pleroma, Pleroma.Web.RichMedia.Backfill,
stream_out: Pleroma.Web.ActivityPub.ActivityPubMock
config :pleroma, Pleroma.Web.Plugs.HTTPSecurityPlug, enable: false
config :pleroma, Pleroma.User.Backup, tempdir: "test/tmp"
if File.exists?("./config/test.secret.exs") do
import_config "test.secret.exs"
end
# Avoid noisy shutdown logs from os_mon during tests.
config :os_mon,
start_cpu_sup: false,
start_memsup: false
diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 9efa1c8b3..b0a7087a7 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -1,1244 +1,1245 @@
# Configuration Cheat Sheet
This is a cheat sheet for Pleroma configuration file, any setting possible to configure should be listed here.
For OTP installations the configuration is typically stored in `/etc/pleroma/config.exs`.
For from source installations Pleroma configuration works by first importing the base config `config/config.exs`, then overriding it by the environment config `config/$MIX_ENV.exs` and then overriding it by user config `config/$MIX_ENV.secret.exs`. In from source installations you should always make the changes to the user config and NEVER to the base config to avoid breakages and merge conflicts. So for production you change/add configuration to `config/prod.secret.exs`.
To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted.
## :shout
* `enabled` - Enables the backend Shoutbox chat feature. Defaults to `true`.
* `limit` - Shout character limit. Defaults to `5_000`
## :instance
* `name`: The instance’s name.
* `email`: Email used to reach an Administrator/Moderator of the instance.
* `notify_email`: Email used for notifications.
* `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``.
* `short_description`: Shorter version of instance description, can be seen on ``/api/v1/instance``.
* `limit`: Posts character limit (CW/Subject included in the counter).
* `description_limit`: The character limit for image descriptions.
* `remote_limit`: Hard character limit beyond which remote posts will be dropped.
* `upload_limit`: File size limit of uploads (except for avatar, background, banner).
* `avatar_upload_limit`: File size limit of user’s profile avatars.
* `background_upload_limit`: File size limit of user’s profile backgrounds.
* `banner_upload_limit`: File size limit of user’s profile banners.
* `poll_limits`: A map with poll limits for **local** polls.
* `max_options`: Maximum number of options.
* `max_option_chars`: Maximum number of characters per option.
* `min_expiration`: Minimum expiration time (in seconds).
* `max_expiration`: Maximum expiration time (in seconds).
* `registrations_open`: Enable registrations for anyone, invitations can be enabled when false.
* `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`).
* `account_activation_required`: Require users to confirm their emails before signing in.
* `account_approval_required`: Require users to be manually approved by an admin before signing in.
* `federating`: Enable federation with other instances.
* `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes.
* `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it.
* `allow_relay`: Permits remote instances to subscribe to all public posts of your instance. This may increase the visibility of your instance.
* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details.
* `quarantined_instances`: ActivityPub instances where private (DMs, followers-only) activities will not be send.
* `rejected_instances`: ActivityPub instances to reject requests from if authorized_fetch_mode is enabled.
* `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML).
* `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with
older software for theses nicknames.
* `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature.
* `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow.
* `autofollowing_nicknames`: Set to nicknames of (local) users that automatically follows every newly registered user.
* `attachment_links`: Set to true to enable automatically adding attachment link text to statuses.
* `max_report_comment_size`: The maximum size of the report comment (Default: `1000`).
* `report_strip_status`: Strip associated statuses in reports to ids when closed/resolved, otherwise keep a copy.
* `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). Default: `false`.
* `healthcheck`: If set to true, system data will be shown on ``/api/v1/pleroma/healthcheck``.
* `remote_post_retention_days`: The default amount of days to retain remote posts when pruning the database.
* `user_bio_length`: A user bio maximum length (default: `5000`).
* `user_name_length`: A user name maximum length (default: `100`).
* `skip_thread_containment`: Skip filter out broken threads. The default is `false`.
* `limit_to_local_content`: Limit unauthenticated users to search for local statutes and users only. Possible values: `:unauthenticated`, `:all` and `false`. The default is `:unauthenticated`.
* `max_account_fields`: The maximum number of custom fields in the user profile (default: `10`).
* `max_remote_account_fields`: The maximum number of custom fields in the remote user profile (default: `20`).
* `account_field_name_length`: An account field name maximum length (default: `512`).
* `account_field_value_length`: An account field value maximum length (default: `2048`).
* `registration_reason_length`: Maximum registration reason length (default: `500`).
* `external_user_synchronization`: Enabling following/followers counters synchronization for external users.
* `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances.
* `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`).
* `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day).
* `admin_privileges`: A list of privileges an admin has (e.g. delete messages, manage reports...)
* Possible values are:
* `:users_read`
* Allows admins to fetch users through the admin API.
* `:users_manage_invites`
* Allows admins to manage invites. This includes sending, resending, revoking and approving invites.
* `:users_manage_activation_state`
* Allows admins to activate and deactivate accounts. This also allows them to see deactivated users through the Mastodon API.
* `:users_manage_tags`
* Allows admins to set and remove tags for users. This can be useful in combination with MRF policies, such as `Pleroma.Web.ActivityPub.MRF.TagPolicy`.
* `:users_manage_credentials`
* Allows admins to trigger a password reset and set new credentials for an user.
* `:users_delete`
* Allows admins to delete accounts. Note that deleting an account is actually deactivating it and removing all data like posts, profile information, etc.
* `:messages_read`
* Allows admins to read messages through the admin API, including non-public posts and chats.
* `:messages_delete`
* Allows admins to delete messages from other users.
* `:instances_delete,`
* Allows admins to remove a whole remote instance from your instance. This will delete all users and messages from that remote instance.
* `:reports_manage_reports`
* Allows admins to see and manage reports.
* `:moderation_log_read,`
* Allows admins to read the entries in the moderation log.
* `:emoji_manage_emoji`
* Allows admins to manage custom emoji on the instance.
* `:statistics_read,`
* Allows admins to see some simple statistics about the instance.
* `moderator_privileges`: A list of privileges a moderator has (e.g. delete messages, manage reports...)
* Possible values are the same as for `admin_privileges`
## :features
* `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).
## Background migrations
* `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances).
* `populate_hashtags_table/fault_rate_allowance`: Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records).
## Welcome
* `direct_message`: - welcome message sent as a direct message.
* `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`.
* `sender_nickname`: The nickname of the local user that sends the welcome message.
* `message`: A message that will be send to a newly registered users as a direct message.
* `chat_message`: - welcome message sent as a chat message.
* `enabled`: Enables the send a chat message to a newly registered user. Defaults to `false`.
* `sender_nickname`: The nickname of the local user that sends the welcome message.
* `message`: A message that will be send to a newly registered users as a chat message.
* `email`: - welcome message sent as a email.
* `enabled`: Enables the send a welcome email to a newly registered user. Defaults to `false`.
* `sender`: The email address or tuple with `{nickname, email}` that will use as sender to the welcome email.
* `subject`: A subject of welcome email.
* `html`: A html that will be send to a newly registered users as a email.
* `text`: A text that will be send to a newly registered users as a email.
Example:
```elixir
config :pleroma, :welcome,
direct_message: [
enabled: true,
sender_nickname: "lain",
message: "Hi! Welcome on board!"
],
email: [
enabled: true,
sender: {"Pleroma App", "welcome@pleroma.app"},
subject: "Welcome to <%= instance_name %>",
html: "Welcome to <%= instance_name %>",
text: "Welcome to <%= instance_name %>"
]
```
## Message rewrite facility
### :mrf
* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default:
* `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default).
* `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production.
* `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)).
* `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive).
* `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)).
* `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)).
* `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:.
* `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links.
* `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed.
* `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)).
* `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)).
* `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)).
* `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled deletions.
* `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines.
* `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. Local accounts, locked accounts, and users with "#nobot" in their bio are respected and excluded from being followed.
* `Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy`: Drops follow requests from followbots. Users can still allow bots to follow them by first following the bot.
* `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
* `Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent`: Forces every mentioned user to be reflected in the post content.
* `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Forces quote post URLs to be reflected in the message content inline.
* `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions).
* `Pleroma.Web.ActivityPub.MRF.ForceMention`: Forces posts to include a mention of the author of parent post or the author of quoted post.
* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.
## Federation
### MRF policies
!!! note
Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `policies` under [:mrf](#mrf) section.
#### :mrf_simple
* `media_removal`: List of instances to strip media attachments from and the reason for doing so.
* `media_nsfw`: List of instances to tag all media as NSFW (sensitive) from and the reason for doing so.
* `federated_timeline_removal`: List of instances to remove from the Federated Timeline (aka The Whole Known Network) and the reason for doing so.
* `reject`: List of instances to reject activities (except deletes) from and the reason for doing so.
* `accept`: List of instances to only accept activities (except deletes) from and the reason for doing so.
* `followers_only`: Force posts from the given instances to be visible by followers only and the reason for doing so.
* `report_removal`: List of instances to reject reports from and the reason for doing so.
* `avatar_removal`: List of instances to strip avatars from and the reason for doing so.
* `banner_removal`: List of instances to strip banners from and the reason for doing so.
* `reject_deletes`: List of instances to reject deletions from and the reason for doing so.
#### :mrf_subchain
This policy processes messages through an alternate pipeline when a given message matches certain criteria.
All criteria are configured as a map of regular expressions to lists of policy modules.
* `match_actor`: Matches a series of regular expressions against the actor field.
Example:
```elixir
config :pleroma, :mrf_subchain,
match_actor: %{
~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy]
}
```
#### :mrf_rejectnonpublic
* `allow_followersonly`: whether to allow followers-only posts.
* `allow_direct`: whether to allow direct messages.
#### :mrf_hellthread
* `delist_threshold`: Number of mentioned users after which the message gets delisted (the message can still be seen, but it will not show up in public timelines and mentioned users won't get notifications about it). Set to 0 to disable.
* `reject_threshold`: Number of mentioned users after which the messaged gets rejected. Set to 0 to disable.
#### :mrf_keyword
* `reject`: A list of patterns which result in message being rejected, each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
* `federated_timeline_removal`: A list of patterns which result in message being removed from federated timelines (a.k.a unlisted), each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
* `replace`: A list of tuples containing `{pattern, replacement}`, `pattern` can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
#### :mrf_mention
* `actors`: A list of actors, for which to drop any posts mentioning.
#### :mrf_vocabulary
* `accept`: A list of ActivityStreams terms to accept. If empty, all supported messages are accepted.
* `reject`: A list of ActivityStreams terms to reject. If empty, no messages are rejected.
#### :mrf_user_allowlist
The keys in this section are the domain names that the policy should apply to.
Each key should be assigned a list of users that should be allowed through by
their ActivityPub ID.
An example:
```elixir
config :pleroma, :mrf_user_allowlist, %{
"example.org" => ["https://example.org/users/admin"]
}
```
#### :mrf_object_age
* `threshold`: Required time offset (in seconds) compared to your server clock of an incoming post before actions are taken.
e.g., A value of 900 results in any post with a timestamp older than 15 minutes will be acted upon.
* `actions`: A list of actions to apply to the post:
* `:delist` removes the post from public timelines
* `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines, additionally for followers-only it degrades to a direct message
* `:reject` rejects the message entirely
#### :mrf_steal_emoji
* `hosts`: List of hosts to steal emojis from
* `rejected_shortcodes`: Regex-list of shortcodes to reject
* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk
#### :mrf_activity_expiration
* `days`: Default global expiration time for all local Create activities (in days)
#### :mrf_hashtag
* `sensitive`: List of hashtags to mark activities as sensitive (default: `nsfw`)
* `federated_timeline_removal`: List of hashtags to remove activities from the federated timeline (aka TWNK)
* `reject`: List of hashtags to reject activities from
Notes:
- The hashtags in the configuration do not have a leading `#`.
- This MRF Policy is always enabled, if you want to disable it you have to set empty lists
#### :mrf_follow_bot
* `follower_nickname`: The name of the bot account to use for following newly discovered users. Using `followbot` or similar is strongly suggested.
#### :mrf_emoji
* `remove_url`: A list of patterns which result in emoji whose URL matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
* `remove_shortcode`: A list of patterns which result in emoji whose shortcode matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
* `federated_timeline_removal_url`: A list of patterns which result in message with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
* `federated_timeline_removal_shortcode`: A list of patterns which result in message with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html).
#### :mrf_inline_quote
* `template`: The template to append to the post. `{url}` will be replaced with the actual link to the quoted post. Default: `<bdi>RT:</bdi> {url}`
#### :mrf_force_mention
* `mention_parent`: Whether to append mention of parent post author
* `mention_quoted`: Whether to append mention of parent quoted author
### :activitypub
* `unfollow_blocked`: Whether blocks result in people getting unfollowed
* `outgoing_blocks`: Whether to federate blocks to other instances
* `blockers_visible`: Whether a user can see the posts of users who blocked them
* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question
* `sign_object_fetches`: Sign object fetches with HTTP signatures
* `authorized_fetch_mode`: Require HTTP signatures for AP fetches
* `authorized_fetch_mode_exceptions`: List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)
## Pleroma.User
* `restricted_nicknames`: List of nicknames users may not register with.
* `email_blacklist`: List of email domains users may not register with.
## Pleroma.ScheduledActivity
* `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`)
* `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`)
* `enabled`: whether scheduled activities are sent to the job queue to be executed
### :frontend_configurations
This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Pleroma-FE configuration and customization for instance administrators](/frontend/CONFIGURATION/#options).
Frontends can access these settings at `/api/v1/pleroma/frontend_configurations`
To add your own configuration for PleromaFE, use it like this:
```elixir
config :pleroma, :frontend_configurations,
pleroma_fe: %{
theme: "pleroma-dark",
# ... see /priv/static/static/config.json for the available keys.
}
```
These settings **need to be complete**, they will override the defaults.
### :static_fe
Render profiles and posts using server-generated HTML that is viewable without using JavaScript.
Available options:
* `enabled` - Enables the rendering of static HTML. Defaults to `false`.
### :assets
This section configures assets to be used with various frontends. Currently the only option
relates to mascots on the mastodon frontend
* `mascots`: KeywordList of mascots, each element __MUST__ contain both a `url` and a
`mime_type` key.
* `default_mascot`: An element from `mascots` - This will be used as the default mascot
on MastoFE (default: `:pleroma_fox_tan`).
### :manifest
This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE.
* `icons`: Describe the icons of the app, this a list of maps describing icons in the same way as the
[spec](https://www.w3.org/TR/appmanifest/#imageresource-and-its-members) describes it.
Example:
```elixir
config :pleroma, :manifest,
icons: [
%{
src: "/static/logo.png"
},
%{
src: "/static/icon.png",
type: "image/png"
},
%{
src: "/static/icon.ico",
sizes: "72x72 96x96 128x128 256x256"
}
]
```
* `theme_color`: Describe the theme color of the app. (Example: `"#282c37"`, `"rebeccapurple"`).
* `background_color`: Describe the background color of the app. (Example: `"#191b22"`, `"aliceblue"`).
## :emoji
* `shortcode_globs`: Location of custom emoji files. `*` can be used as a wildcard. Example `["/emoji/custom/**/*.png"]`
* `pack_extensions`: A list of file extensions for emojis, when no emoji.txt for a pack is present. Example `[".png", ".gif"]`
* `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]`
* `default_manifest`: Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download. Currently only one manifest can be added (no arrays).
* `shared_pack_cache_seconds_per_file`: When an emoji pack is shared, the archive is created and cached in
memory for this amount of seconds multiplied by the number of files.
## :media_proxy
* `enabled`: Enables proxying of remote media to the instance’s proxy
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.
* `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`.
* `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`)
* `invalidation`: options for remove media from cache after delete object:
* `enabled`: Enables purge cache
* `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use.
## :media_preview_proxy
* `enabled`: Enables proxying of remote media preview to the instance’s proxy. Requires enabled media proxy (`media_proxy/enabled`).
* `thumbnail_max_width`: Max width of preview thumbnail for images (video preview always has original dimensions).
* `thumbnail_max_height`: Max height of preview thumbnail for images (video preview always has original dimensions).
* `image_quality`: Quality of the output. Ranges from 0 (min quality) to 100 (max quality).
* `min_content_length`: Min content length to perform preview, in bytes. If greater than 0, media smaller in size will be served as is, without thumbnailing.
### Purge cache strategy
#### Pleroma.Web.MediaProxy.Invalidation.Script
This strategy allow perform external shell script to purge cache.
Urls of attachments are passed to the script as arguments.
* `script_path`: Path to the external script.
* `url_format`: Set to `:htcacheclean` if using Apache's htcacheclean utility.
Example:
```elixir
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script,
script_path: "./installation/nginx-cache-purge.example"
```
#### Pleroma.Web.MediaProxy.Invalidation.Http
This strategy allow perform custom http request to purge cache.
* `method`: http method. default is `purge`
* `headers`: http headers.
* `options`: request options.
Example:
```elixir
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http,
method: :purge,
headers: [],
options: []
```
## Link previews
### Pleroma.Web.Metadata (provider)
* `providers`: a list of metadata providers to enable. Providers available:
* `Pleroma.Web.Metadata.Providers.OpenGraph`
* `Pleroma.Web.Metadata.Providers.TwitterCard`
* `unfurl_nsfw`: If set to `true` nsfw attachments will be shown in previews.
### :rich_media (consumer)
* `enabled`: if enabled the instance will parse metadata from attached links to generate link previews.
* `ignore_hosts`: list of hosts which will be ignored by the metadata parser. For example `["accounts.google.com", "xss.website"]`, defaults to `[]`.
* `ignore_tld`: list TLDs (top-level domains) which will ignore for parse metadata. default is ["local", "localdomain", "lan"].
* `parsers`: list of Rich Media parsers.
* `timeout`: Amount of milliseconds after which the HTTP request is forcibly terminated.
## HTTP server
### Pleroma.Web.Endpoint
!!! note
`Phoenix` endpoint configuration, all configuration options can be viewed [here](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#module-dynamic-configuration), only common options are listed here.
* `http` - a list containing http protocol configuration, all configuration options can be viewed [here](https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html#module-options), only common options are listed here. For deployment using docker, you need to set this to `[ip: {0,0,0,0}, port: 4000]` to make pleroma accessible from other containers (such as your nginx server).
- `ip` - a tuple consisting of 4 integers
- `port`
* `url` - a list containing the configuration for generating urls, accepts
- `host` - the host without the scheme and a post (e.g `example.com`, not `https://example.com:2020`)
- `scheme` - e.g `http`, `https`
- `port`
- `path`
* `extra_cookie_attrs` - a list of `Key=Value` strings to be added as non-standard cookie attributes. Defaults to `["SameSite=Lax"]`. See the [SameSite article](https://www.owasp.org/index.php/SameSite) on OWASP for more info.
Example:
```elixir
config :pleroma, Pleroma.Web.Endpoint,
url: [host: "example.com", port: 2020, scheme: "https"],
http: [
port: 8080,
ip: {127, 0, 0, 1}
]
```
This will make Pleroma listen on `127.0.0.1` port `8080` and generate urls starting with `https://example.com:2020`
### :http_security
* ``enabled``: Whether the managed content security policy is enabled.
* ``sts``: Whether to additionally send a `Strict-Transport-Security` header.
* ``sts_max_age``: The maximum age for the `Strict-Transport-Security` header if sent.
* ``ct_max_age``: The maximum age for the `Expect-CT` header if sent.
* ``referrer_policy``: The referrer policy to use, either `"same-origin"` or `"no-referrer"`.
* ``report_uri``: Adds the specified url to `report-uri` and `report-to` group in CSP header.
* `allow_unsafe_eval`: Adds `wasm-unsafe-eval` to the CSP header. Needed for some non-essential frontend features like Flash emulation.
### Pleroma.Web.Plugs.RemoteIp
!!! warning
If your instance is not behind at least one reverse proxy, you should not enable this plug.
`Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration.
Available options:
* `enabled` - Enable/disable the plug. Defaults to `false`.
* `headers` - A list of strings naming the HTTP headers to use when deriving the true client IP address. Defaults to `["x-forwarded-for"]`.
* `proxies` - A list of upstream proxy IP subnets in CIDR notation from which we will parse the content of `headers`. Defaults to `[]`. IPv4 entries without a bitmask will be assumed to be /32 and IPv6 /128.
* `reserved` - A list of reserved IP subnets in CIDR notation which should be ignored if found in `headers`. Defaults to `["127.0.0.0/8", "::1/128", "fc00::/7", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]`.
### :rate_limit
!!! note
If your instance is behind a reverse proxy ensure [`Pleroma.Web.Plugs.RemoteIp`](#pleroma-plugs-remoteip) is enabled (it is enabled by default).
A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where:
* The first element: `scale` (Integer). The time scale in milliseconds.
* The second element: `limit` (Integer). How many requests to limit in the time scale provided.
It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated.
For example:
```elixir
config :pleroma, :rate_limit,
authentication: {60_000, 15},
search: [{1000, 10}, {1000, 30}]
```
Means that:
1. In 60 seconds, 15 authentication attempts can be performed from the same IP address.
2. In 1 second, 10 search requests can be performed from the same IP address by unauthenticated users, while authenticated users can perform 30 search requests per second.
Supported rate limiters:
* `:search` - Account/Status search.
* `:timeline` - Timeline requests (each timeline has it's own limiter).
* `:app_account_creation` - Account registration from the API.
* `:relations_actions` - Following/Unfollowing in general.
* `:relation_id_action` - Following/Unfollowing for a specific user.
* `:statuses_actions` - Status actions such as: (un)repeating, (un)favouriting, creating, deleting.
* `:status_id_action` - (un)Repeating/(un)Favouriting a particular status.
* `:authentication` - Authentication actions, i.e getting an OAuth token.
* `:password_reset` - Requesting password reset emails.
* `:account_confirmation_resend` - Requesting resending account confirmation emails.
* `:ap_routes` - Requesting statuses via ActivityPub.
### :web_cache_ttl
The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration.
Available caches:
* `:activity_pub` - activity pub routes (except question activities). Defaults to `nil` (no expiration).
* `:activity_pub_question` - activity pub routes (question activities). Defaults to `30_000` (30 seconds).
## HTTP client
### :http
* `proxy_url`: an upstream proxy to fetch posts and/or media with, (default: `nil`)
* `send_user_agent`: should we include a user agent with HTTP requests? (default: `true`)
* `user_agent`: what user agent should we use? (default: `:default`), must be string or `:default`
* `adapter`: array of adapter options
### :hackney_pools
Advanced. Tweaks Hackney (http client) connections pools.
There's three pools used:
* `:federation` for the federation jobs.
You may want this pool max_connections to be at least equal to the number of federator jobs + retry queue jobs.
* `:media` for rich media, media proxy
* `:upload` for uploaded media (if using a remote uploader and `proxy_remote: true`)
For each pool, the options are:
* `max_connections` - how much connections a pool can hold
* `timeout` - retention duration for connections
### :connections_pool
*For `gun` adapter*
Settings for HTTP connection pool.
* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries.
* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart.
* `:max_connections` - Maximum number of connections in the pool.
* `:connect_timeout` - Timeout to connect to the host.
* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded.
### :pools
*For `gun` adapter*
Settings for request pools. These pools are limited on top of `:connections_pool`.
There are four pools used:
* `:federation` for the federation jobs. You may want this pool's max_connections to be at least equal to the number of federator jobs + retry queue jobs.
* `:media` - for rich media, media proxy.
* `:upload` - for proxying media when a remote uploader is used and `proxy_remote: true`.
* `:default` - for other requests.
For each pool, the options are:
* `:size` - limit to how much requests can be concurrently executed.
* `:recv_timeout` - timeout while `gun` will wait for response
* `:max_waiting` - limit to how much requests can be waiting for others to finish, after this is reached, subsequent requests will be dropped.
## Captcha
### Pleroma.Captcha
* `enabled`: Whether the captcha should be shown on registration.
* `method`: The method/service to use for captcha.
* `seconds_valid`: The time in seconds for which the captcha is valid.
### Captcha providers
#### Pleroma.Captcha.Native
A built-in captcha provider. Enabled by default.
#### Pleroma.Captcha.Kocaptcha
Kocaptcha is a very simple captcha service with a single API endpoint,
the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). The default endpoint
`https://captcha.kotobank.ch` is hosted by the developer.
* `endpoint`: the Kocaptcha endpoint to use.
## Uploads
### Pleroma.Upload
* `uploader`: Which one of the [uploaders](#uploaders) to use.
* `filters`: List of [upload filters](#upload-filters) to use.
* `link_name`: When enabled Pleroma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers when using filters like `Pleroma.Upload.Filter.Dedupe`
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to host the media files via another domain or are using a 3rd party S3 provider.
* `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it.
* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation.
* `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30.
* `default_description`: Sets which default description an image has if none is set explicitly. Options: nil (default) - Don't set a default, :filename - use the filename of the file, a string (e.g. "attachment") - Use this string
!!! warning
`strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`.
### Uploaders
#### Pleroma.Uploaders.Local
* `uploads`: Which directory to store the user-uploads in, relative to pleroma’s working directory.
#### Pleroma.Uploaders.S3
Don't forget to configure [Ex AWS S3](#ex-aws-s3-settings)
* `bucket`: S3 bucket name.
* `bucket_namespace`: S3 bucket namespace.
* `truncated_namespace`: If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or "" etc.
* `streaming_enabled`: Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems.
#### Ex AWS S3 settings
* `access_key_id`: Access key ID
* `secret_access_key`: Secret access key
* `host`: S3 host
Example:
```elixir
config :ex_aws, :s3,
access_key_id: "xxxxxxxxxx",
secret_access_key: "yyyyyyyyyy",
host: "s3.eu-central-1.amazonaws.com"
```
#### Pleroma.Uploaders.IPFS
* `post_gateway_url`: URL with port of POST Gateway (unauthenticated)
* `get_gateway_url`: URL of public GET Gateway
Example:
```elixir
config :pleroma, Pleroma.Uploaders.IPFS,
post_gateway_url: "http://localhost:5001",
get_gateway_url: "http://{CID}.ipfs.mydomain.com"
```
### Upload filters
#### Pleroma.Upload.Filter.AnonymizeFilename
This filter replaces the filename (not the path) of an upload. For complete obfuscation, add
`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename.
* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`.
#### Pleroma.Upload.Filter.Dedupe
No specific configuration.
#### Pleroma.Upload.Filter.Exiftool.StripLocation
This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact.
No specific configuration.
#### Pleroma.Upload.Filter.Exiftool.ReadDescription
This filter reads the ImageDescription and iptc:Caption-Abstract fields with Exiftool so clients can prefill the media description field.
No specific configuration.
#### Pleroma.Upload.Filter.OnlyMedia
This filter rejects uploads that are not identified with Content-Type matching audio/\*, image/\*, or video/\*
No specific configuration.
#### Pleroma.Upload.Filter.Mogrify
* `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`.
## Email
### Pleroma.Emails.Mailer
* `adapter`: one of the mail adapters listed in [Swoosh readme](https://github.com/swoosh/swoosh#adapters), or `Swoosh.Adapters.Local` for in-memory mailbox.
* `api_key` / `password` and / or other adapter-specific settings, per the above documentation.
* `enabled`: Allows enable/disable send emails. Default: `false`.
An example for Sendgrid adapter:
```elixir
config :pleroma, Pleroma.Emails.Mailer,
enabled: true,
adapter: Swoosh.Adapters.Sendgrid,
api_key: "YOUR_API_KEY"
```
An example for SMTP adapter:
```elixir
config :pleroma, Pleroma.Emails.Mailer,
enabled: true,
adapter: Swoosh.Adapters.Mua,
relay: "smtp.gmail.com",
auth: [username: "YOUR_USERNAME@gmail.com", password: "YOUR_SMTP_PASSWORD"],
port: 465,
protocol: :ssl
```
An example for Mua adapter:
```elixir
config :pleroma, Pleroma.Emails.Mailer,
enabled: true,
adapter: Swoosh.Adapters.Mua,
relay: "mail.example.com",
port: 465,
auth: [
username: "YOUR_USERNAME@domain.tld",
password: "YOUR_SMTP_PASSWORD"
],
protocol: :ssl
```
### :email_notifications
Email notifications settings.
- digest - emails of "what you've missed" for users who have been
inactive for a while.
- active: globally enable or disable digest emails
- schedule: When to send digest email, in [crontab format](https://en.wikipedia.org/wiki/Cron).
"0 0 * * 0" is the default, meaning "once a week at midnight on Sunday morning"
- interval: Minimum interval between digest emails to one user
- inactivity_threshold: Minimum user inactivity threshold
### Pleroma.Emails.UserEmail
- `:logo` - a path to a custom logo. Set it to `nil` to use the default Pleroma logo.
- `:styling` - a map with color settings for email templates.
### Pleroma.Emails.NewUsersDigestEmail
- `:enabled` - a boolean, enables new users admin digest email when `true`. Defaults to `false`.
## Background jobs
### Oban
[Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration.
Configuration options described in [Oban readme](https://github.com/sorentwo/oban#usage):
* `repo` - app's Ecto repo (`Pleroma.Repo`)
* `log` - logs verbosity
* `queues` - job queues (see below)
* `crontab` - periodic jobs, see [`Oban.Cron`](#obancron)
Pleroma has the following queues:
* `activity_expiration` - Activity expiration
* `federator_outgoing` - Outgoing federation
* `federator_incoming` - Incoming federation
* `mailer` - Email sender, see [`Pleroma.Emails.Mailer`](#pleromaemailsmailer)
* `transmogrifier` - Transmogrifier
* `web_push` - Web push notifications
* `scheduled_activities` - Scheduled activities, see [`Pleroma.ScheduledActivity`](#pleromascheduledactivity)
#### Oban.Cron
Pleroma has these periodic job workers:
* `Pleroma.Workers.Cron.DigestEmailsWorker` - digest emails for users with new mentions and follows
* `Pleroma.Workers.Cron.NewUsersDigestWorker` - digest emails for admins with new registrations
```elixir
config :pleroma, Oban,
repo: Pleroma.Repo,
verbose: false,
prune: {:maxlen, 1500},
queues: [
federator_incoming: 50,
federator_outgoing: 50
],
crontab: [
{"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker},
{"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}
]
```
This config contains two queues: `federator_incoming` and `federator_outgoing`. Both have the number of max concurrent jobs set to `50`.
#### Migrating `pleroma_job_queue` settings
`config :pleroma_job_queue, :queues` is replaced by `config :pleroma, Oban, :queues` and uses the same format (keys are queues' names, values are max concurrent jobs numbers).
### :workers
Includes custom worker options not interpretable directly by `Oban`.
* `retries` — keyword lists where keys are `Oban` queues (see above) and values are numbers of max attempts for failed jobs.
Example:
```elixir
config :pleroma, :workers,
retries: [
federator_incoming: 5,
federator_outgoing: 5
]
```
#### Migrating `Pleroma.Web.Federator.RetryQueue` settings
* `max_retries` is replaced with `config :pleroma, :workers, retries: [federator_outgoing: 5]`
* `enabled: false` corresponds to `config :pleroma, :workers, retries: [federator_outgoing: 1]`
* deprecated options: `max_jobs`, `initial_timeout`
## :web_push_encryption, :vapid_details
Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it.
* ``subject``: a mailto link for the administrative contact. It’s best if this email is not a personal email address, but rather a group email so that if a person leaves an organization, is unavailable for an extended period, or otherwise can’t respond, someone else on the list can.
* ``public_key``: VAPID public key
* ``private_key``: VAPID private key
## :logger
-* `backends`: `:console` is used to send logs to stdout, `{ExSyslogger, :ex_syslogger}` to log to syslog
+* Logging to console/stdout is done by default, use `{ExSyslogger, :ex_syslogger}` to log to syslog
An example to enable ONLY ExSyslogger (f/ex in ``prod.secret.exs``) with info and debug suppressed:
```elixir
-config :logger,
+config :pleroma, :logger,
backends: [{ExSyslogger, :ex_syslogger}]
+config :logger, default_handler: false
+
config :logger, :ex_syslogger,
level: :warning
```
Another example, keeping console output and adding the pid to syslog output:
```elixir
-config :logger,
- backends: [:console, {ExSyslogger, :ex_syslogger}]
+config :pleroma, :logger,
+ backends: [{ExSyslogger, :ex_syslogger}]
config :logger, :ex_syslogger,
level: :warning,
option: [:pid, :ndelay]
```
See: [logger’s documentation](https://hexdocs.pm/logger/Logger.html) and [ex_syslogger’s documentation](https://hexdocs.pm/ex_syslogger/)
An example of logging info to local syslog, but debug to console:
```elixir
-config :logger,
- backends: [ {ExSyslogger, :ex_syslogger}, :console ],
- level: :info
+config :pleroma, :logger,
+ backends: [{ExSyslogger, :ex_syslogger}]
config :logger, :ex_syslogger,
level: :info,
ident: "pleroma",
format: "$metadata[$level] $message"
-config :logger, :console,
- level: :debug,
+config :logger, :default_handler,
+ level: :debug
+
+config :logger, :default_formatter,
format: "\n$time $metadata[$level] $message\n",
metadata: [:request_id]
```
-
-
## Database options
### RUM indexing for full text search
* `rum_enabled`: If RUM indexes should be used. Defaults to `false`.
RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. While they may eventually be mainlined, for now they have to be installed as a PostgreSQL extension from [https://github.com/postgrespro/rum](https://github.com/postgrespro/rum).
Their advantage over the standard GIN indexes is that they allow efficient ordering of search results by timestamp, which makes search queries a lot faster on larger servers, by one or two orders of magnitude. They take up around 3-4 times as much space as GIN indexes.
To enable them, both the `rum_enabled` flag has to be set and the following special migration has to be run:
* Source install:
- Stop Pleroma
- `mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`
* OTP install:
- Stop Pleroma
- `pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`
This will probably take a long time.
!!! note
It is recommended to `VACUUM FULL` the objects table after the migration has completed, to do that run:
```
# sudo -Hu postgres vacuumdb --full --analyze -t objects <pleroma DB name>
```
Now you can start Pleroma back up.
## Alternative client protocols
### BBS / SSH access
This feature has been removed from Pleroma core.
However, a client has been made and is available at https://git.pleroma.social/Duponin/sshocial.
### :gopher
* `enabled`: Enables the gopher interface
* `ip`: IP address to bind to
* `port`: Port to bind to
* `dstport`: Port advertised in urls (optional, defaults to `port`)
## Authentication
### :admin_token
Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter or `x-admin-token` HTTP header. Example:
```elixir
config :pleroma, :admin_token, "somerandomtoken"
```
You can then do
```shell
curl "http://localhost:4000/api/v1/pleroma/admin/users/invites?admin_token=somerandomtoken"
```
or
```shell
curl -H "X-Admin-Token: somerandomtoken" "http://localhost:4000/api/v1/pleroma/admin/users/invites"
```
Warning: it's discouraged to use this feature because of the associated security risk: static / rarely changed instance-wide token is much weaker compared to email-password pair of a real admin user; consider using HTTP Basic Auth or OAuth-based authentication instead.
### :auth
Authentication / authorization settings.
* `auth_template`: authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.eex`.
* `oauth_consumer_template`: OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`.
* `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by `OAUTH_CONSUMER_STRATEGIES` environment variable. Each entry in this space-delimited string should be of format `<strategy>` or `<strategy>:<dependency>` (e.g. `twitter` or `keycloak:ueberauth_keycloak_strategy` in case dependency is named differently than `ueberauth_<strategy>`).
### Pleroma.Web.Auth.Authenticator
* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator.
* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication.
### :ldap
Use LDAP for user authentication. When a user logs in to the Pleroma
instance, the name and password will be verified by trying to authenticate
(bind) to an LDAP server. If a user exists in the LDAP directory but there
is no account with the same name yet on the Pleroma instance then a new
Pleroma account will be created with the same name as the LDAP user name.
* `enabled`: enables LDAP authentication
* `host`: LDAP server hostname
* `port`: LDAP port, e.g. 389 or 636
* `ssl`: true to use implicit SSL/TLS, usually port 636
* `sslopts`: additional SSL options
* `tls`: true to use explicit TLS (STARTTLS), usually port 389
* `tlsopts`: additional TLS options
* `base`: LDAP base, e.g. "dc=example,dc=com"
* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base"
* `cacertfile`: Path to alternate CA root certificates file
Note, if your LDAP server is an Active Directory server the correct value is commonly `uid: "cn"`, but if you use an
OpenLDAP server the value may be `uid: "uid"`.
### :oauth2 (Pleroma as OAuth 2.0 provider settings)
OAuth 2.0 provider settings:
* `token_expires_in` - The lifetime in seconds of the access token.
* `issue_new_refresh_token` - Keeps old refresh token or generate new refresh token when to obtain an access token.
* `clean_expired_tokens` - Enable a background job to clean expired oauth tokens. Defaults to `false`.
OAuth 2.0 provider and related endpoints:
* `POST /api/v1/apps` creates client app basing on provided params.
* `GET/POST /oauth/authorize` renders/submits authorization form.
* `POST /oauth/token` creates/renews OAuth token.
* `POST /oauth/revoke` revokes provided OAuth token.
* `GET /api/v1/accounts/verify_credentials` (with proper `Authorization` header or `access_token` URI param) returns user info on requester (with `acct` field containing local nickname and `fqn` field containing fully-qualified nickname which could generally be used as email stub for OAuth software that demands email field in identity endpoint response, like Peertube).
### OAuth consumer mode
OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.).
Implementation is based on Ueberauth; see the list of [available strategies](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies).
!!! note
Each strategy is shipped as a separate dependency; in order to get the strategies, run `OAUTH_CONSUMER_STRATEGIES="..." mix deps.get`, e.g. `OAUTH_CONSUMER_STRATEGIES="twitter facebook google microsoft" mix deps.get`. The server should also be started with `OAUTH_CONSUMER_STRATEGIES="..." mix phx.server` in case you enable any strategies.
!!! note
Each strategy requires separate setup (on external provider side and Pleroma side). Below are the guidelines on setting up most popular strategies.
!!! note
Make sure that `"SameSite=Lax"` is set in `extra_cookie_attrs` when you have this feature enabled. OAuth consumer mode will not work with `"SameSite=Strict"`
* For Twitter, [register an app](https://developer.twitter.com/en/apps), configure callback URL to https://<your_host>/oauth/twitter/callback
* For Facebook, [register an app](https://developers.facebook.com/apps), configure callback URL to https://<your_host>/oauth/facebook/callback, enable Facebook Login service at https://developers.facebook.com/apps/<app_id>/fb-login/settings/
* For Google, [register an app](https://console.developers.google.com), configure callback URL to https://<your_host>/oauth/google/callback
* For Microsoft, [register an app](https://portal.azure.com), configure callback URL to https://<your_host>/oauth/microsoft/callback
Once the app is configured on external OAuth provider side, add app's credentials and strategy-specific settings (if any — e.g. see Microsoft below) to `config/prod.secret.exs`,
per strategy's documentation (e.g. [ueberauth_twitter](https://github.com/ueberauth/ueberauth_twitter)). Example config basing on environment variables:
```elixir
# Twitter
config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
# Facebook
config :ueberauth, Ueberauth.Strategy.Facebook.OAuth,
client_id: System.get_env("FACEBOOK_APP_ID"),
client_secret: System.get_env("FACEBOOK_APP_SECRET"),
redirect_uri: System.get_env("FACEBOOK_REDIRECT_URI")
# Google
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
client_id: System.get_env("GOOGLE_CLIENT_ID"),
client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
redirect_uri: System.get_env("GOOGLE_REDIRECT_URI")
# Microsoft
config :ueberauth, Ueberauth.Strategy.Microsoft.OAuth,
client_id: System.get_env("MICROSOFT_CLIENT_ID"),
client_secret: System.get_env("MICROSOFT_CLIENT_SECRET")
config :ueberauth, Ueberauth,
providers: [
microsoft: {Ueberauth.Strategy.Microsoft, [callback_params: []]}
]
# Keycloak
# Note: make sure to add `keycloak:ueberauth_keycloak_strategy` entry to `OAUTH_CONSUMER_STRATEGIES` environment variable
keycloak_url = "https://publicly-reachable-keycloak-instance.org:8080"
config :ueberauth, Ueberauth.Strategy.Keycloak.OAuth,
client_id: System.get_env("KEYCLOAK_CLIENT_ID"),
client_secret: System.get_env("KEYCLOAK_CLIENT_SECRET"),
site: keycloak_url,
authorize_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/auth",
token_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/token",
userinfo_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/userinfo",
token_method: :post
config :ueberauth, Ueberauth,
providers: [
keycloak: {Ueberauth.Strategy.Keycloak, [uid_field: :email]}
]
```
## Link parsing
### :uri_schemes
* `valid_schemes`: List of the scheme part that is considered valid to be an URL.
### Pleroma.Formatter
Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.
* `class` - specify the class to be added to the generated link (default: `false`)
* `rel` - specify the rel attribute (default: `ugc`)
* `new_window` - adds `target="_blank"` attribute (default: `false`)
* `truncate` - Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...` (default: `false`)
* `strip_prefix` - Strip the scheme prefix (default: `false`)
* `extra` - link URLs with rarely used schemes (magnet, ipfs, irc, etc.) (default: `true`)
* `validate_tld` - Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't) (default: `:no_scheme`)
Example:
```elixir
config :pleroma, Pleroma.Formatter,
class: false,
rel: "ugc",
new_window: false,
truncate: false,
strip_prefix: false,
extra: true,
validate_tld: :no_scheme
```
## Custom Runtime Modules (`:modules`)
* `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
## :configurable_from_database
Boolean, enables/disables in-database configuration. Read [Transferring the config to/from the database](../administration/CLI_tasks/config.md) for more information.
## :database_config_whitelist
List of valid configuration sections which are allowed to be configured from the
database. Settings stored in the database before the whitelist is configured are
still applied. Consider running the `mix pleroma.config filter_whitelisted` task
after updating the whitelist. Read [Remove non-whitelisted configs from the database](../administration/CLI_tasks/config.md#remove-non-whitelisted-configs-from-the-database)
for more information.
Example:
```elixir
config :pleroma, :database_config_whitelist, [
{:pleroma, :instance},
{:pleroma, Pleroma.Web.Metadata},
{:auto_linker}
]
```
### Multi-factor authentication - :two_factor_authentication
* `totp` - a list containing TOTP configuration
- `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
- `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
* `backup_codes` - a list containing backup codes configuration
- `number` - number of backup codes to generate.
- `length` - backup code length. Defaults to 16 characters.
## Restrict entities access for unauthenticated users
### :restrict_unauthenticated
Restrict access for unauthenticated users to timelines (public and federated), user profiles and statuses.
* `timelines`: public and federated timelines
* `local`: public timeline
* `federated`: federated timeline (includes public timeline)
* `profiles`: user profiles
* `local`
* `remote`
* `activities`: statuses
* `local`
* `remote`
Note: when `:instance, :public` is set to `false`, all `:restrict_unauthenticated` items be effectively set to `true` by default. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`.
Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline).
## Pleroma.Web.ApiSpec.CastAndValidate
* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.
## :instances_favicons
Control favicons for instances.
* `enabled`: Allow/disallow displaying and getting instances favicons
## Pleroma.User.Backup
!!! note
Requires enabled email
* `:purge_after_days` an integer, remove backup achieves after N days.
* `:limit_days` an integer, limit user to export not more often than once per N days.
* `:dir` a string with a path to backup temporary directory or `nil` to let Pleroma choose temporary directory in the following order:
1. the directory named by the TMPDIR environment variable
2. the directory named by the TEMP environment variable
3. the directory named by the TMP environment variable
4. C:\TMP on Windows or /tmp on Unix-like operating systems
5. as a last resort, the current working directory
* `:timeout` an integer representing seconds
## Frontend management
Frontends in Pleroma are swappable - you can specify which one to use here.
You can set a frontends for the key `primary` and `admin` and the options of `name` and `ref`. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref.
The key `primary` refers to the frontend that will be served by default for general requests. The key `admin` refers to the frontend that will be served at the `/pleroma/admin` path.
If you don't set anything here, the bundled frontends will be used.
Example:
```
config :pleroma, :frontends,
primary: %{
"name" => "pleroma",
"ref" => "stable"
},
admin: %{
"name" => "admin",
"ref" => "develop"
}
```
This would serve the frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. You can choose the name and ref any way you like, but they will be used by mix tasks to automate installation in the future, the name referring to the project and the ref referring to a commit.
## Ephemeral activities (Pleroma.Workers.PurgeExpiredActivity)
Settings to enable and configure expiration for ephemeral activities
* `:enabled` - enables ephemeral activities creation
* `:min_lifetime` - minimum lifetime for ephemeral activities (in seconds). Default: 10 minutes.
## ConcurrentLimiter
Settings to restrict concurrently running jobs. Jobs which can be configured:
* `Pleroma.Web.RichMedia.Helpers` - generating link previews of URLs in activities
* `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy` - warming remote media cache via MediaProxyWarmingPolicy
Each job has these settings:
* `:max_running` - max concurrently runnings jobs
* `:max_waiting` - max waiting jobs
diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include
index 588a6c019..382f07a6c 100644
--- a/docs/installation/generic_dependencies.include
+++ b/docs/installation/generic_dependencies.include
@@ -1,16 +1,16 @@
## Required dependencies
* PostgreSQL >=11.0
-* Elixir >=1.15.0 <1.19
-* Erlang OTP >=23.0.0 (supported: <28)
+* Elixir >=1.15.0, <1.20
+* Erlang OTP >=24.0.0 (supported: <29)
* git
* file / libmagic
* gcc or clang
* GNU make
* CMake
## Optional dependencies
* ImageMagick
* FFmpeg
* exiftool
diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex
index c5f2e9b0a..a10506236 100644
--- a/lib/mix/tasks/pleroma/config.ex
+++ b/lib/mix/tasks/pleroma/config.ex
@@ -1,497 +1,541 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.Config do
use Mix.Task
import Ecto.Query
import Mix.Pleroma
alias Pleroma.ConfigDB
alias Pleroma.Repo
@shortdoc "Manages the location of the config"
@moduledoc File.read!("docs/administration/CLI_tasks/config.md")
def run(["migrate_to_db"]) do
check_configdb(fn ->
start_pleroma()
migrate_to_db()
end)
end
def run(["migrate_from_db" | options]) do
check_configdb(fn ->
start_pleroma()
{opts, _} =
OptionParser.parse!(options,
strict: [env: :string, delete: :boolean, path: :string],
aliases: [d: :delete]
)
migrate_from_db(opts)
end)
end
def run(["dump"]) do
check_configdb(fn ->
start_pleroma()
header = config_header()
settings =
ConfigDB
|> Repo.all()
|> Enum.sort()
unless settings == [] do
shell_info("#{header}")
Enum.each(settings, &dump(&1))
else
shell_error("No settings in ConfigDB.")
end
end)
end
def run(["dump", group, key]) do
check_configdb(fn ->
start_pleroma()
group = maybe_atomize(group)
key = maybe_atomize(key)
group
|> ConfigDB.get_by_group_and_key(key)
|> dump()
end)
end
def run(["dump", group]) do
check_configdb(fn ->
start_pleroma()
group = maybe_atomize(group)
dump_group(group)
end)
end
def run(["groups"]) do
check_configdb(fn ->
start_pleroma()
groups =
ConfigDB
|> distinct([c], true)
|> select([c], c.group)
|> Repo.all()
if length(groups) > 0 do
shell_info("The following configuration groups are set in ConfigDB:\r\n")
groups |> Enum.each(fn x -> shell_info("- #{x}") end)
shell_info("\r\n")
end
end)
end
def run(["reset", "--force"]) do
check_configdb(fn ->
start_pleroma()
truncatedb()
shell_info("The ConfigDB settings have been removed from the database.")
end)
end
def run(["reset"]) do
check_configdb(fn ->
start_pleroma()
shell_info("The following settings will be permanently removed:")
ConfigDB
|> Repo.all()
|> Enum.sort()
|> Enum.each(&dump(&1))
shell_error("\nTHIS CANNOT BE UNDONE!")
if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
truncatedb()
shell_info("The ConfigDB settings have been removed from the database.")
else
shell_error("No changes made.")
end
end)
end
def run(["delete", "--force", group, key]) do
start_pleroma()
group = maybe_atomize(group)
key = maybe_atomize(key)
with true <- key_exists?(group, key) do
shell_info("The following settings will be removed from ConfigDB:\n")
group
|> ConfigDB.get_by_group_and_key(key)
|> dump()
delete_key(group, key)
else
_ ->
shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.")
end
end
def run(["delete", "--force", group]) do
start_pleroma()
group = maybe_atomize(group)
with true <- group_exists?(group) do
shell_info("The following settings will be removed from ConfigDB:\n")
dump_group(group)
delete_group(group)
else
_ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.")
end
end
def run(["delete", group, key]) do
start_pleroma()
group = maybe_atomize(group)
key = maybe_atomize(key)
with true <- key_exists?(group, key) do
shell_info("The following settings will be removed from ConfigDB:\n")
group
|> ConfigDB.get_by_group_and_key(key)
|> dump()
if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
delete_key(group, key)
else
shell_error("No changes made.")
end
else
_ ->
shell_error("No settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.")
end
end
def run(["delete", group]) do
start_pleroma()
group = maybe_atomize(group)
with true <- group_exists?(group) do
shell_info("The following settings will be removed from ConfigDB:\n")
dump_group(group)
if shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
delete_group(group)
else
shell_error("No changes made.")
end
else
_ -> shell_error("No settings in ConfigDB for #{inspect(group)}. Aborting.")
end
end
# Removes any policies that are not a real module
# as they will prevent the server from starting
def run(["fix_mrf_policies"]) do
check_configdb(fn ->
start_pleroma()
group = :pleroma
key = :mrf
%{value: value} =
group
|> ConfigDB.get_by_group_and_key(key)
policies =
Keyword.get(value, :policies, [])
|> Enum.filter(&is_atom(&1))
|> Enum.filter(fn mrf ->
case Code.ensure_compiled(mrf) do
{:module, _} -> true
{:error, _} -> false
end
end)
value = Keyword.put(value, :policies, policies)
ConfigDB.update_or_create(%{group: group, key: key, value: value})
end)
end
# Removes non-whitelisted configuration sections
def run(["filter_whitelisted" | rest]) do
{options, [], []} =
OptionParser.parse(
rest,
strict: [force: :boolean],
aliases: [f: :force]
)
force = Keyword.get(options, :force, false)
start_pleroma()
whitelisted_configs = Pleroma.Config.get(:database_config_whitelist)
if whitelisted_configs in [nil, false] do
shell_error("No unwanted settings in ConfigDB. No changes made.")
else
- whitelisted_groups =
- whitelisted_configs
- |> Enum.filter(fn
- {_group} -> true
- _ -> false
- end)
- |> Enum.map(fn {group} -> group end)
-
- whitelisted_keys =
- whitelisted_configs
- |> Enum.filter(fn
- {_group, _key} -> true
- _ -> false
- end)
+ {whitelisted_groups, whitelisted_keys} = get_filters(whitelisted_configs)
filtered =
from(c in ConfigDB)
|> Repo.all()
|> Enum.filter(&not_whitelisted?(&1, whitelisted_groups, whitelisted_keys))
- if not Enum.empty?(filtered) do
- shell_info("The following settings will be removed from ConfigDB:\n")
- Enum.each(filtered, &dump(&1))
+ do_config_filter(filtered, force)
+ end
+ end
- if force or shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
- filtered_ids = Enum.map(filtered, fn %{id: id} -> id end)
+ # Removes blacklisted configuration sections
+ def run(["filter_blacklisted" | rest]) do
+ {options, [], []} =
+ OptionParser.parse(
+ rest,
+ strict: [force: :boolean],
+ aliases: [f: :force]
+ )
- Repo.delete_all(from(c in ConfigDB, where: c.id in ^filtered_ids))
- else
- shell_error("No changes made.")
- end
- else
- shell_error("No unwanted settings in ConfigDB. No changes made.")
- end
+ force = Keyword.get(options, :force, false)
+
+ start_pleroma()
+
+ blacklisted_configs = Pleroma.Config.get(:database_config_blacklist)
+
+ if blacklisted_configs in [nil, false] do
+ shell_error("No unwanted settings in ConfigDB. No changes made.")
+ else
+ {blacklisted_groups, blacklisted_keys} = get_filters(blacklisted_configs)
+
+ filtered =
+ from(c in ConfigDB)
+ |> Repo.all()
+ |> Enum.filter(&blacklisted?(&1, blacklisted_groups, blacklisted_keys))
+
+ do_config_filter(filtered, force)
end
end
@spec migrate_to_db(Path.t() | nil) :: any()
def migrate_to_db(file_path \\ nil) do
with :ok <- Pleroma.Config.DeprecationWarnings.warn() do
config_file =
if file_path do
file_path
else
if Pleroma.Config.get(:release) do
Pleroma.Config.get(:config_path)
else
"config/#{Pleroma.Config.get(:env)}.secret.exs"
end
end
do_migrate_to_db(config_file)
else
_ ->
shell_error("Migration is not allowed until all deprecation warnings have been resolved.")
end
end
defp do_migrate_to_db(config_file) do
if File.exists?(config_file) do
shell_info("Migrating settings from file: #{Path.expand(config_file)}")
truncatedb()
custom_config =
config_file
|> read_file()
|> elem(0)
custom_config
|> Keyword.keys()
|> Enum.each(&create(&1, custom_config))
else
shell_info("To migrate settings, you must define custom settings in #{config_file}.")
end
end
defp create(group, settings) do
group
|> Pleroma.Config.Loader.filter_group(settings)
|> Enum.each(fn {key, value} ->
{:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value})
shell_info("Settings for key #{key} migrated.")
end)
shell_info("Settings for group #{inspect(group)} migrated.")
end
defp migrate_from_db(opts) do
env = opts[:env] || Pleroma.Config.get(:env)
filename = "#{env}.exported_from_db.secret.exs"
config_path =
cond do
opts[:path] ->
opts[:path]
Pleroma.Config.get(:release) ->
:config_path
|> Pleroma.Config.get()
|> Path.dirname()
true ->
"config"
end
|> Path.join(filename)
with {:ok, file} <- File.open(config_path, [:write, :utf8]) do
write_config(file, config_path, opts)
shell_info("Database configuration settings have been exported to #{config_path}")
else
_ ->
shell_error("Impossible to save settings to this directory #{Path.dirname(config_path)}")
tmp_config_path = Path.join(System.tmp_dir!(), filename)
file = File.open!(tmp_config_path)
shell_info(
"Saving database configuration settings to #{tmp_config_path}. Copy it to the #{Path.dirname(config_path)} manually."
)
write_config(file, tmp_config_path, opts)
end
end
defp write_config(file, path, opts) do
IO.write(file, config_header())
ConfigDB
|> Repo.all()
|> Enum.each(&write_and_delete(&1, file, opts[:delete]))
:ok = File.close(file)
# Ensure `mix format` runs in the same env as the current task and doesn't
# emit config-time stderr noise (e.g. dev secret warnings) into `mix test`.
System.cmd("mix", ["format", path],
env: [{"MIX_ENV", to_string(Mix.env())}],
stderr_to_stdout: true
)
end
defp config_header, do: "import Config\r\n\r\n"
defp read_file(config_file), do: Config.Reader.read_imports!(config_file)
defp write_and_delete(config, file, delete?) do
config
|> write(file)
|> delete(delete?)
end
defp write(config, file) do
value = inspect(config.value, limit: :infinity)
IO.write(file, "config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n")
config
end
defp delete(config, true) do
{:ok, _} = Repo.delete(config)
shell_info(
"config #{inspect(config.group)}, #{inspect(config.key)} was deleted from the ConfigDB."
)
end
defp delete(_config, _), do: :ok
defp dump(%ConfigDB{} = config) do
value = inspect(config.value, limit: :infinity)
shell_info("config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n")
end
defp dump(_), do: :noop
defp dump_group(group) when is_atom(group) do
group
|> ConfigDB.get_all_by_group()
|> Enum.each(&dump/1)
end
defp group_exists?(group) do
group
|> ConfigDB.get_all_by_group()
|> Enum.any?()
end
defp key_exists?(group, key) do
group
|> ConfigDB.get_by_group_and_key(key)
|> is_nil
|> Kernel.!()
end
defp maybe_atomize(arg) when is_atom(arg), do: arg
defp maybe_atomize(":" <> arg), do: maybe_atomize(arg)
defp maybe_atomize(arg) when is_binary(arg) do
if ConfigDB.module_name?(arg) do
String.to_existing_atom("Elixir." <> arg)
else
String.to_atom(arg)
end
end
defp check_configdb(callback) do
with true <- Pleroma.Config.get([:configurable_from_database]) do
callback.()
else
_ ->
shell_error(
"ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration."
)
end
end
defp delete_key(group, key) do
check_configdb(fn ->
ConfigDB.delete(%{group: group, key: key})
end)
end
defp delete_group(group) do
check_configdb(fn ->
group
|> ConfigDB.get_all_by_group()
|> Enum.each(&ConfigDB.delete/1)
end)
end
defp truncatedb do
Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;")
Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;")
end
+ defp get_filters(config) do
+ groups =
+ config
+ |> Enum.filter(fn
+ {_group} -> true
+ _ -> false
+ end)
+ |> Enum.map(fn {group} -> group end)
+
+ keys =
+ config
+ |> Enum.filter(fn
+ {_group, _key} -> true
+ _ -> false
+ end)
+
+ {groups, keys}
+ end
+
defp not_whitelisted?(%{group: group, key: key}, whitelisted_groups, whitelisted_keys) do
not Enum.member?(whitelisted_groups, group) and
not Enum.member?(whitelisted_keys, {group, key})
end
+
+ defp blacklisted?(%{group: group, key: key}, blacklisted_groups, blacklisted_keys) do
+ Enum.member?(blacklisted_groups, group) or
+ Enum.member?(blacklisted_keys, {group, key})
+ end
+
+ defp do_config_filter(filtered, _) when length(filtered) == 0 do
+ shell_error("No unwanted settings in ConfigDB. No changes made.")
+ end
+
+ defp do_config_filter(filtered, force) do
+ shell_info("The following settings will be removed from ConfigDB:\n")
+ Enum.each(filtered, &dump(&1))
+
+ if force or shell_prompt("Are you sure you want to continue?", "n") in ~w(Yn Y y) do
+ filtered_ids = Enum.map(filtered, fn %{id: id} -> id end)
+
+ Repo.delete_all(from(c in ConfigDB, where: c.id in ^filtered_ids))
+ else
+ shell_error("No changes made.")
+ end
+ end
end
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 8e1c5de0d..78073b430 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -1,292 +1,331 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Application do
use Application
import Cachex.Spec
alias Pleroma.Config
require Logger
@name Mix.Project.config()[:name]
@version Mix.Project.config()[:version]
@repository Mix.Project.config()[:source_url]
def name, do: @name
def version, do: @version
def named_version, do: @name <> " " <> @version
def repository, do: @repository
def user_agent do
if Process.whereis(Pleroma.Web.Endpoint) do
case Config.get([:http, :user_agent], :default) do
:default ->
info = "#{Pleroma.Web.Endpoint.url()} <#{Config.get([:instance, :email], "")}>"
named_version() <> "; " <> info
custom ->
custom
end
else
# fallback, if endpoint is not started yet
"Pleroma Data Loader"
end
end
# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
# Scrubbers are compiled at runtime and therefore will cause a conflict
# every time the application is restarted, so we disable module
# conflicts at runtime
Code.compiler_options(ignore_module_conflict: true)
+ configure_logger()
Pleroma.Telemetry.Logger.attach()
Config.Holder.save_default()
Pleroma.HTML.compile_scrubbers()
Pleroma.Config.Oban.warn()
Config.DeprecationWarnings.warn()
if Config.get([Pleroma.Web.Plugs.HTTPSecurityPlug, :enable], true) do
Pleroma.Web.Plugs.HTTPSecurityPlug.warn_if_disabled()
end
if Config.get(:env) != :test do
Pleroma.ApplicationRequirements.verify!()
end
load_custom_modules()
Pleroma.Docs.JSON.compile()
limiters_setup()
adapter = Application.get_env(:tesla, :adapter)
if match?({Tesla.Adapter.Finch, _}, adapter) do
Logger.info("Starting Finch")
Finch.start_link(name: MyFinch)
end
# Disable warnings_as_errors at runtime, it breaks Phoenix live reload
# due to protocol consolidation warnings
# :warnings_as_errors is deprecated via Code.compiler_options/2 since 1.18
if Version.compare(System.version(), "1.18.0") == :lt do
Code.compiler_options(warnings_as_errors: false)
end
# Define workers and child supervisors to be supervised
children =
[
Pleroma.PromEx,
Pleroma.LDAP,
Pleroma.Repo,
Config.TransferTask,
Pleroma.Emoji,
Pleroma.Web.Plugs.RateLimiter.Supervisor,
{Task.Supervisor, name: Pleroma.TaskSupervisor}
] ++
cachex_children() ++
http_children(adapter) ++
[
Pleroma.Stats,
Pleroma.JobQueueMonitor,
{Majic.Pool, [name: Pleroma.MajicPool, pool_size: Config.get([:majic_pool, :size], 2)]},
{Oban, Config.get(Oban)},
Pleroma.Web.Endpoint
] ++
task_children() ++
streamer_registry() ++
background_migrators() ++
shout_child(shout_enabled?()) ++
[Pleroma.Gopher.Server] ++
[Pleroma.Search.Healthcheck]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
# If we have a lot of caches, default max_restarts can cause test
# resets to fail.
# Go for the default 3 unless we're in test
max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts]
opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts]
Supervisor.start_link(children, opts)
end
+ def configure_logger do
+ Config.get([:logger, :backends], [])
+ |> Enum.each(fn backend ->
+ case backend_to_logger(backend) do
+ {:ok, logger} ->
+ add_logger(logger)
+
+ {:error, :console_backend} ->
+ Logger.warning(":console is no longer considered a backend and is enabled by default")
+ end
+ end)
+ end
+
+ defp add_logger(backend) do
+ case LoggerBackends.add(backend) do
+ {:ok, _} ->
+ Logger.debug("Successfully added logger backend: #{inspect(backend)}")
+
+ {:error, :already_present} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Failed to add logger backend #{inspect(backend)}: #{inspect(reason)}")
+ end
+ end
+
+ defp backend_to_logger(:console), do: {:error, :console_backend}
+
+ defp backend_to_logger({:ex_syslogger = backend, name}) do
+ Logger.warning(
+ "Configuration {:#{backend}, :#{name}} is incorrect. Use {ExSyslogger, :#{name}} instead!"
+ )
+
+ {:ok, {ExSyslogger, name}}
+ end
+
+ defp backend_to_logger(backend), do: {:ok, backend}
+
def load_custom_modules do
dir = Config.get([:modules, :runtime_dir])
if dir && File.exists?(dir) do
dir
|> Pleroma.Utils.compile_dir()
|> case do
{:error, _errors, _warnings} ->
raise "Invalid custom modules"
{:ok, modules, _warnings} ->
if Application.get_env(:pleroma, __MODULE__)[:load_custom_modules] do
Enum.each(modules, fn mod ->
Logger.info("Custom module loaded: #{inspect(mod)}")
end)
end
:ok
end
end
end
defp cachex_children do
[
build_cachex("used_captcha", ttl_interval: seconds_valid_interval()),
build_cachex("user", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
build_cachex("scrubber", limit: 2500),
build_cachex("scrubber_management", limit: 2500),
build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
build_cachex("web_resp", limit: 2500),
build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
build_cachex("failed_proxy_url", limit: 2500),
build_cachex("failed_media_helper_url", default_ttl: :timer.minutes(15), limit: 2_500),
build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
build_cachex("chat_message_id_idempotency_key",
expiration: chat_message_id_idempotency_key_expiration(),
limit: 500_000
),
build_cachex("rel_me", limit: 2500),
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5_000),
build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000)
]
end
defp emoji_packs_expiration,
do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60))
defp idempotency_expiration,
do: expiration(default: :timer.seconds(6 * 60 * 60), interval: :timer.seconds(60))
defp chat_message_id_idempotency_key_expiration,
do: expiration(default: :timer.minutes(2), interval: :timer.seconds(60))
defp seconds_valid_interval,
do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid]))
@spec build_cachex(String.t(), keyword()) :: map()
def build_cachex(type, opts),
do: %{
id: String.to_atom("cachex_" <> type),
start: {Cachex, :start_link, [String.to_atom(type <> "_cache"), opts]},
type: :worker
}
defp shout_enabled?, do: Config.get([:shout, :enabled])
defp streamer_registry do
if Application.get_env(:pleroma, __MODULE__)[:streamer_registry] do
[
{Registry,
[
name: Pleroma.Web.Streamer.registry(),
keys: :duplicate,
partitions: System.schedulers_online()
]}
]
else
[]
end
end
defp background_migrators do
if Application.get_env(:pleroma, __MODULE__)[:background_migrators] do
[
Pleroma.Migrators.HashtagsTableMigrator,
Pleroma.Migrators.ContextObjectsDeletionMigrator
]
else
[]
end
end
defp shout_child(true) do
[
Pleroma.Web.ShoutChannel.ShoutChannelState,
{Phoenix.PubSub, [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]}
]
end
defp shout_child(_), do: []
defp task_children do
children = [
%{
id: :web_push_init,
start: {Task, :start_link, [&Pleroma.Web.Push.init/0]},
restart: :temporary
}
]
if Application.get_env(:pleroma, __MODULE__)[:internal_fetch] do
children ++
[
%{
id: :internal_fetch_init,
start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]},
restart: :temporary
}
]
else
children
end
end
# start hackney and gun pools in tests
defp http_children(adapter) do
if Application.get_env(:pleroma, __MODULE__)[:test_http_pools] do
http_children_hackney() ++ http_children_gun()
else
cond do
match?(Tesla.Adapter.Hackney, adapter) -> http_children_hackney()
match?(Tesla.Adapter.Gun, adapter) -> http_children_gun()
true -> []
end
end
end
defp http_children_hackney do
pools = [:federation, :media]
pools =
if Config.get([Pleroma.Upload, :proxy_remote]) do
[:upload | pools]
else
pools
end
for pool <- pools do
options = Config.get([:hackney_pools, pool])
:hackney_pool.child_spec(pool, options)
end
end
defp http_children_gun do
Pleroma.Gun.ConnectionPool.children() ++
[{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}]
end
@spec limiters_setup() :: :ok
def limiters_setup do
config = Config.get(ConcurrentLimiter, [])
[
Pleroma.Search
]
|> Enum.each(fn module ->
mod_config = Keyword.get(config, module, [])
max_running = Keyword.get(mod_config, :max_running, 5)
max_waiting = Keyword.get(mod_config, :max_waiting, 5)
ConcurrentLimiter.new(module, max_running, max_waiting)
end)
end
end
diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex
index 926176bfa..6ac1b24b8 100644
--- a/lib/pleroma/chat.ex
+++ b/lib/pleroma/chat.ex
@@ -1,111 +1,113 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Chat do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.Chat
alias Pleroma.Repo
alias Pleroma.User
@moduledoc """
Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet).
It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages.
"""
@type t :: %__MODULE__{}
@primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true}
schema "chats" do
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
field(:recipient, :string)
field(:pinned, :boolean)
timestamps()
end
def changeset(struct, params) do
struct
|> cast(params, [:user_id, :recipient])
|> validate_change(:recipient, fn
:recipient, recipient ->
case User.get_cached_by_ap_id(recipient) do
nil -> [recipient: "must be an existing user"]
_ -> []
end
end)
|> validate_required([:user_id, :recipient])
|> unique_constraint(:user_id, name: :chats_user_id_recipient_index)
end
@spec get_by_user_and_id(User.t(), Ecto.UUID.t()) ::
{:ok, t()} | {:error, :not_found}
def get_by_user_and_id(%User{id: user_id}, id) do
from(c in __MODULE__,
where: c.id == ^id,
where: c.user_id == ^user_id
)
|> Repo.find_resource()
end
@spec get_by_id(Ecto.UUID.t()) :: t() | nil
def get_by_id(id) do
Repo.get(__MODULE__, id)
end
@spec get(Ecto.UUID.t(), String.t()) :: t() | nil
def get(user_id, recipient) do
Repo.get_by(__MODULE__, user_id: user_id, recipient: recipient)
end
@spec get_or_create(Ecto.UUID.t(), String.t()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def get_or_create(user_id, recipient) do
%__MODULE__{}
|> changeset(%{user_id: user_id, recipient: recipient})
|> Repo.insert(
# Need to set something, otherwise we get nothing back at all
on_conflict: [set: [recipient: recipient]],
returning: true,
conflict_target: [:user_id, :recipient]
)
end
@spec bump_or_create(Ecto.UUID.t(), String.t()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def bump_or_create(user_id, recipient) do
%__MODULE__{}
|> changeset(%{user_id: user_id, recipient: recipient})
|> Repo.insert(
on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]],
returning: true,
conflict_target: [:user_id, :recipient]
)
end
@spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t()
def for_user_query(user_id) do
from(c in Chat,
where: c.user_id == ^user_id,
order_by: [desc: c.updated_at]
)
end
def pin(%__MODULE__{} = chat) do
chat
|> cast(%{pinned: true}, [:pinned])
|> Repo.update()
end
def unpin(%__MODULE__{} = chat) do
chat
|> cast(%{pinned: false}, [:pinned])
|> Repo.update()
end
+
+ def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], true)
end
diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex
index 58d164dc7..934a4a02e 100644
--- a/lib/pleroma/config/deprecation_warnings.ex
+++ b/lib/pleroma/config/deprecation_warnings.ex
@@ -1,418 +1,489 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Config.DeprecationWarnings do
alias Pleroma.Config
require Logger
alias Pleroma.Config
@type config_namespace() :: atom() | [atom()]
@type config_map() :: {config_namespace(), config_namespace(), String.t()}
@mrf_config_map [
{[:instance, :rewrite_policy], [:mrf, :policies],
"\n* `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies`"},
{[:instance, :mrf_transparency], [:mrf, :transparency],
"\n* `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency`"},
{[:instance, :mrf_transparency_exclusions], [:mrf, :transparency_exclusions],
"\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"}
]
+ @logger_formatter_config_knobs [:colors, :format, :metadata, :truncate, :utc_log]
+
def check_exiftool_filter do
filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, [])
if Pleroma.Upload.Filter.Exiftool in filters do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool]
```
Is now
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool.StripLocation]
```
""")
new_config =
filters
|> Enum.map(fn
Pleroma.Upload.Filter.Exiftool -> Pleroma.Upload.Filter.Exiftool.StripLocation
filter -> filter
end)
Config.put([Pleroma.Upload, :filters], new_config)
:error
else
:ok
end
end
def check_simple_policy_tuples do
has_strings =
Config.get([:mrf_simple])
|> Enum.any?(fn {_, v} -> Enum.any?(v, &is_binary/1) end)
if has_strings do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using strings in the SimplePolicy configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :mrf_simple,
media_removal: ["instance.tld"],
media_nsfw: ["instance.tld"],
federated_timeline_removal: ["instance.tld"],
report_removal: ["instance.tld"],
reject: ["instance.tld"],
followers_only: ["instance.tld"],
accept: ["instance.tld"],
avatar_removal: ["instance.tld"],
banner_removal: ["instance.tld"],
reject_deletes: ["instance.tld"]
```
Is now
```
config :pleroma, :mrf_simple,
media_removal: [{"instance.tld", "Reason for media removal"}],
media_nsfw: [{"instance.tld", "Reason for media nsfw"}],
federated_timeline_removal: [{"instance.tld", "Reason for federated timeline removal"}],
report_removal: [{"instance.tld", "Reason for report removal"}],
reject: [{"instance.tld", "Reason for reject"}],
followers_only: [{"instance.tld", "Reason for followers only"}],
accept: [{"instance.tld", "Reason for accept"}],
avatar_removal: [{"instance.tld", "Reason for avatar removal"}],
banner_removal: [{"instance.tld", "Reason for banner removal"}],
reject_deletes: [{"instance.tld", "Reason for reject deletes"}]
```
""")
new_config =
Config.get([:mrf_simple])
|> Enum.map(fn {k, v} ->
{k,
Enum.map(v, fn
{instance, reason} -> {instance, reason}
instance -> {instance, ""}
end)}
end)
Config.put([:mrf_simple], new_config)
:error
else
:ok
end
end
def check_quarantined_instances_tuples do
has_strings = Config.get([:instance, :quarantined_instances]) |> Enum.any?(&is_binary/1)
if has_strings do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using strings in the quarantined_instances configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :instance,
quarantined_instances: ["instance.tld"]
```
Is now
```
config :pleroma, :instance,
quarantined_instances: [{"instance.tld", "Reason for quarantine"}]
```
""")
new_config =
Config.get([:instance, :quarantined_instances])
|> Enum.map(fn
{instance, reason} -> {instance, reason}
instance -> {instance, ""}
end)
Config.put([:instance, :quarantined_instances], new_config)
:error
else
:ok
end
end
def check_transparency_exclusions_tuples do
has_strings = Config.get([:mrf, :transparency_exclusions]) |> Enum.any?(&is_binary/1)
if has_strings do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using strings in the transparency_exclusions configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :mrf,
transparency_exclusions: ["instance.tld"]
```
Is now
```
config :pleroma, :mrf,
transparency_exclusions: [{"instance.tld", "Reason to exclude transparency"}]
```
""")
new_config =
Config.get([:mrf, :transparency_exclusions])
|> Enum.map(fn
{instance, reason} -> {instance, reason}
instance -> {instance, ""}
end)
Config.put([:mrf, :transparency_exclusions], new_config)
:error
else
:ok
end
end
def check_hellthread_threshold do
if Config.get([:mrf_hellthread, :threshold]) do
Logger.warning("""
!!!DEPRECATION WARNING!!!
You are using the old configuration mechanism for the hellthread filter. Please check config.md.
""")
:error
else
:ok
end
end
def warn do
[
check_hellthread_threshold(),
check_old_mrf_config(),
check_media_proxy_whitelist_config(),
check_welcome_message_config(),
check_gun_pool_options(),
check_activity_expiration_config(),
check_remote_ip_plug_name(),
check_uploaders_s3_public_endpoint(),
check_old_chat_shoutbox(),
check_quarantined_instances_tuples(),
check_transparency_exclusions_tuples(),
check_simple_policy_tuples(),
- check_exiftool_filter()
+ check_exiftool_filter(),
+ check_deprecated_logger_config()
]
|> Enum.reduce(:ok, fn
:ok, :ok -> :ok
_, _ -> :error
end)
end
def check_welcome_message_config do
instance_config = Pleroma.Config.get([:instance])
use_old_config =
Keyword.has_key?(instance_config, :welcome_user_nickname) or
Keyword.has_key?(instance_config, :welcome_message)
if use_old_config do
Logger.error("""
!!!DEPRECATION WARNING!!!
Your config is using the old namespace for Welcome messages configuration. You need to convert to the new namespace. e.g.,
\n* `config :pleroma, :instance, welcome_user_nickname` and `config :pleroma, :instance, welcome_message` are now equal to:
\n* `config :pleroma, :welcome, direct_message: [enabled: true, sender_nickname: "NICKNAME", message: "Your welcome message"]`"
""")
:error
else
:ok
end
end
def check_old_mrf_config do
warning_preface = """
!!!DEPRECATION WARNING!!!
Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later:
"""
move_namespace_and_warn(@mrf_config_map, warning_preface)
end
@spec move_namespace_and_warn([config_map()], String.t()) :: :ok | :error
def move_namespace_and_warn(config_map, warning_preface) do
warning =
Enum.reduce(config_map, "", fn
{old, new, err_msg}, acc ->
old_config = Config.get(old)
if old_config do
Config.put(new, old_config)
acc <> err_msg
else
acc
end
end)
if warning == "" do
:ok
else
Logger.warning(warning_preface <> warning)
:error
end
end
@spec check_media_proxy_whitelist_config() :: :ok | :error
def check_media_proxy_whitelist_config do
whitelist = Config.get([:media_proxy, :whitelist])
if Enum.any?(whitelist, &(not String.starts_with?(&1, "http"))) do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using old format (only domain) for MediaProxy whitelist option. Setting should work for now, but you are advised to change format to scheme with port to prevent possible issues later.
""")
:error
else
:ok
end
end
def check_gun_pool_options do
pool_config = Config.get(:connections_pool)
if timeout = pool_config[:await_up_timeout] do
Logger.warning("""
!!!DEPRECATION WARNING!!!
Your config is using old setting `config :pleroma, :connections_pool, await_up_timeout`. Please change to `config :pleroma, :connections_pool, connect_timeout` to ensure compatibility with future releases.
""")
Config.put(:connections_pool, Keyword.put_new(pool_config, :connect_timeout, timeout))
end
pools_configs = Config.get(:pools)
warning_preface = """
!!!DEPRECATION WARNING!!!
Your config is using old setting name `timeout` instead of `recv_timeout` in pool settings. The setting will not take effect until updated.
"""
updated_config =
Enum.reduce(pools_configs, [], fn {pool_name, config}, acc ->
if timeout = config[:timeout] do
Keyword.put(acc, pool_name, Keyword.put_new(config, :recv_timeout, timeout))
else
acc
end
end)
if updated_config != [] do
pool_warnings =
updated_config
|> Keyword.keys()
|> Enum.map(fn pool_name ->
"\n* `:timeout` options in #{pool_name} pool is now `:recv_timeout`"
end)
Logger.warning(Enum.join([warning_preface | pool_warnings]))
Config.put(:pools, updated_config)
:error
else
:ok
end
end
@spec check_activity_expiration_config() :: :ok | :error
def check_activity_expiration_config do
warning_preface = """
!!!DEPRECATION WARNING!!!
Your config is using old namespace for activity expiration configuration. Setting should work for now, but you are advised to change to new namespace to prevent possible issues later:
"""
move_namespace_and_warn(
[
{Pleroma.ActivityExpiration, Pleroma.Workers.PurgeExpiredActivity,
"\n* `config :pleroma, Pleroma.ActivityExpiration` is now `config :pleroma, Pleroma.Workers.PurgeExpiredActivity`"}
],
warning_preface
)
end
@spec check_remote_ip_plug_name() :: :ok | :error
def check_remote_ip_plug_name do
warning_preface = """
!!!DEPRECATION WARNING!!!
Your config is using old namespace for RemoteIp Plug. Setting should work for now, but you are advised to change to new namespace to prevent possible issues later:
"""
move_namespace_and_warn(
[
{Pleroma.Plugs.RemoteIp, Pleroma.Web.Plugs.RemoteIp,
"\n* `config :pleroma, Pleroma.Plugs.RemoteIp` is now `config :pleroma, Pleroma.Web.Plugs.RemoteIp`"}
],
warning_preface
)
end
@spec check_uploaders_s3_public_endpoint() :: :ok | :error
def check_uploaders_s3_public_endpoint do
s3_config = Pleroma.Config.get([Pleroma.Uploaders.S3])
use_old_config = Keyword.has_key?(s3_config, :public_endpoint)
if use_old_config do
Logger.error("""
!!!DEPRECATION WARNING!!!
Your config is using the old setting for controlling the URL of media uploaded to your S3 bucket.\n
Please make the following change at your earliest convenience.\n
\n* `config :pleroma, Pleroma.Uploaders.S3, public_endpoint` is now equal to:
\n* `config :pleroma, Pleroma.Upload, base_url`
""")
:error
else
:ok
end
end
@spec check_old_chat_shoutbox() :: :ok | :error
def check_old_chat_shoutbox do
instance_config = Pleroma.Config.get([:instance])
chat_config = Pleroma.Config.get([:chat]) || []
use_old_config =
Keyword.has_key?(instance_config, :chat_limit) or
Keyword.has_key?(chat_config, :enabled)
if use_old_config do
Logger.error("""
!!!DEPRECATION WARNING!!!
Your config is using the old namespace for the Shoutbox configuration. You need to convert to the new namespace. e.g.,
\n* `config :pleroma, :chat, enabled` and `config :pleroma, :instance, chat_limit` are now equal to:
\n* `config :pleroma, :shout, enabled` and `config :pleroma, :shout, limit`
""")
:error
else
:ok
end
end
+
+ defp merge_deprecated_logger_config(config) do
+ formatter_config = Enum.filter(config, fn {k, _} -> k in @logger_formatter_config_knobs end)
+ formatter = Logger.default_formatter(formatter_config)
+ log_level = Keyword.get(config, :level, nil)
+
+ Logger.debug("""
+ Reconfiguring console Logger with deprecated configuration syntax.
+ Handler configuration:
+ log_level: #{inspect(log_level)}
+
+ Formatter:
+ #{inspect(formatter)}
+ """)
+
+ if Config.get(:env) != :test do
+ if log_level, do: :logger.update_handler_config(:default, :level, log_level)
+ :logger.set_handler_config(:default, :formatter, formatter)
+ end
+ end
+
+ @spec check_deprecated_logger_config() :: :ok | :error
+ def check_deprecated_logger_config do
+ backends_config = Application.get_env(:logger, :backends)
+ console_config = Application.get_env(:logger, :console)
+
+ # NOTE: No need to merge the old backends config since it still works.
+ # And new configuration will just add new Logger backends.
+ backend =
+ if backends_config do
+ Logger.warning("""
+ !!!DEPRECATION WARNING!!!
+ Your configuration is using deprecated syntax for configuring backends of Elixir's logger.
+ `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir.
+ Please update your configuration at your earliest convenience to use:
+ `config :pleroma, :logger, backends: [...]`
+
+ Note: `:console` is no longer considered a backend and is used by default, you can disable it using:
+ `config :logger, :default_handler: false`
+ """)
+
+ true
+ else
+ false
+ end
+
+ console =
+ if console_config do
+ Logger.warning("""
+ !!!DEPRECATION WARNING!!!
+ Your configuration is using deprecated syntax for configuring logging to console.
+ `config :logger, :console` is deprecated syntax due to changes in Elixir.
+ Please update your configuration at your earliest convenience to use
+ `config :logger, default_handler` and `config :logger, :default_formatter`.
+
+ Note: `:default_handler` is used only for the `level` setting. All other configurations go under
+ `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility
+ """)
+
+ merge_deprecated_logger_config(console_config)
+
+ true
+ else
+ false
+ end
+
+ if backend or console, do: :error, else: :ok
+ end
end
diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex
index 140dd7711..3e59dc4e0 100644
--- a/lib/pleroma/config/transfer_task.ex
+++ b/lib/pleroma/config/transfer_task.ex
@@ -1,159 +1,159 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Config.TransferTask do
use Task
alias Pleroma.Config
alias Pleroma.ConfigDB
alias Pleroma.Repo
require Logger
@type env() :: :test | :benchmark | :dev | :prod
defp reboot_time_keys,
do: [
{:pleroma, :hackney_pools},
{:pleroma, :shout},
{:pleroma, Oban},
{:pleroma, :rate_limit},
{:pleroma, :markup},
{:pleroma, :streamer},
{:pleroma, :pools},
{:pleroma, :connections_pool},
{:pleroma, :ldap}
]
defp reboot_time_subkeys,
do: [
{:pleroma, Pleroma.Captcha, [:seconds_valid]},
{:pleroma, Pleroma.Upload, [:proxy_remote]},
{:pleroma, :instance, [:upload_limit]},
{:pleroma, :gopher, [:enabled]}
]
def start_link(restart_pleroma? \\ true) do
load_and_update_env([], restart_pleroma?)
if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo)
:ignore
end
@spec load_and_update_env([ConfigDB.t()], boolean()) :: :ok
def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do
with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do
# We need to restart applications for loaded settings take effect
settings =
(Repo.all(ConfigDB) ++ deleted_settings)
|> Enum.map(&merge_with_default/1)
started_applications = Application.started_applications()
- reject = [nil, :postgrex]
+ reject = [nil, :postgrex, :logger]
reject =
if restart_pleroma? do
reject
else
[:pleroma | reject]
end
settings
|> Enum.map(&update/1)
|> Enum.uniq()
|> Enum.reject(&(&1 in reject))
|> maybe_set_pleroma_last()
|> Enum.each(&restart(started_applications, &1, Config.get(:env)))
:ok
else
{:configurable, false} -> Restarter.Pleroma.rebooted()
end
end
defp maybe_set_pleroma_last(apps) do
# to be ensured that pleroma will be restarted last
if :pleroma in apps do
apps
|> List.delete(:pleroma)
|> List.insert_at(-1, :pleroma)
else
Restarter.Pleroma.rebooted()
apps
end
end
defp merge_with_default(%{group: group, key: key, value: value} = setting) do
default = Config.Holder.default_config(group, key)
merged =
cond do
Ecto.get_meta(setting, :state) == :deleted -> default
can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value)
true -> value
end
{group, key, value, merged}
end
defp update({group, key, value, merged}) do
try do
:ok = update_env(group, key, merged)
if group != :pleroma or pleroma_need_restart?(group, key, value), do: group
rescue
error ->
error_msg =
"updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{inspect(value)} error: #{inspect(error)}"
Logger.warning(error_msg)
nil
end
end
defp update_env(group, key, nil), do: Application.delete_env(group, key)
defp update_env(group, key, value), do: Application.put_env(group, key, value)
@spec pleroma_need_restart?(atom(), atom(), any()) :: boolean()
def pleroma_need_restart?(group, key, value) do
group_and_key_need_reboot?(group, key) or group_and_subkey_need_reboot?(group, key, value)
end
defp group_and_key_need_reboot?(group, key) do
Enum.any?(reboot_time_keys(), fn {g, k} -> g == group and k == key end)
end
defp group_and_subkey_need_reboot?(group, key, value) do
Keyword.keyword?(value) and
Enum.any?(reboot_time_subkeys(), fn {g, k, subkeys} ->
g == group and k == key and
Enum.any?(Keyword.keys(value), &(&1 in subkeys))
end)
end
defp restart(_, :pleroma, env), do: Restarter.Pleroma.restart_after_boot(env)
defp restart(started_applications, app, _) do
with {^app, _, _} <- List.keyfind(started_applications, app, 0),
:ok <- Application.stop(app) do
:ok = Application.start(app)
else
nil ->
Logger.warning("#{app} is not started.")
error ->
error
|> inspect()
|> Logger.warning()
end
end
defp can_be_merged?(val1, val2) when is_list(val1) and is_list(val2) do
Keyword.keyword?(val1) and Keyword.keyword?(val2)
end
defp can_be_merged?(_val1, _val2), do: false
end
diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex
index 93b19484f..70413837a 100644
--- a/lib/pleroma/docs/generator.ex
+++ b/lib/pleroma/docs/generator.ex
@@ -1,138 +1,151 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Docs.Generator do
@callback process(keyword()) :: {:ok, String.t()}
@spec process(module(), keyword()) :: {:ok, String.t()}
def process(implementation, descriptions) do
implementation.process(descriptions)
end
@spec list_behaviour_implementations(behaviour :: module()) :: [module()]
def list_behaviour_implementations(behaviour) do
- :code.all_loaded()
- |> Enum.filter(fn {module, _} ->
+ # :code.all_loaded() no longer works on Elixir 1.19 and newer since modules are lazily loaded.
+ # We can get a complete list of modules by interating over Application.loaded_applications/0,
+ # but that includes every module which would get loaded below, which is very slow.
+ # Hardcode the Pleroma application instead, which is good enough for now.
+ application_modules =
+ case :application.get_key(:pleroma, :modules) do
+ {:ok, modules} -> modules
+ :undefined -> []
+ end
+
+ # At compile time or in tests some modules will be outside of the Pleroma application.
+ loaded_modules = Enum.map(:code.all_loaded(), fn {module, _} -> module end)
+
+ (application_modules ++ loaded_modules)
+ |> Enum.uniq()
+ |> Enum.filter(fn module ->
+ Code.ensure_loaded(module)
+
# This shouldn't be needed as all modules are expected to have module_info/1,
# but in test environments some transient modules `:elixir_compiler_XX`
# are loaded for some reason (where XX is a random integer).
- Code.ensure_loaded(module)
-
if function_exported?(module, :module_info, 1) do
module.module_info(:attributes)
|> Keyword.get_values(:behaviour)
|> List.flatten()
|> Enum.member?(behaviour)
end
end)
- |> Enum.map(fn {module, _} -> module end)
end
@doc """
Converts:
- atoms to strings with leading `:`
- module names to strings, without leading `Elixir.`
- add humanized labels to `keys` if label is not defined, e.g. `:instance` -> `Instance`
"""
@spec convert_to_strings([map()]) :: [map()]
def convert_to_strings(descriptions) do
Enum.map(descriptions, &format_entity(&1))
end
defp format_entity(entity) do
entity
|> format_key()
|> Map.put(:group, atom_to_string(entity[:group]))
|> format_children()
end
defp format_key(%{key: key} = entity) do
entity
|> Map.put(:key, atom_to_string(key))
|> Map.put(:label, entity[:label] || humanize(key))
end
defp format_key(%{group: group} = entity) do
Map.put(entity, :label, entity[:label] || humanize(group))
end
defp format_key(entity), do: entity
defp format_children(%{children: children} = entity) do
Map.put(entity, :children, Enum.map(children, &format_child(&1)))
end
defp format_children(entity), do: entity
defp format_child(%{suggestions: suggestions} = entity) do
entity
|> Map.put(:suggestions, format_suggestions(suggestions))
|> format_key()
|> format_group()
|> format_children()
end
defp format_child(entity) do
entity
|> format_key()
|> format_group()
|> format_children()
end
defp format_group(%{group: group} = entity) do
Map.put(entity, :group, format_suggestion(group))
end
defp format_group(entity), do: entity
defp atom_to_string(entity) when is_binary(entity), do: entity
defp atom_to_string(entity) when is_atom(entity), do: inspect(entity)
defp humanize(entity) do
string = inspect(entity)
if String.starts_with?(string, ":"),
do: Phoenix.Naming.humanize(entity),
else: string
end
defp format_suggestions({:list_behaviour_implementations, behaviour}) do
behaviour
|> list_behaviour_implementations()
|> format_suggestions()
end
defp format_suggestions([]), do: []
defp format_suggestions([suggestion | tail]) do
[format_suggestion(suggestion) | format_suggestions(tail)]
end
defp format_suggestion(entity) when is_atom(entity) do
atom_to_string(entity)
end
defp format_suggestion([head | tail] = entity) when is_list(entity) do
[format_suggestion(head) | format_suggestions(tail)]
end
defp format_suggestion(entity) when is_tuple(entity) do
format_suggestions(Tuple.to_list(entity)) |> List.to_tuple()
end
defp format_suggestion(entity), do: entity
end
defimpl Jason.Encoder, for: Tuple do
def encode(tuple, opts), do: Jason.Encode.list(Tuple.to_list(tuple), opts)
end
defimpl Jason.Encoder, for: [Regex, Function] do
def encode(term, opts), do: Jason.Encode.string(inspect(term), opts)
end
defimpl String.Chars, for: Regex do
def to_string(term), do: inspect(term)
end
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index 75da41da9..768a6f3c2 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -1,2954 +1,2960 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.User do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
import Ecto, only: [assoc: 2]
import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Delivery
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Emoji
alias Pleroma.FollowingRelationship
alias Pleroma.Formatter
alias Pleroma.Hashtag
alias Pleroma.HTML
alias Pleroma.Keys
alias Pleroma.MFA
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Registration
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.User.HashtagFollow
alias Pleroma.UserRelationship
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
alias Pleroma.Web.Endpoint
alias Pleroma.Web.OAuth
alias Pleroma.Web.RelMe
alias Pleroma.Workers.BackgroundWorker
alias Pleroma.Workers.DeleteWorker
alias Pleroma.Workers.UserRefreshWorker
require Logger
require Pleroma.Constants
@type t :: %__MODULE__{}
@type account_status ::
:active
| :deactivated
| :password_reset_pending
| :confirmation_pending
| :approval_pending
@primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true}
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
@email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
@strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/
@extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/
# AP ID user relationships (blocks, mutes etc.)
# Format: [rel_type: [outgoing_rel: :outgoing_rel_target, incoming_rel: :incoming_rel_source]]
@user_relationships_config [
block: [
blocker_blocks: :blocked_users,
blockee_blocks: :blocker_users
],
mute: [
muter_mutes: :muted_users,
mutee_mutes: :muter_users
],
reblog_mute: [
reblog_muter_mutes: :reblog_muted_users,
reblog_mutee_mutes: :reblog_muter_users
],
notification_mute: [
notification_muter_mutes: :notification_muted_users,
notification_mutee_mutes: :notification_muter_users
],
# Note: `inverse_subscription` relationship is inverse: subscriber acts as relationship target
inverse_subscription: [
subscribee_subscriptions: :subscriber_users,
subscriber_subscriptions: :subscribee_users
],
endorsement: [
endorser_endorsements: :endorsed_users,
endorsee_endorsements: :endorser_users
]
]
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
schema "users" do
field(:bio, :string, default: "")
field(:raw_bio, :string)
field(:email, :string)
field(:name, :string)
field(:nickname, :string)
field(:password_hash, :string)
field(:password, :string, virtual: true)
field(:password_confirmation, :string, virtual: true)
field(:keys, :string)
field(:public_key, :string)
field(:ap_id, :string)
field(:avatar, :map, default: %{})
field(:local, :boolean, default: true)
field(:follower_address, :string)
field(:following_address, :string)
field(:featured_address, :string)
field(:search_rank, :float, virtual: true)
field(:search_type, :integer, virtual: true)
field(:tags, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec)
field(:last_digest_emailed_at, :naive_datetime)
field(:banner, :map, default: %{})
field(:background, :map, default: %{})
field(:note_count, :integer, default: 0)
field(:follower_count, :integer, default: 0)
field(:following_count, :integer, default: 0)
field(:is_locked, :boolean, default: false)
field(:is_confirmed, :boolean, default: true)
field(:password_reset_pending, :boolean, default: false)
field(:is_approved, :boolean, default: true)
field(:registration_reason, :string, default: nil)
field(:confirmation_token, :string, default: nil)
field(:default_scope, :string, default: "public")
field(:domain_blocks, {:array, :string}, default: [])
field(:is_active, :boolean, default: true)
field(:no_rich_text, :boolean, default: false)
field(:is_moderator, :boolean, default: false)
field(:is_admin, :boolean, default: false)
field(:show_role, :boolean, default: true)
field(:uri, ObjectValidators.Uri, default: nil)
field(:hide_followers_count, :boolean, default: false)
field(:hide_follows_count, :boolean, default: false)
field(:hide_followers, :boolean, default: false)
field(:hide_follows, :boolean, default: false)
field(:hide_favorites, :boolean, default: true)
field(:email_notifications, :map, default: %{"digest" => false})
field(:mascot, :map, default: nil)
field(:emoji, :map, default: %{})
field(:pleroma_settings_store, :map, default: %{})
field(:fields, {:array, :map}, default: [])
field(:raw_fields, {:array, :map}, default: [])
field(:is_discoverable, :boolean, default: false)
field(:invisible, :boolean, default: false)
field(:allow_following_move, :boolean, default: true)
field(:skip_thread_containment, :boolean, default: false)
field(:actor_type, :string, default: "Person")
field(:also_known_as, {:array, ObjectValidators.BareUri}, default: [])
field(:inbox, :string)
field(:shared_inbox, :string)
field(:accepts_chat_messages, :boolean, default: nil)
field(:last_active_at, :naive_datetime)
field(:disclose_client, :boolean, default: true)
field(:pinned_objects, :map, default: %{})
field(:is_suggested, :boolean, default: false)
field(:last_status_at, :naive_datetime)
field(:birthday, :date)
field(:show_birthday, :boolean, default: false)
field(:language, :string)
embeds_one(
:notification_settings,
Pleroma.User.NotificationSetting,
on_replace: :update
)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
has_many(:deliveries, Delivery)
has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id)
has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id)
many_to_many(:followed_hashtags, Hashtag,
on_replace: :delete,
on_delete: :delete_all,
join_through: HashtagFollow
)
for {relationship_type,
[
{outgoing_relation, outgoing_relation_target},
{incoming_relation, incoming_relation_source}
]} <- @user_relationships_config do
# Definitions of `has_many` relations: :blocker_blocks, :muter_mutes, :reblog_muter_mutes,
# :notification_muter_mutes, :subscribee_subscriptions, :endorser_endorsements
has_many(outgoing_relation, UserRelationship,
foreign_key: :source_id,
where: [relationship_type: relationship_type]
)
# Definitions of `has_many` relations: :blockee_blocks, :mutee_mutes, :reblog_mutee_mutes,
# :notification_mutee_mutes, :subscriber_subscriptions, :endorsee_endorsements
has_many(incoming_relation, UserRelationship,
foreign_key: :target_id,
where: [relationship_type: relationship_type]
)
# Definitions of `has_many` relations: :blocked_users, :muted_users, :reblog_muted_users,
# :notification_muted_users, :subscriber_users, :endorsed_users
has_many(outgoing_relation_target, through: [outgoing_relation, :target])
# Definitions of `has_many` relations: :blocker_users, :muter_users, :reblog_muter_users,
# :notification_muter_users, :subscribee_users, :endorser_users
has_many(incoming_relation_source, through: [incoming_relation, :source])
end
# `:blocks` is deprecated (replaced with `blocked_users` relation)
field(:blocks, {:array, :string}, default: [])
# `:mutes` is deprecated (replaced with `muted_users` relation)
field(:mutes, {:array, :string}, default: [])
# `:muted_reblogs` is deprecated (replaced with `reblog_muted_users` relation)
field(:muted_reblogs, {:array, :string}, default: [])
# `:muted_notifications` is deprecated (replaced with `notification_muted_users` relation)
field(:muted_notifications, {:array, :string}, default: [])
# `:subscribers` is deprecated (replaced with `subscriber_users` relation)
field(:subscribers, {:array, :string}, default: [])
embeds_one(
:multi_factor_authentication_settings,
MFA.Settings,
on_replace: :delete
)
timestamps()
end
for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <-
@user_relationships_config do
# `def blocked_users_relation/2`, `def muted_users_relation/2`,
# `def reblog_muted_users_relation/2`, `def notification_muted_users_relation/2`,
# `def subscriber_users_relation/2`, `def endorsed_users_relation/2`
def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do
target_users_query = assoc(user, unquote(outgoing_relation_target))
if restrict_deactivated? do
target_users_query
|> User.Query.build(%{deactivated: false})
else
target_users_query
end
end
# `def blocked_users/2`, `def muted_users/2`, `def reblog_muted_users/2`,
# `def notification_muted_users/2`, `def subscriber_users/2`, `def endorsed_users/2`
def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do
__MODULE__
|> apply(unquote(:"#{outgoing_relation_target}_relation"), [
user,
restrict_deactivated?
])
|> Repo.all()
end
# `def blocked_users_ap_ids/2`, `def muted_users_ap_ids/2`, `def reblog_muted_users_ap_ids/2`,
# `def notification_muted_users_ap_ids/2`, `def subscriber_users_ap_ids/2`,
# `def endorsed_users_ap_ids/2`
def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \\ false) do
__MODULE__
|> apply(unquote(:"#{outgoing_relation_target}_relation"), [
user,
restrict_deactivated?
])
|> select([u], u.ap_id)
|> Repo.all()
end
end
def cached_blocked_users_ap_ids(user) do
- @cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ ->
+ @cachex.fetch!(:user_cache, cache_key(:blocked_users_ap_ids, user), fn _ ->
blocked_users_ap_ids(user)
end)
end
def cached_muted_users_ap_ids(user) do
- @cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ ->
+ @cachex.fetch!(:user_cache, cache_key(:muted_users_ap_ids, user), fn _ ->
muted_users_ap_ids(user)
end)
end
defdelegate following_count(user), to: FollowingRelationship
defdelegate following(user), to: FollowingRelationship
defdelegate following?(follower, followed), to: FollowingRelationship
defdelegate following_ap_ids(user), to: FollowingRelationship
defdelegate get_follow_requests_query(user), to: FollowingRelationship
defdelegate get_outgoing_follow_requests_query(user), to: FollowingRelationship
def get_follow_requests(user) do
get_follow_requests_query(user)
|> Repo.all()
end
defdelegate search(query, opts \\ []), to: User.Search
@doc """
Dumps Flake Id to SQL-compatible format (16-byte UUID).
E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>>
"""
def binary_id(source_id) when is_binary(source_id) do
with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do
dumped_id
else
_ -> source_id
end
end
def binary_id(source_ids) when is_list(source_ids) do
Enum.map(source_ids, &binary_id/1)
end
def binary_id(%User{} = user), do: binary_id(user.id)
@doc "Returns account status"
@spec account_status(User.t()) :: account_status()
def account_status(%User{is_active: false}), do: :deactivated
def account_status(%User{password_reset_pending: true}), do: :password_reset_pending
def account_status(%User{local: true, is_approved: false}), do: :approval_pending
def account_status(%User{local: true, is_confirmed: false}), do: :confirmation_pending
def account_status(%User{}), do: :active
@spec visible_for(User.t(), User.t() | nil) ::
:visible
| :invisible
| :restricted_unauthenticated
| :deactivated
| :confirmation_pending
def visible_for(user, for_user \\ nil)
def visible_for(%User{invisible: true}, _), do: :invisible
def visible_for(%User{id: user_id}, %User{id: user_id}), do: :visible
def visible_for(%User{} = user, nil) do
if restrict_unauthenticated?(user) do
:restrict_unauthenticated
else
visible_account_status(user)
end
end
def visible_for(%User{} = user, for_user) do
if privileged?(for_user, :users_manage_activation_state) do
:visible
else
visible_account_status(user)
end
end
def visible_for(_, _), do: :invisible
defp restrict_unauthenticated?(%User{local: true}) do
Config.restrict_unauthenticated_access?(:profiles, :local)
end
defp restrict_unauthenticated?(%User{local: _}) do
Config.restrict_unauthenticated_access?(:profiles, :remote)
end
defp visible_account_status(user) do
status = account_status(user)
if status in [:active, :password_reset_pending] do
:visible
else
status
end
end
@spec privileged?(User.t(), atom()) :: boolean()
def privileged?(%User{is_admin: false, is_moderator: false}, _), do: false
def privileged?(
%User{local: true, is_admin: is_admin, is_moderator: is_moderator},
privilege_tag
),
do:
privileged_for?(privilege_tag, is_admin, :admin_privileges) or
privileged_for?(privilege_tag, is_moderator, :moderator_privileges)
def privileged?(_, _), do: false
defp privileged_for?(privilege_tag, true, config_role_key),
do: privilege_tag in Config.get([:instance, config_role_key])
defp privileged_for?(_, _, _), do: false
@spec privileges(User.t()) :: [atom()]
def privileges(%User{local: false}) do
[]
end
def privileges(%User{is_moderator: false, is_admin: false}) do
[]
end
def privileges(%User{local: true, is_moderator: true, is_admin: true}) do
(Config.get([:instance, :moderator_privileges]) ++ Config.get([:instance, :admin_privileges]))
|> Enum.uniq()
end
def privileges(%User{local: true, is_moderator: true, is_admin: false}) do
Config.get([:instance, :moderator_privileges])
end
def privileges(%User{local: true, is_moderator: false, is_admin: true}) do
Config.get([:instance, :admin_privileges])
end
@spec invisible?(User.t()) :: boolean()
def invisible?(%User{invisible: true}), do: true
def invisible?(_), do: false
def avatar_url(user, options \\ []) do
case user.avatar do
%{"url" => [%{"href" => href} | _]} ->
href
_ ->
unless options[:no_default] do
Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png")
end
end
end
def banner_url(user, options \\ []) do
case user.banner do
%{"url" => [%{"href" => href} | _]} -> href
_ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png"
end
end
def image_description(image, default \\ "")
def image_description(%{"name" => name}, _default), do: name
def image_description(_, default), do: default
# Should probably be renamed or removed
@spec ap_id(User.t()) :: String.t()
def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}"
@spec ap_followers(User.t()) :: String.t()
def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa
def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers"
@spec ap_following(User.t()) :: String.t()
def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa
def ap_following(%User{} = user), do: "#{ap_id(user)}/following"
@spec ap_featured_collection(User.t()) :: String.t()
def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa
def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured"
defp truncate_fields_param(params) do
if Map.has_key?(params, :fields) do
Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1))
else
params
end
end
defp truncate_if_exists(params, key, max_length) do
if Map.has_key?(params, key) and is_binary(params[key]) do
{value, _chopped} = String.split_at(params[key], max_length)
Map.put(params, key, value)
else
params
end
end
defp fix_follower_address(%{follower_address: _, following_address: _} = params), do: params
defp fix_follower_address(%{nickname: nickname} = params),
do: Map.put(params, :follower_address, ap_followers(%User{nickname: nickname}))
defp fix_follower_address(params), do: params
def remote_user_changeset(struct \\ %User{local: false}, params) do
bio_limit = Config.get([:instance, :user_bio_length], 5000)
name_limit = Config.get([:instance, :user_name_length], 100)
fields_limit = Config.get([:instance, :max_remote_account_fields], 0)
name =
case params[:name] do
name when is_binary(name) and byte_size(name) > 0 -> name
_ -> params[:nickname]
end
params =
params
|> Map.put(:name, name)
|> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now())
|> truncate_if_exists(:name, name_limit)
|> truncate_if_exists(:bio, bio_limit)
|> Map.update(:fields, [], &Enum.take(&1, fields_limit))
|> truncate_fields_param()
|> fix_follower_address()
struct
|> cast(
params,
[
:bio,
:emoji,
:ap_id,
:inbox,
:shared_inbox,
:nickname,
:public_key,
:avatar,
:banner,
:is_locked,
:last_refreshed_at,
:uri,
:follower_address,
:following_address,
:featured_address,
:hide_followers,
:hide_follows,
:hide_followers_count,
:hide_follows_count,
:follower_count,
:fields,
:following_count,
:is_discoverable,
:invisible,
:actor_type,
:also_known_as,
:accepts_chat_messages,
:pinned_objects,
:birthday,
:show_birthday
]
)
|> cast(params, [:name], empty_values: [])
|> validate_required([:ap_id])
|> validate_required([:name], trim: false)
|> unique_constraint(:nickname)
|> validate_format(:nickname, @email_regex)
|> validate_length(:bio, max: bio_limit)
|> validate_length(:name, max: name_limit)
|> validate_fields(true)
|> validate_non_local()
end
defp validate_non_local(cng) do
local? = get_field(cng, :local)
if local? do
cng
|> add_error(:local, "User is local, can't update with this changeset.")
else
cng
end
end
def update_changeset(struct, params \\ %{}) do
bio_limit = Config.get([:instance, :user_bio_length], 5000)
name_limit = Config.get([:instance, :user_name_length], 100)
struct
|> cast(
params,
[
:bio,
:raw_bio,
:name,
:emoji,
:avatar,
:public_key,
:inbox,
:shared_inbox,
:is_locked,
:no_rich_text,
:default_scope,
:banner,
:hide_follows,
:hide_followers,
:hide_followers_count,
:hide_follows_count,
:hide_favorites,
:allow_following_move,
:also_known_as,
:background,
:show_role,
:skip_thread_containment,
:fields,
:raw_fields,
:pleroma_settings_store,
:is_discoverable,
:actor_type,
:accepts_chat_messages,
:disclose_client,
:birthday,
:show_birthday
]
)
|> validate_min_age()
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
|> validate_length(:bio, max: bio_limit)
|> validate_length(:name, min: 1, max: name_limit)
|> validate_inclusion(:actor_type, Pleroma.Constants.allowed_user_actor_types())
|> validate_image_description(:avatar_description, params)
|> validate_image_description(:header_description, params)
|> put_fields()
|> put_emoji()
|> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)})
|> put_change_if_present(
:avatar,
&put_upload(&1, :avatar, Map.get(params, :avatar_description))
)
|> put_change_if_present(
:banner,
&put_upload(&1, :banner, Map.get(params, :header_description))
)
|> put_change_if_present(:background, &put_upload(&1, :background))
|> put_change_if_present(
:pleroma_settings_store,
&{:ok, Map.merge(struct.pleroma_settings_store, &1)}
)
|> maybe_update_image_description(:avatar, Map.get(params, :avatar_description))
|> maybe_update_image_description(:banner, Map.get(params, :header_description))
|> validate_fields(false)
end
defp put_fields(changeset) do
if raw_fields = get_change(changeset, :raw_fields) do
old_fields = changeset.data.raw_fields
raw_fields =
raw_fields
|> Enum.filter(fn %{"name" => n} -> n != "" end)
|> Enum.map(fn field ->
previous =
old_fields
|> Enum.find(fn %{"value" => value} -> field["value"] == value end)
if previous && Map.has_key?(previous, "verified_at") do
field
|> Map.put("verified_at", previous["verified_at"])
else
field
end
end)
fields =
raw_fields
|> Enum.map(fn f -> Map.update!(f, "value", &parse_fields(&1)) end)
changeset
|> put_change(:raw_fields, raw_fields)
|> put_change(:fields, fields)
else
changeset
end
end
defp parse_fields(value) do
value
|> Formatter.linkify(mentions_format: :full)
|> elem(0)
end
defp put_emoji(changeset) do
emojified_fields = [:bio, :name, :raw_fields]
if Enum.any?(changeset.changes, fn {k, _} -> k in emojified_fields end) do
bio = Emoji.Formatter.get_emoji_map(get_field(changeset, :bio))
name = Emoji.Formatter.get_emoji_map(get_field(changeset, :name))
emoji = Map.merge(bio, name)
emoji =
changeset
|> get_field(:raw_fields)
|> Enum.reduce(emoji, fn x, acc ->
Map.merge(acc, Emoji.Formatter.get_emoji_map(x["name"] <> x["value"]))
end)
put_change(changeset, :emoji, emoji)
else
changeset
end
end
defp put_change_if_present(changeset, map_field, value_function) do
with {:ok, value} <- fetch_change(changeset, map_field),
{:ok, new_value} <- value_function.(value) do
put_change(changeset, map_field, new_value)
else
{:error, :file_too_large} ->
Ecto.Changeset.validate_change(changeset, map_field, fn map_field, _value ->
[{map_field, "file is too large"}]
end)
_ ->
changeset
end
end
defp put_upload(value, type, description \\ nil) do
with %Plug.Upload{} <- value,
{:ok, object} <- ActivityPub.upload(value, type: type, description: description) do
{:ok, object.data}
end
end
defp validate_image_description(changeset, key, params) do
description_limit = Config.get([:instance, :description_limit], 5_000)
description = Map.get(params, key)
if is_binary(description) and String.length(description) > description_limit do
changeset
|> add_error(key, "#{key} is too long")
else
changeset
end
end
defp maybe_update_image_description(changeset, image_field, description)
when is_binary(description) do
with {:image_missing, true} <- {:image_missing, not changed?(changeset, image_field)},
{:existing_image, %{"id" => id}} <-
{:existing_image, Map.get(changeset.data, image_field)},
{:object, %Object{} = object} <- {:object, Object.get_by_ap_id(id)},
{:ok, object} <- Object.update_data(object, %{"name" => description}) do
put_change(changeset, image_field, object.data)
else
{:description_too_long, true} -> {:error}
_ -> changeset
end
end
defp maybe_update_image_description(changeset, _, _), do: changeset
def update_as_admin_changeset(struct, params) do
struct
|> update_changeset(params)
|> cast(params, [:email])
|> delete_change(:also_known_as)
|> unique_constraint(:email)
|> validate_format(:email, @email_regex)
|> validate_inclusion(:actor_type, ["Person", "Service"])
end
@spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def update_as_admin(user, params) do
params = Map.put(params, "password_confirmation", params["password"])
changeset = update_as_admin_changeset(user, params)
if params["password"] do
reset_password(user, changeset, params)
else
User.update_and_set_cache(changeset)
end
end
def password_update_changeset(struct, params) do
struct
|> cast(params, [:password, :password_confirmation])
|> validate_required([:password, :password_confirmation])
|> validate_confirmation(:password)
|> put_password_hash()
|> put_change(:password_reset_pending, false)
end
@spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def reset_password(%User{} = user, params) do
reset_password(user, user, params)
end
def reset_password(%User{id: user_id} = user, struct, params) do
multi =
Multi.new()
|> Multi.update(:user, password_update_changeset(struct, params))
|> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id))
|> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user))
case Repo.transaction(multi) do
{:ok, %{user: user} = _} -> set_cache(user)
{:error, _, changeset, _} -> {:error, changeset}
end
end
def update_password_reset_pending(user, value) do
user
|> change()
|> put_change(:password_reset_pending, value)
|> update_and_set_cache()
end
def force_password_reset_async(user) do
BackgroundWorker.new(%{"op" => "force_password_reset", "user_id" => user.id})
|> Oban.insert()
end
@spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def force_password_reset(user), do: update_password_reset_pending(user, true)
# Used to auto-register LDAP accounts which won't have a password hash stored locally
def register_changeset_ldap(struct, params = %{password: password})
when is_nil(password) do
params = Map.put_new(params, :accepts_chat_messages, true)
struct
|> cast(params, [
:name,
:nickname,
:email,
:accepts_chat_messages
])
|> validate_required([:name, :nickname])
|> unique_constraint(:nickname)
|> validate_not_restricted_nickname(:nickname)
|> validate_format(:nickname, local_nickname_regex())
|> put_ap_id()
|> unique_constraint(:ap_id)
|> put_following_and_follower_and_featured_address()
|> put_private_key()
end
def register_changeset(struct, params \\ %{}, opts \\ []) do
bio_limit = Config.get([:instance, :user_bio_length], 5000)
name_limit = Config.get([:instance, :user_name_length], 100)
reason_limit = Config.get([:instance, :registration_reason_length], 500)
params = Map.put_new(params, :accepts_chat_messages, true)
confirmed? =
if is_nil(opts[:confirmed]) do
!Config.get([:instance, :account_activation_required])
else
opts[:confirmed]
end
approved? =
if is_nil(opts[:approved]) do
!Config.get([:instance, :account_approval_required])
else
opts[:approved]
end
struct
|> confirmation_changeset(set_confirmation: confirmed?)
|> approval_changeset(set_approval: approved?)
|> cast(params, [
:bio,
:raw_bio,
:email,
:name,
:nickname,
:password,
:password_confirmation,
:emoji,
:accepts_chat_messages,
:registration_reason,
:birthday,
:language
])
|> validate_required([:name, :nickname, :password, :password_confirmation])
|> validate_confirmation(:password)
|> unique_constraint(:email)
|> validate_format(:email, @email_regex)
|> validate_email_not_in_blacklisted_domain(:email)
|> unique_constraint(:nickname)
|> validate_not_restricted_nickname(:nickname)
|> validate_format(:nickname, local_nickname_regex())
|> validate_length(:bio, max: bio_limit)
|> validate_length(:name, min: 1, max: name_limit)
|> validate_length(:registration_reason, max: reason_limit)
|> maybe_validate_required_email(opts[:external])
|> maybe_validate_required_birthday
|> validate_min_age()
|> put_password_hash
|> put_ap_id()
|> unique_constraint(:ap_id)
|> put_following_and_follower_and_featured_address()
|> put_private_key()
end
def validate_not_restricted_nickname(changeset, field) do
validate_change(changeset, field, fn _, value ->
valid? =
Config.get([User, :restricted_nicknames])
|> Enum.all?(fn restricted_nickname ->
String.downcase(value) != String.downcase(restricted_nickname)
end)
if valid?, do: [], else: [nickname: "Invalid nickname"]
end)
end
defp validate_email_not_in_blacklisted_domain(changeset, field) do
validate_change(changeset, field, fn _, value ->
valid? =
Config.get([User, :email_blacklist])
|> Enum.all?(fn blacklisted_domain ->
blacklisted_domain_downcase = String.downcase(blacklisted_domain)
!String.ends_with?(String.downcase(value), [
"@" <> blacklisted_domain_downcase,
"." <> blacklisted_domain_downcase
])
end)
if valid?, do: [], else: [email: "Invalid email"]
end)
end
defp maybe_validate_required_email(changeset, true), do: changeset
defp maybe_validate_required_email(changeset, _) do
if Config.get([:instance, :account_activation_required]) do
validate_required(changeset, [:email])
else
changeset
end
end
defp maybe_validate_required_birthday(changeset) do
if Config.get([:instance, :birthday_required]) do
validate_required(changeset, [:birthday])
else
changeset
end
end
defp validate_min_age(changeset) do
changeset
|> validate_change(:birthday, fn :birthday, birthday ->
valid? =
Date.utc_today()
|> Date.diff(birthday) >=
Config.get([:instance, :birthday_min_age])
if valid?, do: [], else: [birthday: "Invalid age"]
end)
end
defp put_ap_id(changeset) do
ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
put_change(changeset, :ap_id, ap_id)
end
defp put_following_and_follower_and_featured_address(changeset) do
user = %User{nickname: get_field(changeset, :nickname)}
followers = ap_followers(user)
following = ap_following(user)
featured = ap_featured_collection(user)
changeset
|> put_change(:follower_address, followers)
|> put_change(:following_address, following)
|> put_change(:featured_address, featured)
end
defp put_private_key(changeset) do
{:ok, pem} = Keys.generate_rsa_pem()
put_change(changeset, :keys, pem)
end
defp autofollow_users(user) do
candidates = Config.get([:instance, :autofollowed_nicknames])
autofollowed_users =
User.Query.build(%{nickname: candidates, local: true, is_active: true})
|> Repo.all()
follow_all(user, autofollowed_users)
end
defp autofollowing_users(user) do
candidates = Config.get([:instance, :autofollowing_nicknames])
User.Query.build(%{nickname: candidates, local: true, deactivated: false})
|> Repo.all()
|> Enum.each(&follow(&1, user, :follow_accept))
{:ok, :success}
end
@doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
def register(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Repo.insert(changeset) do
post_register_action(user)
end
end
def post_register_action(%User{is_confirmed: false} = user) do
with {:ok, _} <- maybe_send_confirmation_email(user) do
{:ok, user}
end
end
def post_register_action(%User{is_approved: false} = user) do
with {:ok, _} <- send_user_approval_email(user),
{:ok, _} <- send_admin_approval_emails(user) do
{:ok, user}
end
end
def post_register_action(%User{is_approved: true, is_confirmed: true} = user) do
with {:ok, user} <- autofollow_users(user),
{:ok, _} <- autofollowing_users(user),
{:ok, user} <- set_cache(user),
{:ok, _} <- maybe_send_registration_email(user),
{:ok, _} <- maybe_send_welcome_email(user),
{:ok, _} <- maybe_send_welcome_message(user),
{:ok, _} <- maybe_send_welcome_chat_message(user) do
{:ok, user}
end
end
defp send_user_approval_email(%User{email: email} = user) when is_binary(email) do
user
|> Pleroma.Emails.UserEmail.approval_pending_email()
|> Pleroma.Emails.Mailer.deliver_async()
{:ok, :enqueued}
end
defp send_user_approval_email(_user) do
{:ok, :skipped}
end
defp send_admin_approval_emails(user) do
all_superusers()
|> Enum.filter(fn user -> not is_nil(user.email) end)
|> Enum.each(fn superuser ->
superuser
|> Pleroma.Emails.AdminEmail.new_unapproved_registration(user)
|> Pleroma.Emails.Mailer.deliver_async()
end)
{:ok, :enqueued}
end
defp maybe_send_welcome_message(user) do
if User.WelcomeMessage.enabled?() do
User.WelcomeMessage.post_message(user)
{:ok, :enqueued}
else
{:ok, :noop}
end
end
defp maybe_send_welcome_chat_message(user) do
if User.WelcomeChatMessage.enabled?() do
User.WelcomeChatMessage.post_message(user)
{:ok, :enqueued}
else
{:ok, :noop}
end
end
defp maybe_send_welcome_email(%User{email: email} = user) when is_binary(email) do
if User.WelcomeEmail.enabled?() do
User.WelcomeEmail.send_email(user)
{:ok, :enqueued}
else
{:ok, :noop}
end
end
defp maybe_send_welcome_email(_), do: {:ok, :noop}
@spec maybe_send_confirmation_email(User.t()) :: {:ok, :enqueued | :noop}
def maybe_send_confirmation_email(%User{is_confirmed: false, email: email} = user)
when is_binary(email) do
if Config.get([:instance, :account_activation_required]) do
send_confirmation_email(user)
{:ok, :enqueued}
else
{:ok, :noop}
end
end
def maybe_send_confirmation_email(_), do: {:ok, :noop}
@spec send_confirmation_email(User.t()) :: User.t()
def send_confirmation_email(%User{} = user) do
user
|> Pleroma.Emails.UserEmail.account_confirmation_email()
|> Pleroma.Emails.Mailer.deliver_async()
user
end
@spec maybe_send_registration_email(User.t()) :: {:ok, :enqueued | :noop}
defp maybe_send_registration_email(%User{email: email} = user) when is_binary(email) do
with false <- User.WelcomeEmail.enabled?(),
false <- Config.get([:instance, :account_activation_required], false),
false <- Config.get([:instance, :account_approval_required], false) do
user
|> Pleroma.Emails.UserEmail.successful_registration_email()
|> Pleroma.Emails.Mailer.deliver_async()
{:ok, :enqueued}
else
_ ->
{:ok, :noop}
end
end
defp maybe_send_registration_email(_), do: {:ok, :noop}
defp needs_update?(%User{local: true}), do: false
defp needs_update?(%User{local: false, last_refreshed_at: nil}), do: true
defp needs_update?(%User{local: false} = user) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400
end
defp needs_update?(_), do: true
@spec maybe_direct_follow(User.t(), User.t()) ::
{:ok, User.t(), User.t()} | {:error, String.t()}
# "Locked" (self-locked) users demand explicit authorization of follow requests
def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do
follow(follower, followed, :follow_pending)
end
def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do
follow(follower, followed)
end
def maybe_direct_follow(%User{} = follower, %User{} = followed) do
{:ok, follower, followed}
end
@doc "A mass follow for local users. Respects blocks in both directions but does not create activities."
@spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()}
def follow_all(follower, followeds) do
followeds
|> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end)
|> Enum.each(&follow(follower, &1, :follow_accept))
set_cache(follower)
end
def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do
deny_follow_blocked = Config.get([:user, :deny_follow_blocked])
cond do
not followed.is_active ->
{:error, "Could not follow user: #{followed.nickname} is deactivated."}
deny_follow_blocked and blocks?(followed, follower) ->
{:error, "Could not follow user: #{followed.nickname} blocked you."}
true ->
FollowingRelationship.follow(follower, followed, state)
end
end
def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do
{:error, "Not subscribed!"}
end
@spec unfollow(User.t(), User.t()) :: {:ok, User.t(), Activity.t()} | {:error, String.t()}
def unfollow(%User{} = follower, %User{} = followed) do
case do_unfollow(follower, followed) do
{:ok, follower, followed} ->
{:ok, follower, Utils.fetch_latest_follow(follower, followed)}
error ->
error
end
end
@spec do_unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()}
defp do_unfollow(%User{} = follower, %User{} = followed) do
case get_follow_state(follower, followed) do
state when state in [:follow_pending, :follow_accept] ->
FollowingRelationship.unfollow(follower, followed)
nil ->
{:error, "Not subscribed!"}
end
end
@doc "Returns follow state as Pleroma.FollowingRelationship.State value"
def get_follow_state(%User{} = follower, %User{} = following) do
following_relationship = FollowingRelationship.get(follower, following)
get_follow_state(follower, following, following_relationship)
end
def get_follow_state(
%User{} = follower,
%User{} = following,
following_relationship
) do
case {following_relationship, following.local} do
{nil, false} ->
case Utils.fetch_latest_follow(follower, following) do
%Activity{data: %{"state" => state}} when state in ["pending", "accept"] ->
FollowingRelationship.state_to_enum(state)
_ ->
nil
end
{%{state: state}, _} ->
state
{nil, _} ->
nil
end
end
def locked?(%User{} = user) do
user.is_locked || false
end
def get_by_id(id) do
Repo.get_by(User, id: id)
end
def get_by_ap_id(ap_id) do
Repo.get_by(User, ap_id: ap_id)
end
def get_by_uri(uri) do
Repo.get_by(User, uri: uri)
end
def get_all_by_ap_id(ap_ids) do
from(u in __MODULE__,
where: u.ap_id in ^ap_ids
)
|> Repo.all()
end
def get_all_by_ids(ids) do
from(u in __MODULE__, where: u.id in ^ids)
|> Repo.all()
end
# This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
# of the ap_id and the domain and tries to get that user
def get_by_guessed_nickname(ap_id) do
domain = URI.parse(ap_id).host
name = List.last(String.split(ap_id, "/"))
nickname = "#{name}@#{domain}"
get_cached_by_nickname(nickname)
end
+ defp cache_key(:id, %User{id: id}), do: cache_key(:id, id)
+ defp cache_key(:nickname, %User{nickname: nickname}), do: cache_key(:nickname, nickname)
+ defp cache_key(namespace, %User{ap_id: ap_id}), do: cache_key(namespace, ap_id)
+ defp cache_key(namespace, value), do: "#{namespace}:#{value}"
+
def set_cache({:ok, user}), do: set_cache(user)
def set_cache({:error, err}), do: {:error, err}
def set_cache(%User{} = user) do
- @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
- @cachex.put(:user_cache, "nickname:#{user.nickname}", user)
- @cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user))
+ @cachex.put(:user_cache, cache_key(:ap_id, user), user)
+ @cachex.put(:user_cache, cache_key(:nickname, user), user)
+ @cachex.put(:user_cache, cache_key(:friends_ap_ids, user), get_user_friends_ap_ids(user))
{:ok, user}
end
def update_and_set_cache(struct, params) do
struct
|> update_changeset(params)
|> update_and_set_cache()
end
def update_and_set_cache(changeset) do
with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
if get_change(changeset, :raw_fields) do
BackgroundWorker.new(%{"op" => "verify_fields_links", "user_id" => user.id})
|> Oban.insert()
end
set_cache(user)
end
end
def get_user_friends_ap_ids(user) do
from(u in User.get_friends_query(user), select: u.ap_id)
|> Repo.all()
end
@spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()]
def get_cached_user_friends_ap_ids(user) do
- @cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ ->
+ @cachex.fetch!(:user_cache, cache_key(:friends_ap_ids, user), fn _ ->
get_user_friends_ap_ids(user)
end)
end
def invalidate_cache(user) do
- @cachex.del(:user_cache, "ap_id:#{user.ap_id}")
- @cachex.del(:user_cache, "nickname:#{user.nickname}")
- @cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}")
- @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
- @cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}")
+ @cachex.del(:user_cache, cache_key(:ap_id, user))
+ @cachex.del(:user_cache, cache_key(:nickname, user))
+ @cachex.del(:user_cache, cache_key(:id, user))
+ @cachex.del(:user_cache, cache_key(:friends_ap_ids, user))
+ @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
+ @cachex.del(:user_cache, cache_key(:muted_users_ap_ids, user))
end
@spec get_cached_by_ap_id(String.t()) :: User.t() | nil
def get_cached_by_ap_id(ap_id) do
- key = "ap_id:#{ap_id}"
+ key = cache_key(:ap_id, ap_id)
with {:ok, nil} <- @cachex.get(:user_cache, key),
user when not is_nil(user) <- get_by_ap_id(ap_id),
{:ok, true} <- @cachex.put(:user_cache, key, user) do
user
else
{:ok, user} -> user
nil -> nil
end
end
def get_cached_by_id(id) do
- key = "id:#{id}"
+ key = cache_key(:id, id)
ap_id =
@cachex.fetch!(:user_cache, key, fn _ ->
user = get_by_id(id)
if user do
- @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
+ @cachex.put(:user_cache, cache_key(:ap_id, user), user)
{:commit, user.ap_id}
else
{:ignore, ""}
end
end)
get_cached_by_ap_id(ap_id)
end
def get_cached_by_nickname(nickname) do
- key = "nickname:#{nickname}"
+ key = cache_key(:nickname, nickname)
@cachex.fetch!(:user_cache, key, fn _ ->
case get_or_fetch_by_nickname(nickname) do
{:ok, user} -> {:commit, user}
{:error, _error} -> {:ignore, nil}
end
end)
end
def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
restrict_to_local = Config.get([:instance, :limit_to_local_content])
cond do
is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) ->
get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id)
restrict_to_local == false or not String.contains?(nickname_or_id, "@") ->
get_cached_by_nickname(nickname_or_id)
restrict_to_local == :unauthenticated and match?(%User{}, opts[:for]) ->
get_cached_by_nickname(nickname_or_id)
true ->
nil
end
end
@spec get_by_nickname(String.t()) :: User.t() | nil
def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname) ||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()}$)i, nickname) do
Repo.get_by(User, nickname: local_nickname(nickname))
end
end
def get_by_email(email), do: Repo.get_by(User, email: email)
def get_by_nickname_or_email(nickname_or_email) do
get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
end
def fetch_by_nickname(nickname), do: ActivityPub.make_user_from_nickname(nickname)
def get_or_fetch_by_nickname(nickname) do
with %User{} = user <- get_by_nickname(nickname) do
{:ok, user}
else
_e ->
with [_nick, _domain] <- String.split(nickname, "@"),
{:ok, user} <- fetch_by_nickname(nickname) do
{:ok, user}
else
_e -> {:error, "not found " <> nickname}
end
end
end
@spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t()
def get_followers_query(%User{} = user, nil) do
User.Query.build(%{followers: user, is_active: true})
end
def get_followers_query(%User{} = user, page) do
user
|> get_followers_query(nil)
|> User.Query.paginate(page, 20)
end
@spec get_followers_query(User.t()) :: Ecto.Query.t()
def get_followers_query(%User{} = user), do: get_followers_query(user, nil)
@spec get_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())}
def get_followers(%User{} = user, page \\ nil) do
user
|> get_followers_query(page)
|> Repo.all()
end
@spec get_external_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())}
def get_external_followers(%User{} = user, page \\ nil) do
user
|> get_followers_query(page)
|> User.Query.build(%{external: true})
|> Repo.all()
end
def get_followers_ids(%User{} = user, page \\ nil) do
user
|> get_followers_query(page)
|> select([u], u.id)
|> Repo.all()
end
@spec get_friends_query(User.t(), pos_integer() | nil) :: Ecto.Query.t()
def get_friends_query(%User{} = user, nil) do
User.Query.build(%{friends: user, deactivated: false})
end
def get_friends_query(%User{} = user, page) do
user
|> get_friends_query(nil)
|> User.Query.paginate(page, 20)
end
@spec get_friends_query(User.t()) :: Ecto.Query.t()
def get_friends_query(%User{} = user), do: get_friends_query(user, nil)
def get_friends(%User{} = user, page \\ nil) do
user
|> get_friends_query(page)
|> Repo.all()
end
def get_friends_ap_ids(%User{} = user) do
user
|> get_friends_query(nil)
|> select([u], u.ap_id)
|> Repo.all()
end
def get_friends_ids(%User{} = user, page \\ nil) do
user
|> get_friends_query(page)
|> select([u], u.id)
|> Repo.all()
end
@spec get_familiar_followers_query(User.t(), User.t(), pos_integer() | nil) :: Ecto.Query.t()
def get_familiar_followers_query(%User{} = user, %User{} = current_user, nil) do
friends =
get_friends_query(current_user)
|> where([u], not u.hide_follows)
|> select([u], u.id)
User.Query.build(%{is_active: true})
|> where([u], u.id not in ^[user.id, current_user.id])
|> join(:inner, [u], r in FollowingRelationship,
as: :followers_relationships,
on: r.following_id == ^user.id and r.follower_id == u.id
)
|> where([followers_relationships: r], r.state == ^:follow_accept)
|> where([followers_relationships: r], r.follower_id in subquery(friends))
end
def get_familiar_followers_query(%User{} = user, %User{} = current_user, page) do
user
|> get_familiar_followers_query(current_user, nil)
|> User.Query.paginate(page, 20)
end
@spec get_familiar_followers_query(User.t(), User.t()) :: Ecto.Query.t()
def get_familiar_followers_query(%User{} = user, %User{} = current_user),
do: get_familiar_followers_query(user, current_user, nil)
@spec get_familiar_followers(User.t(), User.t(), pos_integer() | nil) :: {:ok, list(User.t())}
def get_familiar_followers(%User{} = user, %User{} = current_user, page \\ nil) do
user
|> get_familiar_followers_query(current_user, page)
|> Repo.all()
end
def increase_note_count(%User{} = user) do
User
|> where(id: ^user.id)
|> update([u], inc: [note_count: 1])
|> select([u], u)
|> Repo.update_all([])
|> case do
{1, [user]} -> set_cache(user)
_ -> {:error, user}
end
end
def decrease_note_count(%User{} = user) do
User
|> where(id: ^user.id)
|> update([u],
set: [
note_count: fragment("greatest(0, note_count - 1)")
]
)
|> select([u], u)
|> Repo.update_all([])
|> case do
{1, [user]} -> set_cache(user)
_ -> {:error, user}
end
end
def update_note_count(%User{} = user, note_count \\ nil) do
note_count =
note_count ||
from(
a in Object,
where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data),
select: count(a.id)
)
|> Repo.one()
user
|> cast(%{note_count: note_count}, [:note_count])
|> update_and_set_cache()
end
@spec maybe_fetch_follow_information(User.t()) :: User.t()
def maybe_fetch_follow_information(user) do
with {:ok, user} <- fetch_follow_information(user) do
user
else
e ->
Logger.error("Follower/Following counter update for #{user.ap_id} failed.\n#{inspect(e)}")
user
end
end
def fetch_follow_information(user) do
with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do
user
|> follow_information_changeset(info)
|> update_and_set_cache()
end
end
defp follow_information_changeset(user, params) do
user
|> cast(params, [
:hide_followers,
:hide_follows,
:follower_count,
:following_count,
:hide_followers_count,
:hide_follows_count
])
end
@spec update_follower_count(User.t()) :: {:ok, User.t()}
def update_follower_count(%User{} = user) do
if user.local or !Config.get([:instance, :external_user_synchronization]) do
follower_count = FollowingRelationship.follower_count(user)
user
|> follow_information_changeset(%{follower_count: follower_count})
|> update_and_set_cache
else
{:ok, maybe_fetch_follow_information(user)}
end
end
@spec update_following_count(User.t()) :: {:ok, User.t()}
def update_following_count(%User{local: false} = user) do
if Config.get([:instance, :external_user_synchronization]) do
{:ok, maybe_fetch_follow_information(user)}
else
{:ok, user}
end
end
def update_following_count(%User{local: true} = user) do
following_count = FollowingRelationship.following_count(user)
user
|> follow_information_changeset(%{following_count: following_count})
|> update_and_set_cache()
end
@spec get_users_from_set([String.t()], keyword()) :: [User.t()]
def get_users_from_set(ap_ids, opts \\ []) do
local_only = Keyword.get(opts, :local_only, true)
criteria = %{ap_id: ap_ids, is_active: true}
criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria
User.Query.build(criteria)
|> Repo.all()
end
@spec get_recipients_from_activity(Activity.t()) :: [User.t()]
def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do
to = [actor | to]
query = User.Query.build(%{recipients_from_activity: to, local: true, is_active: true})
query
|> Repo.all()
end
@spec mute(User.t(), User.t(), map()) ::
{:ok, list(UserRelationship.t())} | {:error, String.t()}
def mute(%User{} = muter, %User{} = mutee, params \\ %{}) do
notifications? = Map.get(params, :notifications, true)
duration = Map.get(params, :duration, 0)
expires_at =
if duration > 0 do
DateTime.utc_now()
|> DateTime.add(duration)
else
nil
end
with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee, expires_at),
{:ok, user_notification_mute} <-
(notifications? &&
UserRelationship.create_notification_mute(
muter,
mutee,
expires_at
)) ||
{:ok, nil} do
if duration > 0 do
Pleroma.Workers.MuteExpireWorker.new(
%{"op" => "unmute_user", "muter_id" => muter.id, "mutee_id" => mutee.id},
scheduled_at: expires_at
)
|> Oban.insert()
end
- @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}")
+ @cachex.del(:user_cache, cache_key(:muted_users_ap_ids, muter))
{:ok, Enum.filter([user_mute, user_notification_mute], & &1)}
end
end
def unmute(%User{} = muter, %User{} = mutee) do
with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee),
{:ok, user_notification_mute} <-
UserRelationship.delete_notification_mute(muter, mutee) do
- @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}")
+ @cachex.del(:user_cache, cache_key(:muted_users_ap_ids, muter))
{:ok, [user_mute, user_notification_mute]}
end
end
def unmute(muter_id, mutee_id) do
with {:muter, %User{} = muter} <- {:muter, User.get_by_id(muter_id)},
{:mutee, %User{} = mutee} <- {:mutee, User.get_by_id(mutee_id)} do
unmute(muter, mutee)
else
{who, result} = error ->
Logger.warning(
"User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}"
)
{:error, error}
end
end
def subscribe(%User{} = subscriber, %User{} = target) do
deny_follow_blocked = Config.get([:user, :deny_follow_blocked])
if blocks?(target, subscriber) and deny_follow_blocked do
{:error, "Could not subscribe: #{target.nickname} is blocking you"}
else
# Note: the relationship is inverse: subscriber acts as relationship target
UserRelationship.create_inverse_subscription(target, subscriber)
end
end
def subscribe(%User{} = subscriber, %{ap_id: ap_id}) do
with %User{} = subscribee <- get_cached_by_ap_id(ap_id) do
subscribe(subscriber, subscribee)
end
end
def unsubscribe(%User{} = unsubscriber, %User{} = target) do
# Note: the relationship is inverse: subscriber acts as relationship target
UserRelationship.delete_inverse_subscription(target, unsubscriber)
end
def unsubscribe(%User{} = unsubscriber, %{ap_id: ap_id}) do
with %User{} = user <- get_cached_by_ap_id(ap_id) do
unsubscribe(unsubscriber, user)
end
end
def block(blocker, blocked, params \\ %{})
def block(%User{} = blocker, %User{} = blocked, params) do
# sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)
blocker =
if following?(blocker, blocked) do
{:ok, blocker, _} = unfollow(blocker, blocked)
blocker
else
blocker
end
# clear any requested follows from both sides as well
blocked =
case CommonAPI.reject_follow_request(blocked, blocker) do
{:ok, %User{} = updated_blocked} -> updated_blocked
nil -> blocked
end
blocker =
case CommonAPI.reject_follow_request(blocker, blocked) do
{:ok, %User{} = updated_blocker} -> updated_blocker
nil -> blocker
end
unsubscribe(blocked, blocker)
unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true)
if unfollowing_blocked && following?(blocked, blocker), do: unfollow(blocked, blocker)
{:ok, blocker} = update_follower_count(blocker)
{:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked)
duration = Map.get(params, :duration, 0)
expires_at =
if duration > 0 do
DateTime.utc_now()
|> DateTime.add(duration)
else
nil
end
user_block = add_to_block(blocker, blocked, expires_at)
if duration > 0 do
Pleroma.Workers.MuteExpireWorker.new(
%{"op" => "unblock_user", "blocker_id" => blocker.id, "blocked_id" => blocked.id},
scheduled_at: expires_at
)
|> Oban.insert()
end
user_block
end
# helper to handle the block given only an actor's AP id
def block(%User{} = blocker, %{ap_id: ap_id}, params) do
block(blocker, get_cached_by_ap_id(ap_id), params)
end
def unblock(%User{} = blocker, %User{} = blocked) do
remove_from_block(blocker, blocked)
end
# helper to handle the block given only an actor's AP id
def unblock(%User{} = blocker, %{ap_id: ap_id}) do
unblock(blocker, get_cached_by_ap_id(ap_id))
end
def endorse(%User{} = endorser, %User{} = target) do
with max_endorsed_users <- Pleroma.Config.get([:instance, :max_endorsed_users], 0),
endorsed_users <-
User.endorsed_users_relation(endorser)
|> Repo.aggregate(:count, :id) do
cond do
endorsed_users >= max_endorsed_users ->
{:error, "You have already pinned the maximum number of users"}
not following?(endorser, target) ->
{:error, "Could not endorse: You are not following #{target.nickname}"}
true ->
UserRelationship.create_endorsement(endorser, target)
end
end
end
def endorse(%User{} = endorser, %{ap_id: ap_id}) do
with %User{} = endorsed <- get_cached_by_ap_id(ap_id) do
endorse(endorser, endorsed)
end
end
def unendorse(%User{} = unendorser, %User{} = target) do
UserRelationship.delete_endorsement(unendorser, target)
end
def unendorse(%User{} = unendorser, %{ap_id: ap_id}) do
with %User{} = user <- get_cached_by_ap_id(ap_id) do
unendorse(unendorser, user)
end
end
def mutes?(nil, _), do: false
def mutes?(%User{} = user, %User{} = target), do: mutes_user?(user, target)
def mutes_user?(%User{} = user, %User{} = target) do
UserRelationship.mute_exists?(user, target)
end
@spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean()
def muted_notifications?(nil, _), do: false
def muted_notifications?(%User{} = user, %User{} = target),
do: UserRelationship.notification_mute_exists?(user, target)
def blocks?(nil, _), do: false
def blocks?(%User{} = user, %User{} = target) do
blocks_user?(user, target) ||
(blocks_domain?(user, target) and not User.following?(user, target))
end
def blocks_user?(%User{} = user, %User{} = target) do
UserRelationship.block_exists?(user, target)
end
def blocks_user?(_, _), do: false
def blocks_domain?(%User{} = user, %User{} = target) do
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
%{host: host} = URI.parse(target.ap_id)
Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host)
end
def blocks_domain?(_, _), do: false
def subscribed_to?(%User{} = user, %User{} = target) do
# Note: the relationship is inverse: subscriber acts as relationship target
UserRelationship.inverse_subscription_exists?(target, user)
end
def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do
with %User{} = target <- get_cached_by_ap_id(ap_id) do
subscribed_to?(user, target)
end
end
def endorses?(%User{} = user, %User{} = target) do
UserRelationship.endorsement_exists?(user, target)
end
@doc """
Returns map of outgoing (blocked, muted etc.) relationships' user AP IDs by relation type.
E.g. `outgoing_relationships_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}`
"""
@spec outgoing_relationships_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())}
def outgoing_relationships_ap_ids(_user, []), do: %{}
def outgoing_relationships_ap_ids(nil, _relationship_types), do: %{}
def outgoing_relationships_ap_ids(%User{} = user, relationship_types)
when is_list(relationship_types) do
db_result =
user
|> assoc(:outgoing_relationships)
|> join(:inner, [user_rel], u in assoc(user_rel, :target))
|> where([user_rel, u], user_rel.relationship_type in ^relationship_types)
|> select([user_rel, u], [user_rel.relationship_type, fragment("array_agg(?)", u.ap_id)])
|> group_by([user_rel, u], user_rel.relationship_type)
|> Repo.all()
|> Enum.into(%{}, fn [k, v] -> {k, v} end)
Enum.into(
relationship_types,
%{},
fn rel_type -> {rel_type, db_result[rel_type] || []} end
)
end
def incoming_relationships_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil)
def incoming_relationships_ungrouped_ap_ids(_user, [], _ap_ids), do: []
def incoming_relationships_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: []
def incoming_relationships_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids)
when is_list(relationship_types) do
user
|> assoc(:incoming_relationships)
|> join(:inner, [user_rel], u in assoc(user_rel, :source))
|> where([user_rel, u], user_rel.relationship_type in ^relationship_types)
|> maybe_filter_on_ap_id(ap_ids)
|> select([user_rel, u], u.ap_id)
|> distinct(true)
|> Repo.all()
end
defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do
where(query, [user_rel, u], u.ap_id in ^ap_ids)
end
defp maybe_filter_on_ap_id(query, _ap_ids), do: query
def set_activation_async(user, status \\ true) do
BackgroundWorker.new(%{"op" => "user_activation", "user_id" => user.id, "status" => status})
|> Oban.insert()
end
@spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def set_activation(users, status) when is_list(users) do
Repo.transaction(fn ->
for user <- users do
{:ok, user} = set_activation(user, status)
user
end
end)
end
@spec set_activation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def set_activation(%User{} = user, status) do
with {:ok, user} <- set_activation_status(user, status) do
user
|> get_followers()
|> Enum.filter(& &1.local)
|> Enum.each(&set_cache(update_following_count(&1)))
# Only update local user counts, remote will be update during the next pull.
user
|> get_friends()
|> Enum.filter(& &1.local)
|> Enum.each(&do_unfollow(user, &1))
{:ok, user}
end
end
def approve(users) when is_list(users) do
Repo.transaction(fn ->
Enum.map(users, fn user ->
with {:ok, user} <- approve(user), do: user
end)
end)
end
def approve(%User{is_approved: false} = user) do
with chg <- change(user, is_approved: true),
{:ok, user} <- update_and_set_cache(chg) do
post_register_action(user)
{:ok, user}
end
end
def approve(%User{} = user), do: {:ok, user}
def confirm(users) when is_list(users) do
Repo.transaction(fn ->
Enum.map(users, fn user ->
with {:ok, user} <- confirm(user), do: user
end)
end)
end
def confirm(%User{is_confirmed: false} = user) do
with chg <- confirmation_changeset(user, set_confirmation: true),
{:ok, user} <- update_and_set_cache(chg) do
post_register_action(user)
{:ok, user}
end
end
def confirm(%User{} = user), do: {:ok, user}
def set_suggestion(users, is_suggested) when is_list(users) do
Repo.transaction(fn ->
Enum.map(users, fn user ->
with {:ok, user} <- set_suggestion(user, is_suggested), do: user
end)
end)
end
def set_suggestion(%User{is_suggested: is_suggested} = user, is_suggested), do: {:ok, user}
def set_suggestion(%User{} = user, is_suggested) when is_boolean(is_suggested) do
user
|> change(is_suggested: is_suggested)
|> update_and_set_cache()
end
def update_notification_settings(%User{} = user, settings) do
user
|> cast(%{notification_settings: settings}, [])
|> cast_embed(:notification_settings)
|> validate_required([:notification_settings])
|> update_and_set_cache()
end
@spec purge_user_changeset(User.t()) :: Ecto.Changeset.t()
defp purge_user_changeset(user) do
# "Right to be forgotten"
# https://gdpr.eu/right-to-be-forgotten/
change(user, %{
bio: "",
raw_bio: nil,
email: nil,
name: nil,
password_hash: nil,
avatar: %{},
tags: [],
last_refreshed_at: nil,
last_digest_emailed_at: nil,
banner: %{},
background: %{},
note_count: 0,
follower_count: 0,
following_count: 0,
is_locked: false,
password_reset_pending: false,
registration_reason: nil,
confirmation_token: nil,
domain_blocks: [],
is_active: false,
is_moderator: false,
is_admin: false,
mascot: nil,
emoji: %{},
pleroma_settings_store: %{},
fields: [],
raw_fields: [],
is_discoverable: false,
also_known_as: []
# id: preserved
# ap_id: preserved
# nickname: preserved
})
end
# Purge doesn't delete the user from the database.
# It just nulls all its fields and deactivates it.
# See `User.purge_user_changeset/1` above.
defp purge(%User{} = user) do
user
|> purge_user_changeset()
|> update_and_set_cache()
end
def delete(users) when is_list(users) do
for user <- users, do: delete(user)
end
def delete(%User{} = user) do
# Purge the user immediately
purge(user)
DeleteWorker.new(%{"op" => "delete_user", "user_id" => user.id})
|> Oban.insert()
end
# *Actually* delete the user from the DB
defp delete_from_db(%User{} = user) do
invalidate_cache(user)
Repo.delete(user)
end
# If the user never finalized their account, it's safe to delete them.
defp maybe_delete_from_db(%User{local: true, is_confirmed: false} = user),
do: delete_from_db(user)
defp maybe_delete_from_db(%User{local: true, is_approved: false} = user),
do: delete_from_db(user)
defp maybe_delete_from_db(user), do: {:ok, user}
def perform(:force_password_reset, user), do: force_password_reset(user)
@spec perform(atom(), User.t()) :: {:ok, User.t()}
def perform(:delete, %User{} = user) do
# Purge the user again, in case perform/2 is called directly
purge(user)
# Remove all relationships
user
|> get_followers()
|> Enum.each(fn follower ->
ActivityPub.unfollow(follower, user)
unfollow(follower, user)
end)
user
|> get_friends()
|> Enum.each(fn followed ->
ActivityPub.unfollow(user, followed)
unfollow(user, followed)
end)
delete_user_activities(user)
delete_notifications_from_user_activities(user)
delete_outgoing_pending_follow_requests(user)
maybe_delete_from_db(user)
end
def perform(:verify_fields_links, user) do
profile_urls = [user.ap_id]
fields =
user.raw_fields
|> Enum.map(&verify_field_link(&1, profile_urls))
changeset =
user
|> update_changeset(%{raw_fields: fields})
with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
set_cache(user)
end
end
def perform(:set_activation_async, user, status), do: set_activation(user, status)
defp verify_field_link(field, profile_urls) do
verified_at =
with %{"value" => value} <- field,
{:verified_at, nil} <- {:verified_at, Map.get(field, "verified_at")},
%{scheme: scheme, userinfo: nil, host: host}
when not_empty_string(host) and scheme in ["http", "https"] <-
URI.parse(value),
{:not_idn, true} <-
{:not_idn, match?(^host, to_string(:idna.encode(to_charlist(host))))},
"me" <- Pleroma.Web.RelMe.maybe_put_rel_me(value, profile_urls) do
CommonUtils.to_masto_date(NaiveDateTime.utc_now())
else
{:verified_at, value} when not_empty_string(value) ->
value
_ ->
nil
end
Map.put(field, "verified_at", verified_at)
end
@spec external_users_query() :: Ecto.Query.t()
def external_users_query do
User.Query.build(%{
external: true,
active: true,
order_by: :id
})
end
@spec external_users(keyword()) :: [User.t()]
def external_users(opts \\ []) do
query =
external_users_query()
|> select([u], struct(u, [:id, :ap_id]))
query =
if opts[:max_id],
do: where(query, [u], u.id > ^opts[:max_id]),
else: query
query =
if opts[:limit],
do: limit(query, ^opts[:limit]),
else: query
Repo.all(query)
end
defp delete_notifications_from_user_activities(%User{ap_id: ap_id}) do
Notification
|> join(:inner, [n], activity in assoc(n, :activity))
|> where([n, a], fragment("? = ?", a.actor, ^ap_id))
|> Repo.delete_all()
end
def delete_user_activities(%User{ap_id: ap_id} = user) do
ap_id
|> Activity.Queries.by_actor()
|> Repo.chunk_stream(50, :batches)
|> Stream.each(fn activities ->
Enum.each(activities, fn activity -> delete_activity(activity, user) end)
end)
|> Stream.run()
end
defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do
with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)},
{:ok, delete_data, _} <- Builder.delete(user, object) do
Pipeline.common_pipeline(delete_data, local: user.local)
else
{:find_object, nil} ->
# We have the create activity, but not the object, it was probably pruned.
# Insert a tombstone and try again
with {:ok, tombstone_data, _} <- Builder.tombstone(user.ap_id, object),
{:ok, _tombstone} <- Object.create(tombstone_data) do
delete_activity(activity, user)
end
e ->
Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}")
Logger.error("Error: #{inspect(e)}")
end
end
defp delete_activity(%{data: %{"type" => type}} = activity, user)
when type in ["Like", "Announce"] do
{:ok, undo, _} = Builder.undo(user, activity)
Pipeline.common_pipeline(undo, local: user.local)
end
defp delete_activity(_activity, _user), do: "Doing nothing"
defp delete_outgoing_pending_follow_requests(user) do
user
|> FollowingRelationship.outgoing_pending_follow_requests_query()
|> Repo.delete_all()
end
def html_filter_policy(%User{no_rich_text: true}) do
Pleroma.HTML.Scrubber.TwitterText
end
def html_filter_policy(_), do: Config.get([:markup, :scrub_policy])
def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id)
@spec get_or_fetch_by_ap_id(String.t()) :: {:ok, User.t()} | {:error, any()}
def get_or_fetch_by_ap_id(ap_id) do
with cached_user = %User{} <- get_cached_by_ap_id(ap_id),
_ <- maybe_refresh(cached_user) do
{:ok, cached_user}
else
_ -> fetch_by_ap_id(ap_id)
end
end
defp maybe_refresh(user) do
if needs_update?(user) do
UserRefreshWorker.new(%{"ap_id" => user.ap_id})
|> Oban.insert()
end
end
@doc """
Creates an internal service actor by URI if missing.
Optionally takes nickname for addressing.
"""
@spec get_or_create_service_actor_by_ap_id(String.t(), String.t()) :: User.t() | nil
def get_or_create_service_actor_by_ap_id(uri, nickname) do
{_, user} =
case get_cached_by_ap_id(uri) do
nil ->
with {:error, %{errors: errors}} <- create_service_actor(uri, nickname) do
Logger.error("Cannot create service actor: #{uri}/.\n#{inspect(errors)}")
{:error, nil}
end
%User{invisible: false} = user ->
set_invisible(user)
user ->
{:ok, user}
end
user
end
@spec set_invisible(User.t()) :: {:ok, User.t()}
defp set_invisible(user) do
user
|> change(%{invisible: true})
|> update_and_set_cache()
end
@spec create_service_actor(String.t(), String.t()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()}
defp create_service_actor(uri, nickname) do
%User{
invisible: true,
local: true,
ap_id: uri,
nickname: nickname,
follower_address: uri <> "/followers"
}
|> change
|> put_private_key()
|> unique_constraint(:nickname)
|> Repo.insert()
|> set_cache()
end
def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do
key =
public_key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
{:ok, key}
end
def public_key(_), do: {:error, "key not found"}
def get_or_fetch_public_key_for_ap_id(ap_id) do
with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id),
{:ok, public_key} <- public_key(user) do
{:ok, public_key}
else
_ -> :error
end
end
def get_public_key_for_ap_id(ap_id) do
with %User{} = user <- get_cached_by_ap_id(ap_id),
{:ok, public_key} <- public_key(user) do
{:ok, public_key}
else
_ -> :error
end
end
@doc "Gets or fetch a user by uri or nickname."
@spec get_or_fetch(String.t()) :: {:ok, User.t()} | {:error, String.t()}
def get_or_fetch("http://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
def get_or_fetch("https://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname)
# wait a period of time and return newest version of the User structs
# this is because we have synchronous follow APIs and need to simulate them
# with an async handshake
def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do
with %User{} = a <- get_cached_by_id(a.id),
%User{} = b <- get_cached_by_id(b.id) do
{:ok, a, b}
else
nil -> :error
end
end
def wait_and_refresh(timeout, %User{} = a, %User{} = b) do
with :ok <- :timer.sleep(timeout),
%User{} = a <- get_cached_by_id(a.id),
%User{} = b <- get_cached_by_id(b.id) do
{:ok, a, b}
else
nil -> :error
end
end
def parse_bio(bio) when is_binary(bio) and bio != "" do
bio
|> CommonUtils.format_input("text/plain", mentions_format: :full)
|> elem(0)
end
def parse_bio(_), do: ""
def parse_bio(bio, user) when is_binary(bio) and bio != "" do
# TODO: get profile URLs other than user.ap_id
profile_urls = [user.ap_id]
bio
|> CommonUtils.format_input("text/plain",
mentions_format: :full,
rel: &RelMe.maybe_put_rel_me(&1, profile_urls)
)
|> elem(0)
end
def parse_bio(_, _), do: ""
def tag(user_identifiers, tags) when is_list(user_identifiers) do
Repo.transaction(fn ->
for user_identifier <- user_identifiers, do: tag(user_identifier, tags)
end)
end
def tag(nickname, tags) when is_binary(nickname),
do: tag(get_by_nickname(nickname), tags)
def tag(%User{} = user, tags),
do: update_tags(user, Enum.uniq((user.tags || []) ++ normalize_tags(tags)))
def untag(user_identifiers, tags) when is_list(user_identifiers) do
Repo.transaction(fn ->
for user_identifier <- user_identifiers, do: untag(user_identifier, tags)
end)
end
def untag(nickname, tags) when is_binary(nickname),
do: untag(get_by_nickname(nickname), tags)
def untag(%User{} = user, tags),
do: update_tags(user, (user.tags || []) -- normalize_tags(tags))
defp update_tags(%User{} = user, new_tags) do
{:ok, updated_user} =
user
|> change(%{tags: new_tags})
|> update_and_set_cache()
updated_user
end
defp normalize_tags(tags) do
[tags]
|> List.flatten()
|> Enum.map(&String.downcase/1)
end
defp local_nickname_regex do
if Config.get([:instance, :extended_nickname_format]) do
@extended_local_nickname_regex
else
@strict_local_nickname_regex
end
end
def local_nickname(nickname_or_mention) do
nickname_or_mention
|> full_nickname()
|> String.split("@")
|> hd()
end
def full_nickname(%User{} = user) do
if String.contains?(user.nickname, "@") do
user.nickname
else
host = Pleroma.Web.WebFinger.host()
user.nickname <> "@" <> host
end
end
def full_nickname(nickname_or_mention),
do: String.trim_leading(nickname_or_mention, "@")
def error_user(ap_id) do
%User{
name: ap_id,
ap_id: ap_id,
nickname: "erroruser@example.com",
inserted_at: NaiveDateTime.utc_now()
}
end
@spec all_superusers() :: [User.t()]
def all_superusers do
User.Query.build(%{super_users: true, local: true, is_active: true})
|> Repo.all()
end
@spec all_users_with_privilege(atom()) :: [User.t()]
def all_users_with_privilege(privilege) do
User.Query.build(%{is_privileged: privilege}) |> Repo.all()
end
def muting_reblogs?(%User{} = user, %User{} = target) do
UserRelationship.reblog_mute_exists?(user, target)
end
def showing_reblogs?(%User{} = user, %User{} = target) do
not muting_reblogs?(user, target)
end
@doc """
The function returns a query to get users with no activity for given interval of days.
Inactive users are those who didn't read any notification, or had any activity where
the user is the activity's actor, during `inactivity_threshold` days.
Deactivated users will not appear in this list.
## Examples
iex> Pleroma.User.list_inactive_users()
%Ecto.Query{}
"""
@spec list_inactive_users_query(integer()) :: Ecto.Query.t()
def list_inactive_users_query(inactivity_threshold \\ 7) do
negative_inactivity_threshold = -inactivity_threshold
now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
# Subqueries are not supported in `where` clauses, join gets too complicated.
has_read_notifications =
from(n in Pleroma.Notification,
where: n.seen == true,
group_by: n.id,
having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
select: n.user_id
)
|> Pleroma.Repo.all()
from(u in Pleroma.User,
left_join: a in Pleroma.Activity,
on: u.ap_id == a.actor,
where: not is_nil(u.nickname),
where: u.is_active == ^true,
where: u.id not in ^has_read_notifications,
group_by: u.id,
having:
max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
is_nil(max(a.inserted_at))
)
end
@doc """
Enable or disable email notifications for user
## Examples
iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => false}}, "digest", true)
Pleroma.User{email_notifications: %{"digest" => true}}
iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => true}}, "digest", false)
Pleroma.User{email_notifications: %{"digest" => false}}
"""
@spec switch_email_notifications(t(), String.t(), boolean()) ::
{:ok, t()} | {:error, Ecto.Changeset.t()}
def switch_email_notifications(user, type, status) do
User.update_email_notifications(user, %{type => status})
end
@doc """
Set `last_digest_emailed_at` value for the user to current time
"""
@spec touch_last_digest_emailed_at(t()) :: t()
def touch_last_digest_emailed_at(user) do
now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
{:ok, updated_user} =
user
|> change(%{last_digest_emailed_at: now})
|> update_and_set_cache()
updated_user
end
@spec set_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def set_confirmation(%User{} = user, bool) do
user
|> confirmation_changeset(set_confirmation: bool)
|> update_and_set_cache()
end
def get_mascot(%{mascot: %{} = mascot}) when not is_nil(mascot) do
mascot
end
def get_mascot(%{mascot: mascot}) when is_nil(mascot) do
# use instance-default
config = Config.get([:assets, :mascots])
default_mascot = Config.get([:assets, :default_mascot])
mascot = Keyword.get(config, default_mascot)
%{
"id" => "default-mascot",
"url" => mascot[:url],
"preview_url" => mascot[:url],
"pleroma" => %{
"mime_type" => mascot[:mime_type]
}
}
end
def get_ap_ids_by_nicknames(nicknames) do
from(u in User,
where: u.nickname in ^nicknames,
order_by: fragment("array_position(?, ?)", ^nicknames, u.nickname),
select: u.ap_id
)
|> Repo.all()
end
defp put_password_hash(
%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
) do
change(changeset, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password))
end
defp put_password_hash(changeset), do: changeset
def internal?(%User{nickname: nil}), do: true
def internal?(%User{local: true, nickname: "internal." <> _}), do: true
def internal?(_), do: false
# A hack because user delete activities have a fake id for whatever reason
# TODO: Get rid of this
def get_delivered_users_by_object_id("pleroma:fake_object_id"), do: []
def get_delivered_users_by_object_id(object_id) do
from(u in User,
inner_join: delivery in assoc(u, :deliveries),
where: delivery.object_id == ^object_id
)
|> Repo.all()
end
def change_email(user, email) do
user
|> cast(%{email: email}, [:email])
|> maybe_validate_required_email(false)
|> unique_constraint(:email)
|> validate_format(:email, @email_regex)
|> update_and_set_cache()
end
def alias_users(user) do
user.also_known_as
|> Enum.map(&User.get_cached_by_ap_id/1)
|> Enum.filter(fn user -> user != nil end)
end
def add_alias(user, new_alias_user) do
current_aliases = user.also_known_as || []
new_alias_ap_id = new_alias_user.ap_id
if new_alias_ap_id in current_aliases do
{:ok, user}
else
user
|> cast(%{also_known_as: current_aliases ++ [new_alias_ap_id]}, [:also_known_as])
|> update_and_set_cache()
end
end
def delete_alias(user, alias_user) do
current_aliases = user.also_known_as || []
alias_ap_id = alias_user.ap_id
if alias_ap_id in current_aliases do
user
|> cast(%{also_known_as: current_aliases -- [alias_ap_id]}, [:also_known_as])
|> update_and_set_cache()
else
{:error, :no_such_alias}
end
end
# Internal function; public one is `set_activation/2`
defp set_activation_status(user, status) do
user
|> cast(%{is_active: status}, [:is_active])
|> update_and_set_cache()
end
def update_banner(user, banner) do
user
|> cast(%{banner: banner}, [:banner])
|> update_and_set_cache()
end
def update_background(user, background) do
user
|> cast(%{background: background}, [:background])
|> update_and_set_cache()
end
defp validate_fields(changeset, remote?) do
limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields
limit = Config.get([:instance, limit_name], 0)
changeset
|> validate_length(:fields, max: limit)
|> validate_change(:fields, fn :fields, fields ->
if Enum.all?(fields, &valid_field?/1) do
[]
else
[fields: "invalid"]
end
end)
end
defp valid_field?(%{"name" => name, "value" => value}) do
name_limit = Config.get([:instance, :account_field_name_length], 255)
value_limit = Config.get([:instance, :account_field_value_length], 255)
is_binary(name) && is_binary(value) && String.length(name) <= name_limit &&
String.length(value) <= value_limit
end
defp valid_field?(_), do: false
defp truncate_field(%{"name" => name, "value" => value}) do
{name, _chopped} =
String.split_at(name, Config.get([:instance, :account_field_name_length], 255))
{value, _chopped} =
String.split_at(value, Config.get([:instance, :account_field_value_length], 255))
%{"name" => name, "value" => value}
end
def admin_api_update(user, params) do
user
|> cast(params, [
:is_moderator,
:is_admin,
:show_role
])
|> update_and_set_cache()
end
@doc "Signs user out of all applications"
def global_sign_out(user) do
OAuth.Authorization.delete_user_authorizations(user)
OAuth.Token.delete_user_tokens(user)
end
def mascot_update(user, url) do
user
|> cast(%{mascot: url}, [:mascot])
|> validate_required([:mascot])
|> update_and_set_cache()
end
@spec confirmation_changeset(User.t(), keyword()) :: Ecto.Changeset.t()
def confirmation_changeset(user, set_confirmation: confirmed?) do
params =
if confirmed? do
%{
is_confirmed: true,
confirmation_token: nil
}
else
%{
is_confirmed: false,
confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64()
}
end
cast(user, params, [:is_confirmed, :confirmation_token])
end
@spec approval_changeset(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t()
def approval_changeset(changeset, set_approval: approved?) do
cast(changeset, %{is_approved: approved?}, [:is_approved])
end
@spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()}
def add_pinned_object_id(%User{} = user, object_id) do
if !user.pinned_objects[object_id] do
params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())}
user
|> cast(params, [:pinned_objects])
|> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects ->
max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0)
if Enum.count(pinned_objects) <= max_pinned_statuses do
[]
else
[pinned_objects: "You have already pinned the maximum number of statuses"]
end
end)
else
change(user)
end
|> update_and_set_cache()
end
@spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()}
def remove_pinned_object_id(%User{} = user, object_id) do
user
|> cast(
%{pinned_objects: Map.delete(user.pinned_objects, object_id)},
[:pinned_objects]
)
|> update_and_set_cache()
end
def update_email_notifications(user, settings) do
email_notifications =
user.email_notifications
|> Map.merge(settings)
|> Map.take(["digest"])
params = %{email_notifications: email_notifications}
fields = [:email_notifications]
user
|> cast(params, fields)
|> validate_required(fields)
|> update_and_set_cache()
end
defp set_domain_blocks(user, domain_blocks) do
params = %{domain_blocks: domain_blocks}
user
|> cast(params, [:domain_blocks])
|> validate_required([:domain_blocks])
|> update_and_set_cache()
end
def block_domain(user, domain_blocked) do
set_domain_blocks(user, Enum.uniq([domain_blocked | user.domain_blocks]))
end
def unblock_domain(user, domain_blocked) do
set_domain_blocks(user, List.delete(user.domain_blocks, domain_blocked))
end
@spec add_to_block(User.t(), User.t(), integer() | nil) ::
{:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()}
defp add_to_block(%User{} = user, %User{} = blocked, expires_at) do
with {:ok, relationship} <- UserRelationship.create_block(user, blocked, expires_at) do
- @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
+ @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
{:ok, relationship}
end
end
@spec remove_from_block(User.t(), User.t()) ::
{:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()}
defp remove_from_block(%User{} = user, %User{} = blocked) do
with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do
- @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
+ @cachex.del(:user_cache, cache_key(:blocked_users_ap_ids, user))
{:ok, relationship}
end
end
def set_invisible(user, invisible) do
params = %{invisible: invisible}
user
|> cast(params, [:invisible])
|> validate_required([:invisible])
|> update_and_set_cache()
end
def sanitize_html(%User{} = user) do
sanitize_html(user, nil)
end
# User data that mastodon isn't filtering (treated as plaintext):
# - field name
# - display name
def sanitize_html(%User{} = user, filter) do
fields =
Enum.map(user.fields, fn %{"name" => name, "value" => value} = fields ->
%{
"name" => name,
"value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly),
"verified_at" => Map.get(fields, "verified_at")
}
end)
user
|> Map.put(:bio, HTML.filter_tags(user.bio, filter))
|> Map.put(:fields, fields)
end
def get_host(%User{ap_id: ap_id} = _user) do
URI.parse(ap_id).host
end
def update_last_active_at(%__MODULE__{local: true} = user) do
user
|> cast(%{last_active_at: NaiveDateTime.utc_now()}, [:last_active_at])
|> update_and_set_cache()
end
def update_last_active_at(user), do: user
def active_user_count(days \\ 30) do
active_after = Timex.shift(NaiveDateTime.utc_now(), days: -days)
__MODULE__
|> where([u], u.last_active_at >= ^active_after)
|> where([u], u.local == true)
|> Repo.aggregate(:count)
end
def update_last_status_at(user) do
User
|> where(id: ^user.id)
|> update([u], set: [last_status_at: fragment("NOW()")])
|> select([u], u)
|> Repo.update_all([])
|> case do
{1, [user]} -> set_cache(user)
_ -> {:error, user}
end
end
def get_friends_birthdays_query(%User{} = user, day, month) do
User.Query.build(%{
friends: user,
deactivated: false,
birthday_day: day,
birthday_month: month
})
end
defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
when is_list(follows),
do: user
defp maybe_load_followed_hashtags(%User{} = user) do
followed_hashtags = HashtagFollow.get_by_user(user)
%{user | followed_hashtags: followed_hashtags}
end
def followed_hashtags(%User{followed_hashtags: follows})
when is_list(follows),
do: follows
def followed_hashtags(%User{} = user) do
{:ok, user} =
user
|> maybe_load_followed_hashtags()
|> set_cache()
user.followed_hashtags
end
def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
user = maybe_load_followed_hashtags(user)
with {:ok, _} <- HashtagFollow.new(user, hashtag),
follows <- HashtagFollow.get_by_user(user),
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
user
|> set_cache()
end
end
def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
user = maybe_load_followed_hashtags(user)
with {:ok, _} <- HashtagFollow.delete(user, hashtag),
follows <- HashtagFollow.get_by_user(user),
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
user
|> set_cache()
end
end
def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
not is_nil(HashtagFollow.get(user, hashtag))
end
end
diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex
index 1e8bfe3dd..4efd18d39 100644
--- a/lib/pleroma/web/activity_pub/activity_pub.ex
+++ b/lib/pleroma/web/activity_pub/activity_pub.ex
@@ -1,2013 +1,2021 @@
# 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]
- )
+ query =
+ from(
+ [_activity, object] in query,
+ join: hto in "hashtags_objects",
+ on: hto.object_id == object.id,
+ where: hto.hashtag_id in ^hashtag_ids
+ )
+
+ if is_nil(query.distinct) do
+ from([_activity, object] in query,
+ distinct: [desc: object.id],
+ order_by: [desc: object.id]
+ )
+ else
+ query
+ end
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 @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, 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/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex
index 88f6ca028..41abe17c0 100644
--- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex
@@ -1,61 +1,63 @@
# 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.MRF.ActivityExpirationPolicy do
@moduledoc "Adds expiration to all local Create activities"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(activity) do
activity =
if note?(activity) and local?(activity) do
maybe_add_expiration(activity)
else
activity
end
{:ok, activity}
end
@impl true
def describe, do: {:ok, %{}}
defp local?(%{"actor" => actor}) do
String.starts_with?(actor, Pleroma.Web.Endpoint.url())
end
defp note?(activity) do
match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity)
end
defp maybe_add_expiration(activity) do
days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365)
expires_at = DateTime.utc_now() |> Timex.shift(days: days)
with %{"expires_at" => existing_expires_at} <- activity,
:lt <- DateTime.compare(existing_expires_at, expires_at) do
activity
else
_ -> Map.put(activity, "expires_at", expires_at)
end
end
@impl true
def config_description do
%{
key: :mrf_activity_expiration,
related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy",
label: "MRF Activity Expiration Policy",
description: "Adds automatic expiration to all local activities",
children: [
%{
key: :days,
type: :integer,
description: "Default global expiration time for all local activities (in days)",
suggestions: [90, 365]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex
index 8ea61aec2..990ff31c9 100644
--- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex
@@ -1,83 +1,83 @@
# 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.MRF.AntiFollowbotPolicy do
- alias Pleroma.User
-
@moduledoc "Prevent followbots from following with a bit of heuristic"
-
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ alias Pleroma.User
+
+ use Pleroma.Web.ActivityPub.MRF.Policy
# XXX: this should become User.normalize_by_ap_id() or similar, really.
defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id)
defp normalize_by_ap_id(uri) when is_binary(uri), do: User.get_cached_by_ap_id(uri)
defp normalize_by_ap_id(_), do: nil
defp score_nickname("followbot@" <> _), do: 1.0
defp score_nickname("federationbot@" <> _), do: 1.0
defp score_nickname("federation_bot@" <> _), do: 1.0
defp score_nickname(_), do: 0.0
defp score_displayname("federation bot"), do: 1.0
defp score_displayname("federationbot"), do: 1.0
defp score_displayname("fedibot"), do: 1.0
defp score_displayname(_), do: 0.0
defp determine_if_followbot(%User{nickname: nickname, name: displayname, actor_type: actor_type}) do
# nickname will be a binary string except when following a relay
nick_score =
if is_binary(nickname) do
nickname
|> String.downcase()
|> score_nickname()
else
0.0
end
# displayname will either be a binary string or nil, if a displayname isn't set.
name_score =
if is_binary(displayname) do
displayname
|> String.downcase()
|> score_displayname()
else
0.0
end
# actor_type "Service" is a Bot account
actor_type_score =
if actor_type == "Service" do
1.0
else
0.0
end
nick_score + name_score + actor_type_score
end
defp bot_allowed?(%{"object" => target}, bot_actor) do
%User{} = user = normalize_by_ap_id(target)
User.following?(user, bot_actor)
end
@impl true
def filter(%{"type" => "Follow", "actor" => actor_id} = activity) do
%User{} = actor = normalize_by_ap_id(actor_id)
score = determine_if_followbot(actor)
if score < 0.8 || bot_allowed?(activity, actor) do
{:ok, activity}
else
{:reject, "[AntiFollowbotPolicy] Scored #{actor_id} as #{score}"}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
index 2be6d8df4..5c3b66e37 100644
--- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
@@ -1,60 +1,62 @@
# 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.MRF.AntiLinkSpamPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
require Logger
@impl true
def history_awareness, do: :auto
# has the user successfully posted before?
defp old_user?(%User{} = u) do
u.note_count > 0 || u.follower_count > 0
end
# does the post contain links?
defp contains_links?(%{"content" => content} = _object) do
content
|> Floki.parse_fragment!()
|> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"],a.zrl")
|> Floki.attribute("a", "href")
|> length() > 0
end
defp contains_links?(_), do: false
@impl true
def filter(%{"type" => "Create", "actor" => actor, "object" => object} = activity) do
with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor),
{:contains_links, true} <- {:contains_links, contains_links?(object)},
{:old_user, true} <- {:old_user, old_user?(u)} do
{:ok, activity}
else
{:ok, %User{local: true}} ->
{:ok, activity}
{:contains_links, false} ->
{:ok, activity}
{:old_user, false} ->
{:reject, "[AntiLinkSpamPolicy] User has no posts nor followers"}
{:error, _} ->
{:reject, "[AntiLinkSpamPolicy] Failed to get or fetch user by ap_id"}
e ->
{:reject, "[AntiLinkSpamPolicy] Unhandled error #{inspect(e)}"}
end
end
# in all other cases, pass through
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex
index 1d76a307b..c6f5781b3 100644
--- a/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/anti_mention_spam_policy.ex
@@ -1,87 +1,89 @@
# 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.MRF.AntiMentionSpamPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.User
require Pleroma.Constants
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp user_has_posted?(%User{} = u), do: u.note_count > 0
defp user_has_age?(%User{} = u) do
user_age_limit = Config.get([:mrf_antimentionspam, :user_age_limit], 30_000)
diff = NaiveDateTime.utc_now() |> NaiveDateTime.diff(u.inserted_at, :millisecond)
diff >= user_age_limit
end
defp good_reputation?(%User{} = u) do
user_has_age?(u) and user_has_posted?(u)
end
# copied from HellthreadPolicy
defp get_recipient_count(activity) do
recipients = (activity["to"] || []) ++ (activity["cc"] || [])
follower_collection =
User.get_cached_by_ap_id(activity["actor"] || activity["attributedTo"]).follower_address
if Enum.member?(recipients, Pleroma.Constants.as_public()) do
recipients =
recipients
|> List.delete(Pleroma.Constants.as_public())
|> List.delete(follower_collection)
{:public, length(recipients)}
else
recipients =
recipients
|> List.delete(follower_collection)
{:not_public, length(recipients)}
end
end
defp object_has_recipients?(%{"object" => object} = activity) do
{_, object_count} = get_recipient_count(object)
{_, activity_count} = get_recipient_count(activity)
object_count + activity_count > 0
end
defp object_has_recipients?(object) do
{_, count} = get_recipient_count(object)
count > 0
end
@impl true
def filter(%{"type" => "Create", "actor" => actor} = activity) do
with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor),
{:has_mentions, true} <- {:has_mentions, object_has_recipients?(activity)},
{:good_reputation, true} <- {:good_reputation, good_reputation?(u)} do
{:ok, activity}
else
{:ok, %User{local: true}} ->
{:ok, activity}
{:has_mentions, false} ->
{:ok, activity}
{:good_reputation, false} ->
{:reject, "[AntiMentionSpamPolicy] User rejected"}
{:error, _} ->
{:reject, "[AntiMentionSpamPolicy] Failed to get or fetch user by ap_id"}
e ->
{:reject, "[AntiMentionSpamPolicy] Unhandled error #{inspect(e)}"}
end
end
# in all other cases, pass through
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex
index cf07db7f3..4aa1968f7 100644
--- a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex
@@ -1,24 +1,26 @@
# 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.MRF.DropPolicy do
- require Logger
@moduledoc "Drop and log everything received"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ require Logger
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(activity) do
Logger.debug("REJECTING #{inspect(activity)}")
{:reject, activity}
end
@impl true
def id_filter(id) do
Logger.debug("REJECTING #{id}")
false
end
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex
index 1de5280d9..cc5fd49bf 100644
--- a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex
@@ -1,281 +1,283 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.EmojiPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Pleroma.Constants
alias Pleroma.Object.Updater
alias Pleroma.Web.ActivityPub.MRF.Utils
@moduledoc "Reject or force-unlisted emojis with certain URLs or names"
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp config_remove_url do
Pleroma.Config.get([:mrf_emoji, :remove_url], [])
end
defp config_remove_shortcode do
Pleroma.Config.get([:mrf_emoji, :remove_shortcode], [])
end
defp config_unlist_url do
Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_url], [])
end
defp config_unlist_shortcode do
Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_shortcode], [])
end
@impl true
def history_awareness, do: :manual
@impl true
def filter(%{"type" => type, "object" => %{"type" => objtype} = object} = activity)
when type in ["Create", "Update"] and objtype in Pleroma.Constants.status_object_types() do
with {:ok, object} <-
Updater.do_with_history(object, fn object ->
{:ok, process_remove(object, :url, config_remove_url())}
end),
{:ok, object} <-
Updater.do_with_history(object, fn object ->
{:ok, process_remove(object, :shortcode, config_remove_shortcode())}
end),
activity <- Map.put(activity, "object", object),
activity <- maybe_delist(activity) do
{:ok, activity}
end
end
@impl true
def filter(%{"type" => type} = object) when type in Pleroma.Constants.actor_types() do
with object <- process_remove(object, :url, config_remove_url()),
object <- process_remove(object, :shortcode, config_remove_shortcode()) do
{:ok, object}
end
end
@impl true
def filter(%{"type" => "EmojiReact"} = object) do
with {:ok, _} <-
matched_emoji_checker(config_remove_url(), config_remove_shortcode()).(object) do
{:ok, object}
else
_ ->
{:reject, "[EmojiPolicy] Rejected for having disallowed emoji"}
end
end
@impl true
def filter(activity) do
{:ok, activity}
end
defp match_string?(string, pattern) when is_binary(pattern) do
string == pattern
end
defp match_string?(string, %Regex{} = pattern) do
String.match?(string, pattern)
end
defp match_any?(string, patterns) do
Enum.any?(patterns, &match_string?(string, &1))
end
defp url_from_tag(%{"icon" => %{"url" => url}}), do: url
defp url_from_tag(_), do: nil
defp url_from_emoji({_name, url}), do: url
defp shortcode_from_tag(%{"name" => name}) when is_binary(name), do: String.trim(name, ":")
defp shortcode_from_tag(_), do: nil
defp shortcode_from_emoji({name, _url}), do: name
defp process_remove(object, :url, patterns) do
process_remove_impl(object, &url_from_tag/1, &url_from_emoji/1, patterns)
end
defp process_remove(object, :shortcode, patterns) do
process_remove_impl(object, &shortcode_from_tag/1, &shortcode_from_emoji/1, patterns)
end
defp process_remove_impl(object, extract_from_tag, extract_from_emoji, patterns) do
object =
if object["tag"] do
Map.put(
object,
"tag",
Enum.filter(
object["tag"],
fn
%{"type" => "Emoji"} = tag ->
str = extract_from_tag.(tag)
if is_binary(str) do
not match_any?(str, patterns)
else
true
end
_ ->
true
end
)
)
else
object
end
object =
if object["emoji"] do
Map.put(
object,
"emoji",
object["emoji"]
|> Enum.reduce(%{}, fn {name, url} = emoji, acc ->
if not match_any?(extract_from_emoji.(emoji), patterns) do
Map.put(acc, name, url)
else
acc
end
end)
)
else
object
end
object
end
defp matched_emoji_checker(urls, shortcodes) do
fn object ->
if any_emoji_match?(object, &url_from_tag/1, &url_from_emoji/1, urls) or
any_emoji_match?(
object,
&shortcode_from_tag/1,
&shortcode_from_emoji/1,
shortcodes
) do
{:matched, nil}
else
{:ok, %{}}
end
end
end
defp maybe_delist(%{"object" => object, "to" => to, "type" => "Create"} = activity) do
check = matched_emoji_checker(config_unlist_url(), config_unlist_shortcode())
should_delist? = fn object ->
with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check) do
false
else
_ -> true
end
end
if Pleroma.Constants.as_public() in to and should_delist?.(object) do
to = List.delete(to, Pleroma.Constants.as_public())
cc = [Pleroma.Constants.as_public() | activity["cc"] || []]
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
else
activity
end
end
defp maybe_delist(activity), do: activity
defp any_emoji_match?(object, extract_from_tag, extract_from_emoji, patterns) do
Kernel.||(
Enum.any?(
object["tag"] || [],
fn
%{"type" => "Emoji"} = tag ->
str = extract_from_tag.(tag)
if is_binary(str) do
match_any?(str, patterns)
else
false
end
_ ->
false
end
),
(object["emoji"] || [])
|> Enum.any?(fn emoji -> match_any?(extract_from_emoji.(emoji), patterns) end)
)
end
@impl true
def describe do
mrf_emoji =
Pleroma.Config.get(:mrf_emoji, [])
|> Enum.map(fn {key, value} ->
{key, Enum.map(value, &Utils.describe_regex_or_string/1)}
end)
|> Enum.into(%{})
{:ok, %{mrf_emoji: mrf_emoji}}
end
@impl true
def config_description do
%{
key: :mrf_emoji,
related_policy: "Pleroma.Web.ActivityPub.MRF.EmojiPolicy",
label: "MRF Emoji",
description:
"Reject or force-unlisted emojis whose URLs or names match a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).",
children: [
%{
key: :remove_url,
type: {:list, :string},
description: """
A list of patterns which result in emoji whose URL matches being removed from the activity. This will apply to statuses, emoji reactions, and user profiles.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu]
},
%{
key: :remove_shortcode,
type: {:list, :string},
description: """
A list of patterns which result in emoji whose shortcode matches being removed from the activity. This will apply to statuses, emoji reactions, and user profiles.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/iu]
},
%{
key: :federated_timeline_removal_url,
type: {:list, :string},
description: """
A list of patterns which result in activity with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu]
},
%{
key: :federated_timeline_removal_shortcode,
type: {:list, :string},
description: """
A list of patterns which result in activities with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/iu]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
index f5983c8a7..6e43e746c 100644
--- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
+++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
@@ -1,47 +1,49 @@
# 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.MRF.EnsureRePrepended do
- alias Pleroma.Object
-
@moduledoc "Ensure a re: is prepended on replies to a post with a Subject"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ alias Pleroma.Object
+
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
def history_awareness, do: :auto
def filter_by_summary(
%{data: %{"summary" => parent_summary}} = _in_reply_to,
%{"summary" => child_summary} = child
)
when not is_nil(child_summary) and byte_size(child_summary) > 0 and
not is_nil(parent_summary) and byte_size(parent_summary) > 0 do
if (child_summary == parent_summary and not Regex.match?(@reply_prefix, child_summary)) or
(Regex.match?(@reply_prefix, parent_summary) &&
Regex.replace(@reply_prefix, parent_summary, "") == child_summary) do
Map.put(child, "summary", "re: " <> child_summary)
else
child
end
end
def filter_by_summary(_in_reply_to, child), do: child
def filter(%{"type" => type, "object" => object} = activity)
when type in ["Create", "Update"] and is_map(object) do
child =
object["inReplyTo"]
|> Object.normalize(fetch: false)
|> filter_by_summary(object)
activity = Map.put(activity, "object", child)
{:ok, activity}
end
def filter(activity), do: {:ok, activity}
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex
index 480a03ef6..4612b2cc5 100644
--- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex
@@ -1,63 +1,66 @@
# 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.MRF.FollowBotPolicy do
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.User
alias Pleroma.Web.CommonAPI
require Logger
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(activity) do
with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]),
%User{actor_type: "Service"} = follower <-
User.get_cached_by_nickname(follower_nickname),
%{"type" => "Create", "object" => %{"type" => "Note"}} <- activity do
try_follow(follower, activity)
else
nil ->
Logger.warning(
"#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname
account does not exist, or the account is not correctly configured as a bot."
)
{:ok, activity}
_ ->
{:ok, activity}
end
end
defp try_follow(follower, activity) do
to = Map.get(activity, "to", [])
cc = Map.get(activity, "cc", [])
actor = [activity["actor"]]
Enum.concat([to, cc, actor])
|> List.flatten()
|> Enum.uniq()
|> User.get_all_by_ap_id()
|> Enum.each(fn user ->
with false <- user.local,
false <- User.following?(follower, user),
false <- User.locked?(user),
false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do
Logger.debug(
"#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}"
)
CommonAPI.follow(user, follower)
end
end)
{:ok, activity}
end
@impl true
def describe do
{:ok, %{}}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex
index 3b3251dc3..58d02f433 100644
--- a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex
@@ -1,56 +1,59 @@
# 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.MRF.ForceBotUnlistedPolicy do
- alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
@moduledoc "Remove bot posts from federated timeline"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ alias Pleroma.User
+
+ use Pleroma.Web.ActivityPub.MRF.Policy
require Pleroma.Constants
defp check_by_actor_type(user), do: user.actor_type in ["Application", "Service"]
defp check_by_nickname(user), do: Regex.match?(~r/.bot@|ebooks@/i, user.nickname)
defp check_if_bot(user), do: check_by_actor_type(user) or check_by_nickname(user)
@impl true
def filter(
%{
"type" => "Create",
"to" => to,
"cc" => cc,
"actor" => actor,
"object" => object
} = activity
) do
user = User.get_cached_by_ap_id(actor)
isbot = check_if_bot(user)
if isbot and Enum.member?(to, Pleroma.Constants.as_public()) do
to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()]
object =
object
|> Map.put("to", to)
|> Map.put("cc", cc)
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Map.put("object", object)
{:ok, activity}
else
{:ok, activity}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/force_mention.ex b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
index 4ea23540d..62aff36bc 100644
--- a/lib/pleroma/web/activity_pub/mrf/force_mention.ex
+++ b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
@@ -1,59 +1,60 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.ForceMention do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
require Pleroma.Constants
alias Pleroma.Config
alias Pleroma.Object
alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp get_author(url) do
with %Object{data: %{"actor" => actor}} <- Object.normalize(url, fetch: false),
%User{ap_id: ap_id, nickname: nickname} <- User.get_cached_by_ap_id(actor) do
%{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
else
_ -> nil
end
end
defp prepend_author(tags, _, false), do: tags
defp prepend_author(tags, nil, _), do: tags
defp prepend_author(tags, url, _) do
actor = get_author(url)
if not is_nil(actor) do
[actor | tags]
else
tags
end
end
@impl true
def filter(%{"type" => "Create", "object" => %{"tag" => tag} = object} = activity) do
tag =
tag
|> prepend_author(
object["inReplyTo"],
Config.get([:mrf_force_mention, :mention_parent, true])
)
|> prepend_author(
object["quoteUrl"],
Config.get([:mrf_force_mention, :mention_quoted, true])
)
|> Enum.uniq()
{:ok, put_in(activity["object"]["tag"], tag)}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex
index caae365e5..e7c3f2d88 100644
--- a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex
+++ b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex
@@ -1,137 +1,139 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Pleroma.Constants
alias Pleroma.Formatter
alias Pleroma.Object
alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
@impl true
def history_awareness, do: :auto
defp do_extract({:a, attrs, _}, acc) do
if Enum.find(attrs, fn {name, value} ->
name == "class" && value in ["mention", "u-url mention", "mention u-url"]
end) do
href = Enum.find(attrs, fn {name, _} -> name == "href" end) |> elem(1)
acc ++ [href]
else
acc
end
end
defp do_extract({_, _, children}, acc) do
do_extract(children, acc)
end
defp do_extract(nodes, acc) when is_list(nodes) do
Enum.reduce(nodes, acc, fn node, acc -> do_extract(node, acc) end)
end
defp do_extract(_, acc), do: acc
defp extract_mention_uris_from_content(content) do
{:ok, tree} = :fast_html.decode(content, format: [:html_atoms])
do_extract(tree, [])
end
defp get_replied_to_user(%{"inReplyTo" => in_reply_to}) do
case Object.normalize(in_reply_to, fetch: false) do
%Object{data: %{"actor" => actor}} -> User.get_cached_by_ap_id(actor)
_ -> nil
end
end
defp get_replied_to_user(_object), do: nil
# Ensure the replied-to user is sorted to the left
defp sort_replied_user([%User{id: user_id} | _] = users, %User{id: user_id}), do: users
defp sort_replied_user(users, %User{id: user_id} = user) do
if Enum.find(users, fn u -> u.id == user_id end) do
users = Enum.reject(users, fn u -> u.id == user_id end)
[user | users]
else
users
end
end
defp sort_replied_user(users, _), do: users
# Drop constants and the actor's own AP ID
defp clean_recipients(recipients, object) do
Enum.reject(recipients, fn ap_id ->
ap_id in [
object["object"]["actor"],
Pleroma.Constants.as_public(),
Pleroma.Web.ActivityPub.Utils.as_local_public()
]
end)
end
@impl true
def filter(
%{
"type" => type,
"object" => %{"type" => "Note", "to" => to, "inReplyTo" => in_reply_to}
} = activity
)
when type in ["Create", "Update"] and is_list(to) and is_binary(in_reply_to) do
# image-only posts from pleroma apparently reach this MRF without the content field
content = activity["object"]["content"] || ""
# Get the replied-to user for sorting
replied_to_user = get_replied_to_user(activity["object"])
mention_users =
to
|> clean_recipients(activity)
|> Enum.map(&User.get_cached_by_ap_id/1)
|> Enum.reject(&is_nil/1)
|> sort_replied_user(replied_to_user)
explicitly_mentioned_uris =
extract_mention_uris_from_content(content)
|> MapSet.new()
added_mentions =
Enum.reduce(mention_users, "", fn %User{ap_id: ap_id, uri: uri} = user, acc ->
if MapSet.disjoint?(MapSet.new([ap_id, uri]), explicitly_mentioned_uris) do
acc <> Formatter.mention_from_user(user, %{mentions_format: :compact}) <> " "
else
acc
end
end)
recipients_inline =
if added_mentions != "",
do: "<span class=\"recipients-inline\">#{added_mentions}</span>",
else: ""
content =
cond do
# For Markdown posts, insert the mentions inside the first <p> tag
recipients_inline != "" && String.starts_with?(content, "<p>") ->
"<p>" <> recipients_inline <> String.trim_leading(content, "<p>")
recipients_inline != "" ->
recipients_inline <> content
true ->
content
end
{:ok, put_in(activity["object"]["content"], content)}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
index 72f2274ed..a3fae3bae 100644
--- a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
@@ -1,144 +1,146 @@
# 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.MRF.HashtagPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Pleroma.Constants
alias Pleroma.Config
alias Pleroma.Object
@moduledoc """
Reject, TWKN-remove or Set-Sensitive activities with specific hashtags (without the leading #)
Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists.
"""
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
@impl true
def history_awareness, do: :manual
defp check_reject(activity, hashtags) do
if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
{:reject, "[HashtagPolicy] Matches with rejected keyword"}
else
{:ok, activity}
end
end
defp check_ftl_removal(%{"to" => to} = activity, hashtags) do
if Pleroma.Constants.as_public() in to and
Enum.any?(Config.get([:mrf_hashtag, :federated_timeline_removal]), fn match ->
match in hashtags
end) do
to = List.delete(to, Pleroma.Constants.as_public())
cc = [Pleroma.Constants.as_public() | activity["cc"] || []]
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Kernel.put_in(["object", "to"], to)
|> Kernel.put_in(["object", "cc"], cc)
{:ok, activity}
else
{:ok, activity}
end
end
defp check_ftl_removal(activity, _hashtags), do: {:ok, activity}
defp check_sensitive(activity) do
{:ok, new_object} =
Object.Updater.do_with_history(activity["object"], fn object ->
hashtags = Object.hashtags(%Object{data: object})
if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
{:ok, Map.put(object, "sensitive", true)}
else
{:ok, object}
end
end)
{:ok, Map.put(activity, "object", new_object)}
end
@impl true
def filter(%{"type" => type, "object" => object} = activity)
when type in ["Create", "Update"] do
history_items =
with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
items
else
_ -> []
end
historical_hashtags =
Enum.reduce(history_items, [], fn item, acc ->
acc ++ Object.hashtags(%Object{data: item})
end)
hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
if hashtags != [] do
with {:ok, activity} <- check_reject(activity, hashtags),
{:ok, activity} <-
(if type == "Create" do
check_ftl_removal(activity, hashtags)
else
{:ok, activity}
end),
{:ok, activity} <- check_sensitive(activity) do
{:ok, activity}
end
else
{:ok, activity}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe do
mrf_hashtag =
Config.get(:mrf_hashtag)
|> Enum.into(%{})
{:ok, %{mrf_hashtag: mrf_hashtag}}
end
@impl true
def config_description do
%{
key: :mrf_hashtag,
related_policy: "Pleroma.Web.ActivityPub.MRF.HashtagPolicy",
label: "MRF Hashtag",
description: @moduledoc,
children: [
%{
key: :reject,
type: {:list, :string},
description: "A list of hashtags which result in the activity being rejected.",
suggestions: ["foo"]
},
%{
key: :federated_timeline_removal,
type: {:list, :string},
description:
"A list of hashtags which result in the activity being removed from federated timelines (a.k.a unlisted).",
suggestions: ["foo"]
},
%{
key: :sensitive,
type: {:list, :string},
description:
"A list of hashtags which result in the activity being set as sensitive (a.k.a NSFW/R-18)",
suggestions: ["nsfw", "r18"]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex
index 3a80d0a69..a84a7b0c8 100644
--- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex
@@ -1,127 +1,128 @@
# 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.MRF.HellthreadPolicy do
+ @moduledoc "Block activities with too much mentions (configurable)"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.User
require Pleroma.Constants
- @moduledoc "Block activities with too much mentions (configurable)"
-
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp delist_activity(activity, threshold) when threshold > 0 do
follower_collection = User.get_cached_by_ap_id(activity["actor"]).follower_address
to = activity["to"] || []
cc = activity["cc"] || []
follower_collection? = Enum.member?(to ++ cc, follower_collection)
activity =
case get_recipient_count(activity) do
{:public, recipients}
when follower_collection? and recipients > threshold ->
activity
|> Map.put("to", [follower_collection])
|> Map.put("cc", [Pleroma.Constants.as_public()])
{:public, recipients} when recipients > threshold ->
activity
|> Map.put("to", [])
|> Map.put("cc", [Pleroma.Constants.as_public()])
_ ->
activity
end
{:ok, activity}
end
defp delist_activity(activity, _threshold), do: {:ok, activity}
defp reject_activity(activity, threshold) when threshold > 0 do
with {_, recipients} <- get_recipient_count(activity) do
if recipients > threshold do
{:reject, "[HellthreadPolicy] #{recipients} recipients is over the limit of #{threshold}"}
else
{:ok, activity}
end
end
end
defp reject_activity(activity, _threshold), do: {:ok, activity}
defp get_recipient_count(activity) do
recipients = (activity["to"] || []) ++ (activity["cc"] || [])
follower_collection = User.get_cached_by_ap_id(activity["actor"]).follower_address
if Enum.member?(recipients, Pleroma.Constants.as_public()) do
recipients =
recipients
|> List.delete(Pleroma.Constants.as_public())
|> List.delete(follower_collection)
{:public, length(recipients)}
else
recipients =
recipients
|> List.delete(follower_collection)
{:not_public, length(recipients)}
end
end
@impl true
def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = activity)
when object_type in ~w{Note Article} do
reject_threshold =
Pleroma.Config.get(
[:mrf_hellthread, :reject_threshold],
Pleroma.Config.get([:mrf_hellthread, :threshold])
)
delist_threshold = Pleroma.Config.get([:mrf_hellthread, :delist_threshold])
with {:ok, activity} <- reject_activity(activity, reject_threshold),
{:ok, activity} <- delist_activity(activity, delist_threshold) do
{:ok, activity}
else
e -> e
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe,
do: {:ok, %{mrf_hellthread: Pleroma.Config.get(:mrf_hellthread) |> Enum.into(%{})}}
@impl true
def config_description do
%{
key: :mrf_hellthread,
related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy",
label: "MRF Hellthread",
description: "Block activities with excessive user mentions",
children: [
%{
key: :delist_threshold,
type: :integer,
description:
"Number of mentioned users after which the activity gets removed from timelines and" <>
"disables notifications. Set to 0 to disable.",
suggestions: [10]
},
%{
key: :reject_threshold,
type: :integer,
description:
"Number of mentioned users after which the activity gets rejected. Set to 0 to disable.",
suggestions: [20]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex
index 51dcd3918..17879b4bd 100644
--- a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex
@@ -1,79 +1,81 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do
@moduledoc "Force a quote line into the message content."
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
defp build_inline_quote(template, url) do
quote_line = String.replace(template, "{url}", "<a href=\"#{url}\">#{url}</a>")
"<span class=\"quote-inline\"><br/><br/>#{quote_line}</span>"
end
defp has_inline_quote?(content, quote_url) do
cond do
# Does the quote URL exist in the content?
content =~ quote_url -> true
# Does the content already have a .quote-inline span?
content =~ "<span class=\"quote-inline\">" -> true
# Does the content already have a .quote-inline p? (Mastodon)
content =~ "<p class=\"quote-inline\">" -> true
# No inline quote found
true -> false
end
end
defp filter_object(%{"quoteUrl" => quote_url} = object) do
content = object["content"] || ""
if has_inline_quote?(content, quote_url) do
object
else
template = Pleroma.Config.get([:mrf_inline_quote, :template])
content =
if String.ends_with?(content, "</p>"),
do:
String.trim_trailing(content, "</p>") <>
build_inline_quote(template, quote_url) <> "</p>",
else: content <> build_inline_quote(template, quote_url)
Map.put(object, "content", content)
end
end
@impl true
def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do
{:ok, Map.put(activity, "object", filter_object(object))}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
@impl true
def history_awareness, do: :auto
@impl true
def config_description do
%{
key: :mrf_inline_quote,
related_policy: "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy",
label: "MRF Inline Quote Policy",
description: "Force quote url to appear in post content.",
children: [
%{
key: :template,
type: :string,
description:
"The template to append to the post. `{url}` will be replaced with the actual link to the quoted post.",
suggestions: ["<bdi>RT:</bdi> {url}"]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
index 6ba6fd509..33f32e781 100644
--- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
@@ -1,193 +1,194 @@
# 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.MRF.KeywordPolicy do
+ @moduledoc "Reject or Word-Replace activities with a keyword or regex"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Pleroma.Constants
alias Pleroma.Web.ActivityPub.MRF.Utils
- @moduledoc "Reject or Word-Replace activities with a keyword or regex"
-
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp string_matches?(string, pattern) when is_binary(pattern) do
String.contains?(string, pattern)
end
defp string_matches?(string, %Regex{} = pattern) do
String.match?(string, pattern)
end
defp object_payload(%{} = object) do
[object["content"], object["summary"], object["name"]]
|> Enum.filter(& &1)
|> Enum.join("\n")
end
defp check_reject(%{"object" => %{} = object} = activity) do
with {:ok, _new_object} <-
Pleroma.Object.Updater.do_with_history(object, fn object ->
payload = object_payload(object)
if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
string_matches?(payload, pattern)
end) do
{:reject, "[KeywordPolicy] Matches with rejected keyword"}
else
{:ok, activity}
end
end) do
{:ok, activity}
else
e -> e
end
end
defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = activity) do
check_keyword = fn object ->
payload = object_payload(object)
if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
string_matches?(payload, pattern)
end) do
{:should_delist, nil}
else
{:ok, %{}}
end
end
should_delist? = fn object ->
with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
false
else
_ -> true
end
end
if Pleroma.Constants.as_public() in to and should_delist?.(object) do
to = List.delete(to, Pleroma.Constants.as_public())
cc = [Pleroma.Constants.as_public() | activity["cc"] || []]
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
{:ok, activity}
else
{:ok, activity}
end
end
defp check_ftl_removal(activity) do
{:ok, activity}
end
defp check_replace(%{"object" => %{} = object} = activity) do
replace_kw = fn object ->
["content", "name", "summary"]
|> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
|> Enum.reduce(object, fn field, object ->
data =
Enum.reduce(
Pleroma.Config.get([:mrf_keyword, :replace]),
object[field],
fn {pat, repl}, acc -> String.replace(acc, pat, repl) end
)
Map.put(object, field, data)
end)
|> (fn object -> {:ok, object} end).()
end
{:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
activity = Map.put(activity, "object", object)
{:ok, activity}
end
@impl true
def filter(%{"type" => type, "object" => %{"content" => _content}} = activity)
when type in ["Create", "Update"] do
with {:ok, activity} <- check_reject(activity),
{:ok, activity} <- check_ftl_removal(activity),
{:ok, activity} <- check_replace(activity) do
{:ok, activity}
else
{:reject, nil} -> {:reject, "[KeywordPolicy] "}
{:reject, _} = e -> e
_e -> {:reject, "[KeywordPolicy] "}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe do
mrf_keyword =
Pleroma.Config.get(:mrf_keyword, [])
|> Enum.map(fn {key, value} ->
{key,
Enum.map(value, fn
{pattern, replacement} ->
%{
"pattern" => Utils.describe_regex_or_string(pattern),
"replacement" => replacement
}
pattern ->
Utils.describe_regex_or_string(pattern)
end)}
end)
|> Enum.into(%{})
{:ok, %{mrf_keyword: mrf_keyword}}
end
@impl true
def config_description do
%{
key: :mrf_keyword,
related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy",
label: "MRF Keyword",
description:
"Reject or Word-Replace activities matching a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).",
children: [
%{
key: :reject,
type: {:list, :string},
description: """
A list of patterns which result in the activity being rejected.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/iu]
},
%{
key: :federated_timeline_removal,
type: {:list, :string},
description: """
A list of patterns which result in the activity being removed from federated timelines (a.k.a unlisted).
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/iu]
},
%{
key: :replace,
type: {:list, :tuple},
key_placeholder: "instance",
value_placeholder: "reason",
description: """
**Pattern**: a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
**Replacement**: a string. Leaving the field empty is permitted.
"""
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
index 43c2c0449..07046a666 100644
--- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
@@ -1,71 +1,73 @@
# 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.MRF.MediaProxyWarmingPolicy do
@moduledoc "Preloads any attachments in the MediaProxy cache by prefetching them"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.HTTP
alias Pleroma.Web.MediaProxy
require Logger
@impl true
def history_awareness, do: :auto
defp prefetch(url) do
# Fetching only proxiable resources
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
# If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests)
prefetch_url = MediaProxy.preview_url(url)
Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}")
fetch(prefetch_url)
end
end
defp fetch(url) do
# This module uses Tesla (Pleroma.HTTP) to fetch the MediaProxy URL.
# Redirect following is handled by Tesla middleware, so we must not enable
# adapter-level redirect logic (Hackney can crash on relative redirects when proxied).
http_client_opts =
[:media_proxy, :proxy_opts, :http]
|> Pleroma.Config.get(pool: :media)
|> Keyword.drop([:follow_redirect, :force_redirect])
HTTP.get(url, [], http_client_opts)
end
defp preload(%{"object" => %{"attachment" => attachments}} = _activity) do
Enum.each(attachments, fn
%{"url" => url} when is_list(url) ->
url
|> Enum.each(fn
%{"href" => href} ->
prefetch(href)
x ->
Logger.debug("Unhandled attachment URL object #{inspect(x)}")
end)
x ->
Logger.debug("Unhandled attachment #{inspect(x)}")
end)
end
@impl true
def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = activity)
when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
preload(activity)
{:ok, activity}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex
index f7bff121f..8e108a0ae 100644
--- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex
@@ -1,46 +1,47 @@
# 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.MRF.MentionPolicy do
@moduledoc "Block activities which mention a user"
-
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(%{"type" => "Create"} = activity) do
reject_actors = Pleroma.Config.get([:mrf_mention, :actors], [])
recipients = (activity["to"] || []) ++ (activity["cc"] || [])
if rejected_mention =
Enum.find(recipients, fn recipient -> Enum.member?(reject_actors, recipient) end) do
{:reject, "[MentionPolicy] Rejected for mention of #{rejected_mention}"}
else
{:ok, activity}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
@impl true
def config_description do
%{
key: :mrf_mention,
related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy",
label: "MRF Mention",
description: "Block activities which mention a specific user",
children: [
%{
key: :actors,
type: {:list, :string},
description: "A list of actors for which any post mentioning them will be dropped",
suggestions: ["actor1", "actor2"]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
index 08dd39878..8283fd504 100644
--- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
@@ -1,70 +1,72 @@
# 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.MRF.NoEmptyPolicy do
@moduledoc "Filter local activities which have no content"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Web.Endpoint
@impl true
def filter(%{"actor" => actor} = activity) do
with true <- local?(actor),
true <- eligible_type?(activity),
true <- note?(activity),
false <- has_attachment?(activity),
true <- only_mentions?(activity) do
{:reject, "[NoEmptyPolicy]"}
else
_ ->
{:ok, activity}
end
end
def filter(activity), do: {:ok, activity}
defp local?(actor) do
if actor |> String.starts_with?("#{Endpoint.url()}") do
true
else
false
end
end
defp has_attachment?(%{
"object" => %{"type" => "Note", "attachment" => attachments}
})
when length(attachments) > 0,
do: true
defp has_attachment?(_), do: false
defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
source =
case source do
%{"content" => text} -> text
_ -> source
end
non_mentions =
source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
if non_mentions > 0 do
false
else
true
end
end
defp only_mentions?(_), do: false
defp note?(%{"object" => %{"type" => "Note"}}), do: true
defp note?(_), do: false
defp eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
defp eligible_type?(_), do: false
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex
index 64a5872bc..3adbb598a 100644
--- a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex
@@ -1,16 +1,18 @@
# 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.MRF.NoOpPolicy do
@moduledoc "Does nothing (lets the messages go through unmodified)"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(activity) do
{:ok, activity}
end
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
index c6f239a5e..2a10c7e97 100644
--- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
@@ -1,28 +1,30 @@
# 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.MRF.NoPlaceholderTextPolicy do
@moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def history_awareness, do: :auto
@impl true
def filter(
%{
"type" => type,
"object" => %{"content" => content, "attachment" => _} = _object
} = activity
)
when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
{:ok, put_in(activity, ["object", "content"], "")}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
index 91855ef84..de0f5b619 100644
--- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
+++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
@@ -1,49 +1,51 @@
# 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.MRF.NormalizeMarkup do
@moduledoc "Scrub configured hypertext markup"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.HTML
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
@impl true
def history_awareness, do: :auto
@impl true
def filter(%{"type" => type, "object" => object} = activity)
when type in ["Create", "Update"] do
scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
content =
object["content"]
|> HTML.filter_tags(scrub_policy)
activity = put_in(activity, ["object", "content"], content)
{:ok, activity}
end
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
@impl true
def config_description do
%{
key: :mrf_normalize_markup,
related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup",
label: "MRF Normalize Markup",
description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.",
children: [
%{
key: :scrub_policy,
type: :module,
suggestions: [Pleroma.HTML.Scrubber.Default]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex
index 52aaf05aa..7e3ec3de5 100644
--- a/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/nsfw_api_policy.ex
@@ -1,264 +1,267 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.NsfwApiPolicy do
@moduledoc """
Hide, delete, or mark sensitive NSFW content with artificial intelligence.
Requires a NSFW API server, configured like so:
config :pleroma, Pleroma.Web.ActivityPub.MRF.NsfwMRF,
url: "http://127.0.0.1:5000/",
threshold: 0.7,
mark_sensitive: true,
unlist: false,
reject: false
The NSFW API server must implement an HTTP endpoint like this:
curl http://localhost:5000/?url=https://fedi.com/images/001.jpg
Returning a response like this:
{"score", 0.314}
Where a score is 0-1, with `1` being definitely NSFW.
A good API server is here: https://github.com/EugenCepoi/nsfw_api
You can run it with Docker with a one-liner:
docker run -it -p 127.0.0.1:5000:5000/tcp --env PORT=5000 eugencepoi/nsfw_api:latest
Options:
- `url`: Base URL of the API server. Default: "http://127.0.0.1:5000/"
- `threshold`: Lowest score to take action on. Default: `0.7`
- `mark_sensitive`: Mark sensitive all detected NSFW content? Default: `true`
- `unlist`: Unlist all detected NSFW content? Default: `false`
- `reject`: Reject all detected NSFW content (takes precedence)? Default: `false`
"""
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.Constants
alias Pleroma.HTTP
alias Pleroma.User
require Logger
require Pleroma.Constants
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@policy :mrf_nsfw_api
def build_request_url(url) do
Config.get([@policy, :url])
|> URI.parse()
|> fix_path()
|> Map.put(:query, "url=#{url}")
|> URI.to_string()
end
def parse_url(url) do
request = build_request_url(url)
with {:ok, %Tesla.Env{body: body}} <- HTTP.get(request) do
Jason.decode(body)
else
error ->
Logger.warning("""
[NsfwApiPolicy]: The API server failed. Skipping.
#{inspect(error)}
""")
error
end
end
def check_url_nsfw(url) when is_binary(url) do
threshold = Config.get([@policy, :threshold])
case parse_url(url) do
{:ok, %{"score" => score}} when score >= threshold ->
{:nsfw, %{url: url, score: score, threshold: threshold}}
{:ok, %{"score" => score}} ->
{:sfw, %{url: url, score: score, threshold: threshold}}
_ ->
{:sfw, %{url: url, score: nil, threshold: threshold}}
end
end
def check_url_nsfw(%{"href" => url}) when is_binary(url) do
check_url_nsfw(url)
end
def check_url_nsfw(url) do
threshold = Config.get([@policy, :threshold])
{:sfw, %{url: url, score: nil, threshold: threshold}}
end
def check_attachment_nsfw(%{"url" => urls} = attachment) when is_list(urls) do
if Enum.all?(urls, &match?({:sfw, _}, check_url_nsfw(&1))) do
{:sfw, attachment}
else
{:nsfw, attachment}
end
end
def check_attachment_nsfw(%{"url" => url} = attachment) when is_binary(url) do
case check_url_nsfw(url) do
{:sfw, _} -> {:sfw, attachment}
{:nsfw, _} -> {:nsfw, attachment}
end
end
def check_attachment_nsfw(attachment), do: {:sfw, attachment}
def check_object_nsfw(%{"attachment" => attachments} = object) when is_list(attachments) do
if Enum.all?(attachments, &match?({:sfw, _}, check_attachment_nsfw(&1))) do
{:sfw, object}
else
{:nsfw, object}
end
end
def check_object_nsfw(%{"object" => %{} = object} = activity) do
case check_object_nsfw(object) do
{:sfw, _} -> {:sfw, activity}
{:nsfw, _} -> {:nsfw, activity}
end
end
def check_object_nsfw(object), do: {:sfw, object}
@impl true
def filter(activity) do
with {:sfw, activity} <- check_object_nsfw(activity) do
{:ok, activity}
else
{:nsfw, _data} -> handle_nsfw(activity)
end
end
defp handle_nsfw(activity) do
if Config.get([@policy, :reject]) do
{:reject, activity}
else
{:ok,
activity
|> maybe_unlist()
|> maybe_mark_sensitive()}
end
end
defp maybe_unlist(activity) do
if Config.get([@policy, :unlist]) do
unlist(activity)
else
activity
end
end
defp maybe_mark_sensitive(activity) do
if Config.get([@policy, :mark_sensitive]) do
mark_sensitive(activity)
else
activity
end
end
def unlist(%{"to" => to, "cc" => cc, "actor" => actor} = activity) do
with %User{} = user <- User.get_cached_by_ap_id(actor) do
to =
[user.follower_address | to]
|> List.delete(Constants.as_public())
|> Enum.uniq()
cc =
[Constants.as_public() | cc]
|> List.delete(user.follower_address)
|> Enum.uniq()
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
else
_ -> raise "[NsfwApiPolicy]: Could not find user #{actor}"
end
end
def mark_sensitive(%{"object" => object} = activity) when is_map(object) do
Map.put(activity, "object", mark_sensitive(object))
end
def mark_sensitive(activity) when is_map(activity) do
tags = (activity["tag"] || []) ++ ["nsfw"]
activity
|> Map.put("tag", tags)
|> Map.put("sensitive", true)
end
# Hackney needs a trailing slash
defp fix_path(%URI{path: path} = uri) when is_binary(path) do
path = String.trim_trailing(path, "/") <> "/"
Map.put(uri, :path, path)
end
defp fix_path(%URI{path: nil} = uri), do: Map.put(uri, :path, "/")
@impl true
def describe do
options = %{
threshold: Config.get([@policy, :threshold]),
mark_sensitive: Config.get([@policy, :mark_sensitive]),
unlist: Config.get([@policy, :unlist]),
reject: Config.get([@policy, :reject])
}
{:ok, %{@policy => options}}
end
@impl true
def config_description do
%{
key: @policy,
related_policy: to_string(__MODULE__),
label: "NSFW API Policy",
description:
"Hide, delete, or mark sensitive NSFW content with artificial intelligence. Requires running an external API server.",
children: [
%{
key: :url,
type: :string,
description: "Base URL of the API server.",
suggestions: ["http://127.0.0.1:5000/"]
},
%{
key: :threshold,
type: :float,
description: "Lowest score to take action on. Between 0 and 1.",
suggestions: [0.7]
},
%{
key: :mark_sensitive,
type: :boolean,
description: "Mark sensitive all detected NSFW content?",
suggestions: [true]
},
%{
key: :unlist,
type: :boolean,
description: "Unlist sensitive all detected NSFW content?",
suggestions: [false]
},
%{
key: :reject,
type: :boolean,
description: "Reject sensitive all detected NSFW content (takes precedence)?",
suggestions: [false]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
index 34905fc21..00f4cf84b 100644
--- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
@@ -1,141 +1,143 @@
# 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.MRF.ObjectAgePolicy do
+ @moduledoc "Filter activities depending on their age"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.User
require Pleroma.Constants
- @moduledoc "Filter activities depending on their age"
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp check_date(%{"object" => %{"published" => published}} = activity) do
with %DateTime{} = now <- DateTime.utc_now(),
{:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published),
max_ttl <- Config.get([:mrf_object_age, :threshold]),
{:ttl, false} <- {:ttl, DateTime.diff(now, then) > max_ttl} do
{:ok, activity}
else
{:ttl, true} ->
{:reject, nil}
e ->
{:error, e}
end
end
defp check_reject(activity, actions) do
if :reject in actions do
{:reject, "[ObjectAgePolicy]"}
else
{:ok, activity}
end
end
defp check_delist(activity, actions) do
if :delist in actions do
with %User{} = user <- User.get_cached_by_ap_id(activity["actor"]) do
to =
List.delete(activity["to"] || [], Pleroma.Constants.as_public()) ++
[user.follower_address]
cc =
List.delete(activity["cc"] || [], user.follower_address) ++
[Pleroma.Constants.as_public()]
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Kernel.put_in(["object", "to"], to)
|> Kernel.put_in(["object", "cc"], cc)
{:ok, activity}
else
_e ->
{:reject, "[ObjectAgePolicy] Unhandled error"}
end
else
{:ok, activity}
end
end
defp check_strip_followers(activity, actions) do
if :strip_followers in actions do
with %User{} = user <- User.get_cached_by_ap_id(activity["actor"]) do
to = List.delete(activity["to"] || [], user.follower_address)
cc = List.delete(activity["cc"] || [], user.follower_address)
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Kernel.put_in(["object", "to"], to)
|> Kernel.put_in(["object", "cc"], cc)
{:ok, activity}
else
_e ->
{:reject, "[ObjectAgePolicy] Unhandled error"}
end
else
{:ok, activity}
end
end
@impl true
def filter(%{"type" => "Create", "object" => %{"published" => _}} = activity) do
with actions <- Config.get([:mrf_object_age, :actions]),
{:reject, _} <- check_date(activity),
{:ok, activity} <- check_reject(activity, actions),
{:ok, activity} <- check_delist(activity, actions),
{:ok, activity} <- check_strip_followers(activity, actions) do
{:ok, activity}
else
# check_date() is allowed to short-circuit the pipeline
e -> e
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe do
mrf_object_age =
Config.get(:mrf_object_age)
|> Enum.into(%{})
{:ok, %{mrf_object_age: mrf_object_age}}
end
@impl true
def config_description do
%{
key: :mrf_object_age,
related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy",
label: "MRF Object Age",
description:
"Rejects or delists posts based on their timestamp deviance from your server's clock.",
children: [
%{
key: :threshold,
type: :integer,
description: "Required age (in seconds) of a post before actions are taken.",
suggestions: [172_800]
},
%{
key: :actions,
type: {:list, :atom},
description:
"A list of actions to apply to the post. `:delist` removes the post from public timelines; " <>
"`:strip_followers` removes followers from the ActivityPub recipient list ensuring they won't be delivered to home timelines, additionally for followers-only it degrades to a direct activity; " <>
"`:reject` rejects the activity entirely",
suggestions: [:delist, :strip_followers, :reject]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex
index 08bcac08a..b9a693c9e 100644
--- a/lib/pleroma/web/activity_pub/mrf/policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/policy.ex
@@ -1,18 +1,26 @@
# 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.MRF.Policy do
@callback filter(Pleroma.Activity.t()) :: {:ok | :reject, Pleroma.Activity.t()}
@callback id_filter(String.t()) :: boolean()
@callback describe() :: {:ok | :error, map()}
@callback config_description() :: %{
optional(:children) => [map()],
key: atom(),
related_policy: String.t(),
label: String.t(),
description: String.t()
}
@callback history_awareness() :: :auto | :manual
@optional_callbacks config_description: 0, history_awareness: 0, id_filter: 1
+
+ defmacro __using__(_) do
+ quote do
+ # Otherwise Pleroma.Docs.Generator.list_behaviour_implementations fails
+ # since the Elixir compiler doesn't automatically load the module since v1.19.
+ @compile {:autoload, true}
+ end
+ end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex
index b3eb0b390..b47e9edb9 100644
--- a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex
+++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex
@@ -1,61 +1,63 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do
@moduledoc """
QuietReply alters the scope of activities from local users when replying by enforcing them to be "Unlisted" or "Quiet Public". This delivers the activity to all the expected recipients and instances, but it will not be published in the Federated / The Whole Known Network timelines. It will still be published to the Home timelines of the user's followers and visible to anyone who opens the thread.
"""
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Pleroma.Constants
alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
@impl true
def history_awareness, do: :auto
@impl true
def filter(
%{
"type" => "Create",
"to" => to,
"cc" => cc,
"object" => %{
"actor" => actor,
"type" => "Note",
"inReplyTo" => in_reply_to
}
} = activity
) do
with true <- is_binary(in_reply_to),
true <- Pleroma.Constants.as_public() in to,
%User{follower_address: followers_collection, local: true} <-
User.get_by_ap_id(actor) do
updated_to =
[followers_collection | to]
|> Kernel.--([Pleroma.Constants.as_public()])
updated_cc =
[Pleroma.Constants.as_public() | cc]
|> Kernel.--([followers_collection])
updated_activity =
activity
|> Map.put("to", updated_to)
|> Map.put("cc", updated_cc)
|> put_in(["object", "to"], updated_to)
|> put_in(["object", "cc"], updated_cc)
{:ok, updated_activity}
else
_ -> {:ok, activity}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex
index 2a17b6761..e1ad17585 100644
--- a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex
@@ -1,49 +1,51 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy do
@moduledoc "Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
require Pleroma.Constants
@impl true
def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do
{:ok, Map.put(activity, "object", filter_object(object))}
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
@impl true
def history_awareness, do: :auto
defp filter_object(%{"quoteUrl" => quote_url} = object) do
tags = object["tag"] || []
if Enum.any?(tags, fn tag ->
CommonFixes.object_link_tag?(tag) and tag["href"] == quote_url
end) do
object
else
object
|> Map.put(
"tag",
tags ++
[
%{
"type" => "Link",
"mediaType" => Pleroma.Constants.activity_json_canonical_mime_type(),
"href" => quote_url
}
]
)
end
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex
index 9d4a7a405..7d113dbf6 100644
--- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex
+++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex
@@ -1,74 +1,75 @@
# 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.MRF.RejectNonPublic do
@moduledoc "Rejects non-public (followers-only, direct) activities"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
alias Pleroma.Config
alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
require Pleroma.Constants
@impl true
def filter(%{"type" => "Create"} = object) do
user = User.get_cached_by_ap_id(object["actor"])
# Determine visibility
visibility =
cond do
Pleroma.Constants.as_public() in object["to"] -> "public"
Pleroma.Constants.as_public() in object["cc"] -> "unlisted"
user.follower_address in object["to"] -> "followers"
true -> "direct"
end
policy = Config.get(:mrf_rejectnonpublic)
cond do
visibility in ["public", "unlisted"] ->
{:ok, object}
visibility == "followers" and Keyword.get(policy, :allow_followersonly) ->
{:ok, object}
visibility == "direct" and Keyword.get(policy, :allow_direct) ->
{:ok, object}
true ->
{:reject, "[RejectNonPublic] visibility: #{visibility}"}
end
end
@impl true
def filter(object), do: {:ok, object}
@impl true
def describe,
do: {:ok, %{mrf_rejectnonpublic: Config.get(:mrf_rejectnonpublic) |> Map.new()}}
@impl true
def config_description do
%{
key: :mrf_rejectnonpublic,
related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic",
description: "RejectNonPublic drops posts with non-public visibility settings.",
label: "MRF Reject Non Public",
children: [
%{
key: :allow_followersonly,
label: "Allow followers-only",
type: :boolean,
description: "Whether to allow followers-only posts"
},
%{
key: :allow_direct,
type: :boolean,
description: "Whether to allow direct messages"
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex
index 8422832dd..f9f8d2298 100644
--- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex
@@ -1,117 +1,119 @@
defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do
@moduledoc "Drop remote reports if they don't contain enough information."
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
@impl true
def filter(%{"type" => "Flag"} = object) do
with {_, false} <- {:local, local?(object)},
{:ok, _} <- maybe_reject_all(object),
{:ok, _} <- maybe_reject_anonymous(object),
{:ok, _} <- maybe_reject_third_party(object),
{:ok, _} <- maybe_reject_empty_message(object) do
{:ok, object}
else
{:local, true} -> {:ok, object}
{:reject, message} -> {:reject, message}
end
end
def filter(object), do: {:ok, object}
defp maybe_reject_all(object) do
if Config.get([:mrf_remote_report, :reject_all]) do
{:reject, "[RemoteReportPolicy] Remote report"}
else
{:ok, object}
end
end
defp maybe_reject_anonymous(%{"actor" => actor} = object) do
with true <- Config.get([:mrf_remote_report, :reject_anonymous]),
%URI{path: "/actor"} <- URI.parse(actor) do
{:reject, "[RemoteReportPolicy] Anonymous: #{actor}"}
else
_ -> {:ok, object}
end
end
defp maybe_reject_third_party(%{"object" => objects} = object) do
{_, to} =
case objects do
[head | tail] when is_binary(head) -> {tail, head}
s when is_binary(s) -> {[], s}
_ -> {[], ""}
end
with true <- Config.get([:mrf_remote_report, :reject_third_party]),
false <- String.starts_with?(to, Pleroma.Web.Endpoint.url()) do
{:reject, "[RemoteReportPolicy] Third-party: #{to}"}
else
_ -> {:ok, object}
end
end
defp maybe_reject_empty_message(%{"content" => content} = object)
when is_binary(content) and content != "" do
{:ok, object}
end
defp maybe_reject_empty_message(object) do
if Config.get([:mrf_remote_report, :reject_empty_message]) do
{:reject, ["RemoteReportPolicy] No content"]}
else
{:ok, object}
end
end
defp local?(%{"actor" => actor}) do
String.starts_with?(actor, Pleroma.Web.Endpoint.url())
end
@impl true
def describe do
mrf_remote_report =
Config.get(:mrf_remote_report)
|> Enum.into(%{})
{:ok, %{mrf_remote_report: mrf_remote_report}}
end
@impl true
def config_description do
%{
key: :mrf_remote_report,
related_policy: "Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy",
label: "MRF Remote Report",
description: "Drop remote reports if they don't contain enough information.",
children: [
%{
key: :reject_all,
type: :boolean,
description: "Reject all remote reports? (this option takes precedence)",
suggestions: [false]
},
%{
key: :reject_anonymous,
type: :boolean,
description: "Reject anonymous remote reports?",
suggestions: [true]
},
%{
key: :reject_third_party,
type: :boolean,
description: "Reject reports on users from third-party instances?",
suggestions: [true]
},
%{
key: :reject_empty_message,
type: :boolean,
description: "Reject remote reports with no message?",
suggestions: [true]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex
index a97e8db7b..7045fe90f 100644
--- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex
@@ -1,376 +1,378 @@
# 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.MRF.SimplePolicy do
@moduledoc "Filter activities depending on their origin instance"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.FollowingRelationship
alias Pleroma.User
alias Pleroma.Web.ActivityPub.MRF
require Pleroma.Constants
defp check_accept(%{host: actor_host} = _actor_info, activity) do
accepts =
instance_list(:accept)
|> MRF.subdomains_regex()
cond do
accepts == [] -> {:ok, activity}
actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, activity}
MRF.subdomain_match?(accepts, actor_host) -> {:ok, activity}
true -> {:reject, "[SimplePolicy] host not in accept list"}
end
end
defp check_reject(%{host: actor_host} = _actor_info, activity) do
rejects =
instance_list(:reject)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(rejects, actor_host) do
{:reject, "[SimplePolicy] host in reject list"}
else
{:ok, activity}
end
end
defp check_media_removal(
%{host: actor_host} = _actor_info,
%{"type" => type, "object" => %{"attachment" => object_attachment}} = activity
)
when length(object_attachment) > 0 and type in ["Create", "Update"] do
media_removal =
instance_list(:media_removal)
|> MRF.subdomains_regex()
activity =
if MRF.subdomain_match?(media_removal, actor_host) do
object = Map.delete(activity["object"], "attachment")
Map.put(activity, "object", object)
else
activity
end
{:ok, activity}
end
defp check_media_removal(_actor_info, activity), do: {:ok, activity}
defp check_media_nsfw(
%{host: actor_host} = _actor_info,
%{
"type" => type,
"object" => %{} = _object
} = activity
)
when type in ["Create", "Update"] do
media_nsfw =
instance_list(:media_nsfw)
|> MRF.subdomains_regex()
activity =
if MRF.subdomain_match?(media_nsfw, actor_host) do
Kernel.put_in(activity, ["object", "sensitive"], true)
else
activity
end
{:ok, activity}
end
defp check_media_nsfw(_actor_info, activity), do: {:ok, activity}
defp check_ftl_removal(%{host: actor_host} = _actor_info, activity) do
timeline_removal =
instance_list(:federated_timeline_removal)
|> MRF.subdomains_regex()
activity =
with true <- MRF.subdomain_match?(timeline_removal, actor_host),
user <- User.get_cached_by_ap_id(activity["actor"]),
true <- Pleroma.Constants.as_public() in activity["to"] do
to = List.delete(activity["to"], Pleroma.Constants.as_public()) ++ [user.follower_address]
cc = List.delete(activity["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()]
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
else
_ -> activity
end
{:ok, activity}
end
defp intersection(list1, list2) do
list1 -- list1 -- list2
end
defp check_followers_only(%{host: actor_host} = _actor_info, activity) do
followers_only =
instance_list(:followers_only)
|> MRF.subdomains_regex()
activity =
with true <- MRF.subdomain_match?(followers_only, actor_host),
user <- User.get_cached_by_ap_id(activity["actor"]) do
# Don't use Map.get/3 intentionally, these must not be nil
fixed_to = activity["to"] || []
fixed_cc = activity["cc"] || []
to = FollowingRelationship.followers_ap_ids(user, fixed_to)
cc = FollowingRelationship.followers_ap_ids(user, fixed_cc)
activity
|> Map.put("to", intersection([user.follower_address | to], fixed_to))
|> Map.put("cc", intersection([user.follower_address | cc], fixed_cc))
else
_ -> activity
end
{:ok, activity}
end
defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = activity) do
report_removal =
instance_list(:report_removal)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(report_removal, actor_host) do
{:reject, "[SimplePolicy] host in report_removal list"}
else
{:ok, activity}
end
end
defp check_report_removal(_actor_info, activity), do: {:ok, activity}
defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = activity) do
avatar_removal =
instance_list(:avatar_removal)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(avatar_removal, actor_host) do
{:ok, Map.delete(activity, "icon")}
else
{:ok, activity}
end
end
defp check_avatar_removal(_actor_info, activity), do: {:ok, activity}
defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = activity) do
banner_removal =
instance_list(:banner_removal)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(banner_removal, actor_host) do
{:ok, Map.delete(activity, "image")}
else
{:ok, activity}
end
end
defp check_banner_removal(_actor_info, activity), do: {:ok, activity}
defp check_object(%{"object" => object} = activity) do
with {:ok, _object} <- filter(object) do
{:ok, activity}
end
end
defp check_object(activity), do: {:ok, activity}
defp instance_list(config_key) do
Config.get([:mrf_simple, config_key])
|> MRF.instance_list_from_tuples()
end
@impl true
def id_filter(id) do
host_info = URI.parse(id)
with {:ok, _} <- check_accept(host_info, %{}),
{:ok, _} <- check_reject(host_info, %{}) do
true
else
_ -> false
end
end
@impl true
def filter(%{"type" => "Delete", "actor" => actor} = activity) do
%{host: actor_host} = URI.parse(actor)
reject_deletes =
instance_list(:reject_deletes)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(reject_deletes, actor_host) do
{:reject, "[SimplePolicy] host in reject_deletes list"}
else
{:ok, activity}
end
end
@impl true
def filter(%{"actor" => actor} = activity) do
actor_info = URI.parse(actor)
with {:ok, activity} <- check_accept(actor_info, activity),
{:ok, activity} <- check_reject(actor_info, activity),
{:ok, activity} <- check_media_removal(actor_info, activity),
{:ok, activity} <- check_media_nsfw(actor_info, activity),
{:ok, activity} <- check_ftl_removal(actor_info, activity),
{:ok, activity} <- check_followers_only(actor_info, activity),
{:ok, activity} <- check_report_removal(actor_info, activity),
{:ok, activity} <- check_object(activity) do
{:ok, activity}
else
{:reject, _} = e -> e
end
end
def filter(%{"id" => actor, "type" => actor_type} = activity)
when actor_type in ["Application", "Group", "Organization", "Person", "Service"] do
actor_info = URI.parse(actor)
with {:ok, activity} <- check_accept(actor_info, activity),
{:ok, activity} <- check_reject(actor_info, activity),
{:ok, activity} <- check_avatar_removal(actor_info, activity),
{:ok, activity} <- check_banner_removal(actor_info, activity) do
{:ok, activity}
else
{:reject, _} = e -> e
end
end
def filter(activity) when is_binary(activity) do
uri = URI.parse(activity)
with {:ok, activity} <- check_accept(uri, activity),
{:ok, activity} <- check_reject(uri, activity) do
{:ok, activity}
else
{:reject, _} = e -> e
end
end
def filter(activity), do: {:ok, activity}
@impl true
def describe do
exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
mrf_simple_excluded =
Config.get(:mrf_simple)
|> Enum.map(fn {rule, instances} ->
{rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)}
end)
mrf_simple =
mrf_simple_excluded
|> Enum.map(fn {rule, instances} ->
{rule, Enum.map(instances, fn {host, _} -> host end)}
end)
|> Map.new()
# 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.
mrf_simple_info =
mrf_simple_excluded
|> Enum.map(fn {rule, instances} ->
{rule, Enum.reject(instances, fn {_, reason} -> reason == "" end)}
end)
|> Enum.reject(fn {_, instances} -> instances == [] end)
|> Enum.map(fn {rule, instances} ->
instances =
instances
|> Enum.map(fn {host, reason} -> {host, %{"reason" => reason}} end)
|> Map.new()
{rule, instances}
end)
|> Map.new()
{:ok, %{mrf_simple: mrf_simple, mrf_simple_info: mrf_simple_info}}
end
@impl true
def config_description do
%{
key: :mrf_simple,
related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy",
label: "MRF Simple",
description: "Simple ingress policies",
children:
[
%{
key: :media_removal,
description:
"List of instances to strip media attachments from and the reason for doing so"
},
%{
key: :media_nsfw,
label: "Media NSFW",
description:
"List of instances to tag all media as NSFW (sensitive) from and the reason for doing so"
},
%{
key: :federated_timeline_removal,
description:
"List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so"
},
%{
key: :reject,
description:
"List of instances to reject activities from (except deletes) and the reason for doing so"
},
%{
key: :accept,
description:
"List of instances to only accept activities from (except deletes) and the reason for doing so"
},
%{
key: :followers_only,
description:
"Force posts from the given instances to be visible by followers only and the reason for doing so"
},
%{
key: :report_removal,
description: "List of instances to reject reports from and the reason for doing so"
},
%{
key: :avatar_removal,
description: "List of instances to strip avatars from and the reason for doing so"
},
%{
key: :banner_removal,
description: "List of instances to strip banners from and the reason for doing so"
},
%{
key: :reject_deletes,
description: "List of instances to reject deletions from and the reason for doing so"
}
]
|> Enum.map(fn setting ->
Map.merge(
setting,
%{
type: {:list, :tuple},
key_placeholder: "instance",
value_placeholder: "reason",
suggestions: [{"example.com", "Some reason"}, {"*.example.com", "Another reason"}]
}
)
end)
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex
index 54f0e6bc1..f1e99aead 100644
--- a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex
@@ -1,162 +1,164 @@
# 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.MRF.StealEmojiPolicy do
+ @moduledoc "Detect new emojis by their shortcode and steals them"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
require Logger
alias Pleroma.Config
- @moduledoc "Detect new emojis by their shortcode and steals them"
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], [])
defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do
shortcode == pattern
end
defp shortcode_matches?(shortcode, pattern) do
String.match?(shortcode, pattern)
end
defp reject_emoji?({shortcode, _url}, installed_emoji) do
valid_shortcode? = String.match?(shortcode, ~r/^[a-zA-Z0-9_-]+$/)
rejected_shortcode? =
[:mrf_steal_emoji, :rejected_shortcodes]
|> Config.get([])
|> Enum.any?(fn pattern -> shortcode_matches?(shortcode, pattern) end)
emoji_installed? = Enum.member?(installed_emoji, shortcode)
!valid_shortcode? or rejected_shortcode? or emoji_installed?
end
defp steal_emoji({shortcode, url}, emoji_dir_path) do
url = Pleroma.Web.MediaProxy.url(url)
with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do
size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000)
if byte_size(response.body) <= size_limit do
extension =
url
|> URI.parse()
|> Map.get(:path)
|> Path.basename()
|> Path.extname()
extension = if extension == "", do: ".png", else: extension
shortcode = Path.basename(shortcode)
file_path = Path.join(emoji_dir_path, shortcode <> extension)
case File.write(file_path, response.body) do
:ok ->
shortcode
e ->
Logger.warning("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}")
nil
end
else
Logger.debug(
"MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)"
)
nil
end
else
e ->
Logger.warning("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}")
nil
end
end
@impl true
def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = activity) do
host = URI.parse(actor).host
if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do
installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end)
emoji_dir_path =
Config.get(
[:mrf_steal_emoji, :path],
Path.join(Config.get([:instance, :static_dir]), "emoji/stolen")
)
Pleroma.Backports.mkdir_p(emoji_dir_path)
new_emojis =
foreign_emojis
|> Enum.reject(&reject_emoji?(&1, installed_emoji))
|> Enum.map(&steal_emoji(&1, emoji_dir_path))
|> Enum.filter(& &1)
if !Enum.empty?(new_emojis) do
Logger.info("Stole new emojis: #{inspect(new_emojis)}")
Pleroma.Emoji.reload()
end
end
{:ok, activity}
end
def filter(activity), do: {:ok, activity}
@impl true
@spec config_description :: %{
children: [
%{
description: <<_::272, _::_*256>>,
key: :hosts | :rejected_shortcodes | :size_limit,
suggestions: [any(), ...],
type: {:list, :string} | {:list, :string} | :integer
},
...
],
description: <<_::448>>,
key: :mrf_steal_emoji,
label: <<_::80>>,
related_policy: <<_::352>>
}
def config_description do
%{
key: :mrf_steal_emoji,
related_policy: "Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy",
label: "MRF Emojis",
description: "Steals emojis from selected instances when it sees them.",
children: [
%{
key: :hosts,
type: {:list, :string},
description: "List of hosts to steal emojis from",
suggestions: [""]
},
%{
key: :rejected_shortcodes,
type: {:list, :string},
description: """
A list of patterns or matches to reject shortcodes with.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/]
},
%{
key: :size_limit,
type: :integer,
description: "File size limit (in bytes), checked before an emoji is saved to the disk",
suggestions: ["100000"]
}
]
}
end
@impl true
def describe do
{:ok, %{}}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex
index 97acca7e8..1fe7b00e9 100644
--- a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex
@@ -1,64 +1,66 @@
# 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.MRF.SubchainPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
alias Pleroma.Config
alias Pleroma.Web.ActivityPub.MRF
require Logger
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
defp lookup_subchain(actor) do
with matches <- Config.get([:mrf_subchain, :match_actor]),
{match, subchain} <- Enum.find(matches, fn {k, _v} -> String.match?(actor, k) end) do
{:ok, match, subchain}
else
_e -> {:error, :notfound}
end
end
@impl true
def filter(%{"actor" => actor} = activity) do
with {:ok, match, subchain} <- lookup_subchain(actor) do
Logger.debug(
"[SubchainPolicy] Matched #{actor} against #{inspect(match)} with subchain #{inspect(subchain)}"
)
MRF.filter(subchain, activity)
else
_e -> {:ok, activity}
end
end
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
@impl true
def config_description do
%{
key: :mrf_subchain,
related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy",
label: "MRF Subchain",
description:
"This policy processes activities through an alternate pipeline when a given activity matches certain criteria." <>
" All criteria are configured as a map of regular expressions to lists of policy modules.",
children: [
%{
key: :match_actor,
type: {:map, {:list, :string}},
description: "Matches a series of regular expressions against the actor field",
suggestions: [
%{
~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy]
}
]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex
index c236a5a99..df5594808 100644
--- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex
@@ -1,163 +1,166 @@
# 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.MRF.TagPolicy do
- alias Pleroma.User
- @behaviour Pleroma.Web.ActivityPub.MRF.Policy
@moduledoc """
Apply policies based on user tags
This policy applies policies on a user activities depending on their tags
on your instance.
- `mrf_tag:media-force-nsfw`: Mark as sensitive on presence of attachments
- `mrf_tag:media-strip`: Remove attachments
- `mrf_tag:force-unlisted`: Mark as unlisted (removes from the federated timeline)
- `mrf_tag:sandbox`: Remove from public (local and federated) timelines
- `mrf_tag:disable-remote-subscription`: Reject non-local follow requests
- `mrf_tag:disable-any-subscription`: Reject any follow requests
"""
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ alias Pleroma.User
+
+ use Pleroma.Web.ActivityPub.MRF.Policy
require Pleroma.Constants
defp get_tags(%User{tags: tags}) when is_list(tags), do: tags
defp get_tags(_), do: []
defp process_tag(
"mrf_tag:media-force-nsfw",
%{
"type" => type,
"object" => %{"attachment" => object_attachment}
} = activity
)
when length(object_attachment) > 0 and type in ["Create", "Update"] do
{:ok, Kernel.put_in(activity, ["object", "sensitive"], true)}
end
defp process_tag(
"mrf_tag:media-strip",
%{
"type" => type,
"object" => %{"attachment" => object_attachment} = object
} = activity
)
when length(object_attachment) > 0 and type in ["Create", "Update"] do
object = Map.delete(object, "attachment")
activity = Map.put(activity, "object", object)
{:ok, activity}
end
defp process_tag(
"mrf_tag:force-unlisted",
%{
"type" => "Create",
"to" => to,
"cc" => cc,
"actor" => actor,
"object" => object
} = activity
) do
user = User.get_cached_by_ap_id(actor)
if Enum.member?(to, Pleroma.Constants.as_public()) do
to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()]
object =
object
|> Map.put("to", to)
|> Map.put("cc", cc)
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Map.put("object", object)
{:ok, activity}
else
{:ok, activity}
end
end
defp process_tag(
"mrf_tag:sandbox",
%{
"type" => "Create",
"to" => to,
"cc" => cc,
"actor" => actor,
"object" => object
} = activity
) do
user = User.get_cached_by_ap_id(actor)
if Enum.member?(to, Pleroma.Constants.as_public()) or
Enum.member?(cc, Pleroma.Constants.as_public()) do
to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
cc = List.delete(cc, Pleroma.Constants.as_public())
object =
object
|> Map.put("to", to)
|> Map.put("cc", cc)
activity =
activity
|> Map.put("to", to)
|> Map.put("cc", cc)
|> Map.put("object", object)
{:ok, activity}
else
{:ok, activity}
end
end
defp process_tag(
"mrf_tag:disable-remote-subscription",
%{"type" => "Follow", "actor" => actor} = activity
) do
user = User.get_cached_by_ap_id(actor)
if user.local == true do
{:ok, activity}
else
{:reject,
"[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-remote-subscription"}
end
end
defp process_tag("mrf_tag:disable-any-subscription", %{"type" => "Follow", "actor" => actor}),
do: {:reject, "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-any-subscription"}
defp process_tag(_, activity), do: {:ok, activity}
def filter_activity(actor, activity) do
User.get_cached_by_ap_id(actor)
|> get_tags()
|> Enum.reduce({:ok, activity}, fn
tag, {:ok, activity} ->
process_tag(tag, activity)
_, error ->
error
end)
end
@impl true
def filter(%{"object" => target_actor, "type" => "Follow"} = activity),
do: filter_activity(target_actor, activity)
@impl true
def filter(%{"actor" => actor, "type" => type} = activity) when type in ["Create", "Update"],
do: filter_activity(actor, activity)
@impl true
def filter(activity), do: {:ok, activity}
@impl true
def describe, do: {:ok, %{}}
end
diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex
index 10cc0e09d..a04ca90bd 100644
--- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex
@@ -1,65 +1,67 @@
# 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.MRF.UserAllowListPolicy do
- alias Pleroma.Config
-
@moduledoc "Accept-list of users from specified instances"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ alias Pleroma.Config
+
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
defp filter_by_list(activity, []), do: {:ok, activity}
defp filter_by_list(%{"actor" => actor} = activity, allow_list) do
if actor in allow_list do
{:ok, activity}
else
{:reject, "[UserAllowListPolicy] #{actor} not in the list"}
end
end
@impl true
def filter(%{"actor" => actor} = activity) do
actor_info = URI.parse(actor)
allow_list =
Config.get(
[:mrf_user_allowlist, actor_info.host],
[]
)
filter_by_list(activity, allow_list)
end
def filter(activity), do: {:ok, activity}
@impl true
def describe do
mrf_user_allowlist =
Config.get([:mrf_user_allowlist], [])
|> Map.new(fn {k, v} -> {k, length(v)} end)
{:ok, %{mrf_user_allowlist: mrf_user_allowlist}}
end
# TODO: change way of getting settings on `lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex:18` to use `hosts` subkey
# @impl true
# def config_description do
# %{
# key: :mrf_user_allowlist,
# related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy",
# description: "Accept-list of users from specified instances",
# children: [
# %{
# key: :hosts,
# type: :map,
# description:
# "The keys in this section are the domain names that the policy should apply to." <>
# " Each key should be assigned a list of users that should be allowed " <>
# "through by their ActivityPub ID",
# suggestions: [%{"example.org" => ["https://example.org/users/admin"]}]
# }
# ]
# }
# end
end
diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex
index 5671e4cf3..ecba3a2c6 100644
--- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex
@@ -1,68 +1,69 @@
# 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.MRF.VocabularyPolicy do
@moduledoc "Filter activities which belong to certain activity vocabularies"
-
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ use Pleroma.Web.ActivityPub.MRF.Policy
+
@impl true
def filter(%{"type" => "Undo", "object" => object} = activity) do
with {:ok, _} <- filter(object) do
{:ok, activity}
else
{:reject, _} = e -> e
end
end
def filter(%{"type" => activity_type} = activity) do
with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]),
rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]),
{_, true} <-
{:accepted,
Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, activity_type)},
{_, false} <-
{:rejected,
length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, activity_type)},
{:ok, _} <- filter(activity["object"]) do
{:ok, activity}
else
{:reject, _} = e -> e
{:accepted, _} -> {:reject, "[VocabularyPolicy] #{activity_type} not in accept list"}
{:rejected, _} -> {:reject, "[VocabularyPolicy] #{activity_type} in reject list"}
end
end
def filter(activity), do: {:ok, activity}
@impl true
def describe,
do: {:ok, %{mrf_vocabulary: Pleroma.Config.get(:mrf_vocabulary) |> Map.new()}}
@impl true
def config_description do
%{
key: :mrf_vocabulary,
related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy",
label: "MRF Vocabulary",
description: "Filter activities which belong to certain activity vocabularies",
children: [
%{
key: :accept,
type: {:list, :string},
description:
"A list of ActivityStreams terms to accept. If empty, all supported activities are accepted.",
suggestions: ["Create", "Follow", "Mention", "Announce", "Like"]
},
%{
key: :reject,
type: {:list, :string},
description:
"A list of ActivityStreams terms to reject. If empty, no activities are rejected.",
suggestions: ["Create", "Follow", "Mention", "Announce", "Like"]
}
]
}
end
end
diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex
index 8847692c8..c273043e1 100644
--- a/lib/pleroma/web/activity_pub/views/user_view.ex
+++ b/lib/pleroma/web/activity_pub/views/user_view.ex
@@ -1,326 +1,326 @@
# 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.UserView do
use Pleroma.Web, :view
alias Pleroma.Keys
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.CollectionViewHelper
alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Router.Helpers
import Ecto.Query
def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do
%{"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox)}
end
def render("endpoints.json", %{user: %User{local: true} = _user}) do
%{
"oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize),
"oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create),
"oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange),
"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox),
"uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media)
}
end
def render("endpoints.json", _), do: %{}
def render("service.json", %{user: user}) do
Map.merge(common_actor_fields(user), %{
"type" => "Application",
"name" => "Pleroma",
"summary" =>
"An internal service actor for this Pleroma instance. No user-serviceable parts inside.",
"manuallyApprovesFollowers" => false,
"invisible" => User.invisible?(user)
})
|> Map.merge(Utils.make_json_ld_header())
end
# the instance itself is not a Person, but instead an Application
def render("user.json", %{user: %User{nickname: nil} = user}),
do: render("service.json", %{user: user})
def render("user.json", %{user: %User{nickname: "internal." <> _} = user}) do
render("service.json", %{user: user})
|> Map.merge(%{
"preferredUsername" => user.nickname,
"webfinger" => "acct:#{User.full_nickname(user)}"
})
end
def render("user.json", %{user: user}) do
user = User.sanitize_html(user)
emoji_tags = Transmogrifier.take_emoji_tags(user)
fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue"))
capabilities =
if is_boolean(user.accepts_chat_messages) do
%{
- "acceptsChatMessages" => user.accepts_chat_messages
+ "acceptsChatMessages" => Pleroma.Chat.enabled?() and user.accepts_chat_messages
}
else
%{}
end
birthday =
if user.show_birthday && user.birthday,
do: Date.to_iso8601(user.birthday),
else: nil
Map.merge(common_actor_fields(user), %{
"featured" => "#{user.ap_id}/collections/featured",
"preferredUsername" => user.nickname,
"attachment" => fields,
"tag" => emoji_tags,
# Note: key name is indeed "discoverable" (not an error)
"discoverable" => user.is_discoverable,
"capabilities" => capabilities,
"alsoKnownAs" => user.also_known_as,
"vcard:bday" => birthday,
"webfinger" => "acct:#{User.full_nickname(user)}",
"published" => Pleroma.Web.CommonAPI.Utils.to_masto_date(user.inserted_at)
})
|> Map.merge(
maybe_make_image(
&User.avatar_url/2,
User.image_description(user.avatar, nil),
"icon",
user
)
)
|> Map.merge(
maybe_make_image(
&User.banner_url/2,
User.image_description(user.banner, nil),
"image",
user
)
)
|> Map.merge(Utils.make_json_ld_header())
end
def render("following.json", %{user: user, page: page} = opts) do
showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows
showing_count = showing_items || !user.hide_follows_count
query = User.get_friends_query(user)
query = from(user in query, select: [:ap_id])
following = Repo.all(query)
total =
if showing_count do
length(following)
else
0
end
CollectionViewHelper.collection_page_offset(
following,
"#{user.ap_id}/following",
page,
showing_items,
total
)
|> Map.merge(Utils.make_json_ld_header())
end
def render("following.json", %{user: user} = opts) do
showing_items = (opts[:for] && opts[:for] == user) || !user.hide_follows
showing_count = showing_items || !user.hide_follows_count
query = User.get_friends_query(user)
query = from(user in query, select: [:ap_id])
following = Repo.all(query)
total =
if showing_count do
length(following)
else
0
end
%{
"id" => "#{user.ap_id}/following",
"type" => "OrderedCollection",
"totalItems" => total,
"first" =>
if showing_items do
CollectionViewHelper.collection_page_offset(
following,
"#{user.ap_id}/following",
1,
!user.hide_follows
)
else
"#{user.ap_id}/following?page=1"
end
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("followers.json", %{user: user, page: page} = opts) do
showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers
showing_count = showing_items || !user.hide_followers_count
query = User.get_followers_query(user)
query = from(user in query, select: [:ap_id])
followers = Repo.all(query)
total =
if showing_count do
length(followers)
else
0
end
CollectionViewHelper.collection_page_offset(
followers,
"#{user.ap_id}/followers",
page,
showing_items,
total
)
|> Map.merge(Utils.make_json_ld_header())
end
def render("followers.json", %{user: user} = opts) do
showing_items = (opts[:for] && opts[:for] == user) || !user.hide_followers
showing_count = showing_items || !user.hide_followers_count
query = User.get_followers_query(user)
query = from(user in query, select: [:ap_id])
followers = Repo.all(query)
total =
if showing_count do
length(followers)
else
0
end
%{
"id" => "#{user.ap_id}/followers",
"type" => "OrderedCollection",
"first" =>
if showing_items do
CollectionViewHelper.collection_page_offset(
followers,
"#{user.ap_id}/followers",
1,
showing_items
)
else
"#{user.ap_id}/followers?page=1"
end
}
|> maybe_put_total_items(showing_count, total)
|> Map.merge(Utils.make_json_ld_header())
end
def render("activity_collection.json", %{iri: iri}) do
%{
"id" => iri,
"type" => "OrderedCollection",
"first" => "#{iri}?page=true"
}
|> Map.merge(Utils.make_json_ld_header())
end
def render("activity_collection_page.json", %{
activities: activities,
pagination: pagination
}) do
collection =
Enum.map(activities, fn activity ->
{:ok, data} = Transmogrifier.prepare_activity(activity.data)
data
end)
CollectionViewHelper.collection_page_keyset(collection, pagination)
end
def render("featured.json", %{
user: %{featured_address: featured_address, pinned_objects: pinned_objects}
}) do
objects =
pinned_objects
|> Enum.sort_by(fn {_, pinned_at} -> pinned_at end, &>=/2)
|> Enum.map(fn {id, _} ->
ObjectView.render("object.json", %{object: Object.get_cached_by_ap_id(id)})
end)
%{
"id" => featured_address,
"type" => "OrderedCollection",
"orderedItems" => objects,
"totalItems" => length(objects)
}
|> Map.merge(Utils.make_json_ld_header())
end
defp common_actor_fields(%User{} = user) do
endpoints = render("endpoints.json", %{user: user})
{:ok, _, public_key} = Keys.keys_from_pem(user.keys)
public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
public_key = :public_key.pem_encode([public_key])
%{
"id" => user.ap_id,
"type" => user.actor_type,
"following" => "#{user.ap_id}/following",
"followers" => "#{user.ap_id}/followers",
"inbox" => "#{user.ap_id}/inbox",
"outbox" => "#{user.ap_id}/outbox",
"name" => user.name,
"summary" => user.bio,
"url" => user.ap_id,
"manuallyApprovesFollowers" => user.is_locked,
"endpoints" => endpoints,
"publicKey" => %{
"id" => "#{user.ap_id}#main-key",
"owner" => user.ap_id,
"publicKeyPem" => public_key
}
}
end
defp maybe_put_total_items(map, false, _total), do: map
defp maybe_put_total_items(map, true, total) do
Map.put(map, "totalItems", total)
end
defp maybe_make_image(func, description, key, user) do
if image = func.(user, no_default: true) do
%{
key =>
%{
"type" => "Image",
"url" => image
}
|> maybe_put_description(description)
}
else
%{}
end
end
defp maybe_put_description(map, description) when is_binary(description) do
Map.put(map, "name", description)
end
defp maybe_put_description(map, _description), do: map
end
diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex
index 3eba03525..753690d45 100644
--- a/lib/pleroma/web/admin_api/controllers/config_controller.ex
+++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex
@@ -1,200 +1,211 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.ConfigController do
use Pleroma.Web, :controller
alias Pleroma.Config
alias Pleroma.ConfigDB
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action == :update)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read"]}
when action in [:show, :descriptions]
)
action_fallback(Pleroma.Web.AdminAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ConfigOperation
defp translate_descriptions(descriptions, path \\ []) do
Enum.map(descriptions, fn desc -> translate_item(desc, path) end)
end
defp translate_string(str, path, type) do
Gettext.dpgettext(
Pleroma.Web.Gettext,
"config_descriptions",
Pleroma.Docs.Translator.Compiler.msgctxt_for(path, type),
str
)
end
defp maybe_put_translated(item, key, path) do
if item[key] do
Map.put(
item,
key,
translate_string(
item[key],
path ++ [Pleroma.Docs.Translator.Compiler.key_for(item)],
to_string(key)
)
)
else
item
end
end
defp translate_item(item, path) do
item
|> maybe_put_translated(:label, path)
|> maybe_put_translated(:description, path)
|> translate_children(path)
end
defp translate_children(%{children: children} = item, path) when is_list(children) do
item
|> Map.put(
:children,
translate_descriptions(children, path ++ [Pleroma.Docs.Translator.Compiler.key_for(item)])
)
end
defp translate_children(item, _path) do
item
end
def descriptions(conn, _params) do
- descriptions = Enum.filter(Pleroma.Docs.JSON.compiled_descriptions(), &whitelisted_config?/1)
+ descriptions =
+ Pleroma.Docs.JSON.compiled_descriptions()
+ |> Enum.filter(&allowed_config?(&1, :whitelist))
+ |> Enum.reject(&allowed_config?(&1, :blacklist))
json(conn, translate_descriptions(descriptions))
end
def show(%{private: %{open_api_spex: %{params: %{only_db: true}}}} = conn, _) do
with :ok <- configurable_from_database() do
configs = Pleroma.Repo.all(ConfigDB)
render(conn, "index.json", %{
configs: configs,
need_reboot: Restarter.Pleroma.need_reboot?()
})
end
end
def show(conn, _params) do
with :ok <- configurable_from_database() do
configs = ConfigDB.get_all_as_keyword()
merged =
Config.Holder.default_config()
|> ConfigDB.merge(configs)
|> Enum.map(fn {group, values} ->
Enum.map(values, fn {key, value} ->
db =
if configs[group][key] do
ConfigDB.get_db_keys(configs[group][key], key)
end
db_value = configs[group][key]
merged_value =
if not is_nil(db_value) and Keyword.keyword?(db_value) and
ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do
ConfigDB.merge_group(group, key, value, db_value)
else
value
end
%ConfigDB{
group: group,
key: key,
value: merged_value
}
|> Pleroma.Maps.put_if_present(:db, db)
end)
end)
|> List.flatten()
render(conn, "index.json", %{
configs: merged,
need_reboot: Restarter.Pleroma.need_reboot?()
})
end
end
def update(%{private: %{open_api_spex: %{body_params: %{configs: configs}}}} = conn, _) do
with :ok <- configurable_from_database() do
results =
configs
- |> Enum.filter(&whitelisted_config?/1)
+ |> Enum.filter(&allowed_config?(&1, :whitelist))
+ |> Enum.reject(&allowed_config?(&1, :blacklist))
|> Enum.map(fn
%{group: group, key: key, delete: true} = params ->
ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]})
%{group: group, key: key, value: value} ->
ConfigDB.update_or_create(%{group: group, key: key, value: value})
end)
|> Enum.reject(fn {result, _} -> result == :error end)
{deleted, updated} =
results
|> Enum.map(fn {:ok, %{key: key, value: value} = config} ->
Map.put(config, :db, ConfigDB.get_db_keys(value, key))
end)
|> Enum.split_with(&(Ecto.get_meta(&1, :state) == :deleted))
Config.TransferTask.load_and_update_env(deleted, false)
if not Restarter.Pleroma.need_reboot?() do
changed_reboot_settings? =
(updated ++ deleted)
|> Enum.any?(&Config.TransferTask.pleroma_need_restart?(&1.group, &1.key, &1.value))
if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot()
end
render(conn, "index.json", %{
configs: updated,
need_reboot: Restarter.Pleroma.need_reboot?()
})
end
end
defp configurable_from_database do
if Config.get(:configurable_from_database) do
:ok
else
{:error, "You must enable configurable_from_database in your config file."}
end
end
- defp whitelisted_config?(":pleroma", ":database_config_whitelist"), do: false
+ defp allowed_config?(":pleroma", ":database_config_whitelist", _), do: true
+ defp allowed_config?(":pleroma", ":database_config_blacklist", _), do: true
- defp whitelisted_config?(group, key) do
- if whitelisted_configs = Config.get(:database_config_whitelist) do
- Enum.any?(whitelisted_configs, fn
- {whitelisted_group} ->
- group == inspect(whitelisted_group)
+ defp allowed_config?(group, key, mode) do
+ configs =
+ case mode do
+ :whitelist -> Config.get(:database_config_whitelist)
+ :blacklist -> Config.get(:database_config_blacklist)
+ end
+
+ if configs do
+ Enum.any?(configs, fn
+ {allowed_group} ->
+ group == inspect(allowed_group)
- {whitelisted_group, whitelisted_key} ->
- group == inspect(whitelisted_group) && key == inspect(whitelisted_key)
+ {allowed_group, allowed_key} ->
+ group == inspect(allowed_group) && key == inspect(allowed_key)
end)
else
true
end
end
- defp whitelisted_config?(%{group: group, key: key}) do
- whitelisted_config?(group, key)
+ defp allowed_config?(%{group: group, key: key}, mode) do
+ allowed_config?(group, key, mode)
end
- defp whitelisted_config?(%{group: group} = config) do
- whitelisted_config?(group, config[:key])
+ defp allowed_config?(%{group: group} = config, mode) do
+ allowed_config?(group, config[:key], mode)
end
end
diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex
index 76c2db57e..7bc39acc6 100644
--- a/lib/pleroma/web/api_spec/operations/chat_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex
@@ -1,433 +1,443 @@
# 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.ChatOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.Chat
alias Pleroma.Web.ApiSpec.Schemas.ChatMessage
import Pleroma.Web.ApiSpec.Helpers
@spec open_api_operation(atom) :: Operation.t()
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def mark_as_read_operation do
%Operation{
tags: ["Chats"],
summary: "Mark all messages in the chat as read",
operationId: "ChatController.mark_as_read",
parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")],
requestBody: request_body("Parameters", mark_as_read()),
responses: %{
200 =>
Operation.response(
"The updated chat",
"application/json",
Chat
- )
+ ),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def mark_message_as_read_operation do
%Operation{
tags: ["Chats"],
summary: "Mark a message as read",
operationId: "ChatController.mark_message_as_read",
parameters: [
Operation.parameter(:id, :path, :string, "The ID of the Chat"),
Operation.parameter(:message_id, :path, :string, "The ID of the message")
],
responses: %{
200 =>
Operation.response(
"The read ChatMessage",
"application/json",
ChatMessage
- )
+ ),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def show_operation do
%Operation{
tags: ["Chats"],
summary: "Retrieve a chat",
operationId: "ChatController.show",
parameters: [
Operation.parameter(
:id,
:path,
:string,
"The id of the chat",
required: true,
example: "1234"
)
],
responses: %{
200 =>
Operation.response(
"The existing chat",
"application/json",
Chat
- )
+ ),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["read"]
}
]
}
end
def create_operation do
%Operation{
tags: ["Chats"],
summary: "Create a chat",
operationId: "ChatController.create",
parameters: [
Operation.parameter(
:id,
:path,
:string,
"The account id of the recipient of this chat",
required: true,
example: "someflakeid"
)
],
responses: %{
200 =>
Operation.response(
"The created or existing chat",
"application/json",
Chat
- )
+ ),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def index_operation do
%Operation{
tags: ["Chats"],
summary: "Retrieve list of chats (unpaginated)",
deprecated: true,
description:
"Deprecated due to no support for pagination. Using [/api/v2/pleroma/chats](#operation/ChatController.index2) instead is recommended.",
operationId: "ChatController.index",
parameters: [
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include chats from muted users"
),
Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats")
],
responses: %{
- 200 => Operation.response("The chats of the user", "application/json", chats_response())
+ 200 => Operation.response("The chats of the user", "application/json", chats_response()),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["read:chats"]
}
]
}
end
def index2_operation do
%Operation{
tags: ["Chats"],
summary: "Retrieve list of chats",
operationId: "ChatController.index2",
parameters: [
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include chats from muted users"
),
Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats")
| pagination_params()
],
responses: %{
- 200 => Operation.response("The chats of the user", "application/json", chats_response())
+ 200 => Operation.response("The chats of the user", "application/json", chats_response()),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["read:chats"]
}
]
}
end
def messages_operation do
%Operation{
tags: ["Chats"],
summary: "Retrieve chat's messages",
operationId: "ChatController.messages",
parameters:
[Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++
pagination_params(),
responses: %{
200 =>
Operation.response(
"The messages in the chat",
"application/json",
chat_messages_response()
),
404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["read:chats"]
}
]
}
end
def post_chat_message_operation do
%Operation{
tags: ["Chats"],
summary: "Post a message to the chat",
operationId: "ChatController.post_chat_message",
parameters: [
Operation.parameter(:id, :path, :string, "The ID of the Chat")
],
requestBody: request_body("Parameters", chat_message_create()),
responses: %{
200 =>
Operation.response(
"The newly created ChatMessage",
"application/json",
ChatMessage
),
400 => Operation.response("Bad Request", "application/json", ApiError),
+ 404 => Operation.response("Not Found", "application/json", ApiError),
422 => Operation.response("MRF Rejection", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def delete_message_operation do
%Operation{
tags: ["Chats"],
summary: "Delete message",
operationId: "ChatController.delete_message",
parameters: [
Operation.parameter(:id, :path, :string, "The ID of the Chat"),
Operation.parameter(:message_id, :path, :string, "The ID of the message")
],
responses: %{
200 =>
Operation.response(
"The deleted ChatMessage",
"application/json",
ChatMessage
- )
+ ),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def pin_operation do
%Operation{
tags: ["Chats"],
summary: "Pin a chat",
operationId: "ChatController.pin",
parameters: [
Operation.parameter(:id, :path, :string, "The id of the chat", required: true)
],
responses: %{
- 200 => Operation.response("The existing chat", "application/json", Chat)
+ 200 => Operation.response("The existing chat", "application/json", Chat),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def unpin_operation do
%Operation{
tags: ["Chats"],
summary: "Unpin a chat",
operationId: "ChatController.unpin",
parameters: [
Operation.parameter(:id, :path, :string, "The id of the chat", required: true)
],
responses: %{
- 200 => Operation.response("The existing chat", "application/json", Chat)
+ 200 => Operation.response("The existing chat", "application/json", Chat),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
},
security: [
%{
"oAuth" => ["write:chats"]
}
]
}
end
def chats_response do
%Schema{
title: "ChatsResponse",
description: "Response schema for multiple Chats",
type: :array,
items: Chat,
example: [
%{
"account" => %{
"pleroma" => %{
"is_admin" => false,
"is_confirmed" => true,
"hide_followers_count" => false,
"is_moderator" => false,
"hide_favorites" => true,
"ap_id" => "https://dontbulling.me/users/lain",
"hide_follows_count" => false,
"hide_follows" => false,
"background_image" => nil,
"skip_thread_containment" => false,
"hide_followers" => false,
"relationship" => %{},
"tags" => []
},
"avatar" =>
"https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
"following_count" => 0,
"header_static" => "https://originalpatchou.li/images/banner.png",
"source" => %{
"sensitive" => false,
"note" => "lain",
"pleroma" => %{
"discoverable" => false,
"actor_type" => "Person"
},
"fields" => []
},
"statuses_count" => 1,
"locked" => false,
"created_at" => "2020-04-16T13:40:15.000Z",
"display_name" => "lain",
"fields" => [],
"acct" => "lain@dontbulling.me",
"id" => "9u6Qw6TAZANpqokMkK",
"emojis" => [],
"avatar_static" =>
"https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg",
"username" => "lain",
"followers_count" => 0,
"header" => "https://originalpatchou.li/images/banner.png",
"bot" => false,
"note" => "lain",
"url" => "https://dontbulling.me/users/lain"
},
"id" => "1",
"unread" => 2
}
]
}
end
def chat_messages_response do
%Schema{
title: "ChatMessagesResponse",
description: "Response schema for multiple ChatMessages",
type: :array,
items: ChatMessage,
example: [
%{
"emojis" => [
%{
"static_url" => "https://dontbulling.me/emoji/Firefox.gif",
"visible_in_picker" => false,
"shortcode" => "firefox",
"url" => "https://dontbulling.me/emoji/Firefox.gif"
}
],
"created_at" => "2020-04-21T15:11:46.000Z",
"content" => "Check this out :firefox:",
"id" => "13",
"chat_id" => "1",
"account_id" => "someflakeid",
"unread" => false
},
%{
"account_id" => "someflakeid",
"content" => "Whats' up?",
"id" => "12",
"chat_id" => "1",
"emojis" => [],
"created_at" => "2020-04-21T15:06:45.000Z",
"unread" => false
}
]
}
end
def chat_message_create do
%Schema{
title: "ChatMessageCreateRequest",
description: "POST body for creating an chat message",
type: :object,
properties: %{
content: %Schema{
type: :string,
description: "The content of your message. Optional if media_id is present"
},
media_id: %Schema{type: :string, description: "The id of an upload"}
},
example: %{
"content" => "Hey wanna buy feet pics?",
"media_id" => "134234"
}
}
end
def mark_as_read do
%Schema{
title: "MarkAsReadRequest",
description: "POST body for marking a number of chat messages as read",
type: :object,
required: [:last_read_id],
properties: %{
last_read_id: %Schema{
type: :string,
description: "The content of your message."
}
},
example: %{
"last_read_id" => "abcdef12456"
}
}
end
end
diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex
index 07b33f866..8552f5d0a 100644
--- a/lib/pleroma/web/endpoint.ex
+++ b/lib/pleroma/web/endpoint.ex
@@ -1,206 +1,208 @@
# 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
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/"}
)
+ plug(Pleroma.Web.Plugs.StaticNotFoundPlug)
+
# 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 =
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/federator.ex b/lib/pleroma/web/federator.ex
index 90cd2e54a..88b267331 100644
--- a/lib/pleroma/web/federator.ex
+++ b/lib/pleroma/web/federator.ex
@@ -1,145 +1,146 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Federator do
alias Pleroma.Activity
alias Pleroma.Object.Containment
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Publisher
alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Workers.PublisherWorker
alias Pleroma.Workers.ReceiverWorker
alias Pleroma.Workers.SignatureRetryWorker
require Logger
@behaviour Pleroma.Web.Federator.Publishing
@doc """
Returns `true` if the distance to target object does not exceed max configured value.
Serves to prevent fetching of very long threads, especially useful on smaller instances.
Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161).
Applies to fetching of both ancestor (reply-to) and child (reply) objects.
"""
# credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength
def allowed_thread_distance?(distance) do
max_distance = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth])
if max_distance && max_distance >= 0 do
# Default depth is 0 (an object has zero distance from itself in its thread)
(distance || 0) <= max_distance
else
true
end
end
# Client API
def incoming_failed_signature_ap_doc(%{
method: method,
params: params,
req_headers: req_headers,
request_path: request_path,
query_string: query_string
}) do
SignatureRetryWorker.new(
%{
"op" => "incoming_failed_signature_ap_doc",
"method" => method,
"req_headers" => req_headers,
"params" => params,
"request_path" => request_path,
"query_string" => query_string,
"timeout" => :timer.seconds(20)
},
priority: 2
)
|> Oban.insert()
end
def incoming_ap_doc(%{"type" => "Delete"} = params) do
ReceiverWorker.new(%{"op" => "incoming_ap_doc", "params" => params},
priority: 3,
queue: :slow
)
|> Oban.insert()
end
def incoming_ap_doc(params) do
ReceiverWorker.new(%{"op" => "incoming_ap_doc", "params" => params})
|> Oban.insert()
end
@impl true
def publish(%{id: "pleroma:fakeid"} = activity) do
perform(:publish, activity)
end
@impl true
def publish(%Pleroma.Activity{data: %{"type" => type}} = activity) do
PublisherWorker.new(%{"op" => "publish", "activity_id" => activity.id},
priority: publish_priority(type)
)
|> Oban.insert()
end
defp publish_priority("Delete"), do: 3
defp publish_priority(_), do: 0
# Job Worker Callbacks
- @spec perform(atom(), any()) :: {:ok, any()} | {:error, any()}
+ @impl true
+ @spec perform(atom(), any()) :: :ok | {:ok, any()} | {:error, any()}
def perform(:publish_one, params) do
Publisher.prepare_one(params)
|> Publisher.publish_one()
end
def perform(:publish, activity) do
Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end)
%User{} = actor = User.get_cached_by_ap_id(activity.data["actor"])
Publisher.publish(actor, activity)
end
def perform(:incoming_ap_doc, params) do
Logger.debug("Handling incoming AP activity")
actor =
params
|> Map.get("actor")
|> Utils.get_ap_id()
# NOTE: we use the actor ID to do the containment, this is fine because an
# actor shouldn't be acting on objects outside their own AP server.
with {_, {:ok, user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)},
{:user_active, true} <- {:user_active, match?(true, user.is_active)},
nil <- Activity.normalize(params["id"]),
{_, :ok} <-
{:correct_origin?, Containment.contain_origin_from_id(actor, params)},
{:ok, activity} <- Transmogrifier.handle_incoming(params) do
{:ok, activity}
else
{:correct_origin?, _} ->
Logger.debug("Origin containment failure for #{params["id"]}")
{:error, :origin_containment_failed}
%Activity{} ->
Logger.debug("Already had #{params["id"]}")
{:error, :already_present}
{:actor, e} ->
Logger.debug("Unhandled actor #{actor}, #{inspect(e)}")
{:error, e}
{:reject, reason} = e ->
Logger.debug("Rejected by MRF: #{inspect(reason)}")
{:error, e}
e ->
# Just drop those for now
Logger.debug(fn -> "Unhandled activity\n" <> Jason.encode!(params, pretty: true) end)
{:error, e}
end
end
end
diff --git a/lib/pleroma/web/federator/publishing.ex b/lib/pleroma/web/federator/publishing.ex
index 3a242b8dc..889b6b83e 100644
--- a/lib/pleroma/web/federator/publishing.ex
+++ b/lib/pleroma/web/federator/publishing.ex
@@ -1,7 +1,8 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Federator.Publishing do
+ @callback perform(atom(), any()) :: :ok | {:ok, any()} | {:error, any()}
@callback publish(map()) :: any()
end
diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex
index e1ee33d62..b409b1923 100644
--- a/lib/pleroma/web/feed/feed_view.ex
+++ b/lib/pleroma/web/feed/feed_view.ex
@@ -1,190 +1,353 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Feed.FeedView do
use Phoenix.HTML
use Pleroma.Web, :view
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.Gettext
alias Pleroma.Web.MediaProxy
require Pleroma.Constants
@days ~w(Mon Tue Wed Thu Fri Sat Sun)
@months ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)
def prepare_activity(activity, opts \\ []) do
object = Object.normalize(activity, fetch: false)
actor =
if opts[:actor] do
Pleroma.User.get_cached_by_ap_id(activity.actor)
end
%{
activity: activity,
object: object,
data: Map.get(object, :data),
actor: actor
}
end
def most_recent_update(activities) do
with %{updated_at: updated_at} <- List.first(activities) do
to_rfc3339(updated_at)
end
end
+ def most_recent_update([], :rss), do: nil
+
+ def most_recent_update(activities, :rss) do
+ activities
+ |> Enum.map(fn activity ->
+ case activity.object do
+ %Object{updated_at: updated_at} -> updated_at
+ _ -> activity.updated_at
+ end
+ end)
+ |> Enum.max_by(&NaiveDateTime.to_erl/1)
+ |> to_rfc2822()
+ end
+
def most_recent_update(activities, user, :atom) do
(List.first(activities) || user).updated_at
|> to_rfc3339()
end
def most_recent_update(activities, user, :rss) do
(List.first(activities) || user).updated_at
|> to_rfc2822()
end
def feed_logo do
case Pleroma.Config.get([:feed, :logo]) do
nil ->
"#{Pleroma.Web.Endpoint.url()}/static/logo.svg"
logo ->
"#{Pleroma.Web.Endpoint.url()}#{logo}"
end
|> MediaProxy.url()
end
def email(user) do
user.nickname <> "@" <> Pleroma.Web.Endpoint.host()
end
def logo(user) do
user
|> User.avatar_url()
|> MediaProxy.url()
end
def last_activity(activities), do: List.last(activities)
def activity_title(%{"content" => content} = data, opts \\ %{}) do
summary = Map.get(data, "summary", "")
title =
cond do
summary != "" -> summary
content != "" -> activity_content(data)
true -> "a post"
end
title
|> Pleroma.Web.Metadata.Utils.scrub_html_and_truncate(opts[:max_length], opts[:omission])
|> HtmlEntities.encode()
end
def activity_description(data) do
content = activity_content(data)
summary = data["summary"]
cond do
content != "" -> escape(content)
summary != "" -> escape(summary)
true -> escape(data["type"])
end
end
+ def rss_content_encoded(data) do
+ data
+ |> feed_content_encoded()
+ |> String.replace("]]>", "]]&gt;")
+ end
+
+ def atom_content_encoded(data), do: feed_content_encoded(data)
+
+ defp feed_content_encoded(data) do
+ base_content =
+ case activity_content(data) do
+ "" ->
+ (data["summary"] || data["type"] || "")
+ |> escape()
+
+ content ->
+ content
+ end
+
+ attachments_html =
+ if sensitive?(data) do
+ ""
+ else
+ (data["attachment"] || [])
+ |> Enum.map(&rss_attachment_preview/1)
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.join("<br/><br/>")
+ end
+
+ [base_content, attachments_html]
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.join("<br/><br/>")
+ end
+
+ defp rss_attachment_preview(attachment) do
+ href = attachment_href(attachment)
+
+ if is_binary(href) and href != "" do
+ media_type = attachment_type(attachment) || "application/octet-stream"
+ escaped_href = escape(href)
+ name = attachment["name"] || "Attachment"
+ escaped_name = escape(name)
+
+ cond do
+ String.starts_with?(media_type, "image/") ->
+ ~s(<p><img src="#{escaped_href}" alt="#{escaped_name}" loading="lazy"/></p>)
+
+ String.starts_with?(media_type, "video/") ->
+ ~s(<p><video controls preload="metadata" src="#{escaped_href}"></video></p><p><a href="#{escaped_href}">#{escaped_name}</a></p>)
+
+ String.starts_with?(media_type, "audio/") ->
+ ~s(<p><audio controls preload="metadata" src="#{escaped_href}"></audio></p><p><a href="#{escaped_href}">#{escaped_name}</a></p>)
+
+ true ->
+ ~s(<p><a href="#{escaped_href}">#{escaped_name}</a></p>)
+ end
+ else
+ ""
+ end
+ end
+
def activity_content(%{"content" => content}) do
content
|> String.replace(~r/[\n\r]/, "")
end
def activity_content(_), do: ""
def activity_context(activity), do: escape(activity.data["context"])
+ def sensitive?(data), do: data["sensitive"] in [true, "true"]
+
+ def feed_self_url(conn) do
+ conn
+ |> Phoenix.Controller.current_url()
+ |> escape()
+ end
+
+ def activity_link(activity, data) do
+ cond do
+ activity.local -> data["id"] || data["url"]
+ true -> data["external_url"] || data["id"] || data["url"]
+ end
+ end
+
def attachment_href(attachment) do
- attachment["url"]
- |> hd()
+ attachment
+ |> attachment_url_data()
|> Map.get("href")
end
def attachment_type(attachment) do
- attachment["url"]
- |> hd()
- |> Map.get("mediaType")
+ attachment
+ |> attachment_url_data()
+ |> Map.get("mediaType") || attachment["mediaType"]
+ end
+
+ def attachment_size(attachment) do
+ attachment
+ |> attachment_url_data()
+ |> Map.get("size", 0)
+ end
+
+ def attachment_size_positive(attachment) do
+ case attachment_size(attachment) do
+ size when is_integer(size) and size > 0 ->
+ size
+
+ size when is_binary(size) ->
+ case Integer.parse(size) do
+ {parsed, ""} when parsed > 0 -> parsed
+ _ -> nil
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ def attachment_medium(attachment) do
+ case attachment_type(attachment) do
+ "image/" <> _rest -> "image"
+ "video/" <> _rest -> "video"
+ "audio/" <> _rest -> "audio"
+ _ -> "document"
+ end
+ end
+
+ def rss_enclosure_attachment(attachments) do
+ Enum.find(attachments || [], &(not is_nil(attachment_size_positive(&1))))
+ end
+
+ def attachment_description(attachment) do
+ attachment["name"] || ""
+ end
+
+ def media_content_xml(_attachment, sensitive) when sensitive in [true, "true"], do: ""
+
+ def media_content_xml(attachment, _sensitive) do
+ href = escape(attachment_href(attachment) || "")
+ type = escape(attachment_type(attachment) || "application/octet-stream")
+ medium = escape(attachment_medium(attachment))
+
+ file_size_attr =
+ case attachment_size_positive(attachment) do
+ size when is_integer(size) -> ~s( fileSize="#{size}")
+ _ -> ""
+ end
+
+ case attachment_description(attachment) do
+ "" ->
+ ~s(<media:content url="#{href}" type="#{type}"#{file_size_attr} medium="#{medium}"/>\n)
+
+ description ->
+ """
+ <media:content url="#{href}" type="#{type}"#{file_size_attr} medium="#{medium}">
+ <media:description type="plain">#{escape(description)}</media:description>
+ </media:content>
+ """
+ end
+ end
+
+ defp attachment_url_data(attachment) do
+ case attachment["url"] do
+ [first | _] when is_map(first) -> first
+ %{} = map -> map
+ _ -> %{}
+ end
end
def get_href(id) do
with %Object{data: %{"external_url" => external_url}} <- Object.get_cached_by_ap_id(id) do
external_url
else
_e -> id
end
end
def escape(html) do
html
|> html_escape()
|> safe_to_string()
end
@spec to_rfc3339(String.t() | NaiveDateTime.t()) :: String.t()
def to_rfc3339(date) when is_binary(date) do
date
|> Timex.parse!("{ISO:Extended}")
|> to_rfc3339()
end
def to_rfc3339(nd) do
nd
|> Timex.to_datetime()
|> Timex.format!("{RFC3339}")
end
@spec to_rfc2822(String.t() | DateTime.t() | NaiveDateTime.t()) :: String.t()
def to_rfc2822(datestr) when is_binary(datestr) do
datestr
|> Timex.parse!("{ISO:Extended}")
|> to_rfc2822()
end
def to_rfc2822(%DateTime{} = date) do
date
|> DateTime.to_naive()
|> NaiveDateTime.to_erl()
|> rfc2822_from_erl()
end
def to_rfc2822(nd) do
nd
|> Timex.to_datetime()
|> DateTime.to_naive()
|> NaiveDateTime.to_erl()
|> rfc2822_from_erl()
end
@doc """
Builds a RFC2822 timestamp from an Erlang timestamp
[RFC2822 3.3 - Date and Time Specification](https://tools.ietf.org/html/rfc2822#section-3.3)
This function always assumes the Erlang timestamp is in Universal time, not Local time
"""
def rfc2822_from_erl({{year, month, day} = date, {hour, minute, second}}) do
day_name = Enum.at(@days, :calendar.day_of_the_week(date) - 1)
month_name = Enum.at(@months, month - 1)
date_part = "#{day_name}, #{day} #{month_name} #{year}"
time_part = "#{pad(hour)}:#{pad(minute)}:#{pad(second)}"
date_part <> " " <> time_part <> " +0000"
end
defp pad(num) do
num
|> Integer.to_string()
|> String.pad_leading(2, "0")
end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
index 26e38aac8..6cd7469f9 100644
--- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
@@ -1,729 +1,732 @@
# 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.StatusController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [try_render: 3, add_link_headers: 2]
require Ecto.Query
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.Bookmark
alias Pleroma.BookmarkFolder
alias Pleroma.Language.Translation
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.ScheduledActivity
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.ScheduledActivityView
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.RateLimiter
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(:skip_public_check when action in [:index, :show])
@unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
plug(
OAuthScopesPlug,
%{@unauthenticated_access | scopes: ["read:statuses"]}
when action in [
:index,
:show,
:context,
:show_history,
:show_source,
:quotes
]
)
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :translate)
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"]}
when action in [
:create,
:delete,
:reblog,
:unreblog,
:update
]
)
plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites)
plug(
OAuthScopesPlug,
%{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation]
)
plug(
OAuthScopesPlug,
%{@unauthenticated_access | scopes: ["read:accounts"]}
when action in [:favourited_by, :reblogged_by]
)
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin])
plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks)
plug(
OAuthScopesPlug,
%{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark]
)
@rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete translate)a
plug(
RateLimiter,
[name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: [:id]]
when action in ~w(reblog unreblog)a
)
plug(
RateLimiter,
[name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]]
when action in ~w(favourite unfavourite)a
)
plug(RateLimiter, [name: :statuses_actions] when action in @rate_limited_status_actions)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.StatusOperation
@doc """
GET `/api/v1/statuses?ids[]=1&ids[]=2`
`ids` query param is required
"""
def index(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} =
conn,
_
) do
ids = Map.get(params, :id, Map.get(params, :ids))
limit = 100
activities =
ids
|> Enum.take(limit)
|> Activity.all_by_ids_with_object()
|> Enum.filter(&Visibility.visible_for_user?(&1, user))
render(conn, "index.json",
activities: activities,
for: user,
as: :activity,
with_muted: Map.get(params, :with_muted, false)
)
end
@doc """
POST /api/v1/statuses
"""
# Creates a scheduled status when `scheduled_at` param is present and it's far enough
def create(
%{
assigns: %{user: user},
private: %{
open_api_spex: %{body_params: %{status: _, scheduled_at: scheduled_at} = params}
}
} = conn,
_
)
when not is_nil(scheduled_at) do
params =
Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id])
|> put_application(conn)
attrs = %{
params: Map.new(params, fn {key, value} -> {to_string(key), value} end),
scheduled_at: scheduled_at
}
with {:far_enough, true} <- {:far_enough, ScheduledActivity.far_enough?(scheduled_at)},
{:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do
conn
|> put_view(ScheduledActivityView)
|> render("show.json", scheduled_activity: scheduled_activity)
else
{:far_enough, _} ->
params = Map.drop(params, [:scheduled_at])
put_in(
conn,
[Access.key(:private), Access.key(:open_api_spex), Access.key(:body_params)],
params
)
|> do_create
error ->
error
end
end
# Creates a regular status
def create(
%{
private: %{open_api_spex: %{body_params: %{status: _}}}
} = conn,
_
) do
do_create(conn)
end
def create(
%{
assigns: %{user: _user},
private: %{open_api_spex: %{body_params: %{media_ids: _} = params}}
} = conn,
_
) do
params = Map.put(params, :status, "")
put_in(
conn,
[Access.key(:private), Access.key(:open_api_spex), Access.key(:body_params)],
params
)
|> do_create
end
defp do_create(
%{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn
) do
params =
Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id])
|> put_application(conn)
with {:ok, activity} <- CommonAPI.post(user, params) do
try_render(conn, "show.json",
activity: activity,
for: user,
as: :activity,
with_direct_conversation_id: true
)
else
{:error, {:reject, message}} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: message})
{:error, message} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: message})
end
end
@doc "GET /api/v1/statuses/:id/history"
def show_history(
%{assigns: assigns, private: %{open_api_spex: %{params: %{id: id} = params}}} = conn,
_
) do
with user = assigns[:user],
%Activity{} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
try_render(conn, "history.json",
activity: activity,
for: user,
with_direct_conversation_id: true,
with_muted: Map.get(params, :with_muted, false)
)
else
_ -> {:error, :not_found}
end
end
@doc "GET /api/v1/statuses/:id/source"
def show_source(%{assigns: assigns, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with user = assigns[:user],
%Activity{} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
try_render(conn, "source.json",
activity: activity,
for: user
)
else
_ -> {:error, :not_found}
end
end
@doc "PUT /api/v1/statuses/:id"
def update(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: body_params, params: %{id: id} = params}}
} = conn,
_
) do
with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
{_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
{_, true} <- {:is_create, activity.data["type"] == "Create"},
actor <- Activity.user_actor(activity),
{_, true} <- {:own_status, actor.id == user.id},
changes <- body_params |> put_application(conn),
{_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(activity, user, changes)},
{_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
try_render(conn, "show.json",
activity: activity,
for: user,
with_direct_conversation_id: true,
with_muted: Map.get(params, :with_muted, false)
)
else
{:own_status, _} -> {:error, :forbidden}
{:pipeline, _} -> {:error, :internal_server_error}
_ -> {:error, :not_found}
end
end
@doc "GET /api/v1/statuses/:id"
def show(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} =
conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
try_render(conn, "show.json",
activity: activity,
for: user,
with_direct_conversation_id: true,
with_muted: Map.get(params, :with_muted, false)
)
else
_ -> {:error, :not_found}
end
end
@doc "DELETE /api/v1/statuses/:id"
def delete(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
# CommonAPI already checks whether user is allowed to delete
{:ok, %Activity{}} <- CommonAPI.delete(id, user) do
try_render(conn, "show.json",
activity: activity,
for: user,
with_direct_conversation_id: true,
with_source: true
)
else
_e -> {:error, :not_found}
end
end
@doc "POST /api/v1/statuses/:id/reblog"
def reblog(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: params, params: %{id: ap_id_or_id}}}
} = conn,
_
) do
with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params),
# CommonAPI already checks whether user is allowed to reblog
%Activity{} = announce <- Activity.normalize(announce.data) do
try_render(conn, "show.json", %{activity: announce, for: user, as: :activity})
end
end
@doc "POST /api/v1/statuses/:id/unreblog"
def unreblog(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} =
conn,
_
) do
with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user),
%Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", %{activity: activity, for: user, as: :activity})
end
end
@doc "POST /api/v1/statuses/:id/favourite"
def favourite(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} =
conn,
_
) do
with {:ok, _fav} <- CommonAPI.favorite(activity_id, user),
# CommonAPI already checks whether user is allowed to reblog
%Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end
end
@doc "POST /api/v1/statuses/:id/unfavourite"
def unfavourite(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} =
conn,
_
) do
with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user),
%Activity{} = activity <- Activity.get_by_id(activity_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end
end
@doc "POST /api/v1/statuses/:id/pin"
def pin(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: ap_id_or_id}}}} =
conn,
_
) do
with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
# Order matters, if status is not owned by user and is not visible to user
# return 404 just like other endpoints
{:error, :pinned_statuses_limit_reached} ->
{:error, "You have already pinned the maximum number of statuses"}
{:error, :ownership_error} ->
{:error, :unprocessable_entity, "Someone else's status cannot be pinned"}
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
{:error, :non_public_error} ->
{:error, :unprocessable_entity, "Non-public status cannot be pinned"}
error ->
error
end
end
@doc "POST /api/v1/statuses/:id/unpin"
def unpin(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: ap_id_or_id}}}} =
conn,
_
) do
# CommonAPI already checks whether user can unpin
with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
# Order matters, if status is not owned by user and is not visible to user
# return 404 just like other endpoints
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
{:error, :ownership_error} ->
{:error, :unprocessable_entity, "Someone else's status cannot be unpinned"}
error ->
error
end
end
@doc "POST /api/v1/statuses/:id/bookmark"
def bookmark(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: body_params, params: %{id: id}}}
} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
%User{} = user <- User.get_cached_by_nickname(user.nickname),
true <- Visibility.visible_for_user?(activity, user),
folder_id <- Map.get(body_params, :folder_id, nil),
folder_id <-
if(folder_id && BookmarkFolder.belongs_to_user?(folder_id, user.id),
do: folder_id,
else: nil
),
{:ok, _bookmark} <- Bookmark.create(user.id, activity.id, folder_id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
false ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@doc "POST /api/v1/statuses/:id/unbookmark"
def unbookmark(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
%User{} = user <- User.get_cached_by_nickname(user.nickname),
true <- Visibility.visible_for_user?(activity, user),
{:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
false ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@doc "POST /api/v1/statuses/:id/mute"
def mute_conversation(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: params, params: %{id: id}}}
} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id(id),
# CommonAPI already checks whether user is allowed to mute
{:ok, activity} <- CommonAPI.add_mute(activity, user, params) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@doc "POST /api/v1/statuses/:id/unmute"
def unmute_conversation(
%{
assigns: %{user: user},
private: %{open_api_spex: %{params: %{id: id}}}
} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id(id),
# CommonAPI already checks whether user is allowed to unmute
{:ok, activity} <- CommonAPI.remove_mute(activity, user) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity)
else
{:error, :visibility_error} ->
{:error, :not_found, "Record not found"}
error ->
error
end
end
@doc "GET /api/v1/statuses/:id/favourited_by"
def favourited_by(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
_
) do
with true <- Pleroma.Config.get([:instance, :show_reactions]),
%Activity{} = activity <- Activity.get_by_id_with_object(id),
{:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
%Object{data: %{"likes" => likes}} <- Object.normalize(activity, fetch: false) do
users =
User
|> Ecto.Query.where([u], u.ap_id in ^likes)
|> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^likes, u.ap_id))
|> Repo.all()
|> Enum.filter(&(not User.blocks?(user, &1)))
conn
|> put_view(AccountView)
|> render("index.json", for: user, users: users, as: :user)
else
{:visible, false} -> {:error, :not_found}
_ -> json(conn, [])
end
end
@doc "GET /api/v1/statuses/:id/reblogged_by"
def reblogged_by(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
{:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
%Object{data: %{"announcements" => announces, "id" => ap_id}} <-
Object.normalize(activity, fetch: false) do
announces =
"Announce"
|> Activity.Queries.by_type()
|> Ecto.Query.where([a], a.actor in ^announces)
# this is to use the index
|> Activity.Queries.by_object_id(ap_id)
|> Repo.all()
|> Enum.filter(&Visibility.visible_for_user?(&1, user))
|> Enum.map(& &1.actor)
|> Enum.uniq()
users =
User
|> Ecto.Query.where([u], u.ap_id in ^announces)
|> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^announces, u.ap_id))
|> Repo.all()
|> Enum.filter(&(not User.blocks?(user, &1)))
conn
|> put_view(AccountView)
|> render("index.json", for: user, users: users, as: :user)
else
{:visible, false} -> {:error, :not_found}
_ -> json(conn, [])
end
end
@doc "GET /api/v1/statuses/:id/context"
def context(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
_
) do
with %Activity{} = activity <- Activity.get_by_id(id) do
activities =
ActivityPub.fetch_activities_for_context(activity.data["context"], %{
blocking_user: user,
user: user,
exclude_id: activity.id
})
render(conn, "context.json", activity: activity, activities: activities, user: user)
end
end
@doc "POST /api/v1/statuses/:id/translate"
def translate(
%{
assigns: %{user: user},
private: %{open_api_spex: %{body_params: params, params: %{id: status_id}}}
} = conn,
_
) do
with %Activity{object: object} <- Activity.get_by_id_with_object(status_id),
{:visibility, visibility} when visibility in ["public", "unlisted"] <-
{:visibility, Visibility.get_visibility(object)},
{:language, language} when is_binary(language) <-
{:language, Map.get(params, :lang) || user.language},
{:ok, result} <-
Translation.translate(
object.data["content"],
object.data["language"],
language
) do
render(conn, "translation.json", result)
else
{:language, nil} ->
render_error(conn, :bad_request, "Language not specified")
{:visibility, _} ->
render_error(conn, :not_found, "Record not found")
{:error, :not_found} ->
render_error(conn, :not_found, "Translation service not configured")
{:error, error} when error in [:unexpected_response, :quota_exceeded, :too_many_requests] ->
render_error(conn, :service_unavailable, "Translation service not available")
_ ->
render_error(conn, :internal_server_error, "Translation failed")
end
end
@doc "GET /api/v1/favourites"
def favourites(
%{assigns: %{user: %User{} = user}, private: %{open_api_spex: %{params: params}}} = conn,
_
) do
activities = ActivityPub.fetch_favourites(user, params)
conn
|> add_link_headers(activities)
|> render("index.json",
activities: activities,
for: user,
as: :activity
)
end
@doc "GET /api/v1/bookmarks"
def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do
user = User.get_cached_by_id(user.id)
folder_id = Map.get(params, :folder_id)
bookmarks =
user.id
|> Bookmark.for_user_query(folder_id)
|> Pleroma.Pagination.fetch_paginated(params)
activities =
bookmarks
|> Enum.map(fn b -> Map.put(b.activity, :bookmark, Map.delete(b, :activity)) end)
conn
|> add_link_headers(bookmarks)
|> render("index.json",
activities: activities,
for: user,
as: :activity
)
end
@doc "GET /api/v1/statuses/:id/quotes"
def quotes(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} =
conn,
_
) do
with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id),
true <- Visibility.visible_for_user?(activity, user) do
params =
params
|> Map.put(:type, "Create")
|> Map.put(:blocking_user, user)
|> Map.put(:quote_url, object.data["id"])
recipients =
if user do
[Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)]
else
[Pleroma.Constants.as_public()]
end
activities =
recipients
|> ActivityPub.fetch_activities(params)
|> Enum.reverse()
conn
|> add_link_headers(activities)
|> render("index.json",
activities: activities,
for: user,
as: :activity
)
else
nil -> {:error, :not_found}
false -> {:error, :not_found}
end
end
- defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do
+ defp put_application(
+ params,
+ %{assigns: %{token: %Token{} = token, user: %User{} = user}} = _conn
+ ) do
if user.disclose_client do
%{client_name: client_name, website: website} = Repo.preload(token, :app).app
Map.put(params, :generator, %{type: "Application", name: client_name, url: website})
else
Map.put(params, :generator, nil)
end
end
defp put_application(params, _), do: Map.put(params, :generator, nil)
end
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex
index d6f2dd3a3..a65326226 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex
@@ -1,454 +1,463 @@
# 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
user
|> notifications_query(params)
|> Pagination.fetch_paginated(params)
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)
end
defp notification_options(user, params) do
options =
params
|> cast_params()
|> Map.update(:include_types, [], fn include_types -> include_types end)
+ if "pleroma:chat_mention" in options.include_types and not Pleroma.Chat.enabled?() do
+ options
+ |> Map.update(:include_types, [], fn include_types ->
+ List.delete(include_types, "pleroma:chat_mention")
+ end)
+ else
+ options
+ 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/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex
index 5386c5a6c..1e3882fc6 100644
--- a/lib/pleroma/web/mastodon_api/views/account_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/account_view.ex
@@ -1,560 +1,563 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.AccountView do
use Pleroma.Web, :view
alias Pleroma.FollowingRelationship
alias Pleroma.User
alias Pleroma.UserNote
alias Pleroma.UserRelationship
alias Pleroma.Utils.URIEncoding
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MediaProxy
def render("index.json", %{users: users} = opts) do
reading_user = opts[:for]
relationships_opt =
cond do
Map.has_key?(opts, :relationships) ->
opts[:relationships]
is_nil(reading_user) || !opts[:embed_relationships] ->
UserRelationship.view_relationships_option(nil, [])
true ->
UserRelationship.view_relationships_option(reading_user, users)
end
opts =
opts
|> Map.merge(%{relationships: relationships_opt, as: :user})
|> Map.delete(:users)
users
|> render_many(AccountView, "show.json", opts)
|> Enum.filter(&Enum.any?/1)
end
@doc """
Renders specified user account.
:skip_visibility_check option skips visibility check and renders any user (local or remote)
regardless of [:pleroma, :restrict_unauthenticated] setting.
:for option specifies the requester and can be a User record or nil.
Only use `user: user, for: user` when `user` is the actual requester of own profile.
"""
def render("show.json", %{user: _user, skip_visibility_check: true} = opts) do
do_render("show.json", opts)
end
def render("show.json", %{user: user, for: for_user_or_nil} = opts) do
if User.visible_for(user, for_user_or_nil) == :visible do
do_render("show.json", opts)
else
%{}
end
end
def render("show.json", _) do
raise "In order to prevent account accessibility issues, " <>
":skip_visibility_check or :for option is required."
end
def render("mention.json", %{user: user}) do
%{
id: to_string(user.id),
acct: user.nickname,
username: username_from_nickname(user.nickname),
url: user.uri || user.ap_id
}
end
def render("relationship.json", %{user: nil, target: _target}) do
%{}
end
def render(
"relationship.json",
%{user: %User{} = reading_user, target: %User{} = target} = opts
) do
user_relationships = get_in(opts, [:relationships, :user_relationships])
following_relationships = get_in(opts, [:relationships, :following_relationships])
follow_state =
if following_relationships do
user_to_target_following_relation =
FollowingRelationship.find(following_relationships, reading_user, target)
User.get_follow_state(reading_user, target, user_to_target_following_relation)
else
User.get_follow_state(reading_user, target)
end
followed_by = FollowingRelationship.following?(target, reading_user)
following = FollowingRelationship.following?(reading_user, target)
blocking =
UserRelationship.exists?(
user_relationships,
:block,
reading_user,
target,
&User.blocks_user?(&1, &2)
)
muting =
UserRelationship.exists?(
user_relationships,
:mute,
reading_user,
target,
&User.mutes?(&1, &2)
)
requested =
cond do
following -> false
true -> match?(:follow_pending, follow_state)
end
subscribing =
UserRelationship.exists?(
user_relationships,
:inverse_subscription,
target,
reading_user,
&User.subscribed_to?(&2, &1)
)
# NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags
%{
id: to_string(target.id),
following: following,
followed_by: followed_by,
blocking: blocking,
blocked_by:
UserRelationship.exists?(
user_relationships,
:block,
target,
reading_user,
&User.blocks_user?(&1, &2)
),
block_expires_at: nil,
muting: muting,
muting_notifications:
UserRelationship.exists?(
user_relationships,
:notification_mute,
reading_user,
target,
&User.muted_notifications?(&1, &2)
),
mute_expires_at: nil,
subscribing: subscribing,
notifying: subscribing,
requested: requested,
domain_blocking: User.blocks_domain?(reading_user, target),
showing_reblogs:
not UserRelationship.exists?(
user_relationships,
:reblog_mute,
reading_user,
target,
&User.muting_reblogs?(&1, &2)
),
note:
UserNote.show(
reading_user,
target
),
endorsed:
UserRelationship.exists?(
user_relationships,
:endorsement,
reading_user,
target,
&User.endorses?(&1, &2)
)
}
|> maybe_put_mute_expires_at(target, reading_user, %{mutes: muting})
|> maybe_put_block_expires_at(target, reading_user, %{blocks: blocking})
end
def render("relationships.json", %{user: user, targets: targets} = opts) do
relationships_opt =
cond do
Map.has_key?(opts, :relationships) ->
opts[:relationships]
is_nil(user) ->
UserRelationship.view_relationships_option(nil, [])
true ->
UserRelationship.view_relationships_option(user, targets)
end
render_opts = %{as: :target, user: user, relationships: relationships_opt}
render_many(targets, AccountView, "relationship.json", render_opts)
end
def render("familiar_followers.json", %{users: users} = opts) do
opts =
opts
|> Map.merge(%{as: :user})
|> Map.delete(:users)
users
|> render_many(AccountView, "familiar_followers.json", opts)
end
def render("familiar_followers.json", %{user: %{id: id, accounts: accounts}} = opts) do
accounts =
accounts
|> render_many(AccountView, "show.json", opts)
|> Enum.filter(&Enum.any?/1)
%{id: id, accounts: accounts}
end
defp do_render("show.json", %{user: user} = opts) do
self = opts[:for] == user
user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
display_name = user.name || user.nickname
avatar = User.avatar_url(user) |> MediaProxy.url()
avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true)
avatar_description = User.image_description(user.avatar)
header = User.banner_url(user) |> MediaProxy.url()
header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true)
header_description = User.image_description(user.banner)
following_count =
if !user.hide_follows_count or !user.hide_follows or self,
do: user.following_count,
else: 0
followers_count =
if !user.hide_followers_count or !user.hide_followers or self,
do: user.follower_count,
else: 0
bot = bot?(user)
emojis =
Enum.map(user.emoji, fn {shortcode, raw_url} ->
url =
raw_url
|> encode_emoji_url()
|> MediaProxy.url()
%{
shortcode: shortcode,
url: url,
static_url: url,
visible_in_picker: false
}
end)
relationship =
if opts[:embed_relationships] do
render("relationship.json", %{
user: opts[:for],
target: user,
relationships: opts[:relationships]
})
else
%{}
end
favicon =
if Pleroma.Config.get([:instances_favicons, :enabled]) do
user
|> Map.get(:ap_id, "")
|> URI.parse()
|> URI.merge("/")
|> Pleroma.Instances.Instance.get_or_update_favicon()
|> MediaProxy.url()
else
nil
end
last_status_at =
user.last_status_at &&
user.last_status_at |> NaiveDateTime.to_date() |> Date.to_iso8601()
+ accepts_chat_messages = Pleroma.Chat.enabled?() and user.accepts_chat_messages
+
%{
id: to_string(user.id),
username: username_from_nickname(user.nickname),
acct: user.nickname,
display_name: display_name,
locked: user.is_locked,
created_at: Utils.to_masto_date(user.inserted_at),
followers_count: followers_count,
following_count: following_count,
statuses_count: user.note_count,
note: user.bio,
url: user.uri || user.ap_id,
avatar: avatar,
avatar_description: avatar_description,
avatar_static: avatar_static,
header: header,
header_description: header_description,
header_static: header_static,
emojis: emojis,
fields: user.fields,
bot: bot,
source: %{
note: user.raw_bio || "",
sensitive: false,
fields: user.raw_fields,
pleroma: %{
discoverable: user.is_discoverable,
actor_type: user.actor_type
}
},
last_status_at: last_status_at,
# Pleroma extensions
# Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub
fqn: User.full_nickname(user),
pleroma: %{
ap_id: user.ap_id,
also_known_as: user.also_known_as,
is_confirmed: user.is_confirmed,
is_suggested: user.is_suggested,
tags: user.tags,
hide_followers_count: user.hide_followers_count,
hide_follows_count: user.hide_follows_count,
hide_followers: user.hide_followers,
hide_follows: user.hide_follows,
hide_favorites: user.hide_favorites,
relationship: relationship,
skip_thread_containment: user.skip_thread_containment,
background_image: image_url(user.background) |> MediaProxy.url(),
- accepts_chat_messages: user.accepts_chat_messages,
+ accepts_chat_messages: accepts_chat_messages,
favicon: favicon,
avatar_description: avatar_description,
header_description: header_description
}
}
|> maybe_put_role(user, opts[:for])
|> maybe_put_settings(user, opts[:for], opts)
|> maybe_put_notification_settings(user, opts[:for])
|> maybe_put_settings_store(user, opts[:for], opts)
|> maybe_put_chat_token(user, opts[:for], opts)
|> maybe_put_activation_status(user, opts[:for])
|> maybe_put_follow_requests_count(user, opts[:for])
|> maybe_put_allow_following_move(user, opts[:for])
|> maybe_put_unread_conversation_count(user, opts[:for])
|> maybe_put_unread_notification_count(user, opts[:for])
|> maybe_put_email_address(user, opts[:for])
|> maybe_put_mute_expires_at(user, opts[:for], opts, relationship)
|> maybe_put_block_expires_at(user, opts[:for], opts, relationship)
|> maybe_show_birthday(user, opts[:for])
end
defp username_from_nickname(string) when is_binary(string) do
hd(String.split(string, "@"))
end
defp username_from_nickname(_), do: nil
defp maybe_put_follow_requests_count(
data,
%User{id: user_id} = user,
%User{id: user_id}
) do
count =
user
|> User.get_follow_requests_query()
|> Pleroma.Repo.aggregate(:count)
data
|> Kernel.put_in([:follow_requests_count], count)
end
defp maybe_put_follow_requests_count(data, _, _), do: data
defp maybe_put_settings(
data,
%User{id: user_id} = user,
%User{id: user_id},
_opts
) do
data
|> Kernel.put_in([:source, :privacy], user.default_scope)
|> Kernel.put_in([:source, :pleroma, :show_role], user.show_role)
|> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text)
|> Kernel.put_in([:source, :pleroma, :show_birthday], user.show_birthday)
+ |> Kernel.put_in([:source, :pleroma, :accepts_chat_messages], user.accepts_chat_messages)
end
defp maybe_put_settings(data, _, _, _), do: data
defp maybe_put_settings_store(data, %User{} = user, %User{}, %{
with_pleroma_settings: true
}) do
data
|> Kernel.put_in([:pleroma, :settings_store], user.pleroma_settings_store)
end
defp maybe_put_settings_store(data, _, _, _), do: data
defp maybe_put_chat_token(data, %User{id: id}, %User{id: id}, %{
with_chat_token: token
}) do
data
|> Kernel.put_in([:pleroma, :chat_token], token)
end
defp maybe_put_chat_token(data, _, _, _), do: data
defp maybe_put_role(data, %User{show_role: true} = user, _) do
put_role(data, user)
end
defp maybe_put_role(data, %User{id: user_id} = user, %User{id: user_id}) do
put_role(data, user)
end
defp maybe_put_role(data, _, _), do: data
defp put_role(data, user) do
data
|> Kernel.put_in([:pleroma, :is_admin], user.is_admin)
|> Kernel.put_in([:pleroma, :is_moderator], user.is_moderator)
|> Kernel.put_in([:pleroma, :privileges], User.privileges(user))
end
defp maybe_put_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do
Kernel.put_in(
data,
[:pleroma, :notification_settings],
Map.from_struct(user.notification_settings)
)
end
defp maybe_put_notification_settings(data, _, _), do: data
defp maybe_put_allow_following_move(data, %User{id: user_id} = user, %User{id: user_id}) do
Kernel.put_in(data, [:pleroma, :allow_following_move], user.allow_following_move)
end
defp maybe_put_allow_following_move(data, _, _), do: data
defp maybe_put_activation_status(data, user, user_for) do
if User.privileged?(user_for, :users_manage_activation_state),
do: Kernel.put_in(data, [:pleroma, :deactivated], !user.is_active),
else: data
end
defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{id: user_id}) do
data
|> Kernel.put_in(
[:pleroma, :unread_conversation_count],
Pleroma.Conversation.Participation.unread_count(user)
)
end
defp maybe_put_unread_conversation_count(data, _, _), do: data
defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do
Kernel.put_in(
data,
[:pleroma, :unread_notifications_count],
Pleroma.Notification.unread_notifications_count(user)
)
end
defp maybe_put_unread_notification_count(data, _, _), do: data
defp maybe_put_email_address(data, %User{id: user_id}, %User{id: user_id} = user) do
Kernel.put_in(
data,
[:pleroma, :email],
user.email
)
end
defp maybe_put_email_address(data, _, _), do: data
defp maybe_put_mute_expires_at(data, target, user, opts, relationship \\ nil)
defp maybe_put_mute_expires_at(data, _target, _user, %{mutes: true}, %{
mute_expires_at: mute_expires_at
}) do
Map.put(data, :mute_expires_at, mute_expires_at)
end
defp maybe_put_mute_expires_at(data, %User{} = target, user, %{mutes: true}, _relationship) do
Map.put(
data,
:mute_expires_at,
UserRelationship.get_mute_expire_date(user, target)
)
end
defp maybe_put_mute_expires_at(data, _, _, _, _), do: data
defp maybe_put_block_expires_at(data, target, user, opts, relationship \\ nil)
defp maybe_put_block_expires_at(data, _target, _user, %{blocks: true}, %{
block_expires_at: block_expires_at
}) do
Map.put(data, :block_expires_at, block_expires_at)
end
defp maybe_put_block_expires_at(
data,
%User{} = target,
%User{} = user,
%{blocks: true},
_relationship
) do
Map.put(
data,
:block_expires_at,
UserRelationship.get_block_expire_date(user, target)
)
end
defp maybe_put_block_expires_at(data, _, _, _, _), do: data
defp maybe_show_birthday(data, %User{id: user_id} = user, %User{id: user_id}) do
data
|> Kernel.put_in([:pleroma, :birthday], user.birthday)
end
defp maybe_show_birthday(data, %User{show_birthday: true} = user, _) do
data
|> Kernel.put_in([:pleroma, :birthday], user.birthday)
end
defp maybe_show_birthday(data, _, _) do
data
end
defp image_url(%{"url" => [%{"href" => href} | _]}), do: href
defp image_url(_), do: nil
defp bot?(user) do
# Because older and/or Mastodon clients may not recognize a Group actor properly,
# and currently the group actor can only boost things, we should let these clients
# think groups are bots.
# See https://git.pleroma.social/pleroma/pleroma-meta/-/issues/14
user.actor_type == "Service" || user.actor_type == "Group"
end
defp encode_emoji_url(nil), do: nil
defp encode_emoji_url("http" <> _ = url), do: URIEncoding.encode_url(url)
defp encode_emoji_url("/" <> _ = path),
do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true)
defp encode_emoji_url(path) when is_binary(path),
do: URIEncoding.encode_url(path, bypass_parse: true, bypass_decode: true)
end
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
index 1a0f63ffa..437e42580 100644
--- a/lib/pleroma/web/mastodon_api/views/instance_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -1,441 +1,445 @@
# 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 Pleroma.Chat.enabled?() do
+ "pleroma_chat_messages"
+ end,
+ if Pleroma.Chat.enabled?() do
+ "pleroma:pin_chats"
+ end,
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/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex
index ac9bb2779..9c9f17e86 100644
--- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex
+++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex
@@ -1,247 +1,260 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.ChatController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
alias Pleroma.Activity
alias Pleroma.Chat
alias Pleroma.Chat.MessageReference
alias Pleroma.Object
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
alias Pleroma.Web.Plugs.OAuthScopesPlug
import Ecto.Query
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
OAuthScopesPlug,
%{scopes: ["write:chats"]}
when action in [
:post_chat_message,
:create,
:mark_as_read,
:mark_message_as_read,
:delete_message,
:pin,
:unpin
]
)
plug(
OAuthScopesPlug,
%{scopes: ["read:chats"]} when action in [:messages, :index, :index2, :show]
)
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
+ plug(:ensure_chats_enabled)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation
def delete_message(
%{
assigns: %{user: %{id: user_id} = user},
private: %{
open_api_spex: %{
params: %{
message_id: message_id,
id: chat_id
}
}
}
} = conn,
_
) do
with %MessageReference{} = cm_ref <-
MessageReference.get_by_id(message_id),
^chat_id <- to_string(cm_ref.chat_id),
%Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id),
{:ok, _} <- remove_or_delete(cm_ref, user) do
conn
|> put_view(MessageReferenceView)
|> render("show.json", chat_message_reference: cm_ref)
else
_e ->
{:error, :could_not_delete}
end
end
defp remove_or_delete(
%{object: %{data: %{"actor" => actor, "id" => id}}},
%{ap_id: actor} = user
) do
with %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do
CommonAPI.delete(activity.id, user)
end
end
defp remove_or_delete(cm_ref, _), do: MessageReference.delete(cm_ref)
def post_chat_message(
%{
private: %{open_api_spex: %{body_params: params, params: %{id: id}}},
assigns: %{user: user}
} = conn,
_
) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{_, %User{} = recipient} <- {:user, User.get_cached_by_ap_id(chat.recipient)},
{:ok, activity} <-
CommonAPI.post_chat_message(user, recipient, params[:content],
media_id: params[:media_id],
idempotency_key: idempotency_key(conn)
),
message <- Object.normalize(activity, fetch: false),
cm_ref <- MessageReference.for_chat_and_object(chat, message) do
conn
|> put_view(MessageReferenceView)
|> render("show.json", chat_message_reference: cm_ref)
else
{:reject, message} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: message})
{:error, message} ->
conn
|> put_status(:bad_request)
|> json(%{error: message})
{:user, nil} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Recipient does not exist"})
end
end
def mark_message_as_read(
%{
assigns: %{user: %{id: user_id}},
private: %{open_api_spex: %{params: %{id: chat_id, message_id: message_id}}}
} = conn,
_
) do
with %MessageReference{} = cm_ref <- MessageReference.get_by_id(message_id),
^chat_id <- to_string(cm_ref.chat_id),
%Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id),
{:ok, cm_ref} <- MessageReference.mark_as_read(cm_ref) do
conn
|> put_view(MessageReferenceView)
|> render("show.json", chat_message_reference: cm_ref)
end
end
def mark_as_read(
%{
assigns: %{user: user},
private: %{
open_api_spex: %{
body_params: %{last_read_id: last_read_id},
params: %{id: id}
}
}
} = conn,
_
) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{_n, _} <- MessageReference.set_all_seen_for_chat(chat, last_read_id) do
render(conn, "show.json", chat: chat)
end
end
def messages(
%{
assigns: %{user: user},
private: %{open_api_spex: %{params: %{id: id} = params}}
} = conn,
_
) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id) do
chat_message_refs =
chat
|> MessageReference.for_chat_query()
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(chat_message_refs)
|> put_view(MessageReferenceView)
|> render("index.json", chat_message_references: chat_message_refs)
end
end
def index(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do
chats =
index_query(user, params)
|> Repo.all()
render(conn, "index.json", chats: chats)
end
def index2(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do
chats =
index_query(user, params)
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(chats)
|> render("index.json", chats: chats)
end
defp index_query(%{id: user_id} = user, params) do
exclude_users =
User.cached_blocked_users_ap_ids(user) ++
if params[:with_muted], do: [], else: User.cached_muted_users_ap_ids(user)
user_id
|> Chat.for_user_query()
|> where([c], c.recipient not in ^exclude_users)
|> restrict_pinned(params)
end
defp restrict_pinned(query, %{pinned: pinned}) when is_boolean(pinned) do
query
|> where([c], c.pinned == ^pinned)
end
defp restrict_pinned(query, _), do: query
def create(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with %User{ap_id: recipient} <- User.get_cached_by_id(id),
{:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do
render(conn, "show.json", chat: chat)
end
end
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id) do
render(conn, "show.json", chat: chat)
end
end
def pin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{:ok, chat} <- Chat.pin(chat) do
render(conn, "show.json", chat: chat)
end
end
def unpin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, chat} <- Chat.get_by_user_and_id(user, id),
{:ok, chat} <- Chat.unpin(chat) do
render(conn, "show.json", chat: chat)
end
end
defp idempotency_key(conn) do
case get_req_header(conn, "idempotency-key") do
[key] -> key
_ -> nil
end
end
+
+ defp ensure_chats_enabled(conn, _opts) do
+ if Chat.enabled?() do
+ conn
+ else
+ conn
+ |> put_status(:not_found)
+ |> put_view(Pleroma.Web.ErrorView)
+ |> render("404.json")
+ |> halt()
+ end
+ end
end
diff --git a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
index ab2129f9e..960e86d4f 100644
--- a/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
+++ b/lib/pleroma/web/plugs/ensure_host_matches_plug.ex
@@ -1,63 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.EnsureHostMatchesPlug do
@moduledoc "Ensures Host header matches instance"
+ @behaviour Plug
alias Pleroma.Web.Endpoint
import Plug.Conn
def init(options), do: options
@spec call(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
def call(%Plug.Conn{assigns: %{valid_signature: true}} = conn, _opts) do
# Host header is scheme-less, URI.parse needs the //
host_header = get_req_header(conn, "host")
host_uri = URI.parse("//#{host_header}")
instance_uri = URI.parse(Endpoint.url())
case host_header do
[host] ->
cond do
host == "" ->
resp(conn, 400, "Host header not provided") |> halt()
true ->
if host_matches?(host_uri, instance_uri),
do: assign(conn, :valid_host_header, true),
else: resp(conn, 400, "Host header does not match this instance") |> halt()
end
[_head | _rest] ->
conn
|> resp(400, "More than one Host header provided")
|> halt()
[] ->
conn
|> resp(400, "Host header not provided")
|> halt()
end
end
# Host header may not be provided, but signature verification failed anyway
def call(conn, _opts), do: conn
defp case_insensitive_compare(checked, authority) do
String.downcase(checked) == String.downcase(authority)
end
# Host header did not provide port
# Host header is scheme-less, URI.parse does not provide default port
defp host_matches?(%URI{host: req_host, port: nil}, %URI{host: instance_host}),
do: case_insensitive_compare(req_host, instance_host)
# Host header provided a port
# Any port specified in the Endpoint url configuration is valid here
defp host_matches?(%URI{host: req_host, port: port}, %URI{host: instance_host, port: port}),
do: case_insensitive_compare(req_host, instance_host)
defp host_matches?(_, _), do: false
end
diff --git a/lib/pleroma/web/plugs/instance_static.ex b/lib/pleroma/web/plugs/instance_static.ex
index 948188287..d2a674d39 100644
--- a/lib/pleroma/web/plugs/instance_static.ex
+++ b/lib/pleroma/web/plugs/instance_static.ex
@@ -1,102 +1,75 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.InstanceStatic do
require Pleroma.Constants
- import Plug.Conn, only: [put_resp_header: 3, put_status: 2, send_resp: 3, halt: 1]
+ import Plug.Conn, only: [put_resp_header: 3]
@moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration.
Mountpoints are defined directly in the module to avoid calling the configuration for every request including non-static ones.
"""
@behaviour Plug
def file_path(path, frontend_type \\ :primary) do
instance_path =
Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path)
frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type)
(File.exists?(instance_path) && instance_path) ||
(frontend_path && File.exists?(frontend_path) && frontend_path) ||
Path.join(Application.app_dir(:pleroma, "priv/static/"), path)
end
def init(opts) do
opts
|> Keyword.put(:from, "__unconfigured_instance_static_plug")
|> Plug.Static.init()
end
for only <- Pleroma.Constants.static_only_files() do
def call(%{request_path: "/" <> unquote(only) <> _} = conn, opts) do
call_static(
conn,
opts,
Pleroma.Config.get([:instance, :static_dir], "instance/static")
)
end
end
def call(conn, _) do
conn
end
defp call_static(conn, opts, from) do
# Prevent content-type spoofing by setting content_types: false
opts =
opts
|> Map.put(:from, from)
|> Map.put(:content_types, false)
- conn =
- conn
- |> set_content_type(conn.request_path)
- |> Plug.Static.call(opts)
-
- if conn.halted do
- conn
- else
- path = String.trim_leading(conn.request_path, "/")
-
- if not File.exists?(file_path(path)) do
- conn
- |> put_status(:not_found)
- |> send_404()
- |> halt()
- else
- conn
- end
- end
- end
+ conn = set_content_type(conn, conn.request_path)
- defp send_404(conn) do
- if String.ends_with?(String.downcase(conn.request_path), ".json") do
- conn
- |> put_resp_header("content-type", "application/json")
- |> send_resp(404, Jason.encode!(%{error: "not found"}))
- else
- conn
- |> put_resp_header("content-type", "text/plain")
- |> send_resp(404, "Not found")
- end
+ # Call Plug.Static with our sanitized content-type
+ Plug.Static.call(conn, opts)
end
defp set_content_type(conn, "/emoji/" <> filepath) do
real_mime = MIME.from_path(filepath)
clean_mime =
Pleroma.Web.Plugs.Utils.get_safe_mime_type(%{allowed_mime_types: ["image"]}, real_mime)
put_resp_header(conn, "content-type", clean_mime)
end
defp set_content_type(conn, filepath) do
real_mime = MIME.from_path(filepath)
put_resp_header(conn, "content-type", real_mime)
end
end
# I think this needs to be uncleaned except for emoji.
diff --git a/lib/pleroma/web/plugs/o_auth_plug.ex b/lib/pleroma/web/plugs/o_auth_plug.ex
index 488968691..b59ac9d3e 100644
--- a/lib/pleroma/web/plugs/o_auth_plug.ex
+++ b/lib/pleroma/web/plugs/o_auth_plug.ex
@@ -1,109 +1,109 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.OAuthPlug do
@moduledoc "Performs OAuth authentication by token from params / headers / cookies."
import Plug.Conn
import Ecto.Query
alias Pleroma.Helpers.AuthHelper
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.App
alias Pleroma.Web.OAuth.Token
@realm_reg Regex.compile!("Bearer\:?\s+(.*)$", "i")
def init(options), do: options
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(conn, _) do
with {:ok, token_str} <- fetch_token_str(conn) do
with {:ok, user, user_token} <- fetch_user_and_token(token_str),
false <- Token.expired?(user_token) do
conn
|> assign(:token, user_token)
|> assign(:user, user)
else
_ ->
with {:ok, app, app_token} <- fetch_app_and_token(token_str),
false <- Token.expired?(app_token) do
conn
|> assign(:token, app_token)
|> assign(:app, app)
else
_ -> conn
end
end
else
_ -> conn
end
end
# Gets user by token
#
@spec fetch_user_and_token(String.t()) :: {:ok, User.t(), Token.t()} | nil
defp fetch_user_and_token(token) do
token_query =
from(t in Token,
where: t.token == ^token
)
- with %Token{user_id: user_id} = token_record <- Repo.one(token_query) |> Repo.preload(:user),
+ with %Token{user_id: user_id} = token_record <- Repo.one(token_query),
false <- is_nil(user_id),
%User{} = user <- User.get_cached_by_id(user_id) do
{:ok, user, token_record}
else
_ -> nil
end
end
@spec fetch_app_and_token(String.t()) :: {:ok, App.t(), Token.t()} | nil
defp fetch_app_and_token(token) do
query =
from(t in Token, where: t.token == ^token, join: app in assoc(t, :app), preload: [app: app])
with %Token{app: app} = token_record <- Repo.one(query) do
{:ok, app, token_record}
end
end
# Gets token string from conn (in params / headers / session)
#
@spec fetch_token_str(Plug.Conn.t() | list(String.t())) :: :no_token_found | {:ok, String.t()}
defp fetch_token_str(%Plug.Conn{params: %{"access_token" => access_token}} = _conn) do
{:ok, access_token}
end
defp fetch_token_str(%Plug.Conn{} = conn) do
headers = get_req_header(conn, "authorization")
with {:ok, token} <- fetch_token_str(headers) do
{:ok, token}
else
_ -> fetch_token_from_session(conn)
end
end
defp fetch_token_str([token | tail]) do
trimmed_token = String.trim(token)
case Regex.run(@realm_reg, trimmed_token) do
[_, match] -> {:ok, String.trim(match)}
_ -> fetch_token_str(tail)
end
end
defp fetch_token_str([]), do: :no_token_found
@spec fetch_token_from_session(Plug.Conn.t()) :: :no_token_found | {:ok, String.t()}
defp fetch_token_from_session(conn) do
case AuthHelper.get_session_token(conn) do
nil -> :no_token_found
token -> {:ok, token}
end
end
end
diff --git a/lib/pleroma/web/plugs/static_not_found_plug.ex b/lib/pleroma/web/plugs/static_not_found_plug.ex
new file mode 100644
index 000000000..e2e7a622c
--- /dev/null
+++ b/lib/pleroma/web/plugs/static_not_found_plug.ex
@@ -0,0 +1,38 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Plugs.StaticNotFoundPlug do
+ @moduledoc """
+ A small plug that enforces returning 404 on static files when they don't exist.
+ """
+ @behaviour Plug
+
+ require Pleroma.Constants
+ import Plug.Conn, only: [put_resp_header: 3, send_resp: 3, halt: 1]
+
+ def init(opts), do: opts
+
+ for only <- Pleroma.Constants.static_only_files() do
+ def call(%{request_path: "/" <> unquote(only) <> _} = conn, _opts) do
+ send_404(conn)
+ end
+ end
+
+ def call(conn, _) do
+ conn
+ end
+
+ defp send_404(conn) do
+ if String.ends_with?(String.downcase(conn.request_path), ".json") do
+ conn
+ |> put_resp_header("content-type", "application/json")
+ |> send_resp(404, Jason.encode!(%{error: "not found"}))
+ else
+ conn
+ |> put_resp_header("content-type", "text/plain")
+ |> send_resp(404, "Not found")
+ end
+ |> halt()
+ end
+end
diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex
index 120e94751..3c154288b 100644
--- a/lib/pleroma/web/streamer.ex
+++ b/lib/pleroma/web/streamer.ex
@@ -1,442 +1,451 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer do
require Logger
require Pleroma.Constants
alias Pleroma.Activity
alias Pleroma.Chat.MessageReference
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.StreamerView
require Pleroma.Constants
@registry Pleroma.Web.StreamerRegistry
def registry, do: @registry
@public_streams Pleroma.Constants.public_streams()
@local_streams ["public:local", "public:local:media"]
@user_streams ["user", "user:notification", "direct", "user:pleroma_chat"]
@doc "Expands and authorizes a stream, and registers the process for streaming."
@spec get_topic_and_add_socket(
stream :: String.t(),
User.t() | nil,
Token.t() | nil,
map() | nil
) ::
{:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized}
def get_topic_and_add_socket(stream, user, oauth_token, params \\ %{}) do
with {:ok, topic} <- get_topic(stream, user, oauth_token, params) do
add_socket(topic, oauth_token)
end
end
defp can_access_stream(user, oauth_token, kind) do
with {_, true} <- {:restrict?, Config.restrict_unauthenticated_access?(:timelines, kind)},
{_, %User{id: user_id}, %Token{user_id: user_id}} <- {:user, user, oauth_token},
{_, true} <-
{:scopes,
OAuthScopesPlug.filter_descendants(["read:statuses"], oauth_token.scopes) != []} do
true
else
{:restrict?, _} ->
true
_ ->
false
end
end
@doc "Expand and authorizes a stream"
@spec get_topic(stream :: String.t() | nil, User.t() | nil, Token.t() | nil, map()) ::
{:ok, topic :: String.t() | nil} | {:error, :bad_topic}
def get_topic(stream, user, oauth_token, params \\ %{})
def get_topic(nil = _stream, _user, _oauth_token, _params) do
{:ok, nil}
end
# Allow all public steams if the instance allows unauthenticated access.
# Otherwise, only allow users with valid oauth tokens.
def get_topic(stream, user, oauth_token, _params) when stream in @public_streams do
kind = if stream in @local_streams, do: :local, else: :federated
if can_access_stream(user, oauth_token, kind) do
{:ok, stream}
else
{:error, :unauthorized}
end
end
# Allow all hashtags streams.
def get_topic("hashtag", _user, _oauth_token, %{"tag" => tag} = _params) do
{:ok, "hashtag:" <> tag}
end
# Allow remote instance streams.
def get_topic("public:remote", user, oauth_token, %{"instance" => instance} = _params) do
if can_access_stream(user, oauth_token, :federated) do
{:ok, "public:remote:" <> instance}
else
{:error, :unauthorized}
end
end
def get_topic("public:remote:media", user, oauth_token, %{"instance" => instance} = _params) do
if can_access_stream(user, oauth_token, :federated) do
{:ok, "public:remote:media:" <> instance}
else
{:error, :unauthorized}
end
end
# Expand user streams.
def get_topic(
stream,
%User{id: user_id} = user,
%Token{user_id: user_id} = oauth_token,
_params
)
when stream in @user_streams do
# Note: "read" works for all user streams (not mentioning it since it's an ancestor scope)
required_scopes =
if stream == "user:notification" do
["read:notifications"]
else
["read:statuses"]
end
if OAuthScopesPlug.filter_descendants(required_scopes, oauth_token.scopes) == [] do
{:error, :unauthorized}
else
{:ok, stream <> ":" <> to_string(user.id)}
end
end
def get_topic(stream, _user, _oauth_token, _params) when stream in @user_streams do
{:error, :unauthorized}
end
# List streams.
def get_topic(
"list",
%User{id: user_id} = user,
%Token{user_id: user_id} = oauth_token,
%{"list" => id}
) do
cond do
OAuthScopesPlug.filter_descendants(["read", "read:lists"], oauth_token.scopes) == [] ->
{:error, :unauthorized}
Pleroma.List.get(id, user) ->
{:ok, "list:" <> to_string(id)}
true ->
{:error, :bad_topic}
end
end
def get_topic("list", _user, _oauth_token, _params) do
{:error, :unauthorized}
end
def get_topic(_stream, _user, _oauth_token, _params) do
{:error, :bad_topic}
end
@doc "Registers the process for streaming. Use `get_topic/3` to get the full authorized topic."
def add_socket(topic, oauth_token) do
if should_env_send?() do
oauth_token_id = if oauth_token, do: oauth_token.id, else: false
Registry.register(@registry, topic, oauth_token_id)
end
{:ok, topic}
end
def remove_socket(topic) do
if should_env_send?(), do: Registry.unregister(@registry, topic)
end
def stream(topics, items) do
if should_env_send?() do
for topic <- List.wrap(topics), item <- List.wrap(items) do
fun = fn -> do_stream(topic, item) end
if Config.get([__MODULE__, :sync_streaming], false) do
fun.()
else
spawn(fun)
end
end
end
end
def filtered_by_user?(user, item, streamed_type \\ :activity)
def filtered_by_user?(%User{} = user, %Activity{} = item, streamed_type) do
%{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
+ following_ap_ids =
+ if user.domain_blocks == [], do: [], else: User.get_cached_user_friends_ap_ids(user)
+
with parent <- Object.normalize(item, fetch: false) || item,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
true <-
!(streamed_type == :activity && item.data["type"] == "Announce" &&
parent.data["actor"] == user.ap_id),
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]),
- false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
- false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
+ false <-
+ item.actor not in following_ap_ids and
+ Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
+ false <-
+ parent.data["actor"] not in following_ap_ids and
+ Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(parent, user) do
false
else
_ -> true
end
end
def filtered_by_user?(%User{} = user, %Notification{activity: activity}, _) do
filtered_by_user?(user, activity, :notification)
end
defp do_stream("direct", item) do
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "direct:#{id}" end)
Enum.each(recipient_topics, fn user_topic ->
Logger.debug("Trying to push direct message to #{user_topic}\n\n")
push_to_socket(user_topic, item)
end)
end
defp do_stream("follow_relationship", item) do
user_topic = "user:#{item.follower.id}"
text = StreamerView.render("follow_relationships_update.json", item, user_topic)
Logger.debug("Trying to push follow relationship update to #{user_topic}\n\n")
Registry.dispatch(@registry, user_topic, fn list ->
Enum.each(list, fn {pid, _auth} ->
send(pid, {:text, text})
end)
end)
end
defp do_stream("participation", participation) do
user_topic = "direct:#{participation.user_id}"
Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
push_to_socket(user_topic, participation)
end
defp do_stream("list", item) do
# filter the recipient list if the activity is not public, see #270.
recipient_lists =
case Visibility.public?(item) do
true ->
Pleroma.List.get_lists_from_activity(item)
_ ->
Pleroma.List.get_lists_from_activity(item)
|> Enum.filter(fn list ->
owner = User.get_cached_by_id(list.user_id)
Visibility.visible_for_user?(item, owner)
end)
end
recipient_topics =
recipient_lists
|> Enum.map(fn %{id: id} -> "list:#{id}" end)
Enum.each(recipient_topics, fn list_topic ->
Logger.debug("Trying to push message to #{list_topic}\n\n")
push_to_socket(list_topic, item)
end)
end
defp do_stream(topic, %Notification{} = item)
when topic in ["user", "user:notification"] do
user_topic = "#{topic}:#{item.user_id}"
Registry.dispatch(@registry, user_topic, fn list ->
Enum.each(list, fn {pid, _auth} ->
send(pid, {:render_with_user, StreamerView, "notification.json", item, user_topic})
end)
end)
end
defp do_stream(topic, {user, %MessageReference{} = cm_ref})
when topic in ["user", "user:pleroma_chat"] do
- topic = "#{topic}:#{user.id}"
+ if Pleroma.Chat.enabled?() do
+ topic = "#{topic}:#{user.id}"
- text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}, topic)
+ text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}, topic)
- Registry.dispatch(@registry, topic, fn list ->
- Enum.each(list, fn {pid, _auth} ->
- send(pid, {:text, text})
+ Registry.dispatch(@registry, topic, fn list ->
+ Enum.each(list, fn {pid, _auth} ->
+ send(pid, {:text, text})
+ end)
end)
- end)
+ end
end
defp do_stream("user", %Activity{} = item) do
Logger.debug("Trying to push to users")
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
hashtag_recipients =
if Pleroma.Constants.as_public() in item.recipients do
Pleroma.Hashtag.get_recipients_for_activity(item)
|> Enum.map(fn id -> "user:#{id}" end)
else
[]
end
all_recipients = Enum.uniq(recipient_topics ++ hashtag_recipients)
Enum.each(all_recipients, fn topic ->
push_to_socket(topic, item)
end)
end
defp do_stream(topic, %Marker{} = marker) do
Registry.dispatch(@registry, "#{topic}:#{marker.user_id}", fn list ->
Enum.each(list, fn {pid, _auth} ->
text = StreamerView.render("marker.json", marker)
send(pid, {:text, text})
end)
end)
end
defp do_stream(topic, item) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")
push_to_socket(topic, item)
end
defp push_to_socket(topic, %Participation{} = participation) do
rendered = StreamerView.render("conversation.json", participation, topic)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(topic, %Activity{
data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
}) do
rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)})
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do
create_activity =
Pleroma.Activity.get_create_by_object_ap_id(item.object.data["id"])
|> Map.put(:object, item.object)
anon_render = StreamerView.render("status_update.json", create_activity, topic)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
send(
pid,
{:render_with_user, StreamerView, "status_update.json", create_activity, topic}
)
else
send(pid, {:text, anon_render})
end
end)
end)
end
defp push_to_socket(topic, item) do
anon_render = StreamerView.render("update.json", item, topic)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
send(pid, {:render_with_user, StreamerView, "update.json", item, topic})
else
send(pid, {:text, anon_render})
end
end)
end)
end
defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
defp thread_containment(activity, user) do
if Config.get([:instance, :skip_thread_containment]) do
true
else
ActivityPub.contain_activity(activity, user)
end
end
def close_streams_by_oauth_token(oauth_token) do
if should_env_send?() do
Registry.select(
@registry,
[
{
{:"$1", :"$2", :"$3"},
[{:==, :"$3", oauth_token.id}],
[:"$2"]
}
]
)
|> Enum.each(fn pid -> send(pid, :close) end)
end
end
# In dev/prod the streamer registry is expected to be started, so return true
# In test it is possible to have the registry started for a test so it will check
# In benchmark it will never find the process alive and return false
def should_env_send? do
if Application.get_env(:pleroma, Pleroma.Application)[:streamer_registry] do
true
else
case Process.whereis(@registry) do
nil ->
false
pid ->
Process.alive?(pid)
end
end
end
end
diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex
index b774f7984..f94ba0793 100644
--- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex
+++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex
@@ -1,50 +1,41 @@
<entry>
<activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
<activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb>
- <id><%= @data["id"] %></id>
+ <id><%= escape(@data["id"] || "") %></id>
<title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title>
- <content type="html"><%= activity_description(@data) %></content>
+ <content type="html"><%= escape(atom_content_encoded(@data)) %></content>
<published><%= to_rfc3339(@data["published"]) %></published>
<updated><%= to_rfc3339(@data["published"]) %></updated>
<ostatus:conversation ref="<%= activity_context(@activity) %>">
<%= activity_context(@activity) %>
</ostatus:conversation>
<link href="<%= activity_context(@activity) %>" rel="ostatus:conversation"/>
<%= if @data["summary"] != "" do %>
<summary><%= escape(@data["summary"]) %></summary>
<% end %>
- <%= if @activity.local do %>
- <link type="application/atom+xml" href='<%= @data["id"] %>' rel="self"/>
- <link type="text/html" href='<%= @data["id"] %>' rel="alternate"/>
- <% else %>
- <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/>
- <% end %>
+ <link type="text/html" href='<%= escape(activity_link(@activity, @data) || "") %>' rel="alternate"/>
<%= for tag <- Pleroma.Object.hashtags(@object) do %>
<category term="<%= tag %>"></category>
<% end %>
- <%= for attachment <- @data["attachment"] || [] do %>
- <link rel="enclosure" href="<%= attachment_href(attachment) %>" type="<%= attachment_type(attachment) %>"/>
+ <%= unless sensitive?(@data) do %>
+ <%= for attachment <- @data["attachment"] || [] do %>
+ <%= if size = attachment_size_positive(attachment) do %>
+ <link rel="enclosure" href="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size %>"/>
+ <% else %>
+ <link rel="enclosure" href="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>"/>
+ <% end %>
+ <% end %>
<% end %>
<%= if @data["inReplyTo"] do %>
<thr:in-reply-to ref='<%= @data["inReplyTo"] %>' href='<%= get_href(@data["inReplyTo"]) %>'/>
<% end %>
- <%= for id <- @activity.recipients do %>
- <%= if id == Pleroma.Constants.as_public() do %>
- <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/>
- <% else %>
- <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %>
- <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="<%= id %>"/>
- <% end %>
- <% end %>
- <% end %>
-
<%= for {emoji, file} <- @data["emoji"] || %{} do %>
<link name="<%= emoji %>" rel="emoji" href="<%= file %>"/>
<% end %>
</entry>
diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex
index 7de98f736..4d71a45c7 100644
--- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex
+++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex
@@ -1,45 +1,34 @@
<item>
<activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
<activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb>
- <guid><%= @data["id"] %></guid>
+ <guid isPermaLink="true"><%= escape(@data["id"] || "") %></guid>
<title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title>
<description><%= activity_description(@data) %></description>
+ <content:encoded><![CDATA[<%= rss_content_encoded(@data) %>]]></content:encoded>
<pubDate><%= to_rfc2822(@data["published"]) %></pubDate>
<ostatus:conversation ref="<%= activity_context(@activity) %>">
<%= activity_context(@activity) %>
</ostatus:conversation>
- <%= if @activity.local do %>
- <link><%= @data["id"] %></link>
- <% else %>
- <link><%= @data["external_url"] %></link>
- <% end %>
-
- <link rel="ostatus:conversation"><%= activity_context(@activity) %></link>
+ <link><%= escape(activity_link(@activity, @data) || "") %></link>
<%= for tag <- Pleroma.Object.hashtags(@object) do %>
- <category term="<%= tag %>"></category>
+ <%= if tag && tag != "" do %>
+ <category><%= tag %></category>
+ <% end %>
+ <% end %>
+
+ <%= unless sensitive?(@data) do %>
+ <%= with attachment when not is_nil(attachment) <- rss_enclosure_attachment(@data["attachment"]) do %>
+ <enclosure url="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= attachment_size_positive(attachment) %>" />
+ <% end %>
<% end %>
<%= for attachment <- @data["attachment"] || [] do %>
- <enclosure url="<%= attachment_href(attachment) %>" type="<%= attachment_type(attachment) %>" />
+ <%= media_content_xml(attachment, sensitive?(@data)) %>
<% end %>
<%= if @data["inReplyTo"] do %>
<thr:in-reply-to ref='<%= @data["inReplyTo"] %>' href='<%= get_href(@data["inReplyTo"]) %>'/>
<% end %>
-
- <%= for id <- @activity.recipients do %>
- <%= if id == Pleroma.Constants.as_public() do %>
- <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection">http://activityschema.org/collection/public</link>
- <% else %>
- <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %>
- <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person"><%= id %></link>
- <% end %>
- <% end %>
- <% end %>
-
- <%= for {emoji, file} <- @data["emoji"] || %{} do %>
- <link name="<%= emoji %>" rel="emoji"><%= file %></link>
- <% end %>
</item>
diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex
index 03c222975..6c74c2ab7 100644
--- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex
+++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex
@@ -1,49 +1,40 @@
<entry>
<activity:object-type>http://activitystrea.ms/schema/1.0/note</activity:object-type>
<activity:verb>http://activitystrea.ms/schema/1.0/post</activity:verb>
<%= render Phoenix.Controller.view_module(@conn), "_tag_author.atom", assigns %>
- <id><%= @data["id"] %></id>
+ <id><%= escape(@data["id"] || "") %></id>
<title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title>
- <content type="html"><%= activity_description(@data) %></content>
+ <content type="html"><%= escape(atom_content_encoded(@data)) %></content>
<published><%= to_rfc3339(@data["published"]) %></published>
<updated><%= to_rfc3339(@data["published"]) %></updated>
<ostatus:conversation ref="<%= activity_context(@activity) %>">
<%= activity_context(@activity) %>
</ostatus:conversation>
<link href="<%= activity_context(@activity) %>" rel="ostatus:conversation"/>
<%= if @data["summary"] != "" do %>
- <summary><%= @data["summary"] %></summary>
+ <summary><%= escape(@data["summary"]) %></summary>
<% end %>
- <%= if @activity.local do %>
- <link type="application/atom+xml" href='<%= @data["id"] %>' rel="self"/>
- <link type="text/html" href='<%= @data["id"] %>' rel="alternate"/>
- <% else %>
- <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/>
+ <link type="text/html" href='<%= escape(activity_link(@activity, @data) || "") %>' rel="alternate"/>
+
+ <%= for tag <- Pleroma.Object.hashtags(@object) do %>
+ <category term="<%= tag %>"></category>
<% end %>
- <%= for id <- @activity.recipients do %>
- <%= if id == Pleroma.Constants.as_public() do %>
- <link rel="mentioned"
- ostatus:object-type="http://activitystrea.ms/schema/1.0/collection"
- href="http://activityschema.org/collection/public"/>
- <% else %>
- <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %>
- <link rel="mentioned"
- ostatus:object-type="http://activitystrea.ms/schema/1.0/person"
- href="<%= id %>" />
+ <%= unless sensitive?(@data) do %>
+ <%= for attachment <- @data["attachment"] || [] do %>
+ <%= if size = attachment_size_positive(attachment) do %>
+ <link rel="enclosure" href="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= size %>"/>
+ <% else %>
+ <link rel="enclosure" href="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>"/>
<% end %>
<% end %>
<% end %>
- <%= for tag <- Pleroma.Object.hashtags(@object) do %>
- <category term="<%= tag %>"></category>
- <% end %>
-
<%= for {emoji, file} <- @data["emoji"] || %{} do %>
<link name="<%= emoji %>" rel="emoji" href="<%= file %>"/>
<% end %>
</entry>
diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex
index 1b8c34b87..64e4c1d81 100644
--- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex
+++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex
@@ -1,14 +1,24 @@
<item>
+ <guid isPermaLink="true"><%= escape(activity_link(@activity, @data) || "") %></guid>
+ <link><%= escape(activity_link(@activity, @data) || "") %></link>
+ <pubDate><%= to_rfc2822(@data["published"]) %></pubDate>
<title><%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %></title>
+ <description><%= activity_description(@data) %></description>
+ <content:encoded><![CDATA[<%= rss_content_encoded(@data) %>]]></content:encoded>
+ <%= unless sensitive?(@data) do %>
+ <%= with attachment when not is_nil(attachment) <- rss_enclosure_attachment(@data["attachment"]) do %>
+ <enclosure url="<%= escape(attachment_href(attachment) || "") %>" type="<%= escape(attachment_type(attachment) || "application/octet-stream") %>" length="<%= attachment_size_positive(attachment) %>"/>
+ <% end %>
+ <% end %>
- <guid isPermalink="true"><%= activity_context(@activity) %></guid>
- <link><%= activity_context(@activity) %></link>
- <pubDate><%= to_rfc2822(@data["published"]) %></pubDate>
-
- <description><%= activity_description(@data) %></description>
<%= for attachment <- @data["attachment"] || [] do %>
- <enclosure url="<%= attachment_href(attachment) %>" type="<%= attachment_type(attachment) %>"/>
+ <%= media_content_xml(attachment, sensitive?(@data)) %>
<% end %>
+ <%= for tag <- Pleroma.Object.hashtags(@object) do %>
+ <%= if tag && tag != "" do %>
+ <category><%= tag %></category>
+ <% end %>
+ <% end %>
</item>
diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex
index 3449c97ff..fb04544d3 100644
--- a/lib/pleroma/web/templates/feed/feed/tag.atom.eex
+++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex
@@ -1,20 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed
xmlns="http://www.w3.org/2005/Atom"
xmlns:thr="http://purl.org/syndication/thread/1.0"
xmlns:activity="http://activitystrea.ms/spec/1.0/"
xmlns:poco="http://portablecontacts.net/spec/1.0"
xmlns:ostatus="http://ostatus.org/schema/1.0"
xmlns:statusnet="http://status.net/schema/api/1/">
<id><%= Routes.tag_feed_url(@conn, :feed, @tag) <> ".atom" %></id>
<title>#<%= @tag %></title>
- <subtitle><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></subtitle>
+ <%= with subtitle when subtitle not in [nil, ""] <- Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) do %>
+ <subtitle><%= subtitle %></subtitle>
+ <% end %>
<logo><%= feed_logo() %></logo>
- <updated><%= most_recent_update(@activities) %></updated>
- <link rel="self" href="<%= "#{Routes.tag_feed_url(@conn, :feed, @tag)}.atom" %>" type="application/atom+xml"/>
+ <updated><%= most_recent_update(@activities) || to_rfc3339(NaiveDateTime.utc_now()) %></updated>
+ <link rel="self" href="<%= feed_self_url(@conn) %>" type="application/atom+xml"/>
<%= for activity <- @activities do %>
<%= render Phoenix.Controller.view_module(@conn), "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %>
<% end %>
</feed>
diff --git a/lib/pleroma/web/templates/feed/feed/tag.rss.eex b/lib/pleroma/web/templates/feed/feed/tag.rss.eex
index a87f9bf50..b74a573e5 100644
--- a/lib/pleroma/web/templates/feed/feed/tag.rss.eex
+++ b/lib/pleroma/web/templates/feed/feed/tag.rss.eex
@@ -1,16 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:webfeeds="http://webfeeds.org/rss/1.0"
+ xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:media="http://search.yahoo.com/mrss/"
xmlns:thr="http://purl.org/syndication/thread/1.0">
<channel>
<title>#<%= @tag %></title>
<description><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></description>
<link><%= "#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss" %></link>
<webfeeds:logo><%= feed_logo() %></webfeeds:logo>
+ <webfeeds:icon><%= feed_logo() %></webfeeds:icon>
<webfeeds:accentColor>2b90d9</webfeeds:accentColor>
+ <%= with last_build_date when not is_nil(last_build_date) <- most_recent_update(@activities, :rss) do %>
+ <lastBuildDate><%= last_build_date %></lastBuildDate>
+ <% end %>
+ <generator><%= Pleroma.Application.named_version() %></generator>
<%= for activity <- @activities do %>
<%= render Phoenix.Controller.view_module(@conn), "_tag_activity.xml", Map.merge(assigns, prepare_activity(activity)) %>
<% end %>
</channel>
</rss>
diff --git a/lib/pleroma/web/templates/feed/feed/user.atom.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex
index c2c77cfed..cc6883727 100644
--- a/lib/pleroma/web/templates/feed/feed/user.atom.eex
+++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex
@@ -1,25 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed
xmlns="http://www.w3.org/2005/Atom"
xmlns:thr="http://purl.org/syndication/thread/1.0"
xmlns:activity="http://activitystrea.ms/spec/1.0/"
xmlns:poco="http://portablecontacts.net/spec/1.0"
xmlns:ostatus="http://ostatus.org/schema/1.0">
<id><%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id>
<title><%= @user.nickname <> "'s timeline" %></title>
- <subtitle><%= escape(@user.bio) %></subtitle>
+ <%= if @user.bio && @user.bio != "" do %>
+ <subtitle><%= escape(@user.bio) %></subtitle>
+ <% end %>
<updated><%= most_recent_update(@activities, @user, :atom) %></updated>
<logo><%= logo(@user) %></logo>
- <link rel="self" href="<%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom" %>" type="application/atom+xml"/>
+ <link rel="self" href="<%= feed_self_url(@conn) %>" type="application/atom+xml"/>
<%= render Phoenix.Controller.view_module(@conn), "_author.atom", assigns %>
<%= if last_activity(@activities) do %>
<link rel="next" href="<%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}" %>" type="application/atom+xml"/>
<% end %>
<%= for activity <- @activities do %>
<%= render Phoenix.Controller.view_module(@conn), "_activity.atom", Map.merge(assigns, prepare_activity(activity)) %>
<% end %>
</feed>
diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex
index b907a7e57..e2150d8b6 100644
--- a/lib/pleroma/web/templates/feed/feed/user.rss.eex
+++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex
@@ -1,30 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
+ xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:webfeeds="http://webfeeds.org/rss/1.0"
+ xmlns:media="http://search.yahoo.com/mrss/"
xmlns:thr="http://purl.org/syndication/thread/1.0"
xmlns:activity="http://activitystrea.ms/spec/1.0/"
xmlns:ostatus="http://ostatus.org/schema/1.0"
xmlns:poco="http://portablecontacts.net/spec/1.0">
<channel>
<title><%= @user.nickname <> "'s timeline" %></title>
- <link><%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %></link>
- <atom:link href="<%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %>"
+ <atom:link href="<%= feed_self_url(@conn) %>"
rel="self" type="application/rss+xml" />
+ <link><%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %></link>
<description><%= escape(@user.bio) %></description>
<image>
<url><%= logo(@user) %></url>
<title><%= @user.nickname <> "'s timeline" %></title>
<link><%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss" %></link>
</image>
+ <lastBuildDate><%= most_recent_update(@activities, @user, :rss) %></lastBuildDate>
+ <webfeeds:icon><%= logo(@user) %></webfeeds:icon>
+ <generator><%= Pleroma.Application.named_version() %></generator>
<%= render Phoenix.Controller.view_module(@conn), "_author.rss", assigns %>
<%= if last_activity(@activities) do %>
- <link rel="next"><%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}" %></link>
+ <atom:link rel="next" href="<%= "#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}" %>" />
<% end %>
<%= for activity <- @activities do %>
<%= render Phoenix.Controller.view_module(@conn), "_activity.rss", Map.merge(assigns, prepare_activity(activity)) %>
<% end %>
</channel>
</rss>
diff --git a/lib/pleroma/workers/publisher_worker.ex b/lib/pleroma/workers/publisher_worker.ex
index f799af77a..186f6f4ef 100644
--- a/lib/pleroma/workers/publisher_worker.ex
+++ b/lib/pleroma/workers/publisher_worker.ex
@@ -1,58 +1,74 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.PublisherWorker do
alias Pleroma.Activity
+ alias Pleroma.Config
alias Pleroma.Instances
alias Pleroma.Web.Federator
use Oban.Worker, queue: :federator_outgoing, max_attempts: 13
+ @publish_one_param_keys %{
+ "activity_id" => :activity_id,
+ "cc" => :cc,
+ "inbox" => :inbox,
+ "unreachable_since" => :unreachable_since
+ }
+
@impl true
def perform(%Job{args: %{"op" => "publish", "activity_id" => activity_id}}) do
activity = Activity.get_by_id(activity_id)
- Federator.perform(:publish, activity)
+ federator().perform(:publish, activity)
end
def perform(%Job{args: %{"op" => "publish_one", "params" => params}} = job) do
- params = Map.new(params, fn {k, v} -> {String.to_atom(k), v} end)
+ params =
+ Enum.reduce(params, %{}, fn {key, value}, acc ->
+ case @publish_one_param_keys do
+ %{^key => atom_key} -> Map.put(acc, atom_key, value)
+ _ -> acc
+ end
+ end)
# Cancel / skip the job if this server believed to be unreachable now
if not Instances.reachable?(params.inbox) do
{:cancel, :unreachable}
else
- case Federator.perform(:publish_one, params) do
+ case federator().perform(:publish_one, params) do
{:ok, _} ->
:ok
{:error, _} = error ->
# Only mark as unreachable on final failure
if job.attempt == job.max_attempts do
Instances.set_unreachable(params.inbox)
end
error
error ->
# Unexpected error, may have been client side
error
end
end
end
@impl true
def timeout(_job), do: :timer.seconds(10)
+ defp federator, do: Config.get([:pipeline, :federator], Federator)
+
@base_backoff 15
@pow 5
@impl true
def backoff(%Job{attempt: attempt}) when is_integer(attempt) do
backoff =
:math.pow(attempt, @pow) +
@base_backoff +
:rand.uniform(2 * @base_backoff) * attempt
trunc(backoff)
end
end
diff --git a/mix.exs b/mix.exs
index 3d525b321..4c723c9eb 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,364 +1,371 @@
defmodule Pleroma.Mixfile do
use Mix.Project
def project do
[
app: :pleroma,
version: version("2.10.2"),
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: Mix.compilers(),
elixirc_options: [warnings_as_errors: warnings_as_errors(), prune_code_paths: false],
xref: [exclude: [:eldap]],
dialyzer: [plt_add_apps: [:mix, :eldap]],
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
test_coverage: [tool: :covertool, summary: true],
+ test_ignore_filters: [
+ "test/credo/check/consistency/file_location.ex",
+ "test/fixtures/config/temp.exported_from_db.secret.exs",
+ "test/fixtures/config/temp.secret.exs",
+ "test/fixtures/modules/good_mrf.ex",
+ "test/fixtures/modules/runtime_module.ex"
+ ],
# Docs
name: "Pleroma",
homepage_url: "https://pleroma.social/",
source_url: "https://git.pleroma.social/pleroma/pleroma",
docs: [
source_url_pattern:
"https://git.pleroma.social/pleroma/pleroma/blob/develop/%{path}#L%{line}",
logo: "priv/static/images/logo.png",
extras: ["README.md", "CHANGELOG.md"] ++ Path.wildcard("docs/**/*.md"),
groups_for_extras: [
"Installation manuals": Path.wildcard("docs/installation/*.md"),
Configuration: Path.wildcard("docs/config/*.md"),
Administration: Path.wildcard("docs/admin/*.md"),
"Pleroma's APIs and Mastodon API extensions": Path.wildcard("docs/api/*.md")
],
main: "readme",
output: "priv/static/doc"
],
releases: [
pleroma: [
include_executables_for: [:unix],
applications: [ex_syslogger: :load, syslog: :load, eldap: :transient],
steps: [:assemble, &copy_files/1, &copy_nginx_config/1],
config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, []}]
]
]
]
end
def copy_files(%{path: target_path} = release) do
File.cp_r!("./rel/files", target_path)
release
end
def copy_nginx_config(%{path: target_path} = release) do
File.cp!(
"./installation/pleroma.nginx",
Path.join([target_path, "installation", "pleroma.nginx"])
)
release
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Pleroma.Application, []},
extra_applications:
[
:logger,
:runtime_tools,
:comeonin,
:fast_sanitize,
:os_mon,
:ssl,
:eldap
] ++ logger_application(),
included_applications: [:ex_syslogger]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:benchmark), do: ["lib", "benchmarks", "priv/scrubbers"]
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp warnings_as_errors, do: System.get_env("CI") == "true"
# Specifies OAuth dependencies.
defp oauth_deps do
oauth_strategy_packages =
System.get_env("OAUTH_CONSUMER_STRATEGIES")
|> to_string()
|> String.split()
|> Enum.map(fn strategy_entry ->
with [_strategy, dependency] <- String.split(strategy_entry, ":") do
dependency
else
[strategy] -> "ueberauth_#{strategy}"
end
end)
for s <- oauth_strategy_packages, do: {String.to_atom(s), ">= 0.0.0"}
end
defp logger_application do
if Version.match?(System.version(), "<1.15.0-rc.0") do
[]
else
[:logger_backends]
end
end
defp logger_deps do
if Version.match?(System.version(), "<1.15.0-rc.0") do
[]
else
[{:logger_backends, "~> 1.0.0"}]
end
end
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.0"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:ecto_enum, "~> 1.4"},
{:postgrex, ">= 0.21.1"},
{:phoenix_html, "~> 3.3"},
{:phoenix_live_view, "~> 1.1.19"},
{:phoenix_live_dashboard, "~> 0.8.7"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.3"},
{:tzdata, "~> 1.0.5"},
{:plug_cowboy, "~> 2.7"},
{:oban, "~> 2.19.4"},
{:oban_plugins_lazarus, "~> 0.1.1"},
{:oban_web, "~> 2.11"},
{:gettext, "~> 0.24"},
{:bcrypt_elixir, "~> 2.3"},
{:trailing_format_plug, "~> 0.0.7"},
{:fast_sanitize, "~> 0.2.3"},
{:html_entities, "~> 0.5", override: true},
{:calendar, "~> 1.0"},
{:cachex, "~> 3.6"},
{:tesla, "~> 1.15"},
{:castore, "~> 1.0"},
{:cowlib, "~> 2.15"},
{:gun, "~> 2.2"},
{:finch, "~> 0.20"},
{:jason, "~> 1.4"},
{:mogrify, "~> 0.9.3", override: true},
{:ex_aws, "~> 2.1.9"},
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7.5"},
{:earmark, "1.4.46"},
{:bbcode_pleroma, "~> 0.2.0"},
{:pleroma_mfm_parser, "~> 0.2.1"},
{:cors_plug, "~> 2.0"},
{:web_push_encryption, "~> 0.3.1"},
{:swoosh, "~> 1.16.12"},
{:phoenix_swoosh, "~> 1.2"},
{:gen_smtp, "~> 0.15"},
{:mua, "~> 0.2.4"},
{:mail, "~> 0.3.1"},
{:ex_syslogger, "~> 1.5"},
{:floki, "~> 0.38"},
{:timex, "~> 3.7"},
{:ueberauth, "~> 0.10"},
{:linkify, "~> 0.5.3"},
{:http_signatures, "~> 0.1.3"},
{:telemetry, "~> 1.0.0", override: true},
{:poolboy, "~> 1.5"},
{:prom_ex, "~> 1.9"},
{:recon, "~> 2.5"},
{:joken, "~> 2.6"},
{:pot, "~> 1.0"},
{:ex_const, "~> 0.3"},
{:plug_static_index_html, "~> 1.0.0"},
{:flake_id, "~> 0.1.0"},
{:concurrent_limiter, "~> 0.1.1"},
{:remote_ip, "~> 1.2.0"},
{:inet_cidr, "~> 1.0"},
{:captcha, "~> 1.0.3", hex: :pleroma_captcha},
{:restarter, path: "./restarter"},
{:majic, "~> 1.2"},
{:open_api_spex, "~> 3.22"},
{:ecto_psql_extras, "~> 0.8"},
{:vix, "~> 0.36"},
{:elixir_make, "~> 0.7.8", override: true},
{:blurhash, "~> 0.1.0", hex: :rinpatch_blurhash},
{:exile, "~> 0.10.0"},
{:bandit, "~> 1.10"},
{:websock_adapter, "~> 0.5.8"},
{:oban_live_dashboard, "~> 0.1.1"},
{:multipart, "~> 0.4.0", optional: true},
{:argon2_elixir, "~> 4.1"},
## dev & test
{:phoenix_live_reload, "~> 1.3.3", only: :dev},
{:poison, "~> 3.1", only: :test},
{:ex_doc, "~> 0.38", only: :dev, runtime: false},
{:ex_machina, "~> 2.8", only: :test},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mock, "~> 0.3.9", only: :test},
{:covertool, "~> 2.0", only: :test},
{:hackney, "~> 1.20.1", override: true},
{:mox, "~> 1.2", only: :test},
{:websockex, "~> 0.4.3", only: :test},
{:benchee, "~> 1.4", only: :benchmark},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
] ++ oauth_deps() ++ logger_deps()
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to create, migrate and run the seeds file at once:
#
# $ mix ecto.setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
"ecto.migrate": ["pleroma.ecto.migrate"],
"ecto.rollback": ["pleroma.ecto.rollback"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "pleroma.ecto.migrate --quiet", "test --warnings-as-errors"],
docs: ["pleroma.docs", "docs"],
analyze: ["credo --strict --only=warnings,todo,fixme,consistency,readability"],
copyright: &add_copyright/1,
"copyright.bump": &bump_copyright/1
]
end
# Builds a version string made of:
# * the application version
# * a pre-release if ahead of the tag: the describe string (-count-commithash)
# * branch name
# * build metadata:
# * a build name if `PLEROMA_BUILD_NAME` or `:pleroma, :build_name` is defined
# * the mix environment if different than prod
defp version(version) do
identifier_filter = ~r/[^0-9a-z\-]+/i
git_available? = match?({_output, 0}, System.cmd("sh", ["-c", "command -v git"]))
dotgit_present? = File.exists?(".git")
git_pre_release =
if git_available? and dotgit_present? do
{tag, tag_err} =
System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true)
{describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"])
{commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"])
# Pre-release version, denoted from patch version with a hyphen
cond do
tag_err == 0 and describe_err == 0 ->
describe
|> String.trim()
|> String.replace(String.trim(tag), "")
|> String.trim_leading("-")
|> String.trim()
commit_hash_err == 0 ->
"0-g" <> String.trim(commit_hash)
true ->
nil
end
end
# Branch name as pre-release version component, denoted with a dot
branch_name =
with true <- git_available?,
true <- dotgit_present?,
{branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]),
branch_name <- String.trim(branch_name),
branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name,
true <-
!Enum.any?(["master", "HEAD", "release/", "stable"], fn name ->
String.starts_with?(name, branch_name)
end) do
branch_name =
branch_name
|> String.trim()
|> String.replace(identifier_filter, "-")
branch_name
else
_ -> ""
end
build_name =
cond do
name = Application.get_env(:pleroma, :build_name) -> name
name = System.get_env("PLEROMA_BUILD_NAME") -> name
true -> nil
end
env_name = if Mix.env() != :prod, do: to_string(Mix.env())
env_override = System.get_env("PLEROMA_BUILD_ENV")
env_name =
case env_override do
nil -> env_name
env_override when env_override in ["", "prod"] -> nil
env_override -> env_override
end
# Pre-release version, denoted by appending a hyphen
# and a series of dot separated identifiers
pre_release =
[git_pre_release, branch_name]
|> Enum.filter(fn string -> string && string != "" end)
|> Enum.join(".")
|> (fn
"" -> nil
string -> "-" <> String.replace(string, identifier_filter, "-")
end).()
# Build metadata, denoted with a plus sign
build_metadata =
[build_name, env_name]
|> Enum.filter(fn string -> string && string != "" end)
|> Enum.join(".")
|> (fn
"" -> nil
string -> "+" <> String.replace(string, identifier_filter, "-")
end).()
[version, pre_release, build_metadata]
|> Enum.filter(fn string -> string && string != "" end)
|> Enum.join()
end
defp add_copyright(_) do
year = NaiveDateTime.utc_now().year
template = ~s[\
# Pleroma: A lightweight social networking server
# Copyright © 2017-#{year} Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
] |> String.replace("\n", "\\n")
find = "find lib test priv -type f \\( -name '*.ex' -or -name '*.exs' \\) -exec "
grep = "grep -L '# Copyright © [0-9\-]* Pleroma' {} \\;"
xargs = "xargs -n1 sed -i'' '1s;^;#{template};'"
:os.cmd(String.to_charlist("#{find}#{grep} | #{xargs}"))
end
defp bump_copyright(_) do
year = NaiveDateTime.utc_now().year
find = "find lib test priv -type f \\( -name '*.ex' -or -name '*.exs' \\)"
xargs =
"xargs sed -i'' 's;# Copyright © [0-9\-]* Pleroma.*$;# Copyright © 2017-#{year} Pleroma Authors <https://pleroma.social/>;'"
:os.cmd(String.to_charlist("#{find} | #{xargs}"))
end
end
diff --git a/test/mix/tasks/pleroma/config_test.exs b/test/mix/tasks/pleroma/config_test.exs
index f672d8c13..7513e958e 100644
--- a/test/mix/tasks/pleroma/config_test.exs
+++ b/test/mix/tasks/pleroma/config_test.exs
@@ -1,373 +1,407 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.ConfigTest do
use Pleroma.DataCase
import Pleroma.Factory
alias Mix.Tasks.Pleroma.Config, as: MixTask
alias Pleroma.ConfigDB
alias Pleroma.Repo
setup_all do
Mix.shell(Mix.Shell.Process)
on_exit(fn ->
Mix.shell(Mix.Shell.IO)
Application.delete_env(:pleroma, :first_setting)
Application.delete_env(:pleroma, :second_setting)
end)
:ok
end
defp config_records do
ConfigDB
|> Repo.all()
|> Enum.sort()
end
defp insert_config_record(group, key, value) do
insert(:config,
group: group,
key: key,
value: value
)
end
test "error if file with custom settings doesn't exist" do
MixTask.migrate_to_db("config/non_existent_config_file.exs")
msg =
"To migrate settings, you must define custom settings in config/non_existent_config_file.exs."
assert_receive {:mix_shell, :info, [^msg]}, 15
end
describe "migrate_to_db/1" do
setup do
clear_config(:configurable_from_database, true)
end
test "config migration refused when deprecated settings are found" do
clear_config([:media_proxy, :whitelist], ["domain_without_scheme.com"])
assert config_records() == []
MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs")
assert_received {:mix_shell, :error, [message]}
assert message =~
"Migration is not allowed until all deprecation warnings have been resolved."
end
test "filtered settings are migrated to db" do
assert config_records() == []
MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs")
config1 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"})
config2 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":second_setting"})
refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"})
refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"})
refute ConfigDB.get_by_params(%{group: ":pleroma", key: ":database"})
assert config1.value == [key: "value", key2: [Repo]]
assert config2.value == [key: "value2", key2: ["Activity"]]
end
test "config table is truncated before migration" do
insert_config_record(:pleroma, :first_setting, key: "value", key2: ["Activity"])
assert length(config_records()) == 1
MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs")
config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"})
assert config.value == [key: "value", key2: [Repo]]
end
end
describe "with deletion of temp file" do
setup do
clear_config(:configurable_from_database, true)
temp_file = "config/temp.exported_from_db.secret.exs"
on_exit(fn ->
:ok = File.rm(temp_file)
end)
{:ok, temp_file: temp_file}
end
test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do
insert_config_record(:pleroma, :setting_first, key: "value", key2: ["Activity"])
insert_config_record(:pleroma, :setting_second, key: "value2", key2: [Repo])
MixTask.run(["migrate_from_db", "--env", "temp", "-d"])
assert config_records() == []
file = File.read!(temp_file)
assert file =~ "config :pleroma, :setting_first,"
assert file =~ "config :pleroma, :setting_second,"
end
test "load a settings with large values and pass to file", %{temp_file: temp_file} do
insert(:config,
key: :instance,
value: [
name: "Pleroma",
email: "example@example.com",
notify_email: "noreply@example.com",
description: "A Pleroma instance, an alternative fediverse server",
limit: 5_000,
chat_limit: 5_000,
remote_limit: 100_000,
upload_limit: 16_000_000,
avatar_upload_limit: 2_000_000,
background_upload_limit: 4_000_000,
banner_upload_limit: 4_000_000,
poll_limits: %{
max_options: 20,
max_option_chars: 200,
min_expiration: 0,
max_expiration: 365 * 24 * 60 * 60
},
registrations_open: true,
federating: true,
federation_incoming_replies_max_depth: 100,
federation_reachability_timeout_days: 7,
allow_relay: true,
public: true,
quarantined_instances: [],
managed_config: true,
static_dir: "instance/static/",
allowed_post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/bbcode",
"text/x.misskeymarkdown"
],
autofollowed_nicknames: [],
max_pinned_statuses: 1,
attachment_links: false,
max_report_comment_size: 1000,
safe_dm_mentions: false,
healthcheck: false,
remote_post_retention_days: 90,
skip_thread_containment: true,
limit_to_local_content: :unauthenticated,
user_bio_length: 5000,
user_name_length: 100,
max_account_fields: 10,
max_remote_account_fields: 20,
account_field_name_length: 512,
account_field_value_length: 2048,
external_user_synchronization: true,
extended_nickname_format: true,
multi_factor_authentication: [
totp: [
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
]
)
MixTask.run(["migrate_from_db", "--env", "temp", "-d"])
assert config_records() == []
assert File.exists?(temp_file)
{:ok, file} = File.read(temp_file)
assert file =~ "import Config\n"
assert file =~ "A Pleroma instance, an alternative fediverse server"
end
end
describe "migrate_from_db/1" do
setup do: clear_config(:configurable_from_database, true)
setup do
insert_config_record(:pleroma, :setting_first, key: "value", key2: ["Activity"])
insert_config_record(:pleroma, :setting_second, key: "value2", key2: [Repo])
path = "test/instance_static"
file_path = Path.join(path, "temp.exported_from_db.secret.exs")
on_exit(fn -> File.rm!(file_path) end)
[file_path: file_path]
end
test "with path parameter", %{file_path: file_path} do
MixTask.run(["migrate_from_db", "--env", "temp", "--path", Path.dirname(file_path)])
file = File.read!(file_path)
assert file =~ "config :pleroma, :setting_first,"
assert file =~ "config :pleroma, :setting_second,"
end
test "release", %{file_path: file_path} do
clear_config(:release, true)
clear_config(:config_path, file_path)
MixTask.run(["migrate_from_db", "--env", "temp"])
file = File.read!(file_path)
assert file =~ "config :pleroma, :setting_first,"
assert file =~ "config :pleroma, :setting_second,"
end
end
describe "operations on database config" do
setup do: clear_config(:configurable_from_database, true)
test "dumping a specific group" do
insert_config_record(:pleroma, :instance, name: "Pleroma Test")
insert_config_record(:web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com",
public_key:
"BOsPL-_KjNnjj_RMvLeR3dTOrcndi4TbMR0cu56gLGfGaT5m1gXxSfRHOcC4Dd78ycQL1gdhtx13qgKHmTM5xAI",
private_key: "Ism6FNdS31nLCA94EfVbJbDdJXCxAZ8cZiB1JQPN_t4"
)
MixTask.run(["dump", "pleroma"])
assert_receive {:mix_shell, :info,
["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]}
refute_receive {
:mix_shell,
:info,
[
"config :web_push_encryption, :vapid_details, [subject: \"mailto:administrator@example.com\", public_key: \"BOsPL-_KjNnjj_RMvLeR3dTOrcndi4TbMR0cu56gLGfGaT5m1gXxSfRHOcC4Dd78ycQL1gdhtx13qgKHmTM5xAI\", private_key: \"Ism6FNdS31nLCA94EfVbJbDdJXCxAZ8cZiB1JQPN_t4\"]\r\n\r\n"
]
}
# Ensure operations work when using atom syntax
MixTask.run(["dump", ":pleroma"])
assert_receive {:mix_shell, :info,
["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]}
end
test "dumping a specific key in a group" do
insert_config_record(:pleroma, :instance, name: "Pleroma Test")
insert_config_record(:pleroma, Pleroma.Captcha, enabled: false)
MixTask.run(["dump", "pleroma", "Pleroma.Captcha"])
refute_receive {:mix_shell, :info,
["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]}
assert_receive {:mix_shell, :info,
["config :pleroma, Pleroma.Captcha, [enabled: false]\r\n\r\n"]}
end
test "dumps all configuration successfully" do
insert_config_record(:pleroma, :instance, name: "Pleroma Test")
insert_config_record(:pleroma, Pleroma.Captcha, enabled: false)
MixTask.run(["dump"])
assert_receive {:mix_shell, :info,
["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]}
assert_receive {:mix_shell, :info,
["config :pleroma, Pleroma.Captcha, [enabled: false]\r\n\r\n"]}
end
end
describe "when configdb disabled" do
test "refuses to dump" do
clear_config(:configurable_from_database, false)
insert_config_record(:pleroma, :instance, name: "Pleroma Test")
MixTask.run(["dump"])
msg =
"ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration."
assert_receive {:mix_shell, :error, [^msg]}
end
end
describe "destructive operations" do
setup do: clear_config(:configurable_from_database, true)
setup do
insert_config_record(:pleroma, :instance, name: "Pleroma Test")
insert_config_record(:pleroma, Pleroma.Captcha, enabled: false)
insert_config_record(:pleroma2, :key2, z: 1)
assert length(config_records()) == 3
:ok
end
test "deletes group of settings" do
MixTask.run(["delete", "--force", "pleroma"])
assert [%ConfigDB{group: :pleroma2, key: :key2}] = config_records()
end
test "deletes specified key" do
MixTask.run(["delete", "--force", "pleroma", "Pleroma.Captcha"])
assert [
%ConfigDB{group: :pleroma, key: :instance},
%ConfigDB{group: :pleroma2, key: :key2}
] = config_records()
end
test "resets entire config" do
MixTask.run(["reset", "--force"])
assert config_records() == []
end
test "filters non-whitelisted settings" do
clear_config(:database_config_whitelist, [
{:pleroma},
{:web_push_encryption, :vapid_details}
])
insert_config_record(:web_push_encryption, :non_whitelisted_key, a: 1)
insert_config_record(:web_push_encryption, :vapid_details, b: 1)
MixTask.run(["filter_whitelisted", "--force"])
assert [
%ConfigDB{group: :pleroma, key: :instance},
%ConfigDB{group: :pleroma, key: Pleroma.Captcha},
%ConfigDB{group: :web_push_encryption, key: :vapid_details}
] = config_records()
end
test "filter_whitelisted doesn't crash when whitelist is unset" do
clear_config(:database_config_whitelist, nil)
existing = config_records()
MixTask.run(["filter_whitelisted", "--force"])
assert config_records() == existing
end
test "filter_whitelisted doesn't crash when whitelist is disabled" do
clear_config(:database_config_whitelist, false)
existing = config_records()
MixTask.run(["filter_whitelisted", "--force"])
assert config_records() == existing
end
+
+ test "filters blacklisted settings" do
+ clear_config(:database_config_whitelist, [{:pleroma}])
+ clear_config(:database_config_blacklist, [{:pleroma, :logger}, {:blacklisted_group}])
+
+ insert_config_record(:pleroma, :logger, backends: :console)
+ insert_config_record(:web_push_encryption, :vapid_details, a: 1)
+ insert_config_record(:blacklisted_group, :anything, a: 1)
+
+ MixTask.run(["filter_blacklisted", "--force"])
+
+ assert [
+ %ConfigDB{group: :pleroma, key: :instance},
+ %ConfigDB{group: :pleroma, key: Pleroma.Captcha},
+ %ConfigDB{group: :pleroma2, key: :key2},
+ %ConfigDB{group: :web_push_encryption, key: :vapid_details}
+ ] = config_records()
+ end
+
+ test "filter_blacklisted doesn't crash when blacklist is unset" do
+ clear_config(:database_config_blacklist, nil)
+
+ existing = config_records()
+ MixTask.run(["filter_blacklisted", "--force"])
+ assert config_records() == existing
+ end
+
+ test "filter_blacklisted doesn't crash when blacklist is disabled" do
+ clear_config(:database_config_blacklist, false)
+
+ existing = config_records()
+ MixTask.run(["filter_blacklisted", "--force"])
+ assert config_records() == existing
+ end
end
end
diff --git a/test/pleroma/application_test.exs b/test/pleroma/application_test.exs
new file mode 100644
index 000000000..75ddec08e
--- /dev/null
+++ b/test/pleroma/application_test.exs
@@ -0,0 +1,39 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.ApplicationTest do
+ use Pleroma.DataCase
+
+ import ExUnit.CaptureLog
+
+ # TODO HACK: Temporarily disabled.
+ # Currently causes a bunch of warnings about :warn being deprecated in logs.
+ # ex_syslogger needs to either be updated upstream or forked.
+ describe "config :pleroma, :logger, backends: [{:ex_syslogger, :ex_syslogger}]" do
+ setup do
+ clear_config([:logger], backends: [{:ex_syslogger, :ex_syslogger}])
+ end
+
+ @tag :skip
+ test "is handled" do
+ log = capture_log(fn -> Pleroma.Application.configure_logger() end)
+
+ assert log =~
+ "Configuration {:ex_syslogger, :ex_syslogger} is incorrect. Use {ExSyslogger, :ex_syslogger} instead!"
+
+ assert log =~ "Successfully added logger backend: {ExSyslogger, :ex_syslogger}"
+ end
+ end
+
+ describe "config :pleroma, :logger, :backends: [:console]" do
+ setup do
+ clear_config([:logger], backends: [:console])
+ end
+
+ test "emits a warning" do
+ assert capture_log(fn -> Pleroma.Application.configure_logger() end) =~
+ ":console is no longer considered a backend and is enabled by default"
+ end
+ end
+end
diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs
index fca2324ff..62f5bdeea 100644
--- a/test/pleroma/config/deprecation_warnings_test.exs
+++ b/test/pleroma/config/deprecation_warnings_test.exs
@@ -1,391 +1,449 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Config.DeprecationWarningsTest do
- use ExUnit.Case
+ use ExUnit.Case, async: false
use Pleroma.Tests.Helpers
import ExUnit.CaptureLog
alias Pleroma.Config
alias Pleroma.Config.DeprecationWarnings
describe "filter exiftool" do
test "gives warning when still used" do
clear_config(
[Pleroma.Upload, :filters],
[Pleroma.Upload.Filter.Exiftool]
)
assert capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool]
```
Is now
```
config :pleroma, Pleroma.Upload,
filters: [Pleroma.Upload.Filter.Exiftool.StripLocation]
```
"""
end
test "changes setting to exiftool strip location" do
clear_config(
[Pleroma.Upload, :filters],
[Pleroma.Upload.Filter.Exiftool, Pleroma.Upload.Filter.Exiftool.ReadDescription]
)
expected_config = [
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
]
capture_log(fn -> DeprecationWarnings.warn() end)
assert Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) == expected_config
end
test "doesn't give a warning with correct config" do
clear_config(
[Pleroma.Upload, :filters],
[
Pleroma.Upload.Filter.Exiftool.StripLocation,
Pleroma.Upload.Filter.Exiftool.ReadDescription
]
)
assert capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end) == ""
end
end
describe "simple policy tuples" do
test "gives warning when there are still strings" do
clear_config([:mrf_simple],
media_removal: ["some.removal"],
media_nsfw: ["some.nsfw"],
federated_timeline_removal: ["some.tl.removal"],
report_removal: ["some.report.removal"],
reject: ["some.reject"],
followers_only: ["some.followers.only"],
accept: ["some.accept"],
avatar_removal: ["some.avatar.removal"],
banner_removal: ["some.banner.removal"],
reject_deletes: ["some.reject.deletes"]
)
assert capture_log(fn -> DeprecationWarnings.check_simple_policy_tuples() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using strings in the SimplePolicy configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :mrf_simple,
media_removal: ["instance.tld"],
media_nsfw: ["instance.tld"],
federated_timeline_removal: ["instance.tld"],
report_removal: ["instance.tld"],
reject: ["instance.tld"],
followers_only: ["instance.tld"],
accept: ["instance.tld"],
avatar_removal: ["instance.tld"],
banner_removal: ["instance.tld"],
reject_deletes: ["instance.tld"]
```
Is now
```
config :pleroma, :mrf_simple,
media_removal: [{"instance.tld", "Reason for media removal"}],
media_nsfw: [{"instance.tld", "Reason for media nsfw"}],
federated_timeline_removal: [{"instance.tld", "Reason for federated timeline removal"}],
report_removal: [{"instance.tld", "Reason for report removal"}],
reject: [{"instance.tld", "Reason for reject"}],
followers_only: [{"instance.tld", "Reason for followers only"}],
accept: [{"instance.tld", "Reason for accept"}],
avatar_removal: [{"instance.tld", "Reason for avatar removal"}],
banner_removal: [{"instance.tld", "Reason for banner removal"}],
reject_deletes: [{"instance.tld", "Reason for reject deletes"}]
```
"""
end
test "transforms config to tuples" do
clear_config([:mrf_simple],
media_removal: ["some.removal", {"some.other.instance", "Some reason"}]
)
expected_config =
{:media_removal, [{"some.removal", ""}, {"some.other.instance", "Some reason"}]}
capture_log(fn -> DeprecationWarnings.warn() end)
assert expected_config in Config.get([:mrf_simple])
end
test "doesn't give a warning with correct config" do
clear_config([:mrf_simple],
media_removal: [{"some.removal", ""}, {"some.other.instance", "Some reason"}]
)
assert capture_log(fn -> DeprecationWarnings.check_simple_policy_tuples() end) == ""
end
end
describe "quarantined_instances tuples" do
test "gives warning when there are still strings" do
clear_config([:instance, :quarantined_instances], [
{"domain.com", "some reason"},
"somedomain.tld"
])
assert capture_log(fn -> DeprecationWarnings.check_quarantined_instances_tuples() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using strings in the quarantined_instances configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :instance,
quarantined_instances: ["instance.tld"]
```
Is now
```
config :pleroma, :instance,
quarantined_instances: [{"instance.tld", "Reason for quarantine"}]
```
"""
end
test "transforms config to tuples" do
clear_config([:instance, :quarantined_instances], [
{"domain.com", "some reason"},
"some.tld"
])
expected_config = [{"domain.com", "some reason"}, {"some.tld", ""}]
capture_log(fn -> DeprecationWarnings.warn() end)
assert Config.get([:instance, :quarantined_instances]) == expected_config
end
test "doesn't give a warning with correct config" do
clear_config([:instance, :quarantined_instances], [
{"domain.com", "some reason"},
{"some.tld", ""}
])
assert capture_log(fn -> DeprecationWarnings.check_quarantined_instances_tuples() end) == ""
end
end
describe "transparency_exclusions tuples" do
test "gives warning when there are still strings" do
clear_config([:mrf, :transparency_exclusions], [
{"domain.com", "some reason"},
"somedomain.tld"
])
assert capture_log(fn -> DeprecationWarnings.check_transparency_exclusions_tuples() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using strings in the transparency_exclusions configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later:
```
config :pleroma, :mrf,
transparency_exclusions: ["instance.tld"]
```
Is now
```
config :pleroma, :mrf,
transparency_exclusions: [{"instance.tld", "Reason to exclude transparency"}]
```
"""
end
test "transforms config to tuples" do
clear_config([:mrf, :transparency_exclusions], [
{"domain.com", "some reason"},
"some.tld"
])
expected_config = [{"domain.com", "some reason"}, {"some.tld", ""}]
capture_log(fn -> DeprecationWarnings.warn() end)
assert Config.get([:mrf, :transparency_exclusions]) == expected_config
end
test "doesn't give a warning with correct config" do
clear_config([:mrf, :transparency_exclusions], [
{"domain.com", "some reason"},
{"some.tld", ""}
])
assert capture_log(fn -> DeprecationWarnings.check_transparency_exclusions_tuples() end) ==
""
end
end
test "check_old_mrf_config/0" do
clear_config([:instance, :rewrite_policy], [])
clear_config([:instance, :mrf_transparency], true)
clear_config([:instance, :mrf_transparency_exclusions], [])
assert capture_log(fn -> DeprecationWarnings.check_old_mrf_config() end) =~
"""
!!!DEPRECATION WARNING!!!
Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later:
* `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies`
* `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency`
* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`
"""
end
test "move_namespace_and_warn/2" do
old_group1 = [:group, :key]
old_group2 = [:group, :key2]
old_group3 = [:group, :key3]
new_group1 = [:another_group, :key4]
new_group2 = [:another_group, :key5]
new_group3 = [:another_group, :key6]
clear_config(old_group1, 1)
clear_config(old_group2, 2)
clear_config(old_group3, 3)
clear_config(new_group1)
clear_config(new_group2)
clear_config(new_group3)
config_map = [
{old_group1, new_group1, "\n error :key"},
{old_group2, new_group2, "\n error :key2"},
{old_group3, new_group3, "\n error :key3"}
]
assert capture_log(fn ->
DeprecationWarnings.move_namespace_and_warn(
config_map,
"Warning preface"
)
end) =~ "Warning preface\n error :key\n error :key2\n error :key3"
assert Config.get(new_group1) == 1
assert Config.get(new_group2) == 2
assert Config.get(new_group3) == 3
end
test "check_media_proxy_whitelist_config/0" do
clear_config([:media_proxy, :whitelist], ["https://example.com", "example2.com"])
assert capture_log(fn ->
DeprecationWarnings.check_media_proxy_whitelist_config()
end) =~ "Your config is using old format (only domain) for MediaProxy whitelist option"
end
test "check_welcome_message_config/0" do
clear_config([:instance, :welcome_user_nickname], "LainChan")
assert capture_log(fn ->
DeprecationWarnings.check_welcome_message_config()
end) =~ "Your config is using the old namespace for Welcome messages configuration."
end
test "check_hellthread_threshold/0" do
clear_config([:mrf_hellthread, :threshold], 16)
assert capture_log(fn ->
DeprecationWarnings.check_hellthread_threshold()
end) =~ "You are using the old configuration mechanism for the hellthread filter."
end
test "check_activity_expiration_config/0" do
clear_config([Pleroma.ActivityExpiration], enabled: true)
assert capture_log(fn ->
DeprecationWarnings.check_activity_expiration_config()
end) =~ "Your config is using old namespace for activity expiration configuration."
end
test "check_uploaders_s3_public_endpoint/0" do
clear_config([Pleroma.Uploaders.S3], public_endpoint: "https://fake.amazonaws.com/bucket/")
assert capture_log(fn ->
DeprecationWarnings.check_uploaders_s3_public_endpoint()
end) =~
"Your config is using the old setting for controlling the URL of media uploaded to your S3 bucket."
end
describe "check_gun_pool_options/0" do
test "await_up_timeout" do
config = Config.get(:connections_pool)
clear_config(:connections_pool, Keyword.put(config, :await_up_timeout, 5_000))
assert capture_log(fn ->
DeprecationWarnings.check_gun_pool_options()
end) =~
"Your config is using old setting `config :pleroma, :connections_pool, await_up_timeout`."
end
test "pool timeout" do
old_config = [
federation: [
size: 50,
max_waiting: 10,
timeout: 10_000
],
media: [
size: 50,
max_waiting: 10,
timeout: 10_000
],
upload: [
size: 25,
max_waiting: 5,
timeout: 15_000
],
default: [
size: 10,
max_waiting: 2,
timeout: 5_000
]
]
clear_config(:pools, old_config)
assert capture_log(fn ->
DeprecationWarnings.check_gun_pool_options()
end) =~
"Your config is using old setting name `timeout` instead of `recv_timeout` in pool settings"
end
end
test "check_old_chat_shoutbox/0" do
clear_config([:instance, :chat_limit], 1_000)
clear_config([:chat, :enabled], true)
assert capture_log(fn ->
DeprecationWarnings.check_old_chat_shoutbox()
end) =~
"Your config is using the old namespace for the Shoutbox configuration."
end
+
+ describe "check_deprecated_logger_config" do
+ setup do
+ initial_console = Application.get_env(:logger, :console, nil)
+ initial_backends = Application.get_env(:logger, :backends, nil)
+
+ # NOTE: The Logger does not get reconfigured when running in the test env,
+ # the added and on_exit removed settings below should have no effect besides
+ # triggering the deprecation warnings
+ Application.put_env(:logger, :console, level: :all)
+ Application.put_env(:logger, :backends, [:console])
+
+ on_exit(fn ->
+ if initial_console do
+ Application.put_env(:logger, :console, initial_console)
+ else
+ Application.delete_env(:logger, :console)
+ end
+
+ if initial_backends do
+ Application.put_env(:logger, :backends, initial_backends)
+ else
+ Application.delete_env(:logger, :backends)
+ end
+ end)
+ end
+
+ test "warns on deprecated syntax" do
+ log =
+ capture_log(fn ->
+ Pleroma.Config.DeprecationWarnings.check_deprecated_logger_config()
+ end)
+
+ assert log =~
+ """
+ !!!DEPRECATION WARNING!!!
+ Your configuration is using deprecated syntax for configuring backends of Elixir's logger.
+ `config :logger, backends: [...]` is deprecated syntax due to changes in Elixir.
+ Please update your configuration at your earliest convenience to use:
+ `config :pleroma, :logger, backends: [...]`
+
+ Note: `:console` is no longer considered a backend and is used by default, you can disable it using:
+ `config :logger, :default_handler: false`
+ """
+
+ assert log =~
+ """
+ !!!DEPRECATION WARNING!!!
+ Your configuration is using deprecated syntax for configuring logging to console.
+ `config :logger, :console` is deprecated syntax due to changes in Elixir.
+ Please update your configuration at your earliest convenience to use
+ `config :logger, default_handler` and `config :logger, :default_formatter`.
+
+ Note: `:default_handler` is used only for the `level` setting. All other configurations go under
+ `:default_formatter`. For more info visit: https://hexdocs.pm/logger/Logger.html#module-backends-and-backwards-compatibility
+ """
+ end
+ end
end
diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs
index de735cdb7..9ae9cce26 100644
--- a/test/pleroma/user_test.exs
+++ b/test/pleroma/user_test.exs
@@ -1,3098 +1,3110 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.UserTest do
alias Pleroma.Activity
alias Pleroma.Builders.UserBuilder
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.CommonAPI
use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo
import Pleroma.Factory
import ExUnit.CaptureLog
import Swoosh.TestAssertions
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
setup_all do
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
setup do: clear_config([:instance, :account_activation_required])
describe "service actors" do
test "returns updated invisible actor" do
uri = "#{Pleroma.Web.Endpoint.url()}/relay"
followers_uri = "#{uri}/followers"
insert(
:user,
%{
nickname: "relay",
invisible: false,
local: true,
ap_id: uri,
follower_address: followers_uri
}
)
actor = User.get_or_create_service_actor_by_ap_id(uri, "relay")
assert actor.invisible
end
test "returns relay user" do
uri = "#{Pleroma.Web.Endpoint.url()}/relay"
followers_uri = "#{uri}/followers"
assert %User{
nickname: "relay",
invisible: true,
local: true,
ap_id: ^uri,
follower_address: ^followers_uri
} = User.get_or_create_service_actor_by_ap_id(uri, "relay")
assert capture_log(fn ->
refute User.get_or_create_service_actor_by_ap_id("/relay", "relay")
end) =~ "Cannot create service actor:"
end
test "returns invisible actor" do
uri = "#{Pleroma.Web.Endpoint.url()}/internal/fetch-test"
followers_uri = "#{uri}/followers"
user = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
assert %User{
nickname: "internal.fetch-test",
invisible: true,
local: true,
ap_id: ^uri,
follower_address: ^followers_uri
} = user
user2 = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
assert user.id == user2.id
end
end
describe "AP ID user relationships" do
setup do
{:ok, user: insert(:user)}
end
test "outgoing_relationships_ap_ids/1", %{user: user} do
rel_types = [:block, :mute, :notification_mute, :reblog_mute, :inverse_subscription]
ap_ids_by_rel =
Enum.into(
rel_types,
%{},
fn rel_type ->
rel_records =
insert_list(2, :user_relationship, %{source: user, relationship_type: rel_type})
ap_ids = Enum.map(rel_records, fn rr -> Repo.preload(rr, :target).target.ap_id end)
{rel_type, Enum.sort(ap_ids)}
end
)
assert ap_ids_by_rel[:block] == Enum.sort(User.blocked_users_ap_ids(user))
assert ap_ids_by_rel[:block] == Enum.sort(Enum.map(User.blocked_users(user), & &1.ap_id))
assert ap_ids_by_rel[:mute] == Enum.sort(User.muted_users_ap_ids(user))
assert ap_ids_by_rel[:mute] == Enum.sort(Enum.map(User.muted_users(user), & &1.ap_id))
assert ap_ids_by_rel[:notification_mute] ==
Enum.sort(User.notification_muted_users_ap_ids(user))
assert ap_ids_by_rel[:notification_mute] ==
Enum.sort(Enum.map(User.notification_muted_users(user), & &1.ap_id))
assert ap_ids_by_rel[:reblog_mute] == Enum.sort(User.reblog_muted_users_ap_ids(user))
assert ap_ids_by_rel[:reblog_mute] ==
Enum.sort(Enum.map(User.reblog_muted_users(user), & &1.ap_id))
assert ap_ids_by_rel[:inverse_subscription] == Enum.sort(User.subscriber_users_ap_ids(user))
assert ap_ids_by_rel[:inverse_subscription] ==
Enum.sort(Enum.map(User.subscriber_users(user), & &1.ap_id))
outgoing_relationships_ap_ids = User.outgoing_relationships_ap_ids(user, rel_types)
assert ap_ids_by_rel ==
Enum.into(outgoing_relationships_ap_ids, %{}, fn {k, v} -> {k, Enum.sort(v)} end)
end
end
describe "when tags are nil" do
test "tagging a user" do
user = insert(:user, %{tags: nil})
user = User.tag(user, ["cool", "dude"])
assert "cool" in user.tags
assert "dude" in user.tags
end
test "untagging a user" do
user = insert(:user, %{tags: nil})
user = User.untag(user, ["cool", "dude"])
assert user.tags == []
end
end
test "ap_id returns the activity pub id for the user" do
user = UserBuilder.build()
expected_ap_id = "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}"
assert expected_ap_id == User.ap_id(user)
end
test "ap_followers returns the followers collection for the user" do
user = UserBuilder.build()
expected_followers_collection = "#{User.ap_id(user)}/followers"
assert expected_followers_collection == User.ap_followers(user)
end
test "ap_following returns the following collection for the user" do
user = UserBuilder.build()
expected_followers_collection = "#{User.ap_id(user)}/following"
assert expected_followers_collection == User.ap_following(user)
end
test "returns all pending follow requests" do
unlocked = insert(:user)
locked = insert(:user, is_locked: true)
follower = insert(:user)
CommonAPI.follow(unlocked, follower)
CommonAPI.follow(locked, follower)
assert [] = User.get_follow_requests(unlocked)
assert [activity] = User.get_follow_requests(locked)
assert activity
end
test "doesn't return already accepted or duplicate follow requests" do
locked = insert(:user, is_locked: true)
pending_follower = insert(:user)
accepted_follower = insert(:user)
CommonAPI.follow(locked, pending_follower)
CommonAPI.follow(locked, pending_follower)
CommonAPI.follow(locked, accepted_follower)
Pleroma.FollowingRelationship.update(accepted_follower, locked, :follow_accept)
assert [^pending_follower] = User.get_follow_requests(locked)
end
test "doesn't return follow requests for deactivated accounts" do
locked = insert(:user, is_locked: true)
pending_follower = insert(:user, %{is_active: false})
CommonAPI.follow(locked, pending_follower)
refute pending_follower.is_active
assert [] = User.get_follow_requests(locked)
end
test "clears follow requests when requester is blocked" do
followed = insert(:user, is_locked: true)
follower = insert(:user)
CommonAPI.follow(followed, follower)
assert [_activity] = User.get_follow_requests(followed)
{:ok, _user_relationship} = User.block(followed, follower)
assert [] = User.get_follow_requests(followed)
end
test "follow_all follows multiple users" do
user = insert(:user)
followed_zero = insert(:user)
followed_one = insert(:user)
followed_two = insert(:user)
blocked = insert(:user)
not_followed = insert(:user)
reverse_blocked = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked)
{:ok, _user_relationship} = User.block(reverse_blocked, user)
{:ok, user, followed_zero} = User.follow(user, followed_zero)
{:ok, user} = User.follow_all(user, [followed_one, followed_two, blocked, reverse_blocked])
assert User.following?(user, followed_one)
assert User.following?(user, followed_two)
assert User.following?(user, followed_zero)
refute User.following?(user, not_followed)
refute User.following?(user, blocked)
refute User.following?(user, reverse_blocked)
end
test "follow_all follows multiple users without duplicating" do
user = insert(:user)
followed_zero = insert(:user)
followed_one = insert(:user)
followed_two = insert(:user)
{:ok, user} = User.follow_all(user, [followed_zero, followed_one])
assert length(User.following(user)) == 3
{:ok, user} = User.follow_all(user, [followed_one, followed_two])
assert length(User.following(user)) == 4
end
test "follow takes a user and another user" do
user = insert(:user)
followed = insert(:user)
{:ok, user, followed} = User.follow(user, followed)
user = User.get_cached_by_id(user.id)
followed = User.get_cached_by_ap_id(followed.ap_id)
assert followed.follower_count == 1
assert user.following_count == 1
assert User.ap_followers(followed) in User.following(user)
end
test "can't follow a deactivated users" do
user = insert(:user)
followed = insert(:user, %{is_active: false})
{:error, _} = User.follow(user, followed)
end
test "can't follow a user who blocked us" do
blocker = insert(:user)
blockee = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:error, _} = User.follow(blockee, blocker)
end
test "can't subscribe to a user who blocked us" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blocked)
{:error, _} = User.subscribe(blocked, blocker)
end
test "local users do not automatically follow local locked accounts" do
follower = insert(:user, is_locked: true)
followed = insert(:user, is_locked: true)
{:ok, follower, followed} = User.maybe_direct_follow(follower, followed)
refute User.following?(follower, followed)
end
describe "unfollow/2" do
setup do: clear_config([:instance, :external_user_synchronization])
test "unfollow with synchronizes external user" do
clear_config([:instance, :external_user_synchronization], true)
followed =
insert(:user,
nickname: "fuser1",
follower_address: "http://localhost:4001/users/fuser1/followers",
following_address: "http://localhost:4001/users/fuser1/following",
ap_id: "http://localhost:4001/users/fuser1"
)
user =
insert(:user, %{
local: false,
nickname: "fuser2",
ap_id: "http://localhost:4001/users/fuser2",
follower_address: "http://localhost:4001/users/fuser2/followers",
following_address: "http://localhost:4001/users/fuser2/following"
})
{:ok, user, followed} = User.follow(user, followed, :follow_accept)
{:ok, user, _activity} = User.unfollow(user, followed)
user = User.get_cached_by_id(user.id)
assert User.following(user) == []
end
test "unfollow takes a user and another user" do
followed = insert(:user)
user = insert(:user)
{:ok, user, followed} = User.follow(user, followed, :follow_accept)
assert User.following(user) == [user.follower_address, followed.follower_address]
{:ok, user, _activity} = User.unfollow(user, followed)
assert User.following(user) == [user.follower_address]
end
test "unfollow doesn't unfollow yourself" do
user = insert(:user)
{:error, _} = User.unfollow(user, user)
assert User.following(user) == [user.follower_address]
end
end
test "test if a user is following another user" do
followed = insert(:user)
user = insert(:user)
User.follow(user, followed, :follow_accept)
assert User.following?(user, followed)
refute User.following?(followed, user)
end
test "fetches correct profile for nickname beginning with number" do
# Use old-style integer ID to try to reproduce the problem
user = insert(:user, %{id: 1080})
user_with_numbers = insert(:user, %{nickname: "#{user.id}garbage"})
assert user_with_numbers == User.get_cached_by_nickname_or_id(user_with_numbers.nickname)
end
describe "user registration" do
@full_user_data %{
bio: "A guy",
name: "my name",
nickname: "nick",
password: "test",
password_confirmation: "test",
email: "email@example.com"
}
setup do: clear_config([:instance, :autofollowed_nicknames])
setup do: clear_config([:instance, :autofollowing_nicknames])
setup do: clear_config([:welcome])
setup do: clear_config([:instance, :account_activation_required])
test "it autofollows accounts that are set for it" do
user = insert(:user)
remote_user = insert(:user, %{local: false})
clear_config([:instance, :autofollowed_nicknames], [
user.nickname,
remote_user.nickname
])
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
assert User.following?(registered_user, user)
refute User.following?(registered_user, remote_user)
end
test "it adds automatic followers for new registered accounts" do
user1 = insert(:user)
user2 = insert(:user)
clear_config([:instance, :autofollowing_nicknames], [
user1.nickname,
user2.nickname
])
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
assert User.following?(user1, registered_user)
assert User.following?(user2, registered_user)
end
test "it sends a welcome message if it is set" do
welcome_user = insert(:user)
clear_config([:welcome, :direct_message, :enabled], true)
clear_config([:welcome, :direct_message, :sender_nickname], welcome_user.nickname)
clear_config([:welcome, :direct_message, :message], "Hello, this is a direct message")
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
ObanHelpers.perform_all()
activity = Repo.one(Pleroma.Activity)
assert registered_user.ap_id in activity.recipients
assert Object.normalize(activity, fetch: false).data["content"] =~ "direct message"
assert activity.actor == welcome_user.ap_id
end
test "it sends a welcome chat message if it is set" do
welcome_user = insert(:user)
clear_config([:welcome, :chat_message, :enabled], true)
clear_config([:welcome, :chat_message, :sender_nickname], welcome_user.nickname)
clear_config([:welcome, :chat_message, :message], "Hello, this is a chat message")
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
ObanHelpers.perform_all()
activity = Repo.one(Pleroma.Activity)
assert registered_user.ap_id in activity.recipients
assert Object.normalize(activity, fetch: false).data["content"] =~ "chat message"
assert activity.actor == welcome_user.ap_id
end
setup do:
clear_config(:mrf_simple,
media_removal: [],
media_nsfw: [],
federated_timeline_removal: [],
report_removal: [],
reject: [],
followers_only: [],
accept: [],
avatar_removal: [],
banner_removal: [],
reject_deletes: []
)
setup do: clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
test "it sends a welcome chat message when Simple policy applied to local instance" do
clear_config([:mrf_simple, :media_nsfw], [{"localhost", ""}])
welcome_user = insert(:user)
clear_config([:welcome, :chat_message, :enabled], true)
clear_config([:welcome, :chat_message, :sender_nickname], welcome_user.nickname)
clear_config([:welcome, :chat_message, :message], "Hello, this is a chat message")
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
ObanHelpers.perform_all()
activity = Repo.one(Pleroma.Activity)
assert registered_user.ap_id in activity.recipients
assert Object.normalize(activity, fetch: false).data["content"] =~ "chat message"
assert activity.actor == welcome_user.ap_id
end
test "it sends a welcome email message if it is set" do
welcome_user = insert(:user)
clear_config([:welcome, :email, :enabled], true)
clear_config([:welcome, :email, :sender], welcome_user.email)
clear_config(
[:welcome, :email, :subject],
"Hello, welcome to cool site: <%= instance_name %>"
)
instance_name = Pleroma.Config.get([:instance, :name])
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
ObanHelpers.perform_all()
assert_email_sent(
from: {instance_name, welcome_user.email},
to: {registered_user.name, registered_user.email},
subject: "Hello, welcome to cool site: #{instance_name}",
html_body: "Welcome to #{instance_name}"
)
end
test "it sends a confirm email" do
clear_config([:instance, :account_activation_required], true)
cng = User.register_changeset(%User{}, @full_user_data)
{:ok, registered_user} = User.register(cng)
ObanHelpers.perform_all()
Pleroma.Emails.UserEmail.account_confirmation_email(registered_user)
# temporary hackney fix until hackney max_connections bug is fixed
# https://git.pleroma.social/pleroma/pleroma/-/issues/2101
|> Swoosh.Email.put_private(:hackney_options, ssl_options: [versions: [:"tlsv1.2"]])
|> assert_email_sent()
end
test "sends a pending approval email" do
clear_config([:instance, :account_approval_required], true)
{:ok, user} =
User.register_changeset(%User{}, @full_user_data)
|> User.register()
ObanHelpers.perform_all()
assert_email_sent(
from: Pleroma.Config.Helpers.sender(),
to: {user.name, user.email},
subject: "Your account is awaiting approval"
)
end
test "it sends a registration confirmed email if no others will be sent" do
clear_config([:welcome, :email, :enabled], false)
clear_config([:instance, :account_activation_required], false)
clear_config([:instance, :account_approval_required], false)
{:ok, user} =
User.register_changeset(%User{}, @full_user_data)
|> User.register()
ObanHelpers.perform_all()
instance_name = Pleroma.Config.get([:instance, :name])
sender = Pleroma.Config.get([:instance, :notify_email])
assert_email_sent(
from: {instance_name, sender},
to: {user.name, user.email},
subject: "Account registered on #{instance_name}"
)
end
test "it fails gracefully with invalid email config" do
cng = User.register_changeset(%User{}, @full_user_data)
# Disable the mailer but enable all the things that want to send emails
clear_config([Pleroma.Emails.Mailer, :enabled], false)
clear_config([:instance, :account_activation_required], true)
clear_config([:instance, :account_approval_required], true)
clear_config([:welcome, :email, :enabled], true)
clear_config([:welcome, :email, :sender], "lain@lain.com")
# The user is still created
assert {:ok, %User{nickname: "nick"}} = User.register(cng)
# No emails are sent
ObanHelpers.perform_all()
refute_email_sent()
end
test "it works when the registering user does not provide an email" do
clear_config([Pleroma.Emails.Mailer, :enabled], false)
clear_config([:instance, :account_activation_required], false)
clear_config([:instance, :account_approval_required], true)
cng = User.register_changeset(%User{}, @full_user_data |> Map.put(:email, ""))
# The user is still created
assert {:ok, %User{nickname: "nick"}} = User.register(cng)
# No emails are sent
ObanHelpers.perform_all()
refute_email_sent()
end
test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do
clear_config([:instance, :account_activation_required], true)
@full_user_data
|> Map.keys()
|> Enum.each(fn key ->
params = Map.delete(@full_user_data, key)
changeset = User.register_changeset(%User{}, params)
assert if key == :bio, do: changeset.valid?, else: not changeset.valid?
end)
end
test "it requires an name, nickname and password, bio and email are optional when account_activation_required is disabled" do
clear_config([:instance, :account_activation_required], false)
@full_user_data
|> Map.keys()
|> Enum.each(fn key ->
params = Map.delete(@full_user_data, key)
changeset = User.register_changeset(%User{}, params)
assert if key in [:bio, :email], do: changeset.valid?, else: not changeset.valid?
end)
end
test "it restricts certain nicknames" do
clear_config([User, :restricted_nicknames], ["about"])
[restricted_name | _] = Pleroma.Config.get([User, :restricted_nicknames])
assert is_binary(restricted_name)
params =
@full_user_data
|> Map.put(:nickname, restricted_name)
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
end
test "it is case-insensitive when restricting nicknames" do
clear_config([User, :restricted_nicknames], ["about"])
[restricted_name | _] = Pleroma.Config.get([User, :restricted_nicknames])
assert is_binary(restricted_name)
restricted_upcase_name = String.upcase(restricted_name)
params =
@full_user_data
|> Map.put(:nickname, restricted_upcase_name)
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
end
test "it blocks blacklisted email domains" do
clear_config([User, :email_blacklist], ["trolling.world"])
# Block with match
params = Map.put(@full_user_data, :email, "troll@trolling.world")
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
# Block with case-insensitive match
params = Map.put(@full_user_data, :email, "troll@TrOlLing.wOrld")
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
# Block with subdomain match
params = Map.put(@full_user_data, :email, "troll@gnomes.trolling.world")
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
# Pass with different domains that are similar
params = Map.put(@full_user_data, :email, "troll@gnomestrolling.world")
changeset = User.register_changeset(%User{}, params)
assert changeset.valid?
params = Map.put(@full_user_data, :email, "troll@trolling.world.us")
changeset = User.register_changeset(%User{}, params)
assert changeset.valid?
end
test "it sets the password_hash, ap_id, private key and followers collection address" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
assert is_binary(changeset.changes[:password_hash])
assert is_binary(changeset.changes[:keys])
assert changeset.changes[:ap_id] == User.ap_id(%User{nickname: @full_user_data.nickname})
assert changeset.changes.follower_address == "#{changeset.changes.ap_id}/followers"
end
test "it sets the 'accepts_chat_messages' set to true" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
assert user.accepts_chat_messages
end
test "it creates a confirmed user" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
assert user.is_confirmed
end
end
describe "user registration, with :account_activation_required" do
@full_user_data %{
bio: "A guy",
name: "my name",
nickname: "nick",
password: "test",
password_confirmation: "test",
email: "email@example.com"
}
setup do: clear_config([:instance, :account_activation_required], true)
test "it creates unconfirmed user" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
refute user.is_confirmed
assert user.confirmation_token
end
test "it creates confirmed user if :confirmed option is given" do
changeset = User.register_changeset(%User{}, @full_user_data, confirmed: true)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
assert user.is_confirmed
refute user.confirmation_token
end
end
describe "user registration, with :account_approval_required" do
@full_user_data %{
bio: "A guy",
name: "my name",
nickname: "nick",
password: "test",
password_confirmation: "test",
email: "email@example.com",
registration_reason: "I'm a cool guy :)"
}
setup do: clear_config([:instance, :account_approval_required], true)
test "it creates unapproved user" do
changeset = User.register_changeset(%User{}, @full_user_data)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
refute user.is_approved
assert user.registration_reason == "I'm a cool guy :)"
end
test "it restricts length of registration reason" do
reason_limit = Pleroma.Config.get([:instance, :registration_reason_length])
assert is_integer(reason_limit)
params =
@full_user_data
|> Map.put(
:registration_reason,
"Quia et nesciunt dolores numquam ipsam nisi sapiente soluta. Ullam repudiandae nisi quam porro officiis officiis ad. Consequatur animi velit ex quia. Odit voluptatem perferendis quia ut nisi. Dignissimos sit soluta atque aliquid dolorem ut dolorum ut. Labore voluptates iste iusto amet voluptatum earum. Ad fugit illum nam eos ut nemo. Pariatur ea fuga non aspernatur. Dignissimos debitis officia corporis est nisi ab et. Atque itaque alias eius voluptas minus. Accusamus numquam tempore occaecati in."
)
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
end
end
describe "user registration, with :birthday_required and :birthday_min_age" do
@full_user_data %{
bio: "A guy",
name: "my name",
nickname: "nick",
password: "test",
password_confirmation: "test",
email: "email@example.com"
}
setup do
clear_config([:instance, :birthday_required], true)
clear_config([:instance, :birthday_min_age], 18 * 365)
end
test "it passes when correct birth date is provided" do
today = Date.utc_today()
birthday = Date.add(today, -19 * 365)
params =
@full_user_data
|> Map.put(:birthday, birthday)
changeset = User.register_changeset(%User{}, params)
assert changeset.valid?
end
test "it fails when birth date is not provided" do
changeset = User.register_changeset(%User{}, @full_user_data)
refute changeset.valid?
end
test "it fails when provided invalid birth date" do
today = Date.utc_today()
birthday = Date.add(today, -17 * 365)
params =
@full_user_data
|> Map.put(:birthday, birthday)
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
end
end
describe "get_or_fetch/1" do
test "gets an existing user by nickname" do
user = insert(:user)
{:ok, fetched_user} = User.get_or_fetch(user.nickname)
assert user == fetched_user
end
test "gets an existing user by ap_id" do
ap_id = "http://mastodon.example.org/users/admin"
user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: ap_id
)
{:ok, fetched_user} = User.get_or_fetch(ap_id)
freshed_user = refresh_record(user)
assert freshed_user == fetched_user
end
test "gets an existing user by nickname starting with http" do
user = insert(:user, nickname: "httpssome")
{:ok, fetched_user} = User.get_or_fetch("httpssome")
assert user == fetched_user
end
end
describe "get_or_fetch/1 remote users with tld, while BE is running on a subdomain" do
setup do: clear_config([Pleroma.Web.WebFinger, :update_nickname_on_user_fetch], true)
test "fetches a mastodon split-domain nickname" do
nickname = "a@mastodon.example"
{:ok, fetched_user} = User.get_or_fetch(nickname)
assert fetched_user.ap_id == "https://sub.mastodon.example/users/a"
assert fetched_user.nickname == "a@mastodon.example"
end
test "fetches a pleroma split-domain nickname" do
nickname = "a@pleroma.example"
{:ok, fetched_user} = User.get_or_fetch(nickname)
assert fetched_user.ap_id == "https://sub.pleroma.example/users/a"
assert fetched_user.nickname == "a@pleroma.example"
end
end
describe "fetching a user from nickname or trying to build one" do
test "gets an existing user" do
user = insert(:user)
{:ok, fetched_user} = User.get_or_fetch_by_nickname(user.nickname)
assert user == fetched_user
end
test "gets an existing user, case insensitive" do
user = insert(:user, nickname: "nick")
{:ok, fetched_user} = User.get_or_fetch_by_nickname("NICK")
assert user == fetched_user
end
test "gets an existing user by fully qualified nickname" do
user = insert(:user)
{:ok, fetched_user} =
User.get_or_fetch_by_nickname(user.nickname <> "@" <> Pleroma.Web.Endpoint.host())
assert user == fetched_user
end
test "gets an existing user by fully qualified nickname, case insensitive" do
user = insert(:user, nickname: "nick")
casing_altered_fqn = String.upcase(user.nickname <> "@" <> Pleroma.Web.Endpoint.host())
{:ok, fetched_user} = User.get_or_fetch_by_nickname(casing_altered_fqn)
assert user == fetched_user
end
test "returns nil if no user could be fetched" do
{:error, fetched_user} = User.get_or_fetch_by_nickname("nonexistent@social.heldscal.la")
assert fetched_user == "not found nonexistent@social.heldscal.la"
end
test "returns nil for nonexistent local user" do
{:error, fetched_user} = User.get_or_fetch_by_nickname("nonexistent")
assert fetched_user == "not found nonexistent"
end
test "does not rename an existing remote actor from rogue WebFinger data" do
clear_config([Pleroma.Web.WebFinger, :update_nickname_on_user_fetch], true)
actor_id = "https://legit-actor.example/users/alice"
Tesla.Mock.mock(fn
%{url: "https://evil-webfinger.example/.well-known/host-meta"} ->
{:ok, %Tesla.Env{status: 404}}
%{
url:
"https://evil-webfinger.example/.well-known/webfinger?resource=acct:claimed@evil-webfinger.example"
} ->
Tesla.Mock.json(%{
"subject" => "acct:claimed@evil-webfinger.example",
"links" => [
%{
"rel" => "self",
"type" => "application/activity+json",
"href" => actor_id
}
]
})
%{url: ^actor_id} ->
{:ok,
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body:
Jason.encode!(%{
"id" => actor_id,
"type" => "Person",
"preferredUsername" => "alice",
"name" => "Alice",
"summary" => "",
"inbox" => "https://legit-actor.example/users/alice/inbox",
"outbox" => "https://legit-actor.example/users/alice/outbox",
"followers" => "https://legit-actor.example/users/alice/followers",
"following" => "https://legit-actor.example/users/alice/following"
})
}}
%{url: "https://legit-actor.example/.well-known/host-meta"} ->
{:ok, %Tesla.Env{status: 404}}
%{
url:
"https://legit-actor.example/.well-known/webfinger?resource=acct:alice@legit-actor.example"
} ->
Tesla.Mock.json(%{
"subject" => "acct:alice@legit-actor.example",
"links" => [
%{
"rel" => "self",
"type" => "application/activity+json",
"href" => actor_id
}
]
})
end)
assert {:error, {:webfinger_actor_mismatch, "claimed@evil-webfinger.example", ^actor_id}} =
ActivityPub.make_user_from_nickname("claimed@evil-webfinger.example")
refute User.get_by_ap_id(actor_id)
refute User.get_by_nickname("claimed@evil-webfinger.example")
orig_user =
insert(:user,
local: false,
nickname: "alice@legit-actor.example",
ap_id: actor_id
)
assert {:error, {:webfinger_actor_mismatch, "claimed@evil-webfinger.example", ^actor_id}} =
ActivityPub.make_user_from_nickname("claimed@evil-webfinger.example")
assert {:error, _} = User.get_or_fetch_by_nickname("claimed@evil-webfinger.example")
assert User.get_by_id(orig_user.id).nickname == "alice@legit-actor.example"
refute User.get_by_nickname("claimed@evil-webfinger.example")
end
test "updates an existing user, if stale" do
a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800)
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/admin",
last_refreshed_at: a_week_ago
)
assert orig_user.last_refreshed_at == a_week_ago
{:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
# Oban job was generated to refresh the stale user
assert_enqueued(worker: "Pleroma.Workers.UserRefreshWorker", args: %{"ap_id" => user.ap_id})
# Run job to refresh the user; just capture its output instead of fetching it again
assert {:ok, updated_user} =
perform_job(Pleroma.Workers.UserRefreshWorker, %{"ap_id" => user.ap_id})
assert updated_user.inbox
refute updated_user.last_refreshed_at == orig_user.last_refreshed_at
end
test "if nicknames clash, the old user gets a prefix with the old id to the nickname" do
a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800)
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/harinezumigari",
last_refreshed_at: a_week_ago
)
assert orig_user.last_refreshed_at == a_week_ago
{:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin")
assert user.inbox
refute user.id == orig_user.id
orig_user = User.get_by_id(orig_user.id)
assert orig_user.nickname == "#{orig_user.id}.admin@mastodon.example.org"
end
test "it returns the old user if stale, but unfetchable" do
a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800)
orig_user =
insert(
:user,
local: false,
nickname: "admin@mastodon.example.org",
ap_id: "http://mastodon.example.org/users/raymoo",
last_refreshed_at: a_week_ago
)
assert orig_user.last_refreshed_at == a_week_ago
{:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/raymoo")
assert user.last_refreshed_at == orig_user.last_refreshed_at
end
end
test "returns an ap_id for a user" do
user = insert(:user)
assert User.ap_id(user) ==
Pleroma.Web.Router.Helpers.user_feed_url(
Pleroma.Web.Endpoint,
:feed_redirect,
user.nickname
)
end
test "returns an ap_followers link for a user" do
user = insert(:user)
assert User.ap_followers(user) ==
Pleroma.Web.Router.Helpers.user_feed_url(
Pleroma.Web.Endpoint,
:feed_redirect,
user.nickname
) <> "/followers"
end
describe "remote user changeset" do
@valid_remote %{
bio: "hello",
name: "Someone",
nickname: "a@b.de",
ap_id: "http...",
avatar: %{some: "avatar"}
}
setup do: clear_config([:instance, :user_bio_length])
setup do: clear_config([:instance, :user_name_length])
test "it confirms validity" do
cs = User.remote_user_changeset(@valid_remote)
assert cs.valid?
end
test "it sets the follower_address" do
cs = User.remote_user_changeset(@valid_remote)
# remote users get a fake local follower address
assert cs.changes.follower_address ==
User.ap_followers(%User{nickname: @valid_remote[:nickname]})
end
test "it enforces the fqn format for nicknames" do
cs = User.remote_user_changeset(%{@valid_remote | nickname: "bla"})
assert Ecto.Changeset.get_field(cs, :local) == false
assert cs.changes.avatar
refute cs.valid?
end
test "it has required fields" do
[:ap_id]
|> Enum.each(fn field ->
cs = User.remote_user_changeset(Map.delete(@valid_remote, field))
refute cs.valid?
end)
end
test "it is invalid given a local user" do
user = insert(:user)
cs = User.remote_user_changeset(user, %{name: "tom from myspace"})
refute cs.valid?
end
test "it truncates fields" do
clear_config([:instance, :max_remote_account_fields], 2)
fields = [
%{"name" => "One", "value" => "Uno"},
%{"name" => "Two", "value" => "Dos"},
%{"name" => "Three", "value" => "Tres"}
]
cs = User.remote_user_changeset(@valid_remote |> Map.put(:fields, fields))
assert [%{"name" => "One", "value" => "Uno"}, %{"name" => "Two", "value" => "Dos"}] ==
Ecto.Changeset.get_field(cs, :fields)
end
end
describe "followers and friends" do
test "gets all followers for a given user" do
user = insert(:user)
follower_one = insert(:user)
follower_two = insert(:user)
not_follower = insert(:user)
{:ok, follower_one, user} = User.follow(follower_one, user)
{:ok, follower_two, user} = User.follow(follower_two, user)
res = User.get_followers(user)
assert Enum.member?(res, follower_one)
assert Enum.member?(res, follower_two)
refute Enum.member?(res, not_follower)
end
test "gets all friends (followed users) for a given user" do
user = insert(:user)
followed_one = insert(:user)
followed_two = insert(:user)
not_followed = insert(:user)
{:ok, user, followed_one} = User.follow(user, followed_one)
{:ok, user, followed_two} = User.follow(user, followed_two)
res = User.get_friends(user)
followed_one = User.get_cached_by_ap_id(followed_one.ap_id)
followed_two = User.get_cached_by_ap_id(followed_two.ap_id)
assert Enum.member?(res, followed_one)
assert Enum.member?(res, followed_two)
refute Enum.member?(res, not_followed)
end
end
describe "updating note and follower count" do
test "it sets the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
assert user.note_count == 0
{:ok, user} = User.update_note_count(user)
assert user.note_count == 1
end
test "it increases the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
assert user.note_count == 0
{:ok, user} = User.increase_note_count(user)
assert user.note_count == 1
{:ok, user} = User.increase_note_count(user)
assert user.note_count == 2
end
test "it decreases the note_count property" do
note = insert(:note)
user = User.get_cached_by_ap_id(note.data["actor"])
assert user.note_count == 0
{:ok, user} = User.increase_note_count(user)
assert user.note_count == 1
{:ok, user} = User.decrease_note_count(user)
assert user.note_count == 0
{:ok, user} = User.decrease_note_count(user)
assert user.note_count == 0
end
test "it sets the follower_count property" do
user = insert(:user)
follower = insert(:user)
User.follow(follower, user)
assert user.follower_count == 0
{:ok, user} = User.update_follower_count(user)
assert user.follower_count == 1
end
end
describe "mutes" do
test "it mutes people" do
user = insert(:user)
muted_user = insert(:user)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
{:ok, _user_relationships} = User.mute(user, muted_user)
assert User.mutes?(user, muted_user)
assert User.muted_notifications?(user, muted_user)
end
test "expiring" do
user = insert(:user)
muted_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted_user, %{duration: 60})
assert User.mutes?(user, muted_user)
worker = Pleroma.Workers.MuteExpireWorker
args = %{"op" => "unmute_user", "muter_id" => user.id, "mutee_id" => muted_user.id}
assert_enqueued(
worker: worker,
args: args
)
assert :ok = perform_job(worker, args)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
test "it unmutes users" do
user = insert(:user)
muted_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted_user)
{:ok, _user_mute} = User.unmute(user, muted_user)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
test "it unmutes users by id" do
user = insert(:user)
muted_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted_user)
{:ok, _user_mute} = User.unmute(user.id, muted_user.id)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
test "it mutes user without notifications" do
user = insert(:user)
muted_user = insert(:user)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
{:ok, _user_relationships} = User.mute(user, muted_user, %{notifications: false})
assert User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
end
describe "blocks" do
test "it blocks people" do
user = insert(:user)
blocked_user = insert(:user)
refute User.blocks?(user, blocked_user)
{:ok, _user_relationship} = User.block(user, blocked_user)
assert User.blocks?(user, blocked_user)
end
test "it unblocks users" do
user = insert(:user)
blocked_user = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked_user)
{:ok, _user_block} = User.unblock(user, blocked_user)
refute User.blocks?(user, blocked_user)
end
test "blocks tear down cyclical follow relationships" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, blocker, blocked} = User.follow(blocker, blocked)
{:ok, blocked, blocker} = User.follow(blocked, blocker)
assert User.following?(blocker, blocked)
assert User.following?(blocked, blocker)
{:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
end
test "blocks tear down blocker->blocked follow relationships" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, blocker, blocked} = User.follow(blocker, blocked)
assert User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
{:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
end
test "blocks tear down blocked->blocker follow relationships" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, blocked, blocker} = User.follow(blocked, blocker)
refute User.following?(blocker, blocked)
assert User.following?(blocked, blocker)
{:ok, _user_relationship} = User.block(blocker, blocked)
blocked = User.get_cached_by_id(blocked.id)
assert User.blocks?(blocker, blocked)
refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker)
end
test "blocks tear down blocked->blocker subscription relationships" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, _subscription} = User.subscribe(blocked, blocker)
assert User.subscribed_to?(blocked, blocker)
refute User.subscribed_to?(blocker, blocked)
{:ok, _user_relationship} = User.block(blocker, blocked)
assert User.blocks?(blocker, blocked)
refute User.subscribed_to?(blocker, blocked)
refute User.subscribed_to?(blocked, blocker)
end
end
describe "domain blocking" do
test "blocks domains" do
user = insert(:user)
collateral_user = insert(:user, %{ap_id: "https://awful-and-rude-instance.com/user/bully"})
{:ok, user} = User.block_domain(user, "awful-and-rude-instance.com")
assert User.blocks?(user, collateral_user)
end
test "does not block domain with same end" do
user = insert(:user)
collateral_user =
insert(:user, %{ap_id: "https://another-awful-and-rude-instance.com/user/bully"})
{:ok, user} = User.block_domain(user, "awful-and-rude-instance.com")
refute User.blocks?(user, collateral_user)
end
test "does not block domain with same end if wildcard added" do
user = insert(:user)
collateral_user =
insert(:user, %{ap_id: "https://another-awful-and-rude-instance.com/user/bully"})
{:ok, user} = User.block_domain(user, "*.awful-and-rude-instance.com")
refute User.blocks?(user, collateral_user)
end
test "blocks domain with wildcard for subdomain" do
user = insert(:user)
user_from_subdomain =
insert(:user, %{ap_id: "https://subdomain.awful-and-rude-instance.com/user/bully"})
user_with_two_subdomains =
insert(:user, %{
ap_id: "https://subdomain.second_subdomain.awful-and-rude-instance.com/user/bully"
})
user_domain = insert(:user, %{ap_id: "https://awful-and-rude-instance.com/user/bully"})
{:ok, user} = User.block_domain(user, "*.awful-and-rude-instance.com")
assert User.blocks?(user, user_from_subdomain)
assert User.blocks?(user, user_with_two_subdomains)
assert User.blocks?(user, user_domain)
end
test "unblocks domains" do
user = insert(:user)
collateral_user = insert(:user, %{ap_id: "https://awful-and-rude-instance.com/user/bully"})
{:ok, user} = User.block_domain(user, "awful-and-rude-instance.com")
{:ok, user} = User.unblock_domain(user, "awful-and-rude-instance.com")
refute User.blocks?(user, collateral_user)
end
test "follows take precedence over domain blocks" do
user = insert(:user)
good_eggo = insert(:user, %{ap_id: "https://meanies.social/user/cuteposter"})
{:ok, user} = User.block_domain(user, "meanies.social")
{:ok, user, good_eggo} = User.follow(user, good_eggo)
refute User.blocks?(user, good_eggo)
end
end
describe "get_recipients_from_activity" do
test "works for announces" do
actor = insert(:user)
user = insert(:user, local: true)
{:ok, activity} = CommonAPI.post(actor, %{status: "hello"})
{:ok, announce} = CommonAPI.repeat(activity.id, user)
recipients = User.get_recipients_from_activity(announce)
assert user in recipients
end
test "get recipients" do
actor = insert(:user)
user = insert(:user, local: true)
user_two = insert(:user, local: false)
addressed = insert(:user, local: true)
addressed_remote = insert(:user, local: false)
{:ok, activity} =
CommonAPI.post(actor, %{
status: "hey @#{addressed.nickname} @#{addressed_remote.nickname}"
})
assert Enum.map([actor, addressed], & &1.ap_id) --
Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == []
{:ok, user, actor} = User.follow(user, actor)
{:ok, _user_two, _actor} = User.follow(user_two, actor)
recipients = User.get_recipients_from_activity(activity)
assert length(recipients) == 3
assert user in recipients
assert addressed in recipients
end
test "has following" do
actor = insert(:user)
user = insert(:user)
user_two = insert(:user)
addressed = insert(:user, local: true)
{:ok, activity} =
CommonAPI.post(actor, %{
status: "hey @#{addressed.nickname}"
})
assert Enum.map([actor, addressed], & &1.ap_id) --
Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == []
{:ok, _actor, _user} = User.follow(actor, user)
{:ok, _actor, _user_two} = User.follow(actor, user_two)
recipients = User.get_recipients_from_activity(activity)
assert length(recipients) == 2
assert addressed in recipients
end
end
describe ".set_activation" do
test "can de-activate then re-activate a user" do
user = insert(:user)
assert user.is_active
{:ok, user} = User.set_activation(user, false)
refute user.is_active
{:ok, user} = User.set_activation(user, true)
assert user.is_active
end
test "hide a user from followers" do
user = insert(:user)
user2 = insert(:user)
{:ok, user, user2} = User.follow(user, user2)
{:ok, _user} = User.set_activation(user, false)
user2 = User.get_cached_by_id(user2.id)
assert user2.follower_count == 0
assert [] = User.get_followers(user2)
end
test "hide a user from friends" do
user = insert(:user)
user2 = insert(:user)
{:ok, user2, user} = User.follow(user2, user)
assert user2.following_count == 1
assert User.following_count(user2) == 1
{:ok, _user} = User.set_activation(user, false)
user2 = User.get_cached_by_id(user2.id)
assert refresh_record(user2).following_count == 0
assert user2.following_count == 0
assert User.following_count(user2) == 0
assert [] = User.get_friends(user2)
end
test "hide a user's statuses from timelines and notifications" do
user = insert(:user)
user2 = insert(:user)
{:ok, user2, user} = User.follow(user2, user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey @#{user2.nickname}"})
activity = Repo.preload(activity, :bookmark)
[notification] = Pleroma.Notification.for_user(user2)
assert notification.activity.id == activity.id
assert [activity] == ActivityPub.fetch_public_activities(%{}) |> Repo.preload(:bookmark)
assert [%{activity | thread_muted?: CommonAPI.thread_muted?(activity, user2)}] ==
ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{
user: user2
})
{:ok, _user} = User.set_activation(user, false)
assert [] == ActivityPub.fetch_public_activities(%{})
assert [] == Pleroma.Notification.for_user(user2)
assert [] ==
ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{
user: user2
})
end
end
describe "approve" do
test "approves a user" do
user = insert(:user, is_approved: false)
refute user.is_approved
{:ok, user} = User.approve(user)
assert user.is_approved
end
test "approves a list of users" do
unapproved_users = [
insert(:user, is_approved: false),
insert(:user, is_approved: false),
insert(:user, is_approved: false)
]
{:ok, users} = User.approve(unapproved_users)
assert Enum.count(users) == 3
Enum.each(users, fn user ->
assert user.is_approved
end)
end
test "it sends welcome email if it is set" do
clear_config([:welcome, :email, :enabled], true)
clear_config([:welcome, :email, :sender], "tester@test.me")
user = insert(:user, is_approved: false)
welcome_user = insert(:user, email: "tester@test.me")
instance_name = Pleroma.Config.get([:instance, :name])
User.approve(user)
ObanHelpers.perform_all()
assert_email_sent(
from: {instance_name, welcome_user.email},
to: {user.name, user.email},
html_body: "Welcome to #{instance_name}"
)
end
test "approving an approved user does not trigger post-register actions" do
clear_config([:welcome, :email, :enabled], true)
user = insert(:user, is_approved: true)
User.approve(user)
ObanHelpers.perform_all()
assert_no_email_sent()
end
end
describe "confirm" do
test "confirms a user" do
user = insert(:user, is_confirmed: false)
refute user.is_confirmed
{:ok, user} = User.confirm(user)
assert user.is_confirmed
end
test "confirms a list of users" do
unconfirmed_users = [
insert(:user, is_confirmed: false),
insert(:user, is_confirmed: false),
insert(:user, is_confirmed: false)
]
{:ok, users} = User.confirm(unconfirmed_users)
assert Enum.count(users) == 3
Enum.each(users, fn user ->
assert user.is_confirmed
end)
end
test "sends approval emails when `is_approved: false`" do
admin = insert(:user, is_admin: true)
user = insert(:user, is_confirmed: false, is_approved: false)
User.confirm(user)
ObanHelpers.perform_all()
user_email = Pleroma.Emails.UserEmail.approval_pending_email(user)
admin_email = Pleroma.Emails.AdminEmail.new_unapproved_registration(admin, user)
notify_email = Pleroma.Config.get([:instance, :notify_email])
instance_name = Pleroma.Config.get([:instance, :name])
# User approval email
assert_email_sent(
from: {instance_name, notify_email},
to: {user.name, user.email},
html_body: user_email.html_body
)
# Admin email
assert_email_sent(
from: {instance_name, notify_email},
to: {admin.name, admin.email},
html_body: admin_email.html_body
)
end
test "confirming a confirmed user does not trigger post-register actions" do
user = insert(:user, is_confirmed: true, is_approved: false)
User.confirm(user)
ObanHelpers.perform_all()
assert_no_email_sent()
end
end
describe "delete" do
setup do
{:ok, user} = insert(:user) |> User.set_cache()
[user: user]
end
setup do: clear_config([:instance, :federating])
test ".delete_user_activities deletes all create activities", %{user: user} do
{:ok, activity} = CommonAPI.post(user, %{status: "2hu"})
User.delete_user_activities(user)
# TODO: Test removal favorites, repeats, delete activities.
refute Activity.get_by_id(activity.id)
end
test "it deactivates a user, all follow relationships and all activities", %{user: user} do
follower = insert(:user)
{:ok, follower, user} = User.follow(follower, user)
locked_user = insert(:user, name: "locked", is_locked: true)
{:ok, _, _} = User.follow(user, locked_user, :follow_pending)
object = insert(:note, user: user)
activity = insert(:note_activity, user: user, note: object)
object_two = insert(:note, user: follower)
activity_two = insert(:note_activity, user: follower, note: object_two)
{:ok, like} = CommonAPI.favorite(activity_two.id, user)
{:ok, like_two} = CommonAPI.favorite(activity.id, follower)
{:ok, repeat} = CommonAPI.repeat(activity_two.id, user)
{:ok, job} = User.delete(user)
{:ok, _user} = ObanHelpers.perform(job)
follower = User.get_cached_by_id(follower.id)
refute User.following?(follower, user)
assert %{is_active: false} = User.get_by_id(user.id)
assert [] == User.get_follow_requests(locked_user)
user_activities =
user.ap_id
|> Activity.Queries.by_actor()
|> Repo.all()
|> Enum.map(fn act -> act.data["type"] end)
assert Enum.all?(user_activities, fn act -> act in ~w(Delete Undo) end)
refute Activity.get_by_id(activity.id)
refute Activity.get_by_id(like.id)
refute Activity.get_by_id(like_two.id)
refute Activity.get_by_id(repeat.id)
end
end
test "delete/1 when confirmation is pending deletes the user" do
clear_config([:instance, :account_activation_required], true)
user = insert(:user, is_confirmed: false)
{:ok, job} = User.delete(user)
{:ok, _} = ObanHelpers.perform(job)
refute User.get_cached_by_id(user.id)
refute User.get_by_id(user.id)
end
test "delete/1 when approval is pending deletes the user" do
user = insert(:user, is_approved: false)
{:ok, job} = User.delete(user)
{:ok, _} = ObanHelpers.perform(job)
refute User.get_cached_by_id(user.id)
refute User.get_by_id(user.id)
end
test "delete/1 purges a user when they wouldn't be fully deleted" do
user =
insert(:user, %{
bio: "eyy lmao",
name: "qqqqqqq",
password_hash: "pdfk2$1b3n159001",
keys: "RSA begin buplic key",
public_key: "--PRIVATE KEYE--",
avatar: %{"a" => "b"},
tags: ["qqqqq"],
banner: %{"a" => "b"},
background: %{"a" => "b"},
note_count: 9,
follower_count: 9,
following_count: 9001,
is_locked: true,
is_confirmed: true,
password_reset_pending: true,
is_approved: true,
registration_reason: "ahhhhh",
confirmation_token: "qqqq",
domain_blocks: ["lain.com"],
is_active: false,
is_moderator: true,
is_admin: true,
mascot: %{"a" => "b"},
emoji: %{"a" => "b"},
pleroma_settings_store: %{"q" => "x"},
fields: [%{"gg" => "qq"}],
raw_fields: [%{"gg" => "qq"}],
is_discoverable: true,
also_known_as: ["https://lol.olo/users/loll"]
})
{:ok, job} = User.delete(user)
{:ok, _} = ObanHelpers.perform(job)
user = User.get_by_id(user.id)
assert %User{
bio: "",
raw_bio: nil,
email: nil,
name: nil,
password_hash: nil,
keys: "RSA begin buplic key",
public_key: "--PRIVATE KEYE--",
avatar: %{},
tags: [],
last_refreshed_at: nil,
last_digest_emailed_at: nil,
banner: %{},
background: %{},
note_count: 0,
follower_count: 0,
following_count: 0,
is_locked: false,
is_confirmed: true,
password_reset_pending: false,
is_approved: true,
registration_reason: nil,
confirmation_token: nil,
domain_blocks: [],
is_active: false,
is_moderator: false,
is_admin: false,
mascot: nil,
emoji: %{},
pleroma_settings_store: %{},
fields: [],
raw_fields: [],
is_discoverable: false,
also_known_as: []
} = user
end
test "delete/1 purges a remote user" do
user =
insert(:user, %{
name: "qqqqqqq",
avatar: %{"a" => "b"},
banner: %{"a" => "b"},
local: false
})
{:ok, job} = User.delete(user)
{:ok, _} = ObanHelpers.perform(job)
user = User.get_by_id(user.id)
assert user.name == nil
assert user.avatar == %{}
assert user.banner == %{}
end
describe "set_suggestion" do
test "suggests a user" do
user = insert(:user, is_suggested: false)
refute user.is_suggested
{:ok, user} = User.set_suggestion(user, true)
assert user.is_suggested
end
test "suggests a list of users" do
unsuggested_users = [
insert(:user, is_suggested: false),
insert(:user, is_suggested: false),
insert(:user, is_suggested: false)
]
{:ok, users} = User.set_suggestion(unsuggested_users, true)
assert Enum.count(users) == 3
Enum.each(users, fn user ->
assert user.is_suggested
end)
end
test "unsuggests a user" do
user = insert(:user, is_suggested: true)
assert user.is_suggested
{:ok, user} = User.set_suggestion(user, false)
refute user.is_suggested
end
end
test "get_or_fetch_public_key_for_ap_id fetches a user that's not in the db" do
assert {:ok, _key} =
User.get_or_fetch_public_key_for_ap_id("http://mastodon.example.org/users/admin")
end
test "get_public_key_for_ap_id returns correctly for user that's not in the db" do
assert :error = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin")
end
describe "per-user rich-text filtering" do
test "html_filter_policy returns default policies, when rich-text is enabled" do
user = insert(:user)
assert Pleroma.Config.get([:markup, :scrub_policy]) == User.html_filter_policy(user)
end
test "html_filter_policy returns TwitterText scrubber when rich-text is disabled" do
user = insert(:user, no_rich_text: true)
assert Pleroma.HTML.Scrubber.TwitterText == User.html_filter_policy(user)
end
end
describe "caching" do
+ test "set_cache stores friends under the AP ID cache key" do
+ user = insert(:user)
+
+ User.set_cache(user)
+
+ assert {:ok, []} = Cachex.get(:user_cache, "friends_ap_ids:#{user.ap_id}")
+ assert {:ok, nil} = Cachex.get(:user_cache, "friends_ap_ids:#{user.nickname}")
+ end
+
test "invalidate_cache works" do
user = insert(:user)
User.set_cache(user)
+ User.get_cached_by_id(user.id)
User.invalidate_cache(user)
{:ok, nil} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
{:ok, nil} = Cachex.get(:user_cache, "nickname:#{user.nickname}")
+ {:ok, nil} = Cachex.get(:user_cache, "friends_ap_ids:#{user.ap_id}")
+ {:ok, nil} = Cachex.get(:user_cache, "id:#{user.id}")
end
test "User.delete() plugs any possible zombie objects" do
user = insert(:user)
{:ok, job} = User.delete(user)
{:ok, _} = ObanHelpers.perform(job)
{:ok, cached_user} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
assert cached_user != user
{:ok, cached_user} = Cachex.get(:user_cache, "nickname:#{user.ap_id}")
assert cached_user != user
end
end
describe "account_status/1" do
setup do: clear_config([:instance, :account_activation_required])
test "return confirmation_pending for unconfirm user" do
clear_config([:instance, :account_activation_required], true)
user = insert(:user, is_confirmed: false)
assert User.account_status(user) == :confirmation_pending
end
test "return active for confirmed user" do
clear_config([:instance, :account_activation_required], true)
user = insert(:user, is_confirmed: true)
assert User.account_status(user) == :active
end
test "return active for remote user" do
user = insert(:user, local: false)
assert User.account_status(user) == :active
end
test "returns :password_reset_pending for user with reset password" do
user = insert(:user, password_reset_pending: true)
assert User.account_status(user) == :password_reset_pending
end
test "returns :deactivated for deactivated user" do
user = insert(:user, local: true, is_confirmed: true, is_active: false)
assert User.account_status(user) == :deactivated
end
test "returns :approval_pending for unapproved user" do
user = insert(:user, local: true, is_approved: false)
assert User.account_status(user) == :approval_pending
user = insert(:user, local: true, is_confirmed: false, is_approved: false)
assert User.account_status(user) == :approval_pending
end
end
describe "privileged?/1" do
setup do
clear_config([:instance, :admin_privileges], [:cofe, :suya])
clear_config([:instance, :moderator_privileges], [:cofe, :suya])
end
test "returns false for unprivileged users" do
user = insert(:user, local: true)
refute User.privileged?(user, :cofe)
end
test "returns false for remote users" do
user = insert(:user, local: false)
remote_admin_user = insert(:user, local: false, is_admin: true)
refute User.privileged?(user, :cofe)
refute User.privileged?(remote_admin_user, :cofe)
end
test "returns true for local moderators if, and only if, they are privileged" do
user = insert(:user, local: true, is_moderator: true)
assert User.privileged?(user, :cofe)
clear_config([:instance, :moderator_privileges], [])
refute User.privileged?(user, :cofe)
end
test "returns true for local admins if, and only if, they are privileged" do
user = insert(:user, local: true, is_admin: true)
assert User.privileged?(user, :cofe)
clear_config([:instance, :admin_privileges], [])
refute User.privileged?(user, :cofe)
end
end
describe "privileges/1" do
setup do
clear_config([:instance, :moderator_privileges], [:cofe, :only_moderator])
clear_config([:instance, :admin_privileges], [:cofe, :only_admin])
end
test "returns empty list for users without roles" do
user = insert(:user, local: true)
assert [] == User.privileges(user)
end
test "returns list of privileges for moderators" do
moderator = insert(:user, is_moderator: true, local: true)
assert [:cofe, :only_moderator] == User.privileges(moderator) |> Enum.sort()
end
test "returns list of privileges for admins" do
admin = insert(:user, is_admin: true, local: true)
assert [:cofe, :only_admin] == User.privileges(admin) |> Enum.sort()
end
test "returns list of unique privileges for users who are both moderator and admin" do
moderator_admin = insert(:user, is_moderator: true, is_admin: true, local: true)
assert [:cofe, :only_admin, :only_moderator] ==
User.privileges(moderator_admin) |> Enum.sort()
end
test "returns empty list for remote users" do
remote_moderator_admin = insert(:user, is_moderator: true, is_admin: true, local: false)
assert [] == User.privileges(remote_moderator_admin)
end
end
describe "invisible?/1" do
test "returns true for an invisible user" do
user = insert(:user, local: true, invisible: true)
assert User.invisible?(user)
end
test "returns false for a non-invisible user" do
user = insert(:user, local: true)
refute User.invisible?(user)
end
end
describe "visible_for/2" do
test "returns true when the account is itself" do
user = insert(:user, local: true)
assert User.visible_for(user, user) == :visible
end
test "returns false when the account is unconfirmed and confirmation is required" do
clear_config([:instance, :account_activation_required], true)
user = insert(:user, local: true, is_confirmed: false)
other_user = insert(:user, local: true)
refute User.visible_for(user, other_user) == :visible
end
test "returns true when the account is unconfirmed and confirmation is required but the account is remote" do
clear_config([:instance, :account_activation_required], true)
user = insert(:user, local: false, is_confirmed: false)
other_user = insert(:user, local: true)
assert User.visible_for(user, other_user) == :visible
end
test "returns true when the account is unconfirmed and being viewed by a privileged account (privilege :users_manage_activation_state, confirmation required)" do
clear_config([:instance, :account_activation_required], true)
clear_config([:instance, :admin_privileges], [:users_manage_activation_state])
user = insert(:user, local: true, is_confirmed: false)
other_user = insert(:user, local: true, is_admin: true)
assert User.visible_for(user, other_user) == :visible
clear_config([:instance, :admin_privileges], [])
refute User.visible_for(user, other_user) == :visible
end
end
describe "all_users_with_privilege/1" do
setup do
%{
user: insert(:user, local: true, is_admin: false, is_moderator: false),
moderator_user: insert(:user, local: true, is_admin: false, is_moderator: true),
admin_user: insert(:user, local: true, is_admin: true, is_moderator: false),
admin_moderator_user: insert(:user, local: true, is_admin: true, is_moderator: true),
remote_user: insert(:user, local: false, is_admin: true, is_moderator: true),
non_active_user:
insert(:user, local: true, is_admin: true, is_moderator: true, is_active: false)
}
end
test "doesn't return any users when there are no privileged roles" do
clear_config([:instance, :admin_privileges], [])
clear_config([:instance, :moderator_privileges], [])
assert [] = User.Query.build(%{is_privileged: :cofe}) |> Repo.all()
end
test "returns moderator users if they are privileged", %{
moderator_user: moderator_user,
admin_moderator_user: admin_moderator_user
} do
clear_config([:instance, :admin_privileges], [])
clear_config([:instance, :moderator_privileges], [:cofe])
assert [_, _] = User.Query.build(%{is_privileged: :cofe}) |> Repo.all()
assert moderator_user in User.all_users_with_privilege(:cofe)
assert admin_moderator_user in User.all_users_with_privilege(:cofe)
end
test "returns admin users if they are privileged", %{
admin_user: admin_user,
admin_moderator_user: admin_moderator_user
} do
clear_config([:instance, :admin_privileges], [:cofe])
clear_config([:instance, :moderator_privileges], [])
assert [_, _] = User.Query.build(%{is_privileged: :cofe}) |> Repo.all()
assert admin_user in User.all_users_with_privilege(:cofe)
assert admin_moderator_user in User.all_users_with_privilege(:cofe)
end
test "returns admin and moderator users if they are both privileged", %{
moderator_user: moderator_user,
admin_user: admin_user,
admin_moderator_user: admin_moderator_user
} do
clear_config([:instance, :admin_privileges], [:cofe])
clear_config([:instance, :moderator_privileges], [:cofe])
assert [_, _, _] = User.Query.build(%{is_privileged: :cofe}) |> Repo.all()
assert admin_user in User.all_users_with_privilege(:cofe)
assert moderator_user in User.all_users_with_privilege(:cofe)
assert admin_moderator_user in User.all_users_with_privilege(:cofe)
end
end
describe "parse_bio/2" do
test "preserves hosts in user links text" do
remote_user = insert(:user, local: false, nickname: "nick@domain.com")
user = insert(:user)
bio = "A.k.a. @nick@domain.com"
expected_text =
~s(A.k.a. <span class="h-card"><a class="u-url mention" data-user="#{remote_user.id}" href="#{remote_user.ap_id}" rel="ugc">@<span>nick@domain.com</span></a></span>)
assert expected_text == User.parse_bio(bio, user)
end
test "Adds rel=me on linkbacked urls" do
user = insert(:user, ap_id: "https://social.example.org/users/lain")
bio = "http://example.com/rel_me/null"
expected_text = "<a href=\"#{bio}\">#{bio}</a>"
assert expected_text == User.parse_bio(bio, user)
bio = "http://example.com/rel_me/link"
expected_text = "<a href=\"#{bio}\" rel=\"me\">#{bio}</a>"
assert expected_text == User.parse_bio(bio, user)
bio = "http://example.com/rel_me/anchor"
expected_text = "<a href=\"#{bio}\" rel=\"me\">#{bio}</a>"
assert expected_text == User.parse_bio(bio, user)
end
end
test "follower count is updated when a follower is blocked" do
user = insert(:user)
follower = insert(:user)
follower2 = insert(:user)
follower3 = insert(:user)
{:ok, follower, user} = User.follow(follower, user)
{:ok, _follower2, _user} = User.follow(follower2, user)
{:ok, _follower3, _user} = User.follow(follower3, user)
{:ok, _user_relationship} = User.block(user, follower)
user = refresh_record(user)
assert user.follower_count == 2
end
describe "list_inactive_users_query/1" do
defp days_ago(days) do
NaiveDateTime.add(
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
-days * 60 * 60 * 24,
:second
)
end
test "Users are inactive by default" do
total = 10
users =
Enum.map(1..total, fn _ ->
insert(:user, last_digest_emailed_at: days_ago(20), is_active: true)
end)
inactive_users_ids =
Pleroma.User.list_inactive_users_query()
|> Pleroma.Repo.all()
|> Enum.map(& &1.id)
Enum.each(users, fn user ->
assert user.id in inactive_users_ids
end)
end
test "Only includes users who has no recent activity" do
total = 10
users =
Enum.map(1..total, fn _ ->
insert(:user, last_digest_emailed_at: days_ago(20), is_active: true)
end)
{inactive, active} = Enum.split(users, trunc(total / 2))
Enum.map(active, fn user ->
to = Enum.random(users -- [user])
{:ok, _} =
CommonAPI.post(user, %{
status: "hey @#{to.nickname}"
})
end)
inactive_users_ids =
Pleroma.User.list_inactive_users_query()
|> Pleroma.Repo.all()
|> Enum.map(& &1.id)
Enum.each(active, fn user ->
refute user.id in inactive_users_ids
end)
Enum.each(inactive, fn user ->
assert user.id in inactive_users_ids
end)
end
test "Only includes users with no read notifications" do
total = 10
users =
Enum.map(1..total, fn _ ->
insert(:user, last_digest_emailed_at: days_ago(20), is_active: true)
end)
[sender | recipients] = users
{inactive, active} = Enum.split(recipients, trunc(total / 2))
Enum.each(recipients, fn to ->
{:ok, _} =
CommonAPI.post(sender, %{
status: "hey @#{to.nickname}"
})
{:ok, _} =
CommonAPI.post(sender, %{
status: "hey again @#{to.nickname}"
})
end)
Enum.each(active, fn user ->
[n1, _n2] = Pleroma.Notification.for_user(user)
{:ok, _} = Pleroma.Notification.read_one(user, n1.id)
end)
inactive_users_ids =
Pleroma.User.list_inactive_users_query()
|> Pleroma.Repo.all()
|> Enum.map(& &1.id)
Enum.each(active, fn user ->
refute user.id in inactive_users_ids
end)
Enum.each(inactive, fn user ->
assert user.id in inactive_users_ids
end)
end
end
describe "get_ap_ids_by_nicknames" do
test "it returns a list of AP ids for a given set of nicknames" do
user = insert(:user)
user_two = insert(:user)
ap_ids = User.get_ap_ids_by_nicknames([user.nickname, user_two.nickname, "nonexistent"])
assert length(ap_ids) == 2
assert user.ap_id in ap_ids
assert user_two.ap_id in ap_ids
end
test "it returns a list of AP ids in the same order" do
user = insert(:user)
user_two = insert(:user)
user_three = insert(:user)
ap_ids =
User.get_ap_ids_by_nicknames([user.nickname, user_three.nickname, user_two.nickname])
assert [user.ap_id, user_three.ap_id, user_two.ap_id] == ap_ids
end
end
describe "sync followers count" do
setup do
user1 = insert(:user, local: false, ap_id: "http://localhost:4001/users/masto_closed")
user2 = insert(:user, local: false, ap_id: "http://localhost:4001/users/fuser2")
insert(:user, local: true)
insert(:user, local: false, is_active: false)
{:ok, user1: user1, user2: user2}
end
test "external_users/1 external active users with limit", %{user1: user1, user2: user2} do
[fdb_user1] = User.external_users(limit: 1)
assert fdb_user1.ap_id
assert fdb_user1.ap_id == user1.ap_id
assert fdb_user1.id == user1.id
[fdb_user2] = User.external_users(max_id: fdb_user1.id, limit: 1)
assert fdb_user2.ap_id
assert fdb_user2.ap_id == user2.ap_id
assert fdb_user2.id == user2.id
assert User.external_users(max_id: fdb_user2.id, limit: 1) == []
end
end
describe "internal?/1" do
test "non-internal user returns false" do
user = insert(:user)
refute User.internal?(user)
end
test "user with no nickname returns true" do
user = insert(:user, %{nickname: nil})
assert User.internal?(user)
end
test "user with internal-prefixed nickname returns true" do
user = insert(:user, %{nickname: "internal.test"})
assert User.internal?(user)
end
end
describe "update_and_set_cache/1" do
test "returns error when user is stale instead Ecto.StaleEntryError" do
user = insert(:user)
changeset = Ecto.Changeset.change(user, bio: "test")
Repo.delete(user)
assert {:error, %Ecto.Changeset{errors: [id: {"is stale", [stale: true]}], valid?: false}} =
User.update_and_set_cache(changeset)
end
test "performs update cache if user updated" do
user = insert(:user)
assert {:ok, nil} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
changeset = Ecto.Changeset.change(user, bio: "test-bio")
assert {:ok, %User{bio: "test-bio"} = user} = User.update_and_set_cache(changeset)
assert {:ok, user} = Cachex.get(:user_cache, "ap_id:#{user.ap_id}")
assert %User{bio: "test-bio"} = User.get_cached_by_ap_id(user.ap_id)
end
end
describe "following/followers synchronization" do
setup do: clear_config([:instance, :external_user_synchronization])
test "updates the counters normally on following/getting a follow when disabled" do
clear_config([:instance, :external_user_synchronization], false)
user = insert(:user)
other_user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
assert other_user.following_count == 0
assert other_user.follower_count == 0
{:ok, user, other_user} = Pleroma.User.follow(user, other_user)
assert user.following_count == 1
assert other_user.follower_count == 1
end
test "synchronizes the counters with the remote instance for the followed when enabled" do
clear_config([:instance, :external_user_synchronization], false)
user = insert(:user)
other_user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
assert other_user.following_count == 0
assert other_user.follower_count == 0
clear_config([:instance, :external_user_synchronization], true)
{:ok, _user, other_user} = User.follow(user, other_user)
assert other_user.follower_count == 437
end
test "synchronizes the counters with the remote instance for the follower when enabled" do
clear_config([:instance, :external_user_synchronization], false)
user = insert(:user)
other_user =
insert(:user,
local: false,
follower_address: "https://remote.org/users/masto_closed/followers",
following_address: "https://remote.org/users/masto_closed/following"
)
assert other_user.following_count == 0
assert other_user.follower_count == 0
clear_config([:instance, :external_user_synchronization], true)
{:ok, other_user, _user} = User.follow(other_user, user)
assert other_user.following_count == 152
end
end
describe "change_email/2" do
setup do
[user: insert(:user)]
end
test "blank email returns error if we require an email on registration", %{user: user} do
orig_account_activation_required =
Pleroma.Config.get([:instance, :account_activation_required])
Pleroma.Config.put([:instance, :account_activation_required], true)
on_exit(fn ->
Pleroma.Config.put(
[:instance, :account_activation_required],
orig_account_activation_required
)
end)
assert {:error, %{errors: [email: {"can't be blank", _}]}} = User.change_email(user, "")
assert {:error, %{errors: [email: {"can't be blank", _}]}} = User.change_email(user, nil)
end
test "blank email should be fine if we do not require an email on registration", %{user: user} do
orig_account_activation_required =
Pleroma.Config.get([:instance, :account_activation_required])
Pleroma.Config.put([:instance, :account_activation_required], false)
on_exit(fn ->
Pleroma.Config.put(
[:instance, :account_activation_required],
orig_account_activation_required
)
end)
assert {:ok, %User{email: nil}} = User.change_email(user, "")
assert {:ok, %User{email: nil}} = User.change_email(user, nil)
end
test "non unique email returns error", %{user: user} do
%{email: email} = insert(:user)
assert {:error, %{errors: [email: {"has already been taken", _}]}} =
User.change_email(user, email)
end
test "invalid email returns error", %{user: user} do
assert {:error, %{errors: [email: {"has invalid format", _}]}} =
User.change_email(user, "cofe")
end
test "changes email", %{user: user} do
assert {:ok, %User{email: "cofe@cofe.party"}} = User.change_email(user, "cofe@cofe.party")
end
test "adds email", %{user: user} do
orig_account_activation_required =
Pleroma.Config.get([:instance, :account_activation_required])
Pleroma.Config.put([:instance, :account_activation_required], false)
on_exit(fn ->
Pleroma.Config.put(
[:instance, :account_activation_required],
orig_account_activation_required
)
end)
assert {:ok, _} = User.change_email(user, "")
Pleroma.Config.put([:instance, :account_activation_required], true)
assert {:ok, %User{email: "cofe2@cofe.party"}} = User.change_email(user, "cofe2@cofe.party")
end
end
describe "get_cached_by_nickname_or_id" do
setup do
local_user = insert(:user)
remote_user = insert(:user, nickname: "nickname@example.com", local: false)
[local_user: local_user, remote_user: remote_user]
end
setup do: clear_config([:instance, :limit_to_local_content])
test "allows getting remote users by id no matter what :limit_to_local_content is set to", %{
remote_user: remote_user
} do
clear_config([:instance, :limit_to_local_content], false)
assert %User{} = User.get_cached_by_nickname_or_id(remote_user.id)
clear_config([:instance, :limit_to_local_content], true)
assert %User{} = User.get_cached_by_nickname_or_id(remote_user.id)
clear_config([:instance, :limit_to_local_content], :unauthenticated)
assert %User{} = User.get_cached_by_nickname_or_id(remote_user.id)
end
test "disallows getting remote users by nickname without authentication when :limit_to_local_content is set to :unauthenticated",
%{remote_user: remote_user} do
clear_config([:instance, :limit_to_local_content], :unauthenticated)
assert nil == User.get_cached_by_nickname_or_id(remote_user.nickname)
end
test "allows getting remote users by nickname with authentication when :limit_to_local_content is set to :unauthenticated",
%{remote_user: remote_user, local_user: local_user} do
clear_config([:instance, :limit_to_local_content], :unauthenticated)
assert %User{} = User.get_cached_by_nickname_or_id(remote_user.nickname, for: local_user)
end
test "disallows getting remote users by nickname when :limit_to_local_content is set to true",
%{remote_user: remote_user} do
clear_config([:instance, :limit_to_local_content], true)
assert nil == User.get_cached_by_nickname_or_id(remote_user.nickname)
end
test "allows getting local users by nickname no matter what :limit_to_local_content is set to",
%{local_user: local_user} do
clear_config([:instance, :limit_to_local_content], false)
assert %User{} = User.get_cached_by_nickname_or_id(local_user.nickname)
clear_config([:instance, :limit_to_local_content], true)
assert %User{} = User.get_cached_by_nickname_or_id(local_user.nickname)
clear_config([:instance, :limit_to_local_content], :unauthenticated)
assert %User{} = User.get_cached_by_nickname_or_id(local_user.nickname)
end
end
describe "update_email_notifications/2" do
setup do
user = insert(:user, email_notifications: %{"digest" => true})
{:ok, user: user}
end
test "Notifications are updated", %{user: user} do
true = user.email_notifications["digest"]
assert {:ok, result} = User.update_email_notifications(user, %{"digest" => false})
assert result.email_notifications["digest"] == false
end
end
describe "local_nickname/1" do
test "returns nickname without host" do
assert User.local_nickname("@mentioned") == "mentioned"
assert User.local_nickname("a_local_nickname") == "a_local_nickname"
assert User.local_nickname("nickname@host.com") == "nickname"
end
end
describe "full_nickname/1" do
test "returns fully qualified nickname for local users" do
local_user = insert(:user, nickname: "local_user")
assert User.full_nickname(local_user) == "local_user@localhost"
end
test "returns fully qualified nickname for local users when using different domain for webfinger" do
clear_config([Pleroma.Web.WebFinger, :domain], "plemora.dev")
local_user = insert(:user, nickname: "local_user")
assert User.full_nickname(local_user) == "local_user@plemora.dev"
end
test "returns fully qualified nickname for remote users" do
remote_user = insert(:user, nickname: "remote@host.com", local: false)
assert User.full_nickname(remote_user) == "remote@host.com"
end
test "strips leading @ from mentions" do
assert User.full_nickname("@mentioned") == "mentioned"
assert User.full_nickname("@nickname@host.com") == "nickname@host.com"
end
test "does not modify nicknames" do
assert User.full_nickname("nickname") == "nickname"
assert User.full_nickname("nickname@host.com") == "nickname@host.com"
end
end
test "avatar fallback" do
user = insert(:user)
assert User.avatar_url(user) =~ "/images/avi.png"
clear_config([:assets, :default_user_avatar], "avatar.png")
user = User.get_cached_by_nickname_or_id(user.nickname)
assert User.avatar_url(user) =~ "avatar.png"
assert User.avatar_url(user, no_default: true) == nil
end
test "get_host/1" do
user = insert(:user, ap_id: "https://lain.com/users/lain", nickname: "lain")
assert User.get_host(user) == "lain.com"
end
test "update_last_active_at/1" do
user = insert(:user)
assert is_nil(user.last_active_at)
test_started_at = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
assert {:ok, user} = User.update_last_active_at(user)
assert NaiveDateTime.compare(user.last_active_at, test_started_at) in [:gt, :eq]
assert NaiveDateTime.compare(
user.last_active_at,
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
) in [:lt, :eq]
last_active_at =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(-:timer.hours(24), :millisecond)
|> NaiveDateTime.truncate(:second)
assert {:ok, user} =
user
|> cast(%{last_active_at: last_active_at}, [:last_active_at])
|> User.update_and_set_cache()
assert NaiveDateTime.compare(user.last_active_at, last_active_at) == :eq
assert {:ok, user} = User.update_last_active_at(user)
assert NaiveDateTime.compare(user.last_active_at, test_started_at) in [:gt, :eq]
assert NaiveDateTime.compare(
user.last_active_at,
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
) in [:lt, :eq]
end
test "active_user_count/1" do
insert(:user)
insert(:user, %{local: false})
insert(:user, %{last_active_at: NaiveDateTime.utc_now()})
insert(:user, %{last_active_at: Timex.shift(NaiveDateTime.utc_now(), days: -15)})
insert(:user, %{last_active_at: Timex.shift(NaiveDateTime.utc_now(), weeks: -6)})
insert(:user, %{last_active_at: Timex.shift(NaiveDateTime.utc_now(), months: -7)})
insert(:user, %{last_active_at: Timex.shift(NaiveDateTime.utc_now(), years: -2)})
assert User.active_user_count() == 2
assert User.active_user_count(180) == 3
assert User.active_user_count(365) == 4
assert User.active_user_count(1000) == 5
end
describe "pins" do
setup do
user = insert(:user)
[user: user, object_id: object_id_from_created_activity(user)]
end
test "unique pins", %{user: user, object_id: object_id} do
assert {:ok, %{pinned_objects: %{^object_id => pinned_at1} = pins} = updated_user} =
User.add_pinned_object_id(user, object_id)
assert Enum.count(pins) == 1
assert {:ok, %{pinned_objects: %{^object_id => pinned_at2} = pins}} =
User.add_pinned_object_id(updated_user, object_id)
assert pinned_at1 == pinned_at2
assert Enum.count(pins) == 1
end
test "respects max_pinned_statuses limit", %{user: user, object_id: object_id} do
clear_config([:instance, :max_pinned_statuses], 1)
{:ok, updated} = User.add_pinned_object_id(user, object_id)
object_id2 = object_id_from_created_activity(user)
{:error, %{errors: errors}} = User.add_pinned_object_id(updated, object_id2)
assert Keyword.has_key?(errors, :pinned_objects)
end
test "remove_pinned_object_id/2", %{user: user, object_id: object_id} do
assert {:ok, updated} = User.add_pinned_object_id(user, object_id)
{:ok, after_remove} = User.remove_pinned_object_id(updated, object_id)
assert after_remove.pinned_objects == %{}
end
end
defp object_id_from_created_activity(user) do
%{id: id} = insert(:note_activity, user: user)
%{object: %{data: %{"id" => object_id}}} = Activity.get_by_id_with_object(id)
object_id
end
describe "add_alias/2" do
test "should add alias for another user" do
user = insert(:user)
user2 = insert(:user)
assert {:ok, user_updated} = user |> User.add_alias(user2)
assert user_updated.also_known_as |> length() == 1
assert user2.ap_id in user_updated.also_known_as
end
test "should add multiple aliases" do
user = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
assert {:ok, user} = user |> User.add_alias(user2)
assert {:ok, user_updated} = user |> User.add_alias(user3)
assert user_updated.also_known_as |> length() == 2
assert user2.ap_id in user_updated.also_known_as
assert user3.ap_id in user_updated.also_known_as
end
test "should not add duplicate aliases" do
user = insert(:user)
user2 = insert(:user)
assert {:ok, user} = user |> User.add_alias(user2)
assert {:ok, user_updated} = user |> User.add_alias(user2)
assert user_updated.also_known_as |> length() == 1
assert user2.ap_id in user_updated.also_known_as
end
test "should tolerate non-http(s) aliases" do
user =
insert(:user, %{
also_known_as: ["at://did:plc:xgvzy7ni6ig6ievcbls5jaxe"]
})
assert "at://did:plc:xgvzy7ni6ig6ievcbls5jaxe" in user.also_known_as
end
end
describe "alias_users/1" do
test "should get aliases for a user" do
user = insert(:user)
user2 = insert(:user, also_known_as: [user.ap_id])
aliases = user2 |> User.alias_users()
assert aliases |> length() == 1
alias_user = aliases |> Enum.at(0)
assert alias_user.ap_id == user.ap_id
end
end
describe "delete_alias/2" do
test "should delete existing alias" do
user = insert(:user)
user2 = insert(:user, also_known_as: [user.ap_id])
assert {:ok, user_updated} = user2 |> User.delete_alias(user)
assert user_updated.also_known_as == []
end
test "should report error on non-existing alias" do
user = insert(:user)
user2 = insert(:user)
user3 = insert(:user, also_known_as: [user.ap_id])
assert {:error, :no_such_alias} = user3 |> User.delete_alias(user2)
user3_updated = User.get_cached_by_ap_id(user3.ap_id)
assert user3_updated.also_known_as |> length() == 1
assert user.ap_id in user3_updated.also_known_as
end
end
describe "get_familiar_followers/3" do
test "returns familiar followers for a pair of users" do
user1 = insert(:user)
%{id: id2} = user2 = insert(:user)
user3 = insert(:user)
_user4 = insert(:user)
User.follow(user1, user2)
User.follow(user2, user3)
assert [%{id: ^id2}] = User.get_familiar_followers(user3, user1)
end
end
describe "account endorsements" do
test "it pins people" do
user = insert(:user)
pinned_user = insert(:user)
{:ok, _pinned_user, _user} = User.follow(user, pinned_user)
refute User.endorses?(user, pinned_user)
{:ok, _user_relationship} = User.endorse(user, pinned_user)
assert User.endorses?(user, pinned_user)
end
test "it unpins users" do
user = insert(:user)
pinned_user = insert(:user)
{:ok, _pinned_user, _user} = User.follow(user, pinned_user)
{:ok, _user_relationship} = User.endorse(user, pinned_user)
{:ok, _user_pin} = User.unendorse(user, pinned_user)
refute User.endorses?(user, pinned_user)
end
test "it doesn't pin users you do not follow" do
user = insert(:user)
pinned_user = insert(:user)
assert {:error, _message} = User.endorse(user, pinned_user)
refute User.endorses?(user, pinned_user)
end
end
test "it checks fields links for a backlink" do
user = insert(:user, ap_id: "https://social.example.org/users/lain")
fields = [
%{"name" => "Link", "value" => "http://example.com/rel_me/null"},
%{"name" => "Verified link", "value" => "http://example.com/rel_me/link"},
%{"name" => "Not a link", "value" => "i'm not a link"}
]
user
|> User.update_and_set_cache(%{raw_fields: fields})
ObanHelpers.perform_all()
user = User.get_cached_by_id(user.id)
assert [
%{"verified_at" => nil},
%{"verified_at" => verified_at},
%{"verified_at" => nil}
] = user.fields
assert is_binary(verified_at)
end
test "updating fields does not invalidate previously validated links" do
user = insert(:user, ap_id: "https://social.example.org/users/lain")
user
|> User.update_and_set_cache(%{
raw_fields: [%{"name" => "verified link", "value" => "http://example.com/rel_me/link"}]
})
ObanHelpers.perform_all()
%User{fields: [%{"verified_at" => verified_at}]} = user = User.get_cached_by_id(user.id)
user
|> User.update_and_set_cache(%{
raw_fields: [%{"name" => "Verified link", "value" => "http://example.com/rel_me/link"}]
})
user = User.get_cached_by_id(user.id)
assert [%{"verified_at" => ^verified_at}] = user.fields
end
describe "follow_hashtag/2" do
test "should follow a hashtag" do
user = insert(:user)
hashtag = insert(:hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
user = User.get_cached_by_ap_id(user.ap_id)
assert user.followed_hashtags |> Enum.count() == 1
assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
end
test "should not follow a hashtag twice" do
user = insert(:user)
hashtag = insert(:hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
user = User.get_cached_by_ap_id(user.ap_id)
assert user.followed_hashtags |> Enum.count() == 1
assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
end
test "can follow multiple hashtags" do
user = insert(:user)
hashtag = insert(:hashtag)
other_hashtag = insert(:hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
assert {:ok, _} = user |> User.follow_hashtag(other_hashtag)
user = User.get_cached_by_ap_id(user.ap_id)
assert user.followed_hashtags |> Enum.count() == 2
assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
assert other_hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
end
end
describe "unfollow_hashtag/2" do
test "should unfollow a hashtag" do
user = insert(:user)
hashtag = insert(:hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
user = User.get_cached_by_ap_id(user.ap_id)
assert user.followed_hashtags |> Enum.count() == 0
end
test "should not error when trying to unfollow a hashtag twice" do
user = insert(:user)
hashtag = insert(:hashtag)
assert {:ok, _} = user |> User.follow_hashtag(hashtag)
assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
user = User.get_cached_by_ap_id(user.ap_id)
assert user.followed_hashtags |> Enum.count() == 0
end
end
end
diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs
index a3f807ca9..1aed28d5b 100644
--- a/test/pleroma/web/activity_pub/views/user_view_test.exs
+++ b/test/pleroma/web/activity_pub/views/user_view_test.exs
@@ -1,224 +1,232 @@
# 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.UserViewTest do
use Pleroma.DataCase, async: false
import Pleroma.Factory
alias Pleroma.User
alias Pleroma.Web.ActivityPub.UserView
alias Pleroma.Web.CommonAPI
test "Renders a user, including the public key" do
user = insert(:user)
result = UserView.render("user.json", %{user: user})
assert result["id"] == user.ap_id
assert result["preferredUsername"] == user.nickname
assert String.contains?(result["publicKey"]["publicKeyPem"], "BEGIN PUBLIC KEY")
end
test "Renders profile fields" do
fields = [
%{"name" => "foo", "value" => "bar"}
]
{:ok, user} =
insert(:user)
|> User.update_changeset(%{fields: fields})
|> User.update_and_set_cache()
assert %{
"attachment" => [%{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}]
} = UserView.render("user.json", %{user: user})
end
test "Renders with emoji tags" do
user = insert(:user, emoji: %{"bib" => "/test"})
assert %{
"tag" => [
%{
"icon" => %{"type" => "Image", "url" => "/test"},
"id" => "/test",
"name" => ":bib:",
"type" => "Emoji",
"updated" => "1970-01-01T00:00:00Z"
}
]
} = UserView.render("user.json", %{user: user})
end
test "Does not add an avatar image if the user hasn't set one" do
user = insert(:user)
result = UserView.render("user.json", %{user: user})
refute result["icon"]
refute result["image"]
user =
insert(:user,
avatar: %{"url" => [%{"href" => "https://someurl"}]},
banner: %{"url" => [%{"href" => "https://somebanner"}]}
)
result = UserView.render("user.json", %{user: user})
assert result["icon"]["url"] == "https://someurl"
assert result["image"]["url"] == "https://somebanner"
refute result["icon"]["name"]
refute result["image"]["name"]
end
test "Avatar has a description if the user set one" do
user =
insert(:user,
avatar: %{
"url" => [%{"href" => "https://someurl"}],
"name" => "a drawing of pleroma-tan using pleroma groups"
}
)
result = UserView.render("user.json", %{user: user})
assert result["icon"]["name"] == "a drawing of pleroma-tan using pleroma groups"
end
test "renders an invisible user with the invisible property set to true" do
user = insert(:user, invisible: true)
assert %{"invisible" => true} = UserView.render("service.json", %{user: user})
end
test "service has a few essential fields" do
user = insert(:user)
result = UserView.render("service.json", %{user: user})
assert result["id"]
assert result["type"] == "Application"
assert result["inbox"]
assert result["outbox"]
end
test "renders AKAs" do
akas = ["https://i.tusooa.xyz/users/test-pleroma"]
user = insert(:user, also_known_as: akas)
assert %{"alsoKnownAs" => ^akas} = UserView.render("user.json", %{user: user})
end
test "renders full nickname" do
clear_config([Pleroma.Web.WebFinger, :domain], "plemora.dev")
user = insert(:user, nickname: "user")
assert %{"webfinger" => "acct:user@plemora.dev"} = UserView.render("user.json", %{user: user})
end
describe "endpoints" do
test "local users have a usable endpoints structure" do
user = insert(:user)
result = UserView.render("user.json", %{user: user})
assert result["id"] == user.ap_id
%{
"sharedInbox" => _,
"oauthAuthorizationEndpoint" => _,
"oauthRegistrationEndpoint" => _,
"oauthTokenEndpoint" => _
} = result["endpoints"]
end
test "remote users have an empty endpoints structure" do
user = insert(:user, local: false)
result = UserView.render("user.json", %{user: user})
assert result["id"] == user.ap_id
assert result["endpoints"] == %{}
end
test "instance users do not expose oAuth endpoints" do
user = insert(:user, nickname: nil, local: true)
result = UserView.render("user.json", %{user: user})
refute result["endpoints"]["oauthAuthorizationEndpoint"]
refute result["endpoints"]["oauthRegistrationEndpoint"]
refute result["endpoints"]["oauthTokenEndpoint"]
end
end
describe "followers" do
test "sets totalItems to zero when followers are hidden" do
user = insert(:user)
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
user = Map.merge(user, %{hide_followers_count: true, hide_followers: true})
refute UserView.render("followers.json", %{user: user}) |> Map.has_key?("totalItems")
end
test "sets correct totalItems when followers are hidden but the follower counter is not" do
user = insert(:user)
other_user = insert(:user)
{:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user)
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
user = Map.merge(user, %{hide_followers_count: false, hide_followers: true})
assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user})
end
test "does not hide follower items based on `hide_follows`" do
user = insert(:user)
follower = insert(:user)
{:ok, user, _follower, _activity} = CommonAPI.follow(user, follower)
user = Map.merge(user, %{hide_followers: false, hide_follows: true})
follower_ap_id = follower.ap_id
assert %{"first" => %{"orderedItems" => [^follower_ap_id]}} =
UserView.render("followers.json", %{user: user})
end
end
describe "following" do
test "sets totalItems to zero when follows are hidden" do
user = insert(:user)
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
user = Map.merge(user, %{hide_follows_count: true, hide_follows: true})
assert %{"totalItems" => 0} = UserView.render("following.json", %{user: user})
end
test "sets correct totalItems when follows are hidden but the follow counter is not" do
user = insert(:user)
other_user = insert(:user)
{:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user)
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
user = Map.merge(user, %{hide_follows_count: false, hide_follows: true})
assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user})
end
end
describe "acceptsChatMessages" do
test "it returns this value if it is set" do
true_user = insert(:user, accepts_chat_messages: true)
false_user = insert(:user, accepts_chat_messages: false)
nil_user = insert(:user, accepts_chat_messages: nil)
assert %{"capabilities" => %{"acceptsChatMessages" => true}} =
UserView.render("user.json", user: true_user)
assert %{"capabilities" => %{"acceptsChatMessages" => false}} =
UserView.render("user.json", user: false_user)
refute Map.has_key?(
UserView.render("user.json", user: nil_user)["capabilities"],
"acceptsChatMessages"
)
end
+
+ test "it returns false if chat is disabled" do
+ clear_config([Pleroma.Chat, :enabled], false)
+ user = insert(:user, accepts_chat_messages: true)
+
+ assert %{"capabilities" => %{"acceptsChatMessages" => false}} =
+ UserView.render("user.json", user: user)
+ end
end
end
diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs
index e62d95fad..c11ece10d 100644
--- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs
+++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs
@@ -1,1519 +1,1593 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do
use Pleroma.Web.ConnCase
import ExUnit.CaptureLog
import Pleroma.Factory
alias Pleroma.ConfigDB
setup do
admin = insert(:user, is_admin: true)
token = insert(:oauth_admin_token, user: admin)
conn =
build_conn()
|> assign(:user, admin)
|> assign(:token, token)
{:ok, %{admin: admin, token: token, conn: conn}}
end
describe "GET /api/pleroma/admin/config" do
setup do: clear_config(:configurable_from_database, true)
test "when configuration from database is off", %{conn: conn} do
clear_config(:configurable_from_database, false)
conn = get(conn, "/api/pleroma/admin/config")
assert json_response_and_validate_schema(conn, 400) ==
%{
"error" => "You must enable configurable_from_database in your config file."
}
end
test "with settings only in db", %{conn: conn} do
config1 = insert(:config)
config2 = insert(:config)
conn = get(conn, "/api/pleroma/admin/config?only_db=true")
%{
"configs" => [
%{
"group" => ":pleroma",
"key" => key1,
"value" => _
},
%{
"group" => ":pleroma",
"key" => key2,
"value" => _
}
]
} = json_response_and_validate_schema(conn, 200)
assert key1 == inspect(config1.key)
assert key2 == inspect(config2.key)
end
test "db is added to settings that are in db", %{conn: conn} do
_config = insert(:config, key: ":instance", value: [name: "Some name"])
%{"configs" => configs} =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
[instance_config] =
Enum.filter(configs, fn %{"group" => group, "key" => key} ->
group == ":pleroma" and key == ":instance"
end)
assert instance_config["db"] == [":name"]
end
test "merged default setting with db settings", %{conn: conn} do
config1 = insert(:config)
config2 = insert(:config)
config3 =
insert(:config,
value: [k1: :v1, k2: :v2]
)
%{"configs" => configs} =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
assert length(configs) > 3
saved_configs = [config1, config2, config3]
keys = Enum.map(saved_configs, &inspect(&1.key))
received_configs =
Enum.filter(configs, fn %{"group" => group, "key" => key} ->
group == ":pleroma" and key in keys
end)
assert length(received_configs) == 3
db_keys =
config3.value
|> Keyword.keys()
|> ConfigDB.to_json_types()
keys = Enum.map(saved_configs -- [config3], &inspect(&1.key))
values = Enum.map(saved_configs, &ConfigDB.to_json_types(&1.value))
mapset_keys = MapSet.new(keys ++ db_keys)
Enum.each(received_configs, fn %{"value" => value, "db" => db} ->
db = MapSet.new(db)
assert MapSet.subset?(db, mapset_keys)
assert value in values
end)
end
test "subkeys with full update right merge", %{conn: conn} do
insert(:config,
key: ":emoji",
value: [groups: [a: 1, b: 2], key: [a: 1]]
)
insert(:config,
key: ":assets",
value: [mascots: [a: 1, b: 2], key: [a: 1]]
)
%{"configs" => configs} =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
vals =
Enum.filter(configs, fn %{"group" => group, "key" => key} ->
group == ":pleroma" and key in [":emoji", ":assets"]
end)
emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end)
assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end)
emoji_val = ConfigDB.to_elixir_types(emoji["value"])
assets_val = ConfigDB.to_elixir_types(assets["value"])
assert emoji_val[:groups] == [a: 1, b: 2]
assert assets_val[:mascots] == [a: 1, b: 2]
end
test "with valid `admin_token` query parameter, skips OAuth scopes check" do
clear_config([:admin_token], "password123")
build_conn()
|> get("/api/pleroma/admin/config?admin_token=password123")
|> json_response_and_validate_schema(200)
end
end
test "POST /api/pleroma/admin/config with configdb disabled", %{conn: conn} do
clear_config(:configurable_from_database, false)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{"configs" => []})
assert json_response_and_validate_schema(conn, 400) ==
%{"error" => "You must enable configurable_from_database in your config file."}
end
describe "POST /api/pleroma/admin/config" do
setup do
http = Application.get_env(:pleroma, :http)
on_exit(fn ->
Application.delete_env(:pleroma, :key1)
Application.delete_env(:pleroma, :key2)
Application.delete_env(:pleroma, :key3)
Application.delete_env(:pleroma, :key4)
Application.delete_env(:pleroma, :keyaa1)
Application.delete_env(:pleroma, :keyaa2)
Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal)
Application.delete_env(:pleroma, Pleroma.Captcha.NotReal)
Application.put_env(:pleroma, :http, http)
Application.put_env(:tesla, :adapter, Tesla.Mock)
Restarter.Pleroma.refresh()
end)
end
setup do: clear_config(:configurable_from_database, true)
- setup do:
- clear_config(:database_config_whitelist, [
- {:pleroma},
- {:http},
- {:idna},
- {:oban},
- {:tesla},
- {:ueberauth}
- ])
-
test "create new config setting in db", %{conn: conn} do
- ueberauth = Application.get_env(:ueberauth, Ueberauth)
- on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end)
+ aws = Application.get_env(:ex_aws, :s3)
+ on_exit(fn -> Application.put_env(:ex_aws, :s3, aws) end)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":key1", value: "value1"},
%{
- group: ":ueberauth",
- key: "Ueberauth",
- value: [%{"tuple" => [":consumer_secret", "aaaa"]}]
+ group: ":cors_plug",
+ key: ":key1",
+ value: [%{"tuple" => [":key", "value"]}]
},
%{
group: ":pleroma",
key: ":key2",
value: %{
":nested_1" => "nested_value1",
":nested_2" => [
%{":nested_22" => "nested_value222"},
%{":nested_33" => %{":nested_44" => "nested_444"}}
]
}
},
%{
group: ":pleroma",
key: ":key3",
value: [
%{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
%{"nested_4" => true}
]
},
%{
group: ":pleroma",
key: ":key4",
value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"}
},
%{
- group: ":idna",
- key: ":key5",
- value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}
+ group: ":ex_aws",
+ key: ":s3",
+ value: %{"tuple" => ["access_key_id", "aaaa", []]}
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => "value1",
"db" => [":key1"]
},
%{
- "group" => ":ueberauth",
- "key" => "Ueberauth",
- "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}],
- "db" => [":consumer_secret"]
+ "group" => ":cors_plug",
+ "key" => ":key1",
+ "value" => [%{"tuple" => [":key", "value"]}],
+ "db" => [":key"]
},
%{
"group" => ":pleroma",
"key" => ":key2",
"value" => %{
":nested_1" => "nested_value1",
":nested_2" => [
%{":nested_22" => "nested_value222"},
%{":nested_33" => %{":nested_44" => "nested_444"}}
]
},
"db" => [":key2"]
},
%{
"group" => ":pleroma",
"key" => ":key3",
"value" => [
%{"nested_3" => ":nested_3", "nested_33" => "nested_33"},
%{"nested_4" => true}
],
"db" => [":key3"]
},
%{
"group" => ":pleroma",
"key" => ":key4",
"value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"},
"db" => [":key4"]
},
%{
- "group" => ":idna",
- "key" => ":key5",
- "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]},
- "db" => [":key5"]
+ "group" => ":ex_aws",
+ "key" => ":s3",
+ "value" => %{"tuple" => ["access_key_id", "aaaa", []]},
+ "db" => [":s3"]
}
],
"need_reboot" => false
}
assert Application.get_env(:pleroma, :key1) == "value1"
assert Application.get_env(:pleroma, :key2) == %{
nested_1: "nested_value1",
nested_2: [
%{nested_22: "nested_value222"},
%{nested_33: %{nested_44: "nested_444"}}
]
}
assert Application.get_env(:pleroma, :key3) == [
%{"nested_3" => :nested_3, "nested_33" => "nested_33"},
%{"nested_4" => true}
]
assert Application.get_env(:pleroma, :key4) == %{
"endpoint" => "https://example.com",
nested_5: :upload
}
- assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []}
+ assert Application.get_env(:cors_plug, :key1) == [key: "value"]
+ assert Application.get_env(:ex_aws, :s3) == {"access_key_id", "aaaa", []}
end
test "save configs setting without explicit key", %{conn: conn} do
- adapter = Application.get_env(:http, :adapter)
- send_user_agent = Application.get_env(:http, :send_user_agent)
- user_agent = Application.get_env(:http, :user_agent)
+ mime = Application.get_env(:mime, :library)
+ prometheus = Application.get_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter)
+ web_push = Application.get_env(:web_push_encryption, :vapid_details)
on_exit(fn ->
- Application.put_env(:http, :adapter, adapter)
- Application.put_env(:http, :send_user_agent, send_user_agent)
- Application.put_env(:http, :user_agent, user_agent)
+ Application.put_env(:mime, :library, mime)
+ Application.put_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter, prometheus)
+ Application.put_env(:web_push_encryption, :vapid_details, web_push)
end)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
- group: ":http",
- key: ":adapter",
+ group: ":mime",
+ key: ":library",
value: [":someval"]
},
%{
- group: ":http",
- key: ":send_user_agent",
+ group: ":prometheus",
+ key: "Pleroma.Web.Endpoint.MetricsExporter",
value: true
},
%{
- group: ":http",
- key: ":user_agent",
- value: [":default"]
+ group: ":web_push_encryption",
+ key: ":vapid_details",
+ value: [":nope"]
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
- "group" => ":http",
- "key" => ":adapter",
+ "group" => ":mime",
+ "key" => ":library",
"value" => [":someval"],
- "db" => [":adapter"]
+ "db" => [":library"]
},
%{
- "group" => ":http",
- "key" => ":send_user_agent",
+ "group" => ":prometheus",
+ "key" => "Pleroma.Web.Endpoint.MetricsExporter",
"value" => true,
- "db" => [":send_user_agent"]
+ "db" => ["Pleroma.Web.Endpoint.MetricsExporter"]
},
%{
- "group" => ":http",
- "key" => ":user_agent",
- "value" => [":default"],
- "db" => [":user_agent"]
+ "group" => ":web_push_encryption",
+ "key" => ":vapid_details",
+ "value" => [":nope"],
+ "db" => [":vapid_details"]
}
],
"need_reboot" => false
}
- assert Application.get_env(:http, :adapter) == [:someval]
- assert Application.get_env(:http, :send_user_agent) == true
- assert Application.get_env(:http, :user_agent) == [:default]
+ assert Application.get_env(:mime, :library) == [:someval]
+ assert Application.get_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter) == true
+ assert Application.get_env(:web_push_encryption, :vapid_details) == [:nope]
end
test "saving config with partial update", %{conn: conn} do
insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2))
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key1", 1]},
%{"tuple" => [":key2", 2]},
%{"tuple" => [":key3", 3]}
],
"db" => [":key1", ":key2", ":key3"]
}
],
"need_reboot" => false
}
end
test "saving config which need pleroma reboot", %{conn: conn} do
clear_config([:shout, :enabled], true)
assert conn
|> put_req_header("content-type", "application/json")
|> post(
"/api/pleroma/admin/config",
%{
configs: [
%{group: ":pleroma", key: ":shout", value: [%{"tuple" => [":enabled", true]}]}
]
}
)
|> json_response_and_validate_schema(200) == %{
"configs" => [
%{
"db" => [":enabled"],
"group" => ":pleroma",
"key" => ":shout",
"value" => [%{"tuple" => [":enabled", true]}]
}
],
"need_reboot" => true
}
configs =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
assert configs["need_reboot"]
capture_log(fn ->
assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) ==
%{}
end) =~ "pleroma restarted"
configs =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
assert configs["need_reboot"] == false
end
test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do
clear_config([:shout, :enabled], true)
assert conn
|> put_req_header("content-type", "application/json")
|> post(
"/api/pleroma/admin/config",
%{
configs: [
%{group: ":pleroma", key: ":shout", value: [%{"tuple" => [":enabled", true]}]}
]
}
)
|> json_response_and_validate_schema(200) == %{
"configs" => [
%{
"db" => [":enabled"],
"group" => ":pleroma",
"key" => ":shout",
"value" => [%{"tuple" => [":enabled", true]}]
}
],
"need_reboot" => true
}
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]}
]
})
|> json_response_and_validate_schema(200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key3", 3]}
],
"db" => [":key3"]
}
],
"need_reboot" => true
}
capture_log(fn ->
assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) ==
%{}
end) =~ "pleroma restarted"
configs =
conn
|> get("/api/pleroma/admin/config")
|> json_response_and_validate_schema(200)
assert configs["need_reboot"] == false
end
test "saving config with nested merge", %{conn: conn} do
insert(:config, key: :key1, value: [key1: 1, key2: [k1: 1, k2: 2]])
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":key1",
value: [
%{"tuple" => [":key3", 3]},
%{
"tuple" => [
":key2",
[
%{"tuple" => [":k2", 1]},
%{"tuple" => [":k3", 3]}
]
]
}
]
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key1", 1]},
%{"tuple" => [":key3", 3]},
%{
"tuple" => [
":key2",
[
%{"tuple" => [":k1", 1]},
%{"tuple" => [":k2", 1]},
%{"tuple" => [":k3", 3]}
]
]
}
],
"db" => [":key1", ":key3", ":key2"]
}
],
"need_reboot" => false
}
end
test "saving special atoms", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{
"tuple" => [
":ssl_options",
[%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}]
]
}
]
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{
"tuple" => [
":ssl_options",
[%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}]
]
}
],
"db" => [":ssl_options"]
}
],
"need_reboot" => false
}
assert Application.get_env(:pleroma, :key1) == [
ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]]
]
end
test "saving full setting if value is not keyword", %{conn: conn} do
insert(:config,
group: :tesla,
key: :adapter,
value: Tesla.Adapter.Hackey
)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
- %{group: ":tesla", key: ":adapter", value: "Tesla.Adapter.Httpc"}
+ %{group: ":pleroma", key: ":key1", value: "Pleroma.Web.Endpoint"}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
- "group" => ":tesla",
- "key" => ":adapter",
- "value" => "Tesla.Adapter.Httpc",
- "db" => [":adapter"]
+ "group" => ":pleroma",
+ "key" => ":key1",
+ "value" => "Pleroma.Web.Endpoint",
+ "db" => [":key1"]
}
],
"need_reboot" => false
}
end
test "update config setting & delete with fallback to default value", %{
conn: conn,
admin: admin,
token: token
} do
- ueberauth = Application.get_env(:ueberauth, Ueberauth)
+ prometheus = Application.get_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter)
insert(:config, key: :keyaa1)
insert(:config, key: :keyaa2)
config3 =
insert(:config,
- group: :ueberauth,
- key: Ueberauth
+ group: :prometheus,
+ key: Pleroma.Web.Endpoint.MetricsExporter
)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":keyaa1", value: "another_value"},
%{group: ":pleroma", key: ":keyaa2", value: "another_value"}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":keyaa1",
"value" => "another_value",
"db" => [":keyaa1"]
},
%{
"group" => ":pleroma",
"key" => ":keyaa2",
"value" => "another_value",
"db" => [":keyaa2"]
}
],
"need_reboot" => false
}
assert Application.get_env(:pleroma, :keyaa1) == "another_value"
assert Application.get_env(:pleroma, :keyaa2) == "another_value"
- assert Application.get_env(:ueberauth, Ueberauth) == config3.value
+
+ assert Application.get_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter) ==
+ config3.value
conn =
build_conn()
|> assign(:user, admin)
|> assign(:token, token)
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":keyaa2", delete: true},
%{
- group: ":ueberauth",
- key: "Ueberauth",
+ group: ":prometheus",
+ key: "Pleroma.Web.Endpoint.MetricsExporter",
delete: true
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [],
"need_reboot" => false
}
- assert Application.get_env(:ueberauth, Ueberauth) == ueberauth
+ assert Application.get_env(:prometheus, Pleroma.Web.Endpoint.MetricsExporter) == prometheus
refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2)
end
test "common config example", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
"group" => ":pleroma",
"key" => "Pleroma.Captcha.NotReal",
"value" => [
%{"tuple" => [":enabled", false]},
%{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]},
%{"tuple" => [":seconds_valid", 60]},
%{"tuple" => [":path", ""]},
%{"tuple" => [":key1", nil]},
%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]},
%{"tuple" => [":regex1", "~r/https:\/\/example.com/"]},
%{"tuple" => [":regex2", "~r/https:\/\/example.com/u"]},
%{"tuple" => [":regex3", "~r/https:\/\/example.com/i"]},
%{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]},
%{"tuple" => [":name", "Pleroma"]}
]
}
]
})
assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma"
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => "Pleroma.Captcha.NotReal",
"value" => [
%{"tuple" => [":enabled", false]},
%{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]},
%{"tuple" => [":seconds_valid", 60]},
%{"tuple" => [":path", ""]},
%{"tuple" => [":key1", nil]},
%{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]},
%{"tuple" => [":regex1", "~r/https:\\/\\/example.com/"]},
%{"tuple" => [":regex2", "~r/https:\\/\\/example.com/u"]},
%{"tuple" => [":regex3", "~r/https:\\/\\/example.com/i"]},
%{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]},
%{"tuple" => [":name", "Pleroma"]}
],
"db" => [
":enabled",
":method",
":seconds_valid",
":path",
":key1",
":partial_chain",
":regex1",
":regex2",
":regex3",
":regex4",
":name"
]
}
],
"need_reboot" => false
}
end
test "tuples with more than two values", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
"group" => ":pleroma",
"key" => "Pleroma.Web.Endpoint.NotReal",
"value" => [
%{
"tuple" => [
":http",
[
%{
"tuple" => [
":key2",
[
%{
"tuple" => [
":_",
[
%{
"tuple" => [
"/api/v1/streaming",
"Pleroma.Web.MastodonAPI.WebsocketHandler",
[]
]
},
%{
"tuple" => [
"/websocket",
":sth",
%{
"tuple" => [
"Phoenix.Transports.WebSocket",
%{
"tuple" => [
"Pleroma.Web.Endpoint",
"Pleroma.Web.UserSocket",
[]
]
}
]
}
]
},
%{
"tuple" => [
":_",
"Plug.Cowboy.Handler",
%{"tuple" => ["Pleroma.Web.Endpoint", []]}
]
}
]
]
}
]
]
}
]
]
}
]
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => "Pleroma.Web.Endpoint.NotReal",
"value" => [
%{
"tuple" => [
":http",
[
%{
"tuple" => [
":key2",
[
%{
"tuple" => [
":_",
[
%{
"tuple" => [
"/api/v1/streaming",
"Pleroma.Web.MastodonAPI.WebsocketHandler",
[]
]
},
%{
"tuple" => [
"/websocket",
":sth",
%{
"tuple" => [
"Phoenix.Transports.WebSocket",
%{
"tuple" => [
"Pleroma.Web.Endpoint",
"Pleroma.Web.UserSocket",
[]
]
}
]
}
]
},
%{
"tuple" => [
":_",
"Plug.Cowboy.Handler",
%{"tuple" => ["Pleroma.Web.Endpoint", []]}
]
}
]
]
}
]
]
}
]
]
}
],
"db" => [":http"]
}
],
"need_reboot" => false
}
end
test "settings with nesting map", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key2", "some_val"]},
%{
"tuple" => [
":key3",
%{
":max_options" => 20,
":max_option_chars" => 200,
":min_expiration" => 0,
":max_expiration" => 31_536_000,
"nested" => %{
":max_options" => 20,
":max_option_chars" => 200,
":min_expiration" => 0,
":max_expiration" => 31_536_000
}
}
]
}
]
}
]
})
assert json_response_and_validate_schema(conn, 200) ==
%{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => [
%{"tuple" => [":key2", "some_val"]},
%{
"tuple" => [
":key3",
%{
":max_expiration" => 31_536_000,
":max_option_chars" => 200,
":max_options" => 20,
":min_expiration" => 0,
"nested" => %{
":max_expiration" => 31_536_000,
":max_option_chars" => 200,
":max_options" => 20,
":min_expiration" => 0
}
}
]
}
],
"db" => [":key2", ":key3"]
}
],
"need_reboot" => false
}
end
test "value as map", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => %{"key" => "some_val"}
}
]
})
assert json_response_and_validate_schema(conn, 200) ==
%{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":key1",
"value" => %{"key" => "some_val"},
"db" => [":key1"]
}
],
"need_reboot" => false
}
end
test "queues key as atom", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
- "group" => ":oban",
- "key" => ":queues",
+ "group" => ":pleroma",
+ "key" => ":nonexist_oban_queues",
"value" => [
%{"tuple" => [":federator_incoming", 50]},
%{"tuple" => [":federator_outgoing", 50]},
%{"tuple" => [":web_push", 50]},
%{"tuple" => [":mailer", 10]},
%{"tuple" => [":transmogrifier", 20]},
%{"tuple" => [":scheduled_activities", 10]},
%{"tuple" => [":background", 5]}
]
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
- "group" => ":oban",
- "key" => ":queues",
+ "group" => ":pleroma",
+ "key" => ":nonexist_oban_queues",
"value" => [
%{"tuple" => [":federator_incoming", 50]},
%{"tuple" => [":federator_outgoing", 50]},
%{"tuple" => [":web_push", 50]},
%{"tuple" => [":mailer", 10]},
%{"tuple" => [":transmogrifier", 20]},
%{"tuple" => [":scheduled_activities", 10]},
%{"tuple" => [":background", 5]}
],
"db" => [
":federator_incoming",
":federator_outgoing",
":web_push",
":mailer",
":transmogrifier",
":scheduled_activities",
":background"
]
}
],
"need_reboot" => false
}
end
test "delete part of settings by atom subkeys", %{conn: conn} do
insert(:config,
key: :keyaa1,
value: [subkey1: "val1", subkey2: "val2", subkey3: "val3"]
)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":keyaa1",
subkeys: [":subkey1", ":subkey3"],
delete: true
}
]
})
assert json_response_and_validate_schema(conn, 200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":keyaa1",
"value" => [%{"tuple" => [":subkey2", "val2"]}],
"db" => [":subkey2"]
}
],
"need_reboot" => false
}
end
test "proxy tuple localhost", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":http",
value: [
%{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}
]
}
]
})
assert %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":http",
"value" => value,
"db" => db
}
]
} = json_response_and_validate_schema(conn, 200)
assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value
assert ":proxy_url" in db
end
test "proxy tuple domain", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":http",
value: [
%{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}
]
}
]
})
assert %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":http",
"value" => value,
"db" => db
}
]
} = json_response_and_validate_schema(conn, 200)
assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value
assert ":proxy_url" in db
end
test "proxy tuple ip", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":http",
value: [
%{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}
]
}
]
})
assert %{
"configs" => [
%{
"group" => ":pleroma",
"key" => ":http",
"value" => value,
"db" => db
}
]
} = json_response_and_validate_schema(conn, 200)
assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value
assert ":proxy_url" in db
end
test "doesn't set keys not in the whitelist", %{conn: conn} do
clear_config(:database_config_whitelist, [
{:pleroma, :key1},
{:pleroma, :key2},
{:pleroma, Pleroma.Captcha.NotReal},
{:not_real}
])
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{group: ":pleroma", key: ":key1", value: "value1"},
%{group: ":pleroma", key: ":key2", value: "value2"},
%{group: ":pleroma", key: ":key3", value: "value3"},
%{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"},
%{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"},
%{group: ":not_real", key: ":anything", value: "value6"}
]
})
assert Application.get_env(:pleroma, :key1) == "value1"
assert Application.get_env(:pleroma, :key2) == "value2"
assert Application.get_env(:pleroma, :key3) == nil
assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil
assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5"
assert Application.get_env(:not_real, :anything) == "value6"
end
test "doesn't allow updating the database_config_whitelist itself", %{conn: conn} do
original_whitelist = Pleroma.Config.get(:database_config_whitelist)
refute ConfigDB.get_by_group_and_key(:pleroma, :database_config_whitelist)
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: ":database_config_whitelist",
value: [%{"tuple" => [":pleroma", ":key1"]}]
}
]
})
%{"configs" => configs} = json_response_and_validate_schema(conn, 200)
assert configs == []
assert Pleroma.Config.get(:database_config_whitelist) == original_whitelist
refute ConfigDB.get_by_group_and_key(:pleroma, :database_config_whitelist)
end
+ test "doesn't set keys in the blacklist", %{conn: conn} do
+ clear_config(:database_config_blacklist, [
+ {:pleroma, :logger},
+ {:pleroma, :key2},
+ {:pleroma, Pleroma.Captcha.NotReal},
+ {:blacklisted_group}
+ ])
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/config", %{
+ configs: [
+ %{group: ":pleroma", key: ":logger", value: "logconf"},
+ %{group: ":pleroma", key: ":key1", value: "value1"},
+ %{group: ":pleroma", key: ":key2", value: "value2"},
+ %{group: ":not_real", key: ":anything", value: "value3"},
+ %{group: ":blacklisted_group", key: ":anything", value: "value4"}
+ ]
+ })
+
+ assert Application.get_env(:pleroma, :logger) == nil
+ assert Application.get_env(:pleroma, :key1) == "value1"
+ assert Application.get_env(:pleroma, :key2) == nil
+ assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == nil
+ assert Application.get_env(:blacklisted_group, :anything) == nil
+ end
+
+ test "doesn't allow updating the database_config_blacklist itself", %{conn: conn} do
+ original_blacklist = Pleroma.Config.get(:database_config_blacklist)
+
+ refute ConfigDB.get_by_group_and_key(:pleroma, :database_config_blacklist)
+
+ %{"configs" => configs} =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post("/api/pleroma/admin/config", %{
+ configs: [
+ %{
+ group: ":pleroma",
+ key: ":database_config_blacklist",
+ value: [%{"tuple" => [":pleroma", ":key1"]}]
+ }
+ ]
+ })
+ |> json_response_and_validate_schema(200)
+
+ assert configs == []
+ assert Pleroma.Config.get(:database_config_blacklist) == original_blacklist
+ refute ConfigDB.get_by_group_and_key(:pleroma, :database_config_blacklist)
+ end
+
test "args for Pleroma.Upload.Filter.Mogrify with custom tuples", %{conn: conn} do
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: "Pleroma.Upload.Filter.Mogrify",
value: [
%{"tuple" => [":args", ["auto-orient", "strip"]]}
]
}
]
})
|> json_response_and_validate_schema(200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => "Pleroma.Upload.Filter.Mogrify",
"value" => [
%{"tuple" => [":args", ["auto-orient", "strip"]]}
],
"db" => [":args"]
}
],
"need_reboot" => false
}
config = Config.get(Pleroma.Upload.Filter.Mogrify)
assert {:args, ["auto-orient", "strip"]} in config
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{
configs: [
%{
group: ":pleroma",
key: "Pleroma.Upload.Filter.Mogrify",
value: [
%{
"tuple" => [
":args",
[
"auto-orient",
"strip",
"{\"implode\", \"1\"}",
"{\"resize\", \"3840x1080>\"}"
]
]
}
]
}
]
})
|> json_response(200) == %{
"configs" => [
%{
"group" => ":pleroma",
"key" => "Pleroma.Upload.Filter.Mogrify",
"value" => [
%{
"tuple" => [
":args",
[
"auto-orient",
"strip",
"{\"implode\", \"1\"}",
"{\"resize\", \"3840x1080>\"}"
]
]
}
],
"db" => [":args"]
}
],
"need_reboot" => false
}
config = Config.get(Pleroma.Upload.Filter.Mogrify)
assert {:args, ["auto-orient", "strip", {"implode", "1"}, {"resize", "3840x1080>"}]} in config
end
test "enables the welcome messages", %{conn: conn} do
clear_config([:welcome])
params = %{
"group" => ":pleroma",
"key" => ":welcome",
"value" => [
%{
"tuple" => [
":direct_message",
[
%{"tuple" => [":enabled", true]},
%{"tuple" => [":message", "Welcome to Pleroma!"]},
%{"tuple" => [":sender_nickname", "pleroma"]}
]
]
},
%{
"tuple" => [
":chat_message",
[
%{"tuple" => [":enabled", true]},
%{"tuple" => [":message", "Welcome to Pleroma!"]},
%{"tuple" => [":sender_nickname", "pleroma"]}
]
]
},
%{
"tuple" => [
":email",
[
%{"tuple" => [":enabled", true]},
%{"tuple" => [":sender", %{"tuple" => ["pleroma@dev.dev", "Pleroma"]}]},
%{"tuple" => [":subject", "Welcome to <%= instance_name %>!"]},
%{"tuple" => [":html", "Welcome to <%= instance_name %>!"]},
%{"tuple" => [":text", "Welcome to <%= instance_name %>!"]}
]
]
}
]
}
refute Pleroma.User.WelcomeEmail.enabled?()
refute Pleroma.User.WelcomeMessage.enabled?()
refute Pleroma.User.WelcomeChatMessage.enabled?()
res =
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{"configs" => [params]})
|> json_response_and_validate_schema(200)
assert Pleroma.User.WelcomeEmail.enabled?()
assert Pleroma.User.WelcomeMessage.enabled?()
assert Pleroma.User.WelcomeChatMessage.enabled?()
assert res == %{
"configs" => [
%{
"db" => [":direct_message", ":chat_message", ":email"],
"group" => ":pleroma",
"key" => ":welcome",
"value" => params["value"]
}
],
"need_reboot" => false
}
end
test "custom instance thumbnail", %{conn: conn} do
clear_config([:instance])
params = %{
"group" => ":pleroma",
"key" => ":instance",
"value" => [
%{
"tuple" => [
":instance_thumbnail",
"https://example.com/media/new_thumbnail.jpg"
]
}
]
}
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{"configs" => [params]})
|> json_response_and_validate_schema(200) ==
%{
"configs" => [
%{
"db" => [":instance_thumbnail"],
"group" => ":pleroma",
"key" => ":instance",
"value" => params["value"]
}
],
"need_reboot" => false
}
assert conn
|> get("/api/v1/instance")
|> json_response_and_validate_schema(200)
|> Map.take(["thumbnail"]) ==
%{"thumbnail" => "https://example.com/media/new_thumbnail.jpg"}
end
test "Concurrent Limiter", %{conn: conn} do
clear_config([ConcurrentLimiter])
params = %{
"group" => ":pleroma",
"key" => "ConcurrentLimiter",
"value" => [
%{
"tuple" => [
"Pleroma.Web.RichMedia.Helpers",
[
%{"tuple" => [":max_running", 6]},
%{"tuple" => [":max_waiting", 6]}
]
]
},
%{
"tuple" => [
"Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy",
[
%{"tuple" => [":max_running", 7]},
%{"tuple" => [":max_waiting", 7]}
]
]
}
]
}
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/config", %{"configs" => [params]})
|> json_response_and_validate_schema(200)
end
end
describe "GET /api/pleroma/admin/config/descriptions" do
test "structure", %{conn: conn} do
conn = get(conn, "/api/pleroma/admin/config/descriptions")
assert [child | _others] = json_response_and_validate_schema(conn, 200)
assert child["children"]
assert child["key"]
assert String.starts_with?(child["group"], ":")
assert child["description"]
end
test "filters by database configuration whitelist", %{conn: conn} do
clear_config(:database_config_whitelist, [
{:pleroma, :instance},
{:pleroma, :activitypub},
{:pleroma, Pleroma.Upload}
])
conn = get(conn, "/api/pleroma/admin/config/descriptions")
children = json_response_and_validate_schema(conn, 200)
assert length(children) == 3
assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3
instance = Enum.find(children, fn c -> c["key"] == ":instance" end)
assert instance["children"]
activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end)
assert activitypub["children"]
web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end)
assert web_endpoint["children"]
end
test "all keys from description are whitelisted", %{conn: conn} do
conn = get(conn, "/api/pleroma/admin/config/descriptions")
assert response = json_response_and_validate_schema(conn, 200)
assert length(response) == length(Pleroma.Docs.JSON.compiled_descriptions())
end
+
+ test "filters by database configuration blacklist and whitelist", %{conn: conn} do
+ clear_config(:database_config_whitelist, [
+ {:pleroma, :instance},
+ {:pleroma, :activitypub},
+ {:pleroma, Pleroma.Upload}
+ ])
+
+ clear_config(:database_config_blacklist, [
+ {:pleroma, Pleroma.Upload}
+ ])
+
+ children =
+ conn
+ |> get("/api/pleroma/admin/config/descriptions")
+ |> json_response_and_validate_schema(200)
+
+ assert length(children) == 2
+
+ assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 2
+
+ instance = Enum.find(children, fn c -> c["key"] == ":instance" end)
+ assert instance["children"]
+
+ activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end)
+ assert activitypub["children"]
+
+ web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end)
+ refute web_endpoint["children"]
+ end
end
end
diff --git a/test/pleroma/web/feed/feed_view_test.exs b/test/pleroma/web/feed/feed_view_test.exs
new file mode 100644
index 000000000..786b6df07
--- /dev/null
+++ b/test/pleroma/web/feed/feed_view_test.exs
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.Feed.FeedViewTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Web.Feed.FeedView
+
+ test "escapes fallback content" do
+ data = %{
+ "attachment" => [],
+ "content" => "",
+ "summary" => "<script>alert('feed')</script>"
+ }
+
+ expected = "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;"
+
+ assert FeedView.atom_content_encoded(data) == expected
+ assert FeedView.rss_content_encoded(data) == expected
+ end
+
+ test "does not preview or classify sensitive attachments" do
+ attachment = %{
+ "name" => "spoiler",
+ "url" => [%{"href" => "https://example.com/spoiler.png", "mediaType" => "image/png"}]
+ }
+
+ data = %{
+ "attachment" => [attachment],
+ "content" => "content warning",
+ "sensitive" => true
+ }
+
+ assert FeedView.atom_content_encoded(data) == "content warning"
+ assert FeedView.rss_content_encoded(data) == "content warning"
+ assert FeedView.media_content_xml(attachment, true) == ""
+ assert FeedView.media_content_xml(attachment, "true") == ""
+
+ media_content = FeedView.media_content_xml(attachment, false)
+ assert media_content =~ "<media:description type=\"plain\">spoiler</media:description>"
+ refute media_content =~ "media:rating"
+
+ assert FeedView.media_content_xml(Map.delete(attachment, "name"), false) ==
+ ~s(<media:content url="https://example.com/spoiler.png" type="image/png" medium="image"/>\n)
+ end
+
+ test "does not treat a legacy false string as sensitive" do
+ data = %{
+ "attachment" => [
+ %{"url" => [%{"href" => "https://example.com/image.png", "mediaType" => "image/png"}]}
+ ],
+ "content" => "visible",
+ "sensitive" => "false"
+ }
+
+ assert FeedView.atom_content_encoded(data) =~ "<img"
+ end
+end
diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs
index 662235f31..35e29ed8c 100644
--- a/test/pleroma/web/feed/tag_controller_test.exs
+++ b/test/pleroma/web/feed/tag_controller_test.exs
@@ -1,250 +1,375 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Feed.TagControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
import SweetXml
alias Pleroma.Object
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Feed.FeedView
setup do: clear_config([:feed])
test "gets a feed (ATOM)", %{conn: conn} do
clear_config(
[:feed, :post_title],
%{max_length: 25, omission: "..."}
)
user = insert(:user)
{:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"})
object = Object.normalize(activity1, fetch: false)
object_data =
Map.put(object.data, "attachment", [
%{
"url" => [
%{
- "href" =>
- "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
+ "href" => "https://peertube.moe/video.mp4?first=one&second=two",
"mediaType" => "video/mp4",
+ "size" => "123",
+ "type" => "Link"
+ }
+ ]
+ },
+ %{
+ "url" => [
+ %{
+ "href" => "https://peertube.moe/notes.pdf",
+ "mediaType" => "application/pdf",
"type" => "Link"
}
]
}
])
object
|> Ecto.Changeset.change(data: object_data)
|> Pleroma.Repo.update()
{:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"})
{:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"})
response =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.atom"))
|> response(200)
xml = parse(response)
assert xpath(xml, ~x"//feed/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//feed/entry/title/text()"l) == [
~c"42 This is :moominmamm...",
~c"yeah #PleromaArt"
]
assert xpath(xml, ~x"//feed/entry/author/name/text()"ls) == [user.nickname, user.nickname]
+ assert xpath(xml, ~x"//feed/entry/link[@rel='self']/@href"sl) == []
+ assert length(xpath(xml, ~x"//feed/entry/link[@rel='alternate']/@href"sl)) == 2
+
+ assert xpath(xml, ~x"//feed/entry/link[@rel='enclosure']/@href"sl) == [
+ "https://peertube.moe/video.mp4?first=one&second=two",
+ "https://peertube.moe/notes.pdf"
+ ]
+
+ assert xpath(xml, ~x"//feed/entry/link[@rel='enclosure']/@length"sl) == ["123"]
conn =
conn
|> put_req_header("accept", "application/atom+xml")
|> get("/tags/pleromaart.atom", %{"max_id" => activity2.id})
assert get_resp_header(conn, "content-type") == ["application/atom+xml; charset=utf-8"]
resp = response(conn, 200)
xml = parse(resp)
assert xpath(xml, ~x"//feed/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//feed/entry/title/text()"l) == [
~c"yeah #PleromaArt"
]
end
test "gets a feed (RSS)", %{conn: conn} do
clear_config(
[:feed, :post_title],
%{max_length: 25, omission: "..."}
)
user = insert(:user)
{:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"})
object = Object.normalize(activity1, fetch: false)
object_data =
Map.put(object.data, "attachment", [
%{
"url" => [
%{
- "href" =>
- "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
+ "href" => "https://peertube.moe/video.mp4?first=one&second=two",
"mediaType" => "video/mp4",
+ "size" => "123",
+ "type" => "Link"
+ }
+ ]
+ },
+ %{
+ "url" => [
+ %{
+ "href" => "https://peertube.moe/notes.pdf",
+ "mediaType" => "application/pdf",
"type" => "Link"
}
]
}
])
+ edited_at = NaiveDateTime.add(object.updated_at, 60)
+
object
- |> Ecto.Changeset.change(data: object_data)
+ |> Ecto.Changeset.change(data: object_data, updated_at: edited_at)
|> Pleroma.Repo.update()
{:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"})
{:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"})
response =
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
|> response(200)
xml = parse(response)
assert xpath(xml, ~x"//channel/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//channel/description/text()"s) ==
"These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse."
assert xpath(xml, ~x"//channel/link/text()") ==
~c"#{Pleroma.Web.Endpoint.url()}/tags/pleromaart.rss"
assert xpath(xml, ~x"//channel/webfeeds:logo/text()") ==
~c"#{Pleroma.Web.Endpoint.url()}/static/logo.svg"
assert xpath(xml, ~x"//channel/item/title/text()"l) == [
~c"42 This is :moominmamm...",
~c"yeah #PleromaArt"
]
+ assert xpath(xml, ~x"//channel/item/guid/@isPermaLink"sl) == ["true", "true"]
+
assert xpath(xml, ~x"//channel/item/pubDate/text()"sl) == [
FeedView.to_rfc2822(activity2.data["published"]),
FeedView.to_rfc2822(activity1.data["published"])
]
+ assert xpath(xml, ~x"//channel/lastBuildDate/text()"s) ==
+ FeedView.to_rfc2822(edited_at)
+
+ assert xpath(xml, ~x"//channel/item/media:content/@url"sl) == [
+ "https://peertube.moe/video.mp4?first=one&second=two",
+ "https://peertube.moe/notes.pdf"
+ ]
+
assert xpath(xml, ~x"//channel/item/enclosure/@url"sl) == [
- "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4"
+ "https://peertube.moe/video.mp4?first=one&second=two"
]
+ assert xpath(xml, ~x"//channel/item/enclosure/@length"sl) == ["123"]
+
obj1 = Object.normalize(activity1, fetch: false)
obj2 = Object.normalize(activity2, fetch: false)
assert xpath(xml, ~x"//channel/item/description/text()"sl) == [
HtmlEntities.decode(FeedView.activity_content(obj2.data)),
HtmlEntities.decode(FeedView.activity_content(obj1.data))
]
response =
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
|> response(200)
xml = parse(response)
assert xpath(xml, ~x"//channel/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//channel/description/text()"s) ==
"These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse."
conn =
conn
|> put_req_header("accept", "application/rss+xml")
|> get("/tags/pleromaart.rss", %{"max_id" => activity2.id})
assert get_resp_header(conn, "content-type") == ["application/rss+xml; charset=utf-8"]
resp = response(conn, 200)
xml = parse(resp)
assert xpath(xml, ~x"//channel/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//channel/item/title/text()"l) == [
~c"yeah #PleromaArt"
]
end
+ test "does not fabricate lastBuildDate for an empty RSS feed", %{conn: conn} do
+ response =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get(tag_feed_path(conn, :feed, "empty.rss"))
+ |> response(200)
+
+ refute response =~ "<lastBuildDate>"
+ end
+
+ test "provides a valid updated timestamp for an empty Atom feed", %{conn: conn} do
+ response =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get(tag_feed_path(conn, :feed, "empty.atom"))
+ |> response(200)
+
+ updated = response |> parse() |> xpath(~x"//feed/updated/text()"s)
+ assert {:ok, _datetime, 0} = DateTime.from_iso8601(updated)
+ end
+
+ test "escapes Atom IDs and tag feed self links", %{conn: conn} do
+ user = insert(:user, local: false)
+ object_id = "https://remote.example/object?first=one&second=two"
+
+ note =
+ insert(:note,
+ user: user,
+ object_local: false,
+ data: %{
+ "content" => "remote #atomlink",
+ "external_url" => object_id,
+ "id" => object_id,
+ "summary" => "",
+ "tag" => ["atomlink"]
+ }
+ )
+
+ insert(:note_activity, user: user, note: note, local: false, object_local: false)
+
+ response =
+ conn
+ |> put_req_header("accept", "application/atom+xml")
+ |> get("/tags/atomlink.atom?third=three&fourth=four")
+ |> response(200)
+
+ assert response =~ "https://remote.example/object?first=one&amp;second=two"
+ assert response =~ "third=three&amp;fourth=four"
+
+ xml = parse(response)
+ assert xpath(xml, ~x"//feed/entry/id/text()"s) == object_id
+ end
+
+ test "escapes and round-trips RSS item permalinks", %{conn: conn} do
+ user = insert(:user, local: false)
+ permalink = "https://remote.example/post?first=one&second=two"
+
+ note =
+ insert(:note,
+ user: user,
+ object_local: false,
+ data: %{
+ "content" => "remote #rsslink",
+ "external_url" => permalink,
+ "summary" => "",
+ "tag" => ["rsslink"]
+ }
+ )
+
+ insert(:note_activity, user: user, note: note, local: false, object_local: false)
+
+ response =
+ conn
+ |> put_req_header("accept", "application/rss+xml")
+ |> get(tag_feed_path(conn, :feed, "rsslink.rss"))
+ |> response(200)
+
+ assert response =~ "https://remote.example/post?first=one&amp;second=two"
+
+ xml = parse(response)
+ assert xpath(xml, ~x"//channel/item/guid/text()"s) == permalink
+ assert xpath(xml, ~x"//channel/item/link/text()"s) == permalink
+ end
+
describe "private instance" do
setup do: clear_config([:instance, :public], false)
test "returns 404 for tags feed", %{conn: conn} do
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
|> response(404)
end
end
describe "restricted for unauthenticated" do
test "returns 404 when local timeline is disabled", %{conn: conn} do
clear_config([:restrict_unauthenticated, :timelines], %{local: true, federated: false})
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
|> response(404)
end
test "returns local posts only when federated timeline is disabled", %{conn: conn} do
clear_config([:restrict_unauthenticated, :timelines], %{local: false, federated: true})
local_user = insert(:user)
remote_user = insert(:user, local: false)
local_note =
insert(:note,
user: local_user,
data: %{
"content" => "local post #PleromaArt",
"summary" => "",
"tag" => ["pleromaart"]
}
)
remote_note =
insert(:note,
user: remote_user,
data: %{
"content" => "remote post #PleromaArt",
"summary" => "",
"tag" => ["pleromaart"]
},
local: false
)
insert(:note_activity, user: local_user, note: local_note)
insert(:note_activity, user: remote_user, note: remote_note, local: false)
response =
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "pleromaart.rss"))
|> response(200)
xml = parse(response)
assert xpath(xml, ~x"//channel/title/text()") == ~c"#pleromaart"
assert xpath(xml, ~x"//channel/item/title/text()"l) == [
~c"local post #PleromaArt"
]
end
end
end
diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs
index 916531cd5..d12904ec0 100644
--- a/test/pleroma/web/feed/user_controller_test.exs
+++ b/test/pleroma/web/feed/user_controller_test.exs
@@ -1,326 +1,427 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Feed.UserControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
import SweetXml
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Feed.FeedView
setup do: clear_config([:static_fe, :enabled], false)
describe "feed" do
setup do: clear_config([:feed])
setup do
clear_config(
[:feed, :post_title],
%{max_length: 15, omission: "..."}
)
activity = insert(:note_activity)
note =
insert(:note,
data: %{
"content" => "This & this is :moominmamma: note ",
"source" => "This & this is :moominmamma: note ",
"attachment" => [
%{
"url" => [
%{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"}
]
}
],
"inReplyTo" => activity.data["id"],
"context" => "2hu & as",
"summary" => "2hu & as"
}
)
note_activity = insert(:note_activity, note: note)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
note2 =
insert(:note,
user: user,
data: %{
"content" => "42 & This is :moominmamma: note ",
"inReplyTo" => activity.data["id"]
}
)
note_activity2 = insert(:note_activity, note: note2)
note3 =
insert(:note,
user: user,
data: %{
"content" => "This note tests whether HTML entities are truncated properly",
"summary" => "Won't, didn't fail",
"inReplyTo" => note_activity2.id
}
)
_note_activity3 = insert(:note_activity, note: note3)
object = Object.normalize(note_activity, fetch: false)
encoded_title = FeedView.activity_title(note3.data)
[user: user, object: object, max_id: note_activity2.id, encoded_title: encoded_title]
end
test "gets an atom feed", %{conn: conn, user: user, object: object, max_id: max_id} do
resp =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(200)
- activity_titles =
- resp
- |> SweetXml.parse()
- |> SweetXml.xpath(~x"//entry/title/text()"l)
+ xml = SweetXml.parse(resp)
+ activity_titles = SweetXml.xpath(xml, ~x"//entry/title/text()"l)
assert activity_titles == [~c"Won't, didn'...", ~c"2hu", ~c"2hu & as"]
+ assert SweetXml.xpath(xml, ~x"//entry/link[@rel='self']/@href"sl) == []
+ assert length(SweetXml.xpath(xml, ~x"//entry/link[@rel='alternate']/@href"sl)) == 3
assert resp =~ FeedView.escape(object.data["content"])
assert resp =~ FeedView.escape(object.data["summary"])
assert resp =~ FeedView.escape(object.data["context"])
resp =
conn
|> put_req_header("accept", "application/atom+xml")
|> get("/users/#{user.nickname}/feed", %{"max_id" => max_id})
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//entry/title/text()"l)
assert activity_titles == [~c"2hu & as"]
end
test "gets a rss feed", %{conn: conn, user: user, object: object, max_id: max_id} do
resp =
conn
|> put_req_header("accept", "application/rss+xml")
|> get("/users/#{user.nickname}/feed.rss")
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//item/title/text()"l)
assert activity_titles == [~c"Won't, didn'...", ~c"2hu", ~c"2hu & as"]
assert resp =~ FeedView.escape(object.data["content"])
assert resp =~ FeedView.escape(object.data["summary"])
assert resp =~ FeedView.escape(object.data["context"])
resp =
conn
|> put_req_header("accept", "application/rss+xml")
|> get("/users/#{user.nickname}/feed.rss", %{"max_id" => max_id})
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//item/title/text()"l)
assert activity_titles == [~c"2hu & as"]
end
+ test "escapes query parameters in feed self links", %{conn: conn, user: user} do
+ for format <- ~w[atom rss] do
+ response =
+ conn
+ |> get("/users/#{user.nickname}/feed.#{format}?first=one&second=two")
+ |> response(200)
+
+ assert response =~ "first=one&amp;second=two"
+ refute response =~ "first=one&second=two"
+ end
+ end
+
+ test "keeps fallback summary markup inert in rendered Atom content", %{
+ conn: conn,
+ object: object,
+ user: user
+ } do
+ data =
+ object.data
+ |> Map.put("content", "")
+ |> Map.put("summary", "<script>alert('feed')</script>")
+
+ object
+ |> Ecto.Changeset.change(data: data)
+ |> Pleroma.Repo.update!()
+
+ response =
+ conn
+ |> get("/users/#{user.nickname}/feed.atom")
+ |> response(200)
+
+ assert response =~
+ "&amp;lt;script&amp;gt;alert(&amp;#39;feed&amp;#39;)&amp;lt;/script&amp;gt;"
+
+ content_values = response |> parse() |> xpath(~x"//entry/content/text()"sl)
+
+ assert Enum.any?(content_values, fn content ->
+ String.starts_with?(content, "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;")
+ end)
+ end
+
+ test "does not expose sensitive attachments as enclosures", %{
+ conn: conn,
+ object: object,
+ user: user
+ } do
+ object
+ |> Ecto.Changeset.change(data: Map.put(object.data, "sensitive", true))
+ |> Pleroma.Repo.update!()
+
+ atom_response =
+ conn
+ |> get("/users/#{user.nickname}/feed.atom")
+ |> response(200)
+
+ rss_response =
+ conn
+ |> get("/users/#{user.nickname}/feed.rss")
+ |> response(200)
+
+ refute atom_response =~ "rel=\"enclosure\""
+ refute rss_response =~ "<enclosure "
+ end
+
+ test "uses enclosure fallbacks for user feeds", %{conn: conn, object: object, user: user} do
+ attachment = %{"url" => [%{"href" => "https://example.com/file.bin"}]}
+
+ object
+ |> Ecto.Changeset.change(data: Map.put(object.data, "attachment", [attachment]))
+ |> Pleroma.Repo.update!()
+
+ atom_xml =
+ conn
+ |> get("/users/#{user.nickname}/feed.atom")
+ |> response(200)
+ |> parse()
+
+ assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@href"sl) == [
+ "https://example.com/file.bin"
+ ]
+
+ assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@type"sl) == [
+ "application/octet-stream"
+ ]
+
+ assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@length"sl) == []
+
+ rss_xml =
+ conn
+ |> get("/users/#{user.nickname}/feed.rss")
+ |> response(200)
+ |> parse()
+
+ assert xpath(rss_xml, ~x"//item/enclosure/@url"sl) == []
+ assert xpath(rss_xml, ~x"//item/media:content/@url"sl) == ["https://example.com/file.bin"]
+
+ assert xpath(rss_xml, ~x"//item/media:content/@type"sl) == [
+ "application/octet-stream"
+ ]
+ end
+
test "returns 404 for a missing feed", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, "nonexisting"))
assert response(conn, 404)
end
test "returns noindex meta for missing user", %{conn: conn} do
conn =
conn
|> put_req_header("accept", "text/html")
|> get("/users/nonexisting")
assert html_response(conn, 200) =~ "<meta content=\"noindex, noarchive\" name=\"robots\">"
end
test "returns feed with public and unlisted activities", %{conn: conn} do
user = insert(:user)
{:ok, _} = CommonAPI.post(user, %{status: "public", visibility: "public"})
{:ok, _} = CommonAPI.post(user, %{status: "direct", visibility: "direct"})
{:ok, _} = CommonAPI.post(user, %{status: "unlisted", visibility: "unlisted"})
{:ok, _} = CommonAPI.post(user, %{status: "private", visibility: "private"})
resp =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//entry/title/text()"l)
|> Enum.sort()
assert activity_titles == [~c"public", ~c"unlisted"]
end
test "returns 404 when the user is remote", %{conn: conn} do
user = insert(:user, local: false)
{:ok, _} = CommonAPI.post(user, %{status: "test"})
assert conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(404)
end
test "does not require authentication on non-federating instances", %{conn: conn} do
clear_config([:instance, :federating], false)
user = insert(:user)
conn
|> put_req_header("accept", "application/rss+xml")
|> get("/users/#{user.nickname}/feed.rss")
|> response(200)
end
test "does not mangle HTML entities midway", %{
conn: conn,
user: user,
object: object,
encoded_title: encoded_title
} do
resp =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//entry/title/text()"l)
assert activity_titles == [~c"Won't, didn'...", ~c"2hu", ~c"2hu & as"]
assert resp =~ FeedView.escape(object.data["content"])
assert resp =~ FeedView.escape(object.data["summary"])
assert resp =~ FeedView.escape(object.data["context"])
assert resp =~ encoded_title
end
end
# Note: see ActivityPubControllerTest for JSON format tests
describe "feed_redirect" do
test "with html format, it redirects to user feed", %{conn: conn} do
note_activity = insert(:note_activity)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
response =
conn
|> get("/users/#{user.nickname}")
|> response(200)
assert response ==
Pleroma.Web.Fallback.RedirectController.redirector_with_meta(
conn,
%{user: user}
).resp_body
end
test "with html format, it falls back to frontend when user is remote", %{conn: conn} do
user = insert(:user, local: false)
{:ok, _} = CommonAPI.post(user, %{status: "test"})
response =
conn
|> get("/users/#{user.nickname}")
|> response(200)
assert response =~ "</html>"
end
test "with html format, it falls back to frontend when user is not found", %{conn: conn} do
response =
conn
|> get("/users/jimm")
|> response(200)
assert response =~ "</html>"
end
test "with non-html / non-json format, it redirects to user feed in atom format", %{
conn: conn
} do
note_activity = insert(:note_activity)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
conn =
conn
|> put_req_header("accept", "application/xml")
|> get("/users/#{user.nickname}")
assert conn.status == 302
assert redirected_to(conn) ==
"#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.atom"
end
test "redirects to rss feed when explicitly requested", %{conn: conn} do
note_activity = insert(:note_activity)
user = User.get_cached_by_ap_id(note_activity.data["actor"])
conn =
conn
|> put_req_header("accept", "application/xml")
|> get("/users/#{user.nickname}.rss")
assert conn.status == 302
assert redirected_to(conn) ==
"#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.rss"
end
test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do
response =
conn
|> put_req_header("accept", "application/xml")
|> get(user_feed_path(conn, :feed, "jimm"))
|> response(404)
assert response == ~S({"error":"Not found"})
end
end
describe "private instance" do
setup do: clear_config([:instance, :public])
test "returns 404 for user feed", %{conn: conn} do
clear_config([:instance, :public], false)
user = insert(:user)
{:ok, _} = CommonAPI.post(user, %{status: "test"})
assert conn
|> put_req_header("accept", "application/atom+xml")
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(404)
end
end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs
index b685f4ff8..bed897e23 100644
--- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs
@@ -1,1093 +1,1125 @@
# 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.TimelineControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
import Tesla.Mock
alias Pleroma.User
alias Pleroma.Web.CommonAPI
setup do
mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
describe "home" do
setup do: oauth_access(["read:statuses"])
test "does NOT embed account/pleroma/relationship in statuses", %{
user: user,
conn: conn
} do
other_user = insert(:user)
{:ok, _} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
response =
conn
|> assign(:user, user)
|> get("/api/v1/timelines/home")
|> json_response_and_validate_schema(200)
assert Enum.all?(response, fn n ->
get_in(n, ["account", "pleroma", "relationship"]) == %{}
end)
end
test "the home timeline when the direct messages are excluded", %{user: user, conn: conn} do
{: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"})
conn = get(conn, "/api/v1/timelines/home?exclude_visibilities[]=direct")
assert status_ids = json_response_and_validate_schema(conn, :ok) |> Enum.map(& &1["id"])
assert public_activity.id in status_ids
assert unlisted_activity.id in status_ids
assert private_activity.id in status_ids
refute direct_activity.id in status_ids
end
test "muted emotions", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "."})
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
User.mute(user, other_user)
result =
conn
|> assign(:user, user)
|> get("/api/v1/timelines/home")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => []
}
}
] = result
result =
conn
|> assign(:user, user)
|> get("/api/v1/timelines/home?with_muted=true")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "🎅"}]
}
}
] = result
end
test "filtering", %{conn: conn, user: user} do
local_user = insert(:user)
{:ok, user, local_user} = User.follow(user, local_user)
{:ok, local_activity} = CommonAPI.post(local_user, %{status: "Status"})
with_media = create_with_media_activity(local_user)
remote_user = insert(:user, local: false)
{:ok, _user, remote_user} = User.follow(user, remote_user)
remote_activity = create_remote_activity(remote_user)
without_filter_ids =
conn
|> get("/api/v1/timelines/home")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
assert local_activity.id in without_filter_ids
assert remote_activity.id in without_filter_ids
assert with_media.id in without_filter_ids
only_local_ids =
conn
|> get("/api/v1/timelines/home?local=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
assert local_activity.id in only_local_ids
refute remote_activity.id in only_local_ids
assert with_media.id in only_local_ids
only_local_media_ids =
conn
|> get("/api/v1/timelines/home?local=true&only_media=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
refute local_activity.id in only_local_media_ids
refute remote_activity.id in only_local_media_ids
assert with_media.id in only_local_media_ids
remote_ids =
conn
|> get("/api/v1/timelines/home?remote=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
refute local_activity.id in remote_ids
assert remote_activity.id in remote_ids
refute with_media.id in remote_ids
assert conn
|> get("/api/v1/timelines/home?remote=true&only_media=true")
|> json_response_and_validate_schema(200) == []
assert conn
|> get("/api/v1/timelines/home?remote=true&local=true")
|> json_response_and_validate_schema(200) == []
end
test "the home timeline excludes posts from users in exclusive lists", %{
user: user,
conn: conn
} do
other_user1 = insert(:user)
other_user2 = insert(:user)
{:ok, user, other_user1} = User.follow(user, other_user1)
{:ok, user, other_user2} = User.follow(user, other_user2)
{:ok, list} = Pleroma.List.create(%{title: "foo", exclusive: true}, user)
{:ok, _list} = Pleroma.List.follow(list, other_user1)
{:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi"})
{:ok, %{id: activity2_id}} = CommonAPI.post(other_user2, %{status: "hi too"})
response =
conn
|> assign(:user, user)
|> get("/api/v1/timelines/home")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^activity2_id}] = response
end
end
describe "public" do
test "the public timeline", %{conn: conn} do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "test"})
with_media = create_with_media_activity(user)
remote = insert(:note_activity, local: false)
assert conn
|> get("/api/v1/timelines/public?local=False")
|> json_response_and_validate_schema(:ok)
|> length == 3
local_ids =
conn
|> get("/api/v1/timelines/public?local=True")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in local_ids
assert with_media.id in local_ids
refute remote.id in local_ids
local_ids =
conn
|> get("/api/v1/timelines/public?local=True")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in local_ids
assert with_media.id in local_ids
refute remote.id in local_ids
local_ids =
conn
|> get("/api/v1/timelines/public?local=True&only_media=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
refute activity.id in local_ids
assert with_media.id in local_ids
refute remote.id in local_ids
local_ids =
conn
|> get("/api/v1/timelines/public?local=1")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in local_ids
assert with_media.id in local_ids
refute remote.id in local_ids
remote_id = remote.id
assert [%{"id" => ^remote_id}] =
conn
|> get("/api/v1/timelines/public?remote=true")
|> json_response_and_validate_schema(:ok)
with_media_id = with_media.id
assert [%{"id" => ^with_media_id}] =
conn
|> get("/api/v1/timelines/public?only_media=true")
|> json_response_and_validate_schema(:ok)
assert conn
|> get("/api/v1/timelines/public?remote=true&only_media=true")
|> json_response_and_validate_schema(:ok) == []
# does not contain repeats
{:ok, _} = CommonAPI.repeat(activity.id, user)
assert [_, _] =
conn
|> get("/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(:ok)
end
test "the public timeline includes only public statuses for an authenticated user" do
%{user: user, conn: conn} = oauth_access(["read:statuses"])
{:ok, _activity} = CommonAPI.post(user, %{status: "test"})
{:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "private"})
{:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "unlisted"})
{:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "direct"})
res_conn = get(conn, "/api/v1/timelines/public")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
end
test "doesn't return replies if follower is posting with blocked user" do
%{conn: conn, user: blocker} = oauth_access(["read:statuses"])
[blockee, friend] = insert_list(2, :user)
{:ok, blocker, friend} = User.follow(blocker, friend)
{:ok, _} = User.block(blocker, blockee)
conn = assign(conn, :user, blocker)
{:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, reply_from_blockee} =
CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity})
{:ok, _reply_from_friend} =
CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee})
# Still shows replies from yourself
{:ok, %{id: reply_from_me}} =
CommonAPI.post(blocker, %{status: "status", in_reply_to_status_id: reply_from_blockee})
response =
get(conn, "/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert length(response) == 2
[%{"id" => ^reply_from_me}, %{"id" => ^activity_id}] = response
end
test "doesn't return posts from users who blocked you when :blockers_visible is disabled" do
clear_config([:activitypub, :blockers_visible], false)
%{conn: conn, user: blockee} = oauth_access(["read:statuses"])
blocker = insert(:user)
{:ok, _} = User.block(blocker, blockee)
conn = assign(conn, :user, blockee)
{:ok, _} = CommonAPI.post(blocker, %{status: "hey!"})
response =
get(conn, "/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert length(response) == 0
end
test "doesn't return replies if follow is posting with users from blocked domain" do
%{conn: conn, user: blocker} = oauth_access(["read:statuses"])
friend = insert(:user)
blockee = insert(:user, ap_id: "https://example.com/users/blocked")
{:ok, blocker, friend} = User.follow(blocker, friend)
{:ok, blocker} = User.block_domain(blocker, "example.com")
conn = assign(conn, :user, blocker)
{:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"})
{:ok, reply_from_blockee} =
CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity})
{:ok, _reply_from_friend} =
CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee})
res_conn = get(conn, "/api/v1/timelines/public")
activities = json_response_and_validate_schema(res_conn, 200)
[%{"id" => ^activity_id}] = activities
end
test "can be filtered by instance", %{conn: conn} do
user = insert(:user, ap_id: "https://lain.com/users/lain")
insert(:note_activity, local: false)
insert(:note_activity, local: false)
{:ok, _} = CommonAPI.post(user, %{status: "test"})
conn = get(conn, "/api/v1/timelines/public?instance=lain.com")
assert length(json_response_and_validate_schema(conn, :ok)) == 1
end
test "muted emotions", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["read:statuses"])
conn =
conn
|> assign(:user, user)
|> assign(:token, token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "."})
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
User.mute(user, other_user)
result =
conn
|> get("/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => []
}
}
] = result
result =
conn
|> get("/api/v1/timelines/public?with_muted=true")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "🎅"}]
}
}
] = result
end
test "should return local-only posts for authenticated users" do
user = insert(:user)
%{user: _reader, conn: conn} = oauth_access(["read:statuses"])
{:ok, %{id: id}} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"})
result =
conn
|> get("/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^id}] = result
end
test "should not return local-only posts for users without read:statuses" do
user = insert(:user)
%{user: _reader, conn: conn} = oauth_access([])
{:ok, _activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"})
result =
conn
|> get("/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert [] = result
end
test "should not return local-only posts for anonymous users" do
user = insert(:user)
{:ok, _activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"})
result =
build_conn()
|> get("/api/v1/timelines/public")
|> json_response_and_validate_schema(200)
assert [] = result
end
end
defp local_and_remote_activities do
insert(:note_activity)
insert(:note_activity, local: false)
:ok
end
describe "public with restrict unauthenticated timeline for local and federated timelines" do
setup do: local_and_remote_activities()
setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true)
setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true)
test "if user is unauthenticated", %{conn: conn} do
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
"error" => "authorization required for timeline view"
}
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
"error" => "authorization required for timeline view"
}
end
test "if user is authenticated" do
%{conn: conn} = oauth_access(["read:statuses"])
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
end
describe "public with restrict unauthenticated timeline for local" do
setup do: local_and_remote_activities()
setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true)
test "if user is unauthenticated", %{conn: conn} do
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
"error" => "authorization required for timeline view"
}
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
test "if user is authenticated", %{conn: _conn} do
%{conn: conn} = oauth_access(["read:statuses"])
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
end
describe "public with restrict unauthenticated timeline for remote" do
setup do: local_and_remote_activities()
setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true)
test "if user is unauthenticated", %{conn: conn} do
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert json_response_and_validate_schema(res_conn, :unauthorized) == %{
"error" => "authorization required for timeline view"
}
end
test "if user is authenticated", %{conn: _conn} do
%{conn: conn} = oauth_access(["read:statuses"])
res_conn = get(conn, "/api/v1/timelines/public?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(conn, "/api/v1/timelines/public?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
end
describe "direct" do
test "direct timeline", %{conn: conn} do
user_one = insert(:user)
user_two = insert(:user)
{:ok, user_two, user_one} = User.follow(user_two, user_one)
{:ok, direct} =
CommonAPI.post(user_one, %{
status: "Hi @#{user_two.nickname}!",
visibility: "direct"
})
{:ok, _follower_only} =
CommonAPI.post(user_one, %{
status: "Hi @#{user_two.nickname}!",
visibility: "private"
})
conn_user_two =
conn
|> assign(:user, user_two)
|> assign(:token, insert(:oauth_token, user: user_two, scopes: ["read:statuses"]))
# Only direct should be visible here
res_conn = get(conn_user_two, "/api/v1/timelines/direct")
assert [status] = json_response_and_validate_schema(res_conn, :ok)
assert %{"visibility" => "direct"} = status
assert status["url"] != direct.data["id"]
# User should be able to see their own direct message
res_conn =
build_conn()
|> assign(:user, user_one)
|> assign(:token, insert(:oauth_token, user: user_one, scopes: ["read:statuses"]))
|> get("/api/v1/timelines/direct")
[status] = json_response_and_validate_schema(res_conn, :ok)
assert %{"visibility" => "direct"} = status
# Both should be visible here
res_conn = get(conn_user_two, "/api/v1/timelines/home")
[_s1, _s2] = json_response_and_validate_schema(res_conn, :ok)
# Test pagination
Enum.each(1..20, fn _ ->
{:ok, _} =
CommonAPI.post(user_one, %{
status: "Hi @#{user_two.nickname}!",
visibility: "direct"
})
end)
res_conn = get(conn_user_two, "/api/v1/timelines/direct")
statuses = json_response_and_validate_schema(res_conn, :ok)
assert length(statuses) == 20
max_id = List.last(statuses)["id"]
res_conn = get(conn_user_two, "/api/v1/timelines/direct?max_id=#{max_id}")
assert [status] = json_response_and_validate_schema(res_conn, :ok)
assert status["url"] != direct.data["id"]
end
test "doesn't include DMs from blocked users" do
%{user: blocker, conn: conn} = oauth_access(["read:statuses"])
blocked = insert(:user)
other_user = insert(:user)
{:ok, _user_relationship} = User.block(blocker, blocked)
{:ok, _blocked_direct} =
CommonAPI.post(blocked, %{
status: "Hi @#{blocker.nickname}!",
visibility: "direct"
})
{:ok, direct} =
CommonAPI.post(other_user, %{
status: "Hi @#{blocker.nickname}!",
visibility: "direct"
})
res_conn = get(conn, "/api/v1/timelines/direct")
[status] = json_response_and_validate_schema(res_conn, :ok)
assert status["id"] == direct.id
end
end
describe "list" do
setup do: oauth_access(["read:lists"])
test "does not contain retoots", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."})
{:ok, activity_two} = CommonAPI.post(other_user, %{status: "Marisa is stupid."})
{:ok, _} = CommonAPI.repeat(activity_one.id, other_user)
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user)
conn = get(conn, "/api/v1/timelines/list/#{list.id}")
assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok)
assert id == to_string(activity_two.id)
end
test "works with pagination", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user)
Enum.each(1..30, fn i ->
CommonAPI.post(other_user, %{status: "post number #{i}"})
end)
res =
get(conn, "/api/v1/timelines/list/#{list.id}?limit=1")
|> json_response_and_validate_schema(:ok)
assert length(res) == 1
[first] = res
res =
get(conn, "/api/v1/timelines/list/#{list.id}?max_id=#{first["id"]}&limit=30")
|> json_response_and_validate_schema(:ok)
assert length(res) == 29
end
test "list timeline", %{user: user, conn: conn} do
other_user = insert(:user)
{:ok, _activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."})
{:ok, activity_two} = CommonAPI.post(other_user, %{status: "Marisa is cute."})
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user)
conn = get(conn, "/api/v1/timelines/list/#{list.id}")
assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok)
assert id == to_string(activity_two.id)
end
test "list timeline does not leak non-public statuses for unfollowed users", %{
user: user,
conn: conn
} do
other_user = insert(:user)
{:ok, activity_one} = CommonAPI.post(other_user, %{status: "Marisa is cute."})
{:ok, _activity_two} =
CommonAPI.post(other_user, %{
status: "Marisa is cute.",
visibility: "private"
})
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, other_user)
conn = get(conn, "/api/v1/timelines/list/#{list.id}")
assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok)
assert id == to_string(activity_one.id)
end
test "muted emotions", %{user: user, conn: conn} do
user2 = insert(:user)
user3 = insert(:user)
{:ok, activity} = CommonAPI.post(user2, %{status: "."})
{:ok, _} = CommonAPI.react_with_emoji(activity.id, user3, "🎅")
User.mute(user, user3)
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
{:ok, list} = Pleroma.List.follow(list, user2)
result =
conn
|> get("/api/v1/timelines/list/#{list.id}")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => []
}
}
] = result
result =
conn
|> get("/api/v1/timelines/list/#{list.id}?with_muted=true")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "🎅"}]
}
}
] = result
end
test "filtering", %{user: user, conn: conn} do
{:ok, list} = Pleroma.List.create(%{title: "name"}, user)
local_user = insert(:user)
{:ok, local_activity} = CommonAPI.post(local_user, %{status: "Marisa is stupid."})
with_media = create_with_media_activity(local_user)
{:ok, list} = Pleroma.List.follow(list, local_user)
remote_user = insert(:user, local: false)
remote_activity = create_remote_activity(remote_user)
{:ok, list} = Pleroma.List.follow(list, remote_user)
all_ids =
conn
|> get("/api/v1/timelines/list/#{list.id}")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
assert local_activity.id in all_ids
assert with_media.id in all_ids
assert remote_activity.id in all_ids
only_local_ids =
conn
|> get("/api/v1/timelines/list/#{list.id}?local=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
assert local_activity.id in only_local_ids
assert with_media.id in only_local_ids
refute remote_activity.id in only_local_ids
only_local_media_ids =
conn
|> get("/api/v1/timelines/list/#{list.id}?local=true&only_media=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
refute local_activity.id in only_local_media_ids
assert with_media.id in only_local_media_ids
refute remote_activity.id in only_local_media_ids
remote_ids =
conn
|> get("/api/v1/timelines/list/#{list.id}?remote=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
refute local_activity.id in remote_ids
refute with_media.id in remote_ids
assert remote_activity.id in remote_ids
assert conn
|> get("/api/v1/timelines/list/#{list.id}?remote=true&only_media=true")
|> json_response_and_validate_schema(200) == []
only_media_ids =
conn
|> get("/api/v1/timelines/list/#{list.id}?only_media=true")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["id"])
refute local_activity.id in only_media_ids
assert with_media.id in only_media_ids
refute remote_activity.id in only_media_ids
assert conn
|> get("/api/v1/timelines/list/#{list.id}?only_media=true&local=true&remote=true")
|> json_response_and_validate_schema(200) == []
end
end
describe "hashtag" do
setup do: oauth_access(["n/a"])
test "hashtag timeline", %{conn: conn} do
following = insert(:user)
{:ok, activity} = CommonAPI.post(following, %{status: "test #2hu"})
with_media = create_with_media_activity(following)
remote = insert(:user, local: false)
remote_activity = create_remote_activity(remote)
all_ids =
conn
|> get("/api/v1/timelines/tag/2hu")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in all_ids
assert with_media.id in all_ids
assert remote_activity.id in all_ids
# works for different capitalization too
all_ids =
conn
|> get("/api/v1/timelines/tag/2HU")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in all_ids
assert with_media.id in all_ids
assert remote_activity.id in all_ids
local_ids =
conn
|> get("/api/v1/timelines/tag/2hu?local=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
assert activity.id in local_ids
assert with_media.id in local_ids
refute remote_activity.id in local_ids
remote_ids =
conn
|> get("/api/v1/timelines/tag/2hu?remote=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
refute activity.id in remote_ids
refute with_media.id in remote_ids
assert remote_activity.id in remote_ids
media_ids =
conn
|> get("/api/v1/timelines/tag/2hu?only_media=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
refute activity.id in media_ids
assert with_media.id in media_ids
refute remote_activity.id in media_ids
media_local_ids =
conn
|> get("/api/v1/timelines/tag/2hu?only_media=true&local=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
refute activity.id in media_local_ids
assert with_media.id in media_local_ids
refute remote_activity.id in media_local_ids
ids =
conn
|> get("/api/v1/timelines/tag/2hu?only_media=true&local=true&remote=true")
|> json_response_and_validate_schema(:ok)
|> Enum.map(& &1["id"])
refute activity.id in ids
refute with_media.id in ids
refute remote_activity.id in ids
assert conn
|> get("/api/v1/timelines/tag/2hu?only_media=true&remote=true")
|> json_response_and_validate_schema(:ok) == []
end
test "multi-hashtag timeline", %{conn: conn} do
user = insert(:user)
{:ok, activity_test} = CommonAPI.post(user, %{status: "#test"})
{:ok, activity_test1} = CommonAPI.post(user, %{status: "#test #test1"})
{:ok, activity_none} = CommonAPI.post(user, %{status: "#test #none"})
- any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1")
+ for hashtag_timeline_mode <- [:enabled, :disabled] do
+ clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_mode)
- [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok)
+ any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1")
- assert to_string(activity_test.id) == status_test["id"]
- assert to_string(activity_test1.id) == status_test1["id"]
- assert to_string(activity_none.id) == status_none["id"]
+ [status_none, status_test1, status_test] =
+ json_response_and_validate_schema(any_test, :ok)
- restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none")
+ assert to_string(activity_test.id) == status_test["id"]
+ assert to_string(activity_test1.id) == status_test1["id"]
+ assert to_string(activity_none.id) == status_none["id"]
- assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok)
+ restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none")
- all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none")
+ assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok)
- assert [status_none] == json_response_and_validate_schema(all_test, :ok)
+ all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none")
+
+ assert [status_none] == json_response_and_validate_schema(all_test, :ok)
+ end
+ end
+
+ test "multi-hashtag timeline pagination returns unique statuses", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, activity_one} = CommonAPI.post(user, %{status: "#test #test1"})
+ {:ok, activity_two} = CommonAPI.post(user, %{status: "#test #test1"})
+ {:ok, activity_three} = CommonAPI.post(user, %{status: "#test #test1"})
+
+ for hashtag_timeline_mode <- [:enabled, :disabled] do
+ clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_mode)
+
+ first_page =
+ conn
+ |> get("/api/v1/timelines/tag/test?any[]=test1&limit=2")
+ |> json_response_and_validate_schema(:ok)
+
+ assert Enum.map(first_page, & &1["id"]) ==
+ Enum.map([activity_three, activity_two], &to_string(&1.id))
+
+ second_page =
+ conn
+ |> get("/api/v1/timelines/tag/test?any[]=test1&limit=2&max_id=#{activity_two.id}")
+ |> json_response_and_validate_schema(:ok)
+
+ assert Enum.map(second_page, & &1["id"]) == [to_string(activity_one.id)]
+ end
end
test "muted emotions", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["read:statuses"])
conn =
conn
|> assign(:user, user)
|> assign(:token, token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "test #2hu"})
{:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
User.mute(user, other_user)
result =
conn
|> get("/api/v1/timelines/tag/2hu")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => []
}
}
] = result
result =
conn
|> get("/api/v1/timelines/tag/2hu?with_muted=true")
|> json_response_and_validate_schema(200)
assert [
%{
"pleroma" => %{
"emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "🎅"}]
}
}
] = result
end
end
describe "hashtag timeline handling of restrict_unauthenticated setting" do
setup do
user = insert(:user)
{:ok, activity1} = CommonAPI.post(user, %{status: "test #tag1"})
{:ok, _activity2} = CommonAPI.post(user, %{status: "test #tag1"})
activity1
|> Ecto.Changeset.change(%{local: false})
|> Pleroma.Repo.update()
base_uri = "/api/v1/timelines/tag/tag1"
error_response = %{"error" => "authorization required for timeline view"}
%{base_uri: base_uri, error_response: error_response}
end
defp ensure_authenticated_access(base_uri) do
%{conn: auth_conn} = oauth_access(["read:statuses"])
res_conn = get(auth_conn, "#{base_uri}?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(auth_conn, "#{base_uri}?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
end
test "with default settings on private instances, returns 403 for unauthenticated users", %{
conn: conn,
base_uri: base_uri,
error_response: error_response
} do
clear_config([:instance, :public], false)
clear_config([:restrict_unauthenticated, :timelines])
for local <- [true, false] do
res_conn = get(conn, "#{base_uri}?local=#{local}")
assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response
end
ensure_authenticated_access(base_uri)
end
test "with `%{local: true, federated: true}`, returns 403 for unauthenticated users", %{
conn: conn,
base_uri: base_uri,
error_response: error_response
} do
clear_config([:restrict_unauthenticated, :timelines, :local], true)
clear_config([:restrict_unauthenticated, :timelines, :federated], true)
for local <- [true, false] do
res_conn = get(conn, "#{base_uri}?local=#{local}")
assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response
end
ensure_authenticated_access(base_uri)
end
test "with `%{local: false, federated: true}`, forbids unauthenticated access to federated timeline",
%{conn: conn, base_uri: base_uri, error_response: error_response} do
clear_config([:restrict_unauthenticated, :timelines, :local], false)
clear_config([:restrict_unauthenticated, :timelines, :federated], true)
res_conn = get(conn, "#{base_uri}?local=true")
assert length(json_response_and_validate_schema(res_conn, 200)) == 1
res_conn = get(conn, "#{base_uri}?local=false")
assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response
ensure_authenticated_access(base_uri)
end
test "with `%{local: true, federated: false}`, forbids unauthenticated access to public timeline" <>
"(but not to local public activities which are delivered as part of federated timeline)",
%{conn: conn, base_uri: base_uri, error_response: error_response} do
clear_config([:restrict_unauthenticated, :timelines, :local], true)
clear_config([:restrict_unauthenticated, :timelines, :federated], false)
res_conn = get(conn, "#{base_uri}?local=true")
assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response
# Note: local activities get delivered as part of federated timeline
res_conn = get(conn, "#{base_uri}?local=false")
assert length(json_response_and_validate_schema(res_conn, 200)) == 2
ensure_authenticated_access(base_uri)
end
end
defp create_remote_activity(user) do
obj =
insert(:note, %{
data: %{
"to" => [
"https://www.w3.org/ns/activitystreams#Public",
User.ap_followers(user)
]
},
user: user
})
insert(:note_activity, %{
note: obj,
recipients: [
"https://www.w3.org/ns/activitystreams#Public",
User.ap_followers(user)
],
user: user,
local: false
})
end
defp create_with_media_activity(user) do
obj = insert(:attachment_note, user: user)
insert(:note_activity, %{
note: obj,
recipients: ["https://www.w3.org/ns/activitystreams#Public", User.ap_followers(user)],
user: user
})
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
index 66485be0e..5f1e21098 100644
--- a/test/pleroma/web/mastodon_api/views/instance_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/instance_view_test.exs
@@ -1,675 +1,702 @@
# 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",
+ "pleroma_chat_messages",
+ "pleroma:pin_chats",
"relay",
"safe_dm_mentions",
"exposable_reactions",
"profile_directory",
"pleroma:language_detection"
]
@configurable_features_flags [
[:activitypub, :blockers_visible],
[:media_proxy, :enabled],
[:gopher, :enabled],
[:shout, :enabled],
+ [Pleroma.Chat, :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 "chat features" do
+ test "advertises Shout without Chats" do
+ clear_config([:shout, :enabled], true)
+ clear_config([Pleroma.Chat, :enabled], false)
+
+ features = InstanceView.features()
+
+ assert "chat" in features
+ assert "shout" in features
+ refute "pleroma_chat_messages" in features
+ refute "pleroma:pin_chats" in features
+ end
+
+ test "advertises Chats without Shout" do
+ clear_config([:shout, :enabled], false)
+ clear_config([Pleroma.Chat, :enabled], true)
+
+ features = InstanceView.features()
+
+ refute "chat" in features
+ refute "shout" in features
+ assert "pleroma_chat_messages" in features
+ assert "pleroma:pin_chats" in 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/pleroma_api/controllers/chat_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs
index 0dc0e7014..8244ba434 100644
--- a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs
+++ b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs
@@ -1,551 +1,559 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Chat
alias Pleroma.Chat.MessageReference
alias Pleroma.Object
alias Pleroma.Tests.Helpers
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.CommonAPI
import Mox
import Pleroma.Factory
describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do
setup do: oauth_access(["write:chats"])
test "it marks one message as read", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup")
{:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2")
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
object = Object.normalize(create, fetch: false)
cm_ref = MessageReference.for_chat_and_object(chat, object)
assert cm_ref.unread == true
result =
conn
|> post("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}/read")
|> json_response_and_validate_schema(200)
assert result["unread"] == false
cm_ref = MessageReference.for_chat_and_object(chat, object)
assert cm_ref.unread == false
end
end
describe "POST /api/v1/pleroma/chats/:id/read" do
setup do: oauth_access(["write:chats"])
test "given a `last_read_id`, it marks everything until then as read", %{
conn: conn,
user: user
} do
other_user = insert(:user)
{:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup")
{:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2")
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
object = Object.normalize(create, fetch: false)
cm_ref = MessageReference.for_chat_and_object(chat, object)
assert cm_ref.unread == true
result =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/pleroma/chats/#{chat.id}/read", %{"last_read_id" => cm_ref.id})
|> json_response_and_validate_schema(200)
assert result["unread"] == 1
cm_ref = MessageReference.for_chat_and_object(chat, object)
assert cm_ref.unread == false
end
end
describe "POST /api/v1/pleroma/chats/:id/messages" do
setup do: oauth_access(["write:chats"])
test "it posts a message to the chat", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("idempotency-key", "123")
|> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"})
|> json_response_and_validate_schema(200)
assert result["content"] == "Hallo!!"
assert result["chat_id"] == chat.id |> to_string()
assert result["idempotency_key"] == "123"
end
test "it fails if there is no content", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/pleroma/chats/#{chat.id}/messages")
|> json_response_and_validate_schema(400)
assert %{"error" => "no_content"} == result
end
test "it works with an attachment", %{conn: conn, user: user} do
file = %Plug.Upload{
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
filename: "an_image.jpg"
}
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
{:ok, upload} = ActivityPub.upload(file, actor: user.ap_id)
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{
"media_id" => to_string(upload.id)
})
|> json_response_and_validate_schema(200)
assert result["attachment"]
end
test "gets MRF reason when rejected", %{conn: conn, user: user} do
clear_config([:mrf_keyword, :reject], ["GNO"])
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "GNO/Linux"})
|> json_response_and_validate_schema(422)
assert %{"error" => "[KeywordPolicy] Matches with rejected keyword"} == result
end
end
describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do
setup do: oauth_access(["write:chats"])
test "it deletes a message from the chat", %{conn: conn, user: user} do
recipient = insert(:user)
{:ok, message} =
CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend")
{:ok, other_message} = CommonAPI.post_chat_message(recipient, user, "nico nico ni")
object = Object.normalize(message, fetch: false)
chat = Chat.get(user.id, recipient.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
# Deleting your own message removes the message and the reference
result =
conn
|> put_req_header("content-type", "application/json")
|> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}")
|> json_response_and_validate_schema(200)
assert result["id"] == cm_ref.id
refute MessageReference.get_by_id(cm_ref.id)
assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id)
# Deleting other people's messages just removes the reference
object = Object.normalize(other_message, fetch: false)
cm_ref = MessageReference.for_chat_and_object(chat, object)
result =
conn
|> put_req_header("content-type", "application/json")
|> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}")
|> json_response_and_validate_schema(200)
assert result["id"] == cm_ref.id
refute MessageReference.get_by_id(cm_ref.id)
assert Object.get_by_id(object.id)
end
end
describe "GET /api/v1/pleroma/chats/:id/messages" do
setup do: oauth_access(["read:chats"])
test "it paginates", %{conn: conn, user: user} do
recipient = insert(:user)
Enum.each(1..30, fn _ ->
{:ok, _} = CommonAPI.post_chat_message(user, recipient, "hey")
end)
chat = Chat.get(user.id, recipient.ap_id)
response = get(conn, "/api/v1/pleroma/chats/#{chat.id}/messages")
result = json_response_and_validate_schema(response, 200)
[next, prev] = get_resp_header(response, "link") |> hd() |> String.split(", ")
api_endpoint = Pleroma.Web.Endpoint.url() <> "/api/v1/pleroma/chats/"
[next_url, next_rel] = String.split(next, ";")
next_url = String.trim_trailing(next_url, ">") |> String.trim_leading("<")
next_url_sorted = Helpers.uri_query_sort(next_url)
assert String.match?(
next_url_sorted,
~r(#{api_endpoint}.*/messages\?limit=\d+&max_id=.*&offset=\d+$)
)
assert next_rel =~ "next"
[prev_url, prev_rel] = String.split(prev, ";")
prev_url = String.trim_trailing(prev_url, ">") |> String.trim_leading("<")
prev_url_sorted = Helpers.uri_query_sort(prev_url)
assert String.match?(
prev_url_sorted,
~r(#{api_endpoint}.*/messages\?limit=\d+&min_id=.*&offset=\d+$)
)
assert prev_rel =~ "prev"
assert length(result) == 20
response = get(conn, "#{api_endpoint}#{chat.id}/messages?max_id=#{List.last(result)["id"]}")
result = json_response_and_validate_schema(response, 200)
[next, prev] = get_resp_header(response, "link") |> hd() |> String.split(", ")
[next_url, next_rel] = String.split(next, ";")
next_url = String.trim_trailing(next_url, ">") |> String.trim_leading("<")
next_url_sorted = Helpers.uri_query_sort(next_url)
assert String.match?(
next_url_sorted,
~r(#{api_endpoint}.*/messages\?limit=\d+&max_id=.*&offset=\d+$)
)
assert next_rel =~ "next"
[prev_url, prev_rel] = String.split(prev, ";")
prev_url = String.trim_trailing(prev_url, ">") |> String.trim_leading("<")
prev_url_sorted = Helpers.uri_query_sort(prev_url)
assert String.match?(
prev_url_sorted,
~r(#{api_endpoint}.*/messages\?limit=\d+&max_id=.*&min_id=.*&offset=\d+$)
)
assert prev_rel =~ "prev"
assert length(result) == 10
end
test "it returns the messages for a given chat", %{conn: conn, user: user} do
other_user = insert(:user)
third_user = insert(:user)
{:ok, _} = CommonAPI.post_chat_message(user, other_user, "hey")
{:ok, _} = CommonAPI.post_chat_message(user, third_user, "hey")
{:ok, _} = CommonAPI.post_chat_message(user, other_user, "how are you?")
{:ok, _} = CommonAPI.post_chat_message(other_user, user, "fine, how about you?")
chat = Chat.get(user.id, other_user.ap_id)
result =
conn
|> get("/api/v1/pleroma/chats/#{chat.id}/messages")
|> json_response_and_validate_schema(200)
result
|> Enum.each(fn message ->
assert message["chat_id"] == chat.id |> to_string()
end)
assert length(result) == 3
# Trying to get the chat of a different user
other_user_chat = Chat.get(other_user.id, user.ap_id)
conn
|> get("/api/v1/pleroma/chats/#{other_user_chat.id}/messages")
|> json_response_and_validate_schema(404)
end
end
describe "POST /api/v1/pleroma/chats/by-account-id/:id" do
setup do: oauth_access(["write:chats"])
test "it creates or returns a chat", %{conn: conn} do
other_user = insert(:user)
result =
conn
|> post("/api/v1/pleroma/chats/by-account-id/#{other_user.id}")
|> json_response_and_validate_schema(200)
assert result["id"]
end
end
describe "GET /api/v1/pleroma/chats/:id" do
setup do: oauth_access(["read:chats"])
test "it returns a chat", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> get("/api/v1/pleroma/chats/#{chat.id}")
|> json_response_and_validate_schema(200)
assert result["id"] == to_string(chat.id)
end
end
describe "POST /api/v1/pleroma/chats/:id/pin" do
setup do: oauth_access(["write:chats"])
test "it pins a chat", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
result =
conn
|> post("/api/v1/pleroma/chats/#{chat.id}/pin")
|> json_response_and_validate_schema(200)
assert %{"pinned" => true} = result
end
end
describe "POST /api/v1/pleroma/chats/:id/unpin" do
setup do: oauth_access(["write:chats"])
test "it unpins a chat", %{conn: conn, user: user} do
other_user = insert(:user)
{:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id)
{:ok, chat} = Chat.pin(chat)
result =
conn
|> post("/api/v1/pleroma/chats/#{chat.id}/unpin")
|> json_response_and_validate_schema(200)
assert %{"pinned" => false} = result
end
end
for tested_endpoint <- ["/api/v1/pleroma/chats", "/api/v2/pleroma/chats"] do
describe "GET #{tested_endpoint}" do
setup do: oauth_access(["read:chats"])
test "it does not return chats with deleted users", %{conn: conn, user: user} do
recipient = insert(:user)
{:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
Pleroma.Repo.delete(recipient)
User.invalidate_cache(recipient)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 0
end
test "it does not return chats with users you blocked", %{conn: conn, user: user} do
recipient = insert(:user)
{:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 1
User.block(user, recipient)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 0
end
test "it does not return chats with users you muted", %{conn: conn, user: user} do
recipient = insert(:user)
{:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 1
User.mute(user, recipient)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 0
result =
conn
|> get("#{unquote(tested_endpoint)}?with_muted=true")
|> json_response_and_validate_schema(200)
assert length(result) == 1
end
test "it only returns pinned chats", %{conn: conn, user: user} do
recipient1 = insert(:user)
recipient2 = insert(:user)
{:ok, %{id: id} = chat} = Chat.get_or_create(user.id, recipient1.ap_id)
{:ok, _} = Chat.get_or_create(user.id, recipient2.ap_id)
Chat.pin(chat)
[%{"id" => ^id, "pinned" => true}] =
conn
|> get("#{unquote(tested_endpoint)}?pinned=true")
|> json_response_and_validate_schema(200)
end
if tested_endpoint == "/api/v1/pleroma/chats" do
test "it returns all chats", %{conn: conn, user: user} do
Enum.each(1..30, fn _ ->
recipient = insert(:user)
{:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
end)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 30
end
else
test "it paginates chats", %{conn: conn, user: user} do
Enum.each(1..30, fn _ ->
recipient = insert(:user)
{:ok, _} = Chat.get_or_create(user.id, recipient.ap_id)
end)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
assert length(result) == 20
last_id = List.last(result)["id"]
result =
conn
|> get(unquote(tested_endpoint) <> "?max_id=#{last_id}")
|> json_response_and_validate_schema(200)
assert length(result) == 10
end
end
test "it return a list of chats the current user is participating in, in descending order of updates",
%{conn: conn, user: user} do
har = insert(:user)
jafnhar = insert(:user)
tridi = insert(:user)
{:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id)
{:ok, chat_1} = time_travel(chat_1, -3)
{:ok, chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id)
{:ok, _chat_2} = time_travel(chat_2, -2)
{:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id)
{:ok, chat_3} = time_travel(chat_3, -1)
# bump the second one
{:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
ids = Enum.map(result, & &1["id"])
assert ids == [
chat_2.id |> to_string(),
chat_3.id |> to_string(),
chat_1.id |> to_string()
]
end
test "it is not affected by :restrict_unauthenticated setting (issue #1973)", %{
conn: conn,
user: user
} do
clear_config([:restrict_unauthenticated, :profiles, :local], true)
clear_config([:restrict_unauthenticated, :profiles, :remote], true)
user2 = insert(:user)
user3 = insert(:user, local: false)
{:ok, _chat_12} = Chat.get_or_create(user.id, user2.ap_id)
{:ok, _chat_13} = Chat.get_or_create(user.id, user3.ap_id)
result =
conn
|> get(unquote(tested_endpoint))
|> json_response_and_validate_schema(200)
account_ids = Enum.map(result, &get_in(&1, ["account", "id"]))
assert Enum.sort(account_ids) == Enum.sort([user2.id, user3.id])
end
+
+ test "it returns 404 when chats are disabled", %{conn: conn} do
+ clear_config([Pleroma.Chat, :enabled], false)
+
+ conn
+ |> get(unquote(tested_endpoint))
+ |> json_response_and_validate_schema(404)
+ end
end
end
end
diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs
index b7c06eacd..7dc7e52cf 100644
--- a/test/pleroma/web/plugs/frontend_static_plug_test.exs
+++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs
@@ -1,149 +1,160 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do
use Pleroma.Web.ConnCase
import Mock
import Mox
alias Pleroma.UnstubbedConfigMock, as: ConfigMock
@dir "test/tmp/instance_static"
setup do
Pleroma.Backports.mkdir_p!(@dir)
on_exit(fn -> File.rm_rf(@dir) end)
end
setup do: clear_config([:instance, :static_dir], @dir)
test "init will give a static plug config + the frontend type" do
opts =
[
at: "/admin",
frontend_type: :admin
]
|> Pleroma.Web.Plugs.FrontendStatic.init()
assert opts[:at] == ["admin"]
assert opts[:frontend_type] == :admin
end
test "overrides existing static files", %{conn: conn} do
name = "pelmora"
ref = "uguu"
clear_config([:frontends, :primary], %{"name" => name, "ref" => ref})
path = "#{@dir}/frontends/#{name}/#{ref}"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
index = get(conn, "/")
assert html_response(index, 200) == "from frontend plug"
end
test "overrides existing static files for the `pleroma/admin` path", %{conn: conn} do
name = "pelmora"
ref = "uguu"
clear_config([:frontends, :admin], %{"name" => name, "ref" => ref})
path = "#{@dir}/frontends/#{name}/#{ref}"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
index = get(conn, "/pleroma/admin/")
assert html_response(index, 200) == "from frontend plug"
end
test "exclude invalid path", %{conn: conn} do
name = "pleroma-fe"
ref = "dist"
clear_config([:media_proxy, :enabled], true)
clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000")
clear_config([:frontends, :primary], %{"name" => name, "ref" => ref})
path = "#{@dir}/frontends/#{name}/#{ref}"
Pleroma.Backports.mkdir_p!("#{path}/proxy/rr/ss")
File.write!("#{path}/proxy/rr/ss/Ek7w8WPVcAApOvN.jpg:large", "FB image")
ConfigMock
|> stub_with(Pleroma.Test.StaticConfig)
url =
Pleroma.Web.MediaProxy.encode_url("https://pbs.twimg.com/media/Ek7w8WPVcAApOvN.jpg:large")
with_mock Pleroma.ReverseProxy,
call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do
assert %Plug.Conn{status: :success} = get(conn, url)
end
end
test "api routes are detected correctly" do
# If this test fails we have probably added something
# new that should be in /api/ instead
expected_routes = [
"api",
"main",
"ostatus_subscribe",
"authorize_interaction",
"oauth",
"objects",
"activities",
"notice",
"users",
"tags",
"mailer",
"frontend_switcher",
"inbox",
"relay",
"internal",
".well-known",
"nodeinfo",
"manifest.json",
"auth",
"embed",
"proxy",
"test",
"user_exists",
"check_password"
]
assert expected_routes == Pleroma.Web.Router.get_api_routes()
end
describe "preferred frontend cookie handling" do
test "returns preferred frontend file", %{conn: conn} do
name = "test-fe"
ref = "develop"
clear_config([:frontends, :pickable], ["#{name}/#{ref}"])
path = "#{@dir}/frontends/#{name}/#{ref}"
+ path2 = "#{@dir}/frontends/#{name}/#{ref}/packs/html"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
+ Pleroma.Backports.mkdir_p!(path2)
+ File.write!("#{path2}/custom.html", "from frontend plug")
+
index =
conn
|> put_req_cookie("preferred_frontend", "#{name}/#{ref}")
|> get("/")
assert html_response(index, 200) == "from frontend plug"
+
+ custom =
+ conn
+ |> put_req_cookie("preferred_frontend", "#{name}/#{ref}")
+ |> get("/packs/html/custom.html")
+
+ assert html_response(custom, 200) == "from frontend plug"
end
test "only returns content from pickable frontends", %{conn: conn} do
clear_config([:instance, :static_dir], "instance/static")
clear_config([:frontends, :pickable], ["pleroma-fe/develop", "pl-fe/develop"])
config_file =
conn
|> put_req_cookie("preferred_frontend", "../../../config")
|> get("/config.exs")
refute response(config_file, 200) =~ "import Config"
end
end
end
diff --git a/test/pleroma/web/plugs/instance_static_test.exs b/test/pleroma/web/plugs/instance_static_test.exs
index 017c49d1e..1d67e8dc3 100644
--- a/test/pleroma/web/plugs/instance_static_test.exs
+++ b/test/pleroma/web/plugs/instance_static_test.exs
@@ -1,183 +1,162 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.InstanceStaticTest do
use Pleroma.Web.ConnCase
@dir "test/tmp/instance_static"
setup do
Pleroma.Backports.mkdir_p!(@dir)
on_exit(fn -> File.rm_rf(@dir) end)
end
setup do: clear_config([:instance, :static_dir], @dir)
test "overrides index" do
bundled_index = get(build_conn(), "/")
refute html_response(bundled_index, 200) == "hello world"
File.write!(@dir <> "/index.html", "hello world")
index = get(build_conn(), "/")
assert html_response(index, 200) == "hello world"
end
test "also overrides frontend files", %{conn: conn} do
name = "pelmora"
ref = "uguu"
clear_config([:frontends, :primary], %{"name" => name, "ref" => ref})
bundled_index = get(conn, "/")
refute html_response(bundled_index, 200) == "from frontend plug"
path = "#{@dir}/frontends/#{name}/#{ref}"
Pleroma.Backports.mkdir_p!(path)
File.write!("#{path}/index.html", "from frontend plug")
index = get(conn, "/")
assert html_response(index, 200) == "from frontend plug"
File.write!(@dir <> "/index.html", "from instance static")
index = get(conn, "/")
assert html_response(index, 200) == "from instance static"
end
test "overrides any file in static/static" do
bundled_index = get(build_conn(), "/static/terms-of-service.html")
assert html_response(bundled_index, 200) ==
File.read!("priv/static/static/terms-of-service.html")
File.mkdir!(@dir <> "/static")
File.write!(@dir <> "/static/terms-of-service.html", "plz be kind")
index = get(build_conn(), "/static/terms-of-service.html")
assert html_response(index, 200) == "plz be kind"
File.write!(@dir <> "/static/kaniini.html", "<h1>rabbit hugs as a service</h1>")
index = get(build_conn(), "/static/kaniini.html")
assert html_response(index, 200) == "<h1>rabbit hugs as a service</h1>"
+
+ File.write!(@dir <> "/static/kaniini the bnuuy.html", "<h1>rabbit hugs as a service</h1>")
+ index = get(build_conn(), "/static/kaniini%20the%20bnuuy.html")
+ assert html_response(index, 200) == "<h1>rabbit hugs as a service</h1>"
+ end
+
+ test "overrides /static/styles files with spaces in URL" do
+ Pleroma.Backports.mkdir_p!(@dir <> "/static/styles")
+ File.write(@dir <> "/static/styles/Redmond DX.iss", "overridden")
+
+ index = get(build_conn(), "/static/styles/Redmond%20DX.iss")
+ assert index.status == 200
+ assert index.resp_body == File.read!(@dir <> "/static/styles/Redmond DX.iss")
+ assert index.halted == true
+ end
+
+ test "serves fallback /static/styles files with spaces in URL" do
+ index = get(build_conn(), "/static/styles/Redmond%20DX.iss")
+
+ assert index.status == 200
+ assert index.resp_body == File.read!("priv/static/static/styles/Redmond DX.iss")
+ assert index.halted == true
end
test "does not sanitize dangerous files in general, as there can be html and javascript files legitimately in this folder" do
# Create a file with a potentially dangerous extension (.json)
# This mimics an attacker trying to serve ActivityPub JSON with a static file
File.mkdir!(@dir <> "/static")
File.write!(@dir <> "/static/malicious.json", "{\"type\": \"ActivityPub\"}")
conn = get(build_conn(), "/static/malicious.json")
assert conn.status == 200
content_type =
Enum.find_value(conn.resp_headers, fn
{"content-type", value} -> value
_ -> nil
end)
assert content_type == "application/json"
File.write!(@dir <> "/static/safe.jpg", "fake image data")
conn = get(build_conn(), "/static/safe.jpg")
assert conn.status == 200
# Get the content-type
content_type =
Enum.find_value(conn.resp_headers, fn
{"content-type", value} -> value
_ -> nil
end)
assert content_type == "image/jpeg"
end
test "always sanitizes emojis to images" do
File.mkdir!(@dir <> "/emoji")
File.write!(@dir <> "/emoji/malicious.html", "<script>HACKED</script>")
# Request the malicious file
conn = get(build_conn(), "/emoji/malicious.html")
# Verify the file was served (status 200)
assert conn.status == 200
# The content should be served, but with a sanitized content-type
content_type =
Enum.find_value(conn.resp_headers, fn
{"content-type", value} -> value
_ -> nil
end)
# It should have been sanitized to application/octet-stream because "application"
# is not in the allowed_mime_types list
assert content_type == "application/octet-stream"
# Create a file with an allowed extension (.jpg)
File.write!(@dir <> "/emoji/safe.jpg", "fake image data")
# Request the safe file
conn = get(build_conn(), "/emoji/safe.jpg")
# Verify the file was served (status 200)
assert conn.status == 200
# Get the content-type
content_type =
Enum.find_value(conn.resp_headers, fn
{"content-type", value} -> value
_ -> nil
end)
# It should be preserved because "image" is in the allowed_mime_types list
assert content_type == "image/jpeg"
end
-
- describe "404s for missing files in static-only paths" do
- test "returns 404 for non-existent static-only JSON files" do
- conn = get(build_conn(), "/static/non-existent.json")
-
- assert conn.status == 404
- assert ["application/json"] = get_resp_header(conn, "content-type")
- assert Jason.decode!(conn.resp_body) == %{"error" => "not found"}
- end
-
- test "returns 404 for non-existent static-only non-JSON files" do
- conn = get(build_conn(), "/static/non-existent.txt")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 404 for non-existent .css files" do
- conn = get(build_conn(), "/static/non-existent.css")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- # Verifies that we forced text/plain for the error body, even though the path was .css
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 404 for non-existent files without an extension" do
- conn = get(build_conn(), "/static/non-existent")
-
- assert conn.status == 404
- assert conn.resp_body == "Not found"
- assert ["text/plain"] = get_resp_header(conn, "content-type")
- end
-
- test "returns 200 (falls through to SPA) for non-static-only paths" do
- # /some-route is NOT in static_only_files, so it should still fall through to the SPA.
- conn = get(build_conn(), "/some-route")
-
- assert conn.status == 200
- assert ["text/html; charset=utf-8"] = get_resp_header(conn, "content-type")
- end
- end
end
diff --git a/test/pleroma/web/plugs/o_auth_plug_test.exs b/test/pleroma/web/plugs/o_auth_plug_test.exs
index 8917eadd1..2509e4203 100644
--- a/test/pleroma/web/plugs/o_auth_plug_test.exs
+++ b/test/pleroma/web/plugs/o_auth_plug_test.exs
@@ -1,128 +1,140 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.OAuthPlugTest do
use Pleroma.Web.ConnCase, async: true
alias Pleroma.Helpers.AuthHelper
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.OAuth.Token.Strategy.Revoke
alias Pleroma.Web.Plugs.OAuthPlug
alias Plug.Session
import Pleroma.Factory
setup %{conn: conn} do
user = insert(:user)
{:ok, oauth_token} = Token.create(insert(:oauth_app), user)
%{user: user, token: oauth_token, conn: conn}
end
test "it does nothing if a user is assigned", %{conn: conn} do
conn = assign(conn, :user, %Pleroma.User{})
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
test "with valid token (uppercase) in auth header, it assigns the user", %{conn: conn} = opts do
conn =
conn
|> put_req_header("authorization", "BEARER #{opts[:token].token}")
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
test "with valid token (downcase) in auth header, it assigns the user", %{conn: conn} = opts do
conn =
conn
|> put_req_header("authorization", "bearer #{opts[:token].token}")
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
+ test "with a valid token, it does not preload the duplicate user association", %{
+ conn: conn,
+ token: token
+ } do
+ conn =
+ conn
+ |> put_req_header("authorization", "bearer #{token.token}")
+ |> OAuthPlug.call(%{})
+
+ assert %Ecto.Association.NotLoaded{} = conn.assigns.token.user
+ end
+
test "with valid token (downcase) in url parameters, it assigns the user", opts do
conn =
:get
|> build_conn("/?access_token=#{opts[:token].token}")
|> put_req_header("content-type", "application/json")
|> fetch_query_params()
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
test "with valid token (downcase) in body parameters, it assigns the user", opts do
conn =
:post
|> build_conn("/api/v1/statuses", access_token: opts[:token].token, status: "test")
|> OAuthPlug.call(%{})
assert conn.assigns[:user] == opts[:user]
end
test "with invalid token, it does not assign the user", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "bearer TTTTT")
|> OAuthPlug.call(%{})
refute conn.assigns[:user]
end
describe "with :oauth_token in session, " do
setup %{token: oauth_token, conn: conn} do
session_opts = [
store: :cookie,
key: "_test",
signing_salt: "cooldude"
]
conn =
conn
|> Session.call(Session.init(session_opts))
|> fetch_session()
|> AuthHelper.put_session_token(oauth_token.token)
%{conn: conn}
end
test "if session-stored token matches a valid OAuth token, assigns :user and :token", %{
conn: conn,
user: user,
token: oauth_token
} do
conn = OAuthPlug.call(conn, %{})
assert conn.assigns.user && conn.assigns.user.id == user.id
assert conn.assigns.token && conn.assigns.token.id == oauth_token.id
end
test "if session-stored token matches an expired OAuth token, does nothing", %{
conn: conn,
token: oauth_token
} do
expired_valid_until = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600 * 24, :second)
oauth_token
|> Ecto.Changeset.change(valid_until: expired_valid_until)
|> Pleroma.Repo.update()
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
test "if session-stored token matches a revoked OAuth token, does nothing", %{
conn: conn,
token: oauth_token
} do
Revoke.revoke(oauth_token)
ret_conn = OAuthPlug.call(conn, %{})
assert ret_conn == conn
end
end
end
diff --git a/test/pleroma/web/plugs/static_not_found_plug_test.exs b/test/pleroma/web/plugs/static_not_found_plug_test.exs
new file mode 100644
index 000000000..a5d063c53
--- /dev/null
+++ b/test/pleroma/web/plugs/static_not_found_plug_test.exs
@@ -0,0 +1,49 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.Web.Plugs.StaticNotFoundPlugTest do
+ use Pleroma.Web.ConnCase
+
+ describe "404s for missing files in static-only paths" do
+ test "returns 404 for non-existent static-only JSON files" do
+ conn = get(build_conn(), "/static/non-existent.json")
+
+ assert conn.status == 404
+ assert ["application/json"] = get_resp_header(conn, "content-type")
+ assert Jason.decode!(conn.resp_body) == %{"error" => "not found"}
+ end
+
+ test "returns 404 for non-existent static-only non-JSON files" do
+ conn = get(build_conn(), "/static/non-existent.txt")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 404 for non-existent .css files" do
+ conn = get(build_conn(), "/static/non-existent.css")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ # Verifies that we forced text/plain for the error body, even though the path was .css
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 404 for non-existent files without an extension" do
+ conn = get(build_conn(), "/static/non-existent")
+
+ assert conn.status == 404
+ assert conn.resp_body == "Not found"
+ assert ["text/plain"] = get_resp_header(conn, "content-type")
+ end
+
+ test "returns 200 (falls through to SPA) for non-static-only paths" do
+ # /some-route is NOT in static_only_files, so it should still fall through to the SPA.
+ conn = get(build_conn(), "/some-route")
+
+ assert conn.status == 200
+ assert ["text/html; charset=utf-8"] = get_resp_header(conn, "content-type")
+ end
+ end
+end
diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs
index b26bd1847..7610b3379 100644
--- a/test/pleroma/web/streamer_test.exs
+++ b/test/pleroma/web/streamer_test.exs
@@ -1,1117 +1,1131 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.StreamerTest do
use Pleroma.DataCase
import Pleroma.Factory
alias Pleroma.Chat
alias Pleroma.Chat.MessageReference
alias Pleroma.Conversation.Participation
alias Pleroma.List
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Streamer
alias Pleroma.Web.StreamerView
@moduletag needs_streamer: true, capture_log: true
setup do
clear_config([:instance, :skip_thread_containment])
Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache)
:ok
end
describe "get_topic/_ (unauthenticated)" do
test "allows no stream" do
assert {:ok, nil} = Streamer.get_topic(nil, nil, nil)
end
test "allows public" do
assert {:ok, "public"} = Streamer.get_topic("public", nil, nil)
assert {:ok, "public:local"} = Streamer.get_topic("public:local", nil, nil)
assert {:ok, "public:media"} = Streamer.get_topic("public:media", nil, nil)
assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", nil, nil)
end
test "rejects local public streams if restricted_unauthenticated is on" do
clear_config([:restrict_unauthenticated, :timelines, :local], true)
assert {:error, :unauthorized} = Streamer.get_topic("public:local", nil, nil)
assert {:error, :unauthorized} = Streamer.get_topic("public:local:media", nil, nil)
end
test "rejects remote public streams if restricted_unauthenticated is on" do
clear_config([:restrict_unauthenticated, :timelines, :federated], true)
assert {:error, :unauthorized} = Streamer.get_topic("public", nil, nil)
assert {:error, :unauthorized} = Streamer.get_topic("public:media", nil, nil)
assert {:error, :unauthorized} =
Streamer.get_topic("public:remote", nil, nil, %{"instance" => "lain.com"})
assert {:error, :unauthorized} =
Streamer.get_topic("public:remote:media", nil, nil, %{"instance" => "lain.com"})
end
test "allows instance streams" do
assert {:ok, "public:remote:lain.com"} =
Streamer.get_topic("public:remote", nil, nil, %{"instance" => "lain.com"})
assert {:ok, "public:remote:media:lain.com"} =
Streamer.get_topic("public:remote:media", nil, nil, %{"instance" => "lain.com"})
end
test "allows hashtag streams" do
assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", nil, nil, %{"tag" => "cofe"})
end
test "disallows user streams" do
assert {:error, _} = Streamer.get_topic("user", nil, nil)
assert {:error, _} = Streamer.get_topic("user:notification", nil, nil)
assert {:error, _} = Streamer.get_topic("direct", nil, nil)
end
test "disallows list streams" do
assert {:error, _} = Streamer.get_topic("list", nil, nil, %{"list" => 42})
end
end
describe "get_topic/_ (authenticated)" do
setup do: oauth_access(["read"])
test "allows public streams (regardless of OAuth token scopes)", %{
user: user,
token: read_oauth_token
} do
with oauth_token <- [nil, read_oauth_token] do
assert {:ok, "public"} = Streamer.get_topic("public", user, oauth_token)
assert {:ok, "public:local"} = Streamer.get_topic("public:local", user, oauth_token)
assert {:ok, "public:media"} = Streamer.get_topic("public:media", user, oauth_token)
assert {:ok, "public:local:media"} =
Streamer.get_topic("public:local:media", user, oauth_token)
end
end
test "allows local public streams if restricted_unauthenticated is on", %{
user: user,
token: oauth_token
} do
clear_config([:restrict_unauthenticated, :timelines, :local], true)
%{token: read_notifications_token} = oauth_access(["read:notifications"], user: user)
%{token: badly_scoped_token} = oauth_access(["irrelevant:scope"], user: user)
assert {:ok, "public:local"} = Streamer.get_topic("public:local", user, oauth_token)
assert {:ok, "public:local:media"} =
Streamer.get_topic("public:local:media", user, oauth_token)
for token <- [read_notifications_token, badly_scoped_token] do
assert {:error, :unauthorized} = Streamer.get_topic("public:local", user, token)
assert {:error, :unauthorized} = Streamer.get_topic("public:local:media", user, token)
end
end
test "allows remote public streams if restricted_unauthenticated is on", %{
user: user,
token: oauth_token
} do
clear_config([:restrict_unauthenticated, :timelines, :federated], true)
%{token: read_notifications_token} = oauth_access(["read:notifications"], user: user)
%{token: badly_scoped_token} = oauth_access(["irrelevant:scope"], user: user)
assert {:ok, "public"} = Streamer.get_topic("public", user, oauth_token)
assert {:ok, "public:media"} = Streamer.get_topic("public:media", user, oauth_token)
assert {:ok, "public:remote:lain.com"} =
Streamer.get_topic("public:remote", user, oauth_token, %{"instance" => "lain.com"})
assert {:ok, "public:remote:media:lain.com"} =
Streamer.get_topic("public:remote:media", user, oauth_token, %{
"instance" => "lain.com"
})
for token <- [read_notifications_token, badly_scoped_token] do
assert {:error, :unauthorized} = Streamer.get_topic("public", user, token)
assert {:error, :unauthorized} = Streamer.get_topic("public:media", user, token)
assert {:error, :unauthorized} =
Streamer.get_topic("public:remote", user, token, %{
"instance" => "lain.com"
})
assert {:error, :unauthorized} =
Streamer.get_topic("public:remote:media", user, token, %{
"instance" => "lain.com"
})
end
end
test "allows user streams (with proper OAuth token scopes)", %{
user: user,
token: read_oauth_token
} do
%{token: read_notifications_token} = oauth_access(["read:notifications"], user: user)
%{token: read_statuses_token} = oauth_access(["read:statuses"], user: user)
%{token: badly_scoped_token} = oauth_access(["irrelevant:scope"], user: user)
expected_user_topic = "user:#{user.id}"
expected_notification_topic = "user:notification:#{user.id}"
expected_direct_topic = "direct:#{user.id}"
expected_pleroma_chat_topic = "user:pleroma_chat:#{user.id}"
for valid_user_token <- [read_oauth_token, read_statuses_token] do
assert {:ok, ^expected_user_topic} = Streamer.get_topic("user", user, valid_user_token)
assert {:ok, ^expected_direct_topic} =
Streamer.get_topic("direct", user, valid_user_token)
assert {:ok, ^expected_pleroma_chat_topic} =
Streamer.get_topic("user:pleroma_chat", user, valid_user_token)
end
for invalid_user_token <- [read_notifications_token, badly_scoped_token],
user_topic <- ["user", "direct", "user:pleroma_chat"] do
assert {:error, :unauthorized} = Streamer.get_topic(user_topic, user, invalid_user_token)
end
for valid_notification_token <- [read_oauth_token, read_notifications_token] do
assert {:ok, ^expected_notification_topic} =
Streamer.get_topic("user:notification", user, valid_notification_token)
end
for invalid_notification_token <- [read_statuses_token, badly_scoped_token] do
assert {:error, :unauthorized} =
Streamer.get_topic("user:notification", user, invalid_notification_token)
end
end
test "allows hashtag streams (regardless of OAuth token scopes)", %{
user: user,
token: read_oauth_token
} do
for oauth_token <- [nil, read_oauth_token] do
assert {:ok, "hashtag:cofe"} =
Streamer.get_topic("hashtag", user, oauth_token, %{"tag" => "cofe"})
end
end
test "disallows registering to another user's stream", %{user: user, token: read_oauth_token} do
another_user = insert(:user)
assert {:error, _} = Streamer.get_topic("user:#{another_user.id}", user, read_oauth_token)
assert {:error, _} =
Streamer.get_topic("user:notification:#{another_user.id}", user, read_oauth_token)
assert {:error, _} = Streamer.get_topic("direct:#{another_user.id}", user, read_oauth_token)
end
test "allows list stream that are owned by the user (with `read` or `read:lists` scopes)", %{
user: user,
token: read_oauth_token
} do
%{token: read_lists_token} = oauth_access(["read:lists"], user: user)
%{token: invalid_token} = oauth_access(["irrelevant:scope"], user: user)
{:ok, list} = List.create(%{title: "Test"}, user)
assert {:error, _} = Streamer.get_topic("list:#{list.id}", user, read_oauth_token)
for valid_token <- [read_oauth_token, read_lists_token] do
assert {:ok, _} = Streamer.get_topic("list", user, valid_token, %{"list" => list.id})
end
assert {:error, _} = Streamer.get_topic("list", user, invalid_token, %{"list" => list.id})
end
test "disallows list stream that are not owned by the user", %{user: user, token: oauth_token} do
another_user = insert(:user)
{:ok, list} = List.create(%{title: "Test"}, another_user)
assert {:error, _} = Streamer.get_topic("list:#{list.id}", user, oauth_token)
assert {:error, _} = Streamer.get_topic("list", user, oauth_token, %{"list" => list.id})
end
end
describe "user streams" do
setup do
%{user: user, token: token} = oauth_access(["read"])
notify = insert(:notification, user: user, activity: build(:note_activity))
{:ok, %{user: user, notify: notify, token: token}}
end
test "it streams the user's post in the 'user' stream", %{user: user, token: oauth_token} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
assert_receive {:render_with_user, _, _, ^activity, _}
refute Streamer.filtered_by_user?(user, activity)
end
test "it streams boosts of the user in the 'user' stream", %{user: user, token: oauth_token} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, announce} = CommonAPI.repeat(activity.id, user)
assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce, _}
refute Streamer.filtered_by_user?(user, announce)
end
test "it does not stream announces of the user's own posts in the 'user' stream", %{
user: user,
token: oauth_token
} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, announce} = CommonAPI.repeat(activity.id, other_user)
assert Streamer.filtered_by_user?(user, announce)
end
test "it does stream notifications announces of the user's own posts in the 'user' stream", %{
user: user,
token: oauth_token
} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, announce} = CommonAPI.repeat(activity.id, other_user)
notification =
Pleroma.Notification
|> Repo.get_by(%{user_id: user.id, activity_id: announce.id})
|> Repo.preload(:activity)
refute Streamer.filtered_by_user?(user, notification)
end
test "it streams boosts of mastodon user in the 'user' stream", %{
user: user,
token: oauth_token
} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
data =
File.read!("test/fixtures/mastodon-announce.json")
|> Jason.decode!()
|> Map.put("object", activity.data["object"])
|> Map.put("actor", user.ap_id)
{:ok, %Pleroma.Activity{data: _data, local: false} = announce} =
Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data)
assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce, _}
refute Streamer.filtered_by_user?(user, announce)
end
test "it sends notify to in the 'user' stream", %{
user: user,
token: oauth_token,
notify: notify
} do
Streamer.get_topic_and_add_socket("user", user, oauth_token)
Streamer.stream("user", notify)
assert_receive {:render_with_user, _, _, ^notify, _}
refute Streamer.filtered_by_user?(user, notify)
end
test "it sends notify to in the 'user:notification' stream", %{
user: user,
token: oauth_token,
notify: notify
} do
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
Streamer.stream("user:notification", notify)
assert_receive {:render_with_user, _, _, ^notify, _}
refute Streamer.filtered_by_user?(user, notify)
end
test "it sends chat messages to the 'user:pleroma_chat' stream", %{
user: user,
token: oauth_token
} do
other_user = insert(:user)
{:ok, create_activity} =
CommonAPI.post_chat_message(other_user, user, "hey cirno", idempotency_key: "123")
object = Object.normalize(create_activity, fetch: false)
chat = Chat.get(user.id, other_user.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
cm_ref = %{cm_ref | chat: chat, object: object}
Streamer.get_topic_and_add_socket("user:pleroma_chat", user, oauth_token)
Streamer.stream("user:pleroma_chat", {user, cm_ref})
text =
StreamerView.render(
"chat_update.json",
%{chat_message_reference: cm_ref},
"user:pleroma_chat:#{user.id}"
)
assert text =~ "hey cirno"
assert_receive {:text, ^text}
end
test "it sends chat messages to the 'user' stream", %{user: user, token: oauth_token} do
other_user = insert(:user)
{:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno")
object = Object.normalize(create_activity, fetch: false)
chat = Chat.get(user.id, other_user.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
cm_ref = %{cm_ref | chat: chat, object: object}
Streamer.get_topic_and_add_socket("user", user, oauth_token)
Streamer.stream("user", {user, cm_ref})
text =
StreamerView.render(
"chat_update.json",
%{chat_message_reference: cm_ref},
"user:#{user.id}"
)
assert text =~ "hey cirno"
assert_receive {:text, ^text}
end
test "it sends chat message notifications to the 'user:notification' stream", %{
user: user,
token: oauth_token
} do
other_user = insert(:user)
{:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey")
notify =
Repo.get_by(Pleroma.Notification, user_id: user.id, activity_id: create_activity.id)
|> Repo.preload(:activity)
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
Streamer.stream("user:notification", notify)
assert_receive {:render_with_user, _, _, ^notify, _}
refute Streamer.filtered_by_user?(user, notify)
end
test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{
user: user,
token: oauth_token
} do
blocked = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked)
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, activity} = CommonAPI.post(user, %{status: ":("})
{:ok, _} = CommonAPI.favorite(activity.id, blocked)
refute_receive _
end
test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{
user: user,
token: oauth_token
} do
user2 = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
{:ok, _} = CommonAPI.add_mute(activity, user)
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, favorite_activity} = CommonAPI.favorite(activity.id, user2)
refute_receive _
assert Streamer.filtered_by_user?(user, favorite_activity)
end
test "it sends favorite to 'user:notification' stream'", %{
user: user,
token: oauth_token
} do
user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
{:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, favorite_activity} = CommonAPI.favorite(activity.id, user2)
assert_receive {:render_with_user, _, "notification.json", notif, _}
assert notif.activity.id == favorite_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{
user: user,
token: oauth_token
} do
user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
{:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
{:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, favorite_activity} = CommonAPI.favorite(activity.id, user2)
refute_receive _
assert Streamer.filtered_by_user?(user, favorite_activity)
end
+ test "it does not filter followed users on blocked domains", %{user: user} do
+ followed =
+ insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/friend"})
+
+ {:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
+ {:ok, activity} = CommonAPI.post(followed, %{status: "still a friend"})
+
+ assert Streamer.filtered_by_user?(user, activity)
+
+ {:ok, user, _followed} = User.follow(user, followed)
+
+ refute Streamer.filtered_by_user?(user, activity)
+ end
+
test "it sends follow activities to the 'user:notification' stream", %{
user: user,
token: oauth_token
} do
user2 = insert(:user)
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user, user2)
assert_receive {:render_with_user, _, "notification.json", notif, _}
assert notif.activity.id == follow_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
test "it sends follow relationships updates to the 'user' stream", %{
user: user,
token: oauth_token
} do
user_id = user.id
other_user = insert(:user)
other_user_id = other_user.id
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, _follower, _followed, _follow_activity} = CommonAPI.follow(other_user, user)
assert_receive {:text, event}
assert %{"event" => "pleroma:follow_relationships_update", "payload" => payload} =
Jason.decode!(event)
assert %{
"follower" => %{
"follower_count" => 0,
"following_count" => 0,
"id" => ^user_id
},
"following" => %{
"follower_count" => 0,
"following_count" => 0,
"id" => ^other_user_id
},
"state" => "follow_pending"
} = Jason.decode!(payload)
assert_receive {:text, event}
assert %{"event" => "pleroma:follow_relationships_update", "payload" => payload} =
Jason.decode!(event)
assert %{
"follower" => %{
"follower_count" => 0,
"following_count" => 1,
"id" => ^user_id
},
"following" => %{
"follower_count" => 1,
"following_count" => 0,
"id" => ^other_user_id
},
"state" => "follow_accept"
} = Jason.decode!(payload)
end
test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} do
sender = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(sender, user)
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, edited} = CommonAPI.update(activity, sender, %{status: "mew mew"})
create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
assert_receive {:render_with_user, _, "status_update.json", ^create, _}
refute Streamer.filtered_by_user?(user, edited)
end
test "it streams own edits in the 'user' stream", %{user: user, token: oauth_token} do
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, edited} = CommonAPI.update(activity, user, %{status: "mew mew"})
create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
assert_receive {:render_with_user, _, "status_update.json", ^create, _}
refute Streamer.filtered_by_user?(user, edited)
end
test "it streams posts containing followed hashtags on the 'user' stream", %{
user: user,
token: oauth_token
} do
hashtag = insert(:hashtag, %{name: "tenshi"})
other_user = insert(:user)
{:ok, user} = User.follow_hashtag(user, hashtag)
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey #tenshi"})
assert_receive {:render_with_user, _, "update.json", ^activity, _}
end
test "should not stream private posts containing followed hashtags on the 'user' stream", %{
user: user,
token: oauth_token
} do
hashtag = insert(:hashtag, %{name: "tenshi"})
other_user = insert(:user)
{:ok, user} = User.follow_hashtag(user, hashtag)
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, activity} =
CommonAPI.post(other_user, %{status: "hey #tenshi", visibility: "private"})
refute_receive {:render_with_user, _, "update.json", ^activity, _}
end
end
describe "public streams" do
test "it sends to public (authenticated)" do
%{user: user, token: oauth_token} = oauth_access(["read"])
other_user = insert(:user)
Streamer.get_topic_and_add_socket("public", user, oauth_token)
{:ok, activity} = CommonAPI.post(other_user, %{status: "Test"})
assert_receive {:render_with_user, _, _, ^activity, _}
refute Streamer.filtered_by_user?(other_user, activity)
end
test "it sends to public (unauthenticated)" do
user = insert(:user)
Streamer.get_topic_and_add_socket("public", nil, nil)
{:ok, activity} = CommonAPI.post(user, %{status: "Test"})
activity_id = activity.id
assert_receive {:text, event}
assert %{"event" => "update", "payload" => payload} = Jason.decode!(event)
assert %{"id" => ^activity_id} = Jason.decode!(payload)
{:ok, _} = CommonAPI.delete(activity.id, user)
assert_receive {:text, event}
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
test "handles deletions" do
%{user: user, token: oauth_token} = oauth_access(["read"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "Test"})
Streamer.get_topic_and_add_socket("public", user, oauth_token)
{:ok, _} = CommonAPI.delete(activity.id, other_user)
activity_id = activity.id
assert_receive {:text, event}
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
test "it streams edits in the 'public' stream" do
sender = insert(:user)
Streamer.get_topic_and_add_socket("public", nil, nil)
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
assert_receive {:text, _}
{:ok, edited} = CommonAPI.update(activity, sender, %{status: "mew mew"})
edited = Pleroma.Activity.normalize(edited)
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
assert_receive {:text, event}
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
assert %{"id" => ^activity_id} = Jason.decode!(payload)
refute Streamer.filtered_by_user?(sender, edited)
end
test "it streams multiple edits in the 'public' stream correctly" do
sender = insert(:user)
Streamer.get_topic_and_add_socket("public", nil, nil)
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
assert_receive {:text, _}
{:ok, edited} = CommonAPI.update(activity, sender, %{status: "mew mew"})
edited = Pleroma.Activity.normalize(edited)
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
assert_receive {:text, event}
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
assert %{"id" => ^activity_id} = Jason.decode!(payload)
refute Streamer.filtered_by_user?(sender, edited)
{:ok, edited} = CommonAPI.update(activity, sender, %{status: "mew mew 2"})
edited = Pleroma.Activity.normalize(edited)
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
assert_receive {:text, event}
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
assert %{"id" => ^activity_id, "content" => "mew mew 2"} = Jason.decode!(payload)
refute Streamer.filtered_by_user?(sender, edited)
end
end
describe "thread_containment/2" do
test "it filters to user if recipients invalid and thread containment is enabled" do
clear_config([:instance, :skip_thread_containment], false)
author = insert(:user)
%{user: user, token: oauth_token} = oauth_access(["read"])
User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
note:
insert(:note,
user: author,
data: %{"to" => ["TEST-FFF"]}
)
)
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
assert_receive {:render_with_user, _, _, ^activity, _}
assert Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is disabled" do
clear_config([:instance, :skip_thread_containment], true)
author = insert(:user)
%{user: user, token: oauth_token} = oauth_access(["read"])
User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
note:
insert(:note,
user: author,
data: %{"to" => ["TEST-FFF"]}
)
)
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
assert_receive {:render_with_user, _, _, ^activity, _}
refute Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do
clear_config([:instance, :skip_thread_containment], false)
author = insert(:user)
user = insert(:user, skip_thread_containment: true)
%{token: oauth_token} = oauth_access(["read"], user: user)
User.follow(user, author, :follow_accept)
activity =
insert(:note_activity,
note:
insert(:note,
user: author,
data: %{"to" => ["TEST-FFF"]}
)
)
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
assert_receive {:render_with_user, _, _, ^activity, _}
refute Streamer.filtered_by_user?(user, activity)
end
end
describe "blocks" do
setup do: oauth_access(["read"])
test "it filters messages involving blocked users", %{user: user, token: oauth_token} do
blocked_user = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked_user)
Streamer.get_topic_and_add_socket("public", user, oauth_token)
{:ok, activity} = CommonAPI.post(blocked_user, %{status: "Test"})
assert_receive {:render_with_user, _, _, ^activity, _}
assert Streamer.filtered_by_user?(user, activity)
end
test "it filters messages transitively involving blocked users", %{
user: blocker,
token: blocker_token
} do
blockee = insert(:user)
friend = insert(:user)
Streamer.get_topic_and_add_socket("public", blocker, blocker_token)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
assert_receive {:render_with_user, _, _, ^activity_one, _}
assert Streamer.filtered_by_user?(blocker, activity_one)
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
assert_receive {:render_with_user, _, _, ^activity_two, _}
assert Streamer.filtered_by_user?(blocker, activity_two)
{:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
assert_receive {:render_with_user, _, _, ^activity_three, _}
assert Streamer.filtered_by_user?(blocker, activity_three)
end
end
describe "lists" do
setup do: oauth_access(["read"])
test "it doesn't send unwanted DMs to list", %{user: user_a, token: user_a_token} do
user_b = insert(:user)
user_c = insert(:user)
{:ok, user_a, user_b} = User.follow(user_a, user_b)
{:ok, list} = List.create(%{title: "Test"}, user_a)
{:ok, list} = List.follow(list, user_b)
Streamer.get_topic_and_add_socket("list", user_a, user_a_token, %{"list" => list.id})
{:ok, _activity} =
CommonAPI.post(user_b, %{
status: "@#{user_c.nickname} Test",
visibility: "direct"
})
refute_receive _
end
test "it doesn't send unwanted private posts to list", %{user: user_a, token: user_a_token} do
user_b = insert(:user)
{:ok, list} = List.create(%{title: "Test"}, user_a)
{:ok, list} = List.follow(list, user_b)
Streamer.get_topic_and_add_socket("list", user_a, user_a_token, %{"list" => list.id})
{:ok, _activity} =
CommonAPI.post(user_b, %{
status: "Test",
visibility: "private"
})
refute_receive _
end
test "it sends wanted private posts to list", %{user: user_a, token: user_a_token} do
user_b = insert(:user)
{:ok, user_a, user_b} = User.follow(user_a, user_b)
{:ok, list} = List.create(%{title: "Test"}, user_a)
{:ok, list} = List.follow(list, user_b)
Streamer.get_topic_and_add_socket("list", user_a, user_a_token, %{"list" => list.id})
{:ok, activity} =
CommonAPI.post(user_b, %{
status: "Test",
visibility: "private"
})
assert_receive {:render_with_user, _, _, ^activity, _}
refute Streamer.filtered_by_user?(user_a, activity)
end
end
describe "muted reblogs" do
setup do: oauth_access(["read"])
test "it filters muted reblogs", %{user: user1, token: user1_token} do
user2 = insert(:user)
user3 = insert(:user)
CommonAPI.follow(user2, user1)
CommonAPI.hide_reblogs(user2, user1)
{:ok, create_activity} = CommonAPI.post(user3, %{status: "I'm kawen"})
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, announce_activity} = CommonAPI.repeat(create_activity.id, user2)
assert_receive {:render_with_user, _, _, ^announce_activity, _}
assert Streamer.filtered_by_user?(user1, announce_activity)
end
test "it filters reblog notification for reblog-muted actors", %{
user: user1,
token: user1_token
} do
user2 = insert(:user)
CommonAPI.follow(user2, user1)
CommonAPI.hide_reblogs(user2, user1)
{:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"})
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, _announce_activity} = CommonAPI.repeat(create_activity.id, user2)
assert_receive {:render_with_user, _, "notification.json", notif, _}
assert Streamer.filtered_by_user?(user1, notif)
end
test "it sends non-reblog notification for reblog-muted actors", %{
user: user1,
token: user1_token
} do
user2 = insert(:user)
CommonAPI.follow(user2, user1)
CommonAPI.hide_reblogs(user2, user1)
{:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"})
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, _favorite_activity} = CommonAPI.favorite(create_activity.id, user2)
assert_receive {:render_with_user, _, "notification.json", notif, _}
refute Streamer.filtered_by_user?(user1, notif)
end
end
describe "muted threads" do
test "it filters posts from muted threads" do
user = insert(:user)
%{user: user2, token: user2_token} = oauth_access(["read"])
Streamer.get_topic_and_add_socket("user", user2, user2_token)
{:ok, user2, user, _activity} = CommonAPI.follow(user, user2)
{:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
{:ok, _} = CommonAPI.add_mute(activity, user2)
assert_receive {:render_with_user, _, _, ^activity, _}
assert Streamer.filtered_by_user?(user2, activity)
end
end
describe "direct streams" do
setup do: oauth_access(["read"])
test "it sends conversation update to the 'direct' stream", %{user: user, token: oauth_token} do
another_user = insert(:user)
Streamer.get_topic_and_add_socket("direct", user, oauth_token)
{:ok, _create_activity} =
CommonAPI.post(another_user, %{
status: "hey @#{user.nickname}",
visibility: "direct"
})
assert_receive {:text, received_event}
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
[participation] = Participation.for_user(user)
assert last_status["pleroma"]["direct_conversation_id"] == participation.id
end
test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted",
%{user: user, token: oauth_token} do
another_user = insert(:user)
Streamer.get_topic_and_add_socket("direct", user, oauth_token)
{:ok, create_activity} =
CommonAPI.post(another_user, %{
status: "hi @#{user.nickname}",
visibility: "direct"
})
create_activity_id = create_activity.id
assert_receive {:render_with_user, _, _, ^create_activity, _}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
{:ok, _} = CommonAPI.delete(create_activity_id, another_user)
assert_receive {:text, received_event}
assert %{"event" => "delete", "payload" => ^create_activity_id} =
Jason.decode!(received_event)
refute_receive _
end
@tag :erratic
test "it sends conversation update to the 'direct' stream when a message is deleted", %{
user: user,
token: oauth_token
} do
another_user = insert(:user)
Streamer.get_topic_and_add_socket("direct", user, oauth_token)
{:ok, create_activity} =
CommonAPI.post(another_user, %{
status: "hi @#{user.nickname}",
visibility: "direct"
})
{:ok, create_activity2} =
CommonAPI.post(another_user, %{
status: "hi @#{user.nickname} 2",
in_reply_to_status_id: create_activity.id,
visibility: "direct"
})
assert_receive {:render_with_user, _, _, ^create_activity, _}
assert_receive {:render_with_user, _, _, ^create_activity2, _}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
{:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
assert_receive {:text, received_event}
assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
assert_receive {:text, received_event}
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
assert last_status["id"] == to_string(create_activity.id)
end
end
describe "stop streaming if token got revoked" do
setup do
child_proc = fn start, finalize ->
fn ->
start.()
receive do
{StreamerTest, :ready} ->
assert_receive {:render_with_user, _, "update.json", _, _}
receive do
{StreamerTest, :revoked} -> finalize.()
end
end
end
end
starter = fn user, token ->
fn -> Streamer.get_topic_and_add_socket("user", user, token) end
end
hit = fn -> assert_receive :close end
miss = fn -> refute_receive :close end
send_all = fn tasks, thing -> Enum.each(tasks, &send(&1.pid, thing)) end
%{
child_proc: child_proc,
starter: starter,
hit: hit,
miss: miss,
send_all: send_all
}
end
test "do not revoke other tokens", %{
child_proc: child_proc,
starter: starter,
hit: hit,
miss: miss,
send_all: send_all
} do
%{user: user, token: token} = oauth_access(["read"])
%{token: token2} = oauth_access(["read"], user: user)
%{user: user2, token: user2_token} = oauth_access(["read"])
post_user = insert(:user)
CommonAPI.follow(post_user, user)
CommonAPI.follow(post_user, user2)
tasks = [
Task.async(child_proc.(starter.(user, token), hit)),
Task.async(child_proc.(starter.(user, token2), miss)),
Task.async(child_proc.(starter.(user2, user2_token), miss))
]
{:ok, _} =
CommonAPI.post(post_user, %{
status: "hi"
})
send_all.(tasks, {StreamerTest, :ready})
Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
send_all.(tasks, {StreamerTest, :revoked})
Enum.each(tasks, &Task.await/1)
end
test "revoke all streams for this token", %{
child_proc: child_proc,
starter: starter,
hit: hit,
send_all: send_all
} do
%{user: user, token: token} = oauth_access(["read"])
post_user = insert(:user)
CommonAPI.follow(post_user, user)
tasks = [
Task.async(child_proc.(starter.(user, token), hit)),
Task.async(child_proc.(starter.(user, token), hit))
]
{:ok, _} =
CommonAPI.post(post_user, %{
status: "hi"
})
send_all.(tasks, {StreamerTest, :ready})
Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
send_all.(tasks, {StreamerTest, :revoked})
Enum.each(tasks, &Task.await/1)
end
end
end
diff --git a/test/pleroma/workers/publisher_worker_test.exs b/test/pleroma/workers/publisher_worker_test.exs
index 4519864a5..ddbbba910 100644
--- a/test/pleroma/workers/publisher_worker_test.exs
+++ b/test/pleroma/workers/publisher_worker_test.exs
@@ -1,123 +1,156 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.PublisherWorkerTest do
use Pleroma.DataCase, async: false
use Oban.Testing, repo: Pleroma.Repo
+ import Mox
import Pleroma.Factory
- import Mock
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Federator
+ alias Pleroma.Web.FederatorMock
+ alias Pleroma.Workers.PublisherWorker
describe "Oban job priority:" do
setup do
user = insert(:user)
{:ok, post} = CommonAPI.post(user, %{status: "Regrettable post"})
object = Object.normalize(post, fetch: false)
{:ok, delete_data, _meta} = Builder.delete(user, object.data["id"])
{:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true)
%{
post: post,
delete: delete
}
end
test "Deletions are lower priority", %{delete: delete} do
assert {:ok, %Oban.Job{priority: 3}} = Federator.publish(delete)
end
test "Creates are normal priority", %{post: post} do
assert {:ok, %Oban.Job{priority: 0}} = Federator.publish(post)
end
end
describe "Server reachability:" do
setup do
user = insert(:user)
remote_user = insert(:user, local: false, inbox: "https://example.com/inbox")
{:ok, _, _} = Pleroma.User.follow(remote_user, user)
{:ok, activity} = CommonAPI.post(user, %{status: "Test post"})
%{
user: user,
remote_user: remote_user,
activity: activity
}
end
test "marks server as unreachable only on final failure", %{activity: activity} do
- with_mock Pleroma.Web.Federator,
- perform: fn :publish_one, _params -> {:error, :connection_error} end do
- # First attempt
- job = %Oban.Job{
- args: %{
- "op" => "publish_one",
- "params" => %{
- "inbox" => "https://example.com/inbox",
- "activity_id" => activity.id
- }
- },
- attempt: 1,
- max_attempts: 5
- }
-
- assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job)
- assert Instances.reachable?("https://example.com/inbox")
-
- # Final attempt
- job = %{job | attempt: 5}
- assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job)
- refute Instances.reachable?("https://example.com/inbox")
- end
+ expect(FederatorMock, :perform, 2, fn :publish_one, _params ->
+ {:error, :connection_error}
+ end)
+
+ # First attempt
+ job = %Oban.Job{
+ args: %{
+ "op" => "publish_one",
+ "params" => %{
+ "inbox" => "https://example.com/inbox",
+ "activity_id" => activity.id
+ }
+ },
+ attempt: 1,
+ max_attempts: 5
+ }
+
+ assert {:error, :connection_error} = PublisherWorker.perform(job)
+ assert Instances.reachable?("https://example.com/inbox")
+
+ # Final attempt
+ job = %{job | attempt: 5}
+ assert {:error, :connection_error} = PublisherWorker.perform(job)
+ refute Instances.reachable?("https://example.com/inbox")
end
test "does not mark server as unreachable on successful publish", %{activity: activity} do
- with_mock Pleroma.Web.Federator,
- perform: fn :publish_one, _params -> {:ok, %{status: 200}} end do
- job = %Oban.Job{
- args: %{
- "op" => "publish_one",
- "params" => %{
- "inbox" => "https://example.com/inbox",
- "activity_id" => activity.id
- }
- },
- attempt: 1,
- max_attempts: 5
- }
-
- assert :ok = Pleroma.Workers.PublisherWorker.perform(job)
- assert Instances.reachable?("https://example.com/inbox")
- end
+ expect(FederatorMock, :perform, fn :publish_one, _params -> {:ok, %{status: 200}} end)
+
+ job = %Oban.Job{
+ args: %{
+ "op" => "publish_one",
+ "params" => %{
+ "inbox" => "https://example.com/inbox",
+ "activity_id" => activity.id
+ }
+ },
+ attempt: 1,
+ max_attempts: 5
+ }
+
+ assert :ok = PublisherWorker.perform(job)
+ assert Instances.reachable?("https://example.com/inbox")
+ end
+
+ test "does not create atoms from unexpected job params", %{activity: activity} do
+ unknown_key = "unknown_#{System.unique_integer([:positive])}"
+
+ assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key) end
+
+ expect(FederatorMock, :perform, fn :publish_one, params ->
+ assert params == %{
+ inbox: "https://example.com/inbox",
+ activity_id: activity.id
+ }
+
+ {:ok, %{status: 200}}
+ end)
+
+ job = %Oban.Job{
+ args: %{
+ "op" => "publish_one",
+ "params" => %{
+ "inbox" => "https://example.com/inbox",
+ "activity_id" => activity.id,
+ unknown_key => "ignored"
+ }
+ },
+ attempt: 1,
+ max_attempts: 5
+ }
+
+ assert :ok = PublisherWorker.perform(job)
+ assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key) end
end
test "cancels job if server is unreachable", %{activity: activity} do
# First mark the server as unreachable
Instances.set_unreachable("https://example.com/inbox")
refute Instances.reachable?("https://example.com/inbox")
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
- assert {:cancel, :unreachable} = Pleroma.Workers.PublisherWorker.perform(job)
+ assert {:cancel, :unreachable} = PublisherWorker.perform(job)
end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Thu, Oct 29, 11:15 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1798806
Default Alt Text
(1 MB)

Event Timeline