From af175fbdfc589be7f80adcb9613cfa3d67a217a8 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 3 May 2026 21:19:24 +0400 Subject: [PATCH 01/20] Woodpecker CI: Build armv7 Docker images --- .woodpecker/docker-combine.yaml | 2 +- .woodpecker/docker.yaml | 47 +++++++++++++++++++++++++++++++++ Dockerfile | 6 +++-- 3 files changed, 52 insertions(+), 3 deletions(-) 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 @@ -27,7 +27,7 @@ steps: settings: &docker_settings registry: "git.pleroma.social" image: "pleroma/pleroma" - architectures: [amd64, arm64] + architectures: [amd64, arm64, armv7] tags: - latest - develop diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index abc6bfa3b..5f174fb99 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -11,6 +11,7 @@ when: matrix: platform: - linux/amd64 + - linux/arm - linux/arm64 # This is needed for the when clauses below. @@ -27,6 +28,13 @@ variables: password: from_secret: pleroma-ci-password kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1 + # HexPM Elixir images are not published for arm/v7. + armv7_build_args: &armv7_build_args + - ELIXIR_IMG=arm32v7/elixir + - ELIXIR_TAG=1.17.3-alpine + - ALPINE_IMG=arm32v7/alpine + - ALPINE_VER=3.22 + armv7_extra_opts: &armv7_extra_opts --custom-platform=linux/arm/v7 steps: docker-develop-amd64: @@ -51,6 +59,19 @@ steps: - 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: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: + - latest-armv7 + - develop-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + docker-stable-amd64: image: *kaniko_image when: @@ -84,6 +105,19 @@ steps: - stable-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 + docker-stable-armv7: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' + settings: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: &armv7_tags + - latest-armv7 + - stable-armv7 + - ${CI_COMMIT_SHA:0:8}-armv7 + docker-stable-tag-arm64: image: *kaniko_image when: @@ -94,3 +128,16 @@ steps: tags: - <<: *arm64_tags - ${CI_COMMIT_TAG}-arm64 + + 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: + <<: *docker_variables + build_args: *armv7_build_args + extra_opts: *armv7_extra_opts + tags: + - <<: *armv7_tags + - ${CI_COMMIT_TAG}-armv7 diff --git a/Dockerfile b/Dockerfile index 11a17b7ad..5fd1cc0a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,10 @@ ARG ELIXIR_IMG=hexpm/elixir ARG ELIXIR_VER=1.17.3 ARG ERLANG_VER=26.2.5.6 ARG ALPINE_VER=3.17.9 +ARG ELIXIR_TAG=${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} +ARG ALPINE_IMG=alpine -FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build +FROM ${ELIXIR_IMG}:${ELIXIR_TAG} AS build COPY . . @@ -20,7 +22,7 @@ RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ mkdir release &&\ mix release --path release -FROM alpine:${ALPINE_VER} +FROM ${ALPINE_IMG}:${ALPINE_VER} ARG BUILD_DATE ARG VCS_REF From 684e9ef24702bf2c509d42445fc665ceb91070d4 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 4 May 2026 09:41:06 +0400 Subject: [PATCH 02/20] Woodpecker CI: Isolate armv7 Docker builds --- .woodpecker/docker-armv7.yaml | 84 +++++++++++++++++++++++++++++++++ .woodpecker/docker-combine.yaml | 2 +- .woodpecker/docker.yaml | 47 ------------------ 3 files changed, 85 insertions(+), 48 deletions(-) create mode 100644 .woodpecker/docker-armv7.yaml diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml new file mode 100644 index 000000000..19f3049b3 --- /dev/null +++ b/.woodpecker/docker-armv7.yaml @@ -0,0 +1,84 @@ +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: + 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 9b9f89fdb..6c91d26e9 100644 --- a/.woodpecker/docker-combine.yaml +++ b/.woodpecker/docker-combine.yaml @@ -27,7 +27,7 @@ steps: settings: &docker_settings registry: "git.pleroma.social" image: "pleroma/pleroma" - architectures: [amd64, arm64, armv7] + architectures: [amd64, arm64] tags: - latest - develop diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index 5f174fb99..abc6bfa3b 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -11,7 +11,6 @@ when: matrix: platform: - linux/amd64 - - linux/arm - linux/arm64 # This is needed for the when clauses below. @@ -28,13 +27,6 @@ variables: password: from_secret: pleroma-ci-password kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1 - # HexPM Elixir images are not published for arm/v7. - armv7_build_args: &armv7_build_args - - ELIXIR_IMG=arm32v7/elixir - - ELIXIR_TAG=1.17.3-alpine - - ALPINE_IMG=arm32v7/alpine - - ALPINE_VER=3.22 - armv7_extra_opts: &armv7_extra_opts --custom-platform=linux/arm/v7 steps: docker-develop-amd64: @@ -59,19 +51,6 @@ steps: - 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: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: - - latest-armv7 - - develop-armv7 - - ${CI_COMMIT_SHA:0:8}-armv7 - docker-stable-amd64: image: *kaniko_image when: @@ -105,19 +84,6 @@ steps: - stable-arm64 - ${CI_COMMIT_SHA:0:8}-arm64 - docker-stable-armv7: - image: *kaniko_image - when: - - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' - settings: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: &armv7_tags - - latest-armv7 - - stable-armv7 - - ${CI_COMMIT_SHA:0:8}-armv7 - docker-stable-tag-arm64: image: *kaniko_image when: @@ -128,16 +94,3 @@ steps: tags: - <<: *arm64_tags - ${CI_COMMIT_TAG}-arm64 - - 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: - <<: *docker_variables - build_args: *armv7_build_args - extra_opts: *armv7_extra_opts - tags: - - <<: *armv7_tags - - ${CI_COMMIT_TAG}-armv7 From 394db0dce0c6ca3e55f2039cca35b1bb8e651875 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 4 May 2026 10:09:44 +0400 Subject: [PATCH 03/20] Woodpecker CI: Target armv7 Docker runner --- .woodpecker/docker-armv7.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml index 19f3049b3..c921a56f9 100644 --- a/.woodpecker/docker-armv7.yaml +++ b/.woodpecker/docker-armv7.yaml @@ -13,6 +13,7 @@ when: # Target the CAX21 arm64 host with native arm32 userspace support, not generic arm64 runners. labels: + platform: linux/arm armv7: native variables: From 3c63877e618468834a4ff31374396723dc8ba313 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 5 May 2026 21:04:13 +0200 Subject: [PATCH 04/20] Add custom Docker image for FE E2E tests Woodpecker cannot do custom commands for services. --- .woodpecker/docker.yaml | 12 ++++++ Dockerfile-e2e | 61 +++++++++++++++++++++++++++ changelog.d/docker-e2e-image.skip | 0 docker-entrypoint-e2e.sh | 70 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 Dockerfile-e2e create mode 100644 changelog.d/docker-e2e-image.skip create mode 100755 docker-entrypoint-e2e.sh diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml index abc6bfa3b..9a5be78e2 100644 --- a/.woodpecker/docker.yaml +++ b/.woodpecker/docker.yaml @@ -62,6 +62,18 @@ steps: - 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: diff --git a/Dockerfile-e2e b/Dockerfile-e2e new file mode 100644 index 000000000..df8bb39aa --- /dev/null +++ b/Dockerfile-e2e @@ -0,0 +1,61 @@ +# https://hub.docker.com/r/hexpm/elixir/tags +ARG ELIXIR_IMG=docker.io/hexpm/elixir +ARG ALPINE_IMG=docker.io/alpine +ARG ELIXIR_VER=1.18.4 +ARG ERLANG_VER=27.3.4.11 +ARG ALPINE_VER=3.23.4 + +FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} AS build + +COPY . . + +ENV MIX_ENV=prod +ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS + +RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ + echo "import Config" > config/prod.secret.exs &&\ + mix local.hex --force &&\ + mix local.rebar --force &&\ + mix deps.clean --all &&\ + mix deps.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 FE E2E test image" \ + org.opencontainers.image.authors="ops@pleroma.social" \ + org.opencontainers.image.vendor="pleroma.social" \ + org.opencontainers.image.documentation="https://git.pleroma.social/pleroma/pleroma" \ + org.opencontainers.image.licenses="AGPL-3.0" \ + org.opencontainers.image.url="https://pleroma.social" \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.created=$BUILD_DATE + +ARG HOME=/opt/pleroma +ARG DATA=/var/lib/pleroma + +RUN apk update &&\ + apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\ + adduser --system --shell /bin/false --home ${HOME} pleroma &&\ + mkdir -p ${DATA}/uploads &&\ + mkdir -p ${DATA}/static &&\ + chown -R pleroma ${DATA} &&\ + mkdir -p /etc/pleroma &&\ + chown -R pleroma /etc/pleroma + +USER pleroma + +COPY --from=build --chown=pleroma:0 /release ${HOME} + +COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs +COPY ./docker-entrypoint-e2e.sh ${HOME} + +EXPOSE 4000 + +ENTRYPOINT ["/opt/pleroma/docker-entrypoint-e2e.sh"] diff --git a/changelog.d/docker-e2e-image.skip b/changelog.d/docker-e2e-image.skip new file mode 100644 index 000000000..e69de29bb diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh new file mode 100755 index 000000000..c83c44a80 --- /dev/null +++ b/docker-entrypoint-e2e.sh @@ -0,0 +1,70 @@ +#!/bin/ash +set -eu + +SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded +CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs + +echo '-- Waiting for database...' +while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do + sleep 1s +done + +echo '-- Writing E2E config overrides...' +cat > $CONFIG_OVERRIDE_PATH </dev/null; then + kill -TERM $PLEROMA_PID + wait $PLEROMA_PID || true + fi +} + +trap cleanup INT TERM + +echo '-- Waiting for API...' +api_ok=false +for _i in $(seq 1 120); do + if wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null 2>&1; then + api_ok=true + break + fi + sleep 1s +done + +if [ $api_ok != true ]; then + echo 'Timed out waiting for Pleroma API to become available' + exit 1 +fi + +if [ ! -f $SEED_SENTINEL_PATH ]; then + if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then + echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...' + if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then + echo '-- User already exists or creation failed, ensuring admin + confirmed...' + /opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed + fi + else + echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' + fi + + touch $SEED_SENTINEL_PATH +fi + +wait $PLEROMA_PID From 7575b7abc2b1559dbb2a780ce55e572969d8184f Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 14 May 2026 12:20:27 +0200 Subject: [PATCH 05/20] Woodpecker CI: Do not trigger on Weblate MRs --- .woodpecker/changelog.yaml | 1 + .woodpecker/lint.yaml | 1 + .woodpecker/unit-testing-elixir-1.15.yaml | 1 + .woodpecker/unit-testing-elixir-1.18.yaml | 1 + changelog.d/weblate-ci.skip | 0 5 files changed, 4 insertions(+) create mode 100644 changelog.d/weblate-ci.skip diff --git a/.woodpecker/changelog.yaml b/.woodpecker/changelog.yaml index 64062f17e..a3732c240 100644 --- a/.woodpecker/changelog.yaml +++ b/.woodpecker/changelog.yaml @@ -1,5 +1,6 @@ when: - event: pull_request + evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"' labels: platform: linux/amd64 diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index 0ab7441a8..8a5718c5d 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -1,6 +1,7 @@ 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/**" ] diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml index a4a8fc266..a5bcc0ef3 100644 --- a/.woodpecker/unit-testing-elixir-1.15.yaml +++ b/.woodpecker/unit-testing-elixir-1.15.yaml @@ -1,6 +1,7 @@ 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/**" ] diff --git a/.woodpecker/unit-testing-elixir-1.18.yaml b/.woodpecker/unit-testing-elixir-1.18.yaml index 9ad9eebc9..6e576fad9 100644 --- a/.woodpecker/unit-testing-elixir-1.18.yaml +++ b/.woodpecker/unit-testing-elixir-1.18.yaml @@ -1,6 +1,7 @@ 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/**" ] diff --git a/changelog.d/weblate-ci.skip b/changelog.d/weblate-ci.skip new file mode 100644 index 000000000..e69de29bb From 9dd02ecd5074cc0c164cbae86253d8eea921c787 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 22 May 2026 16:30:14 +0400 Subject: [PATCH 06/20] Fix WebSocket protocol token handshakes --- changelog.d/mastodon-websocket-protocol.fix | 1 + lib/pleroma/web/endpoint.ex | 7 +- .../web/mastodon_api/websocket_handler.ex | 15 +-- .../web/mastodon_api/websocket_plug.ex | 105 ++++++++++++++++++ .../integration/mastodon_websocket_test.exs | 100 ++++++++++++++--- 5 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 changelog.d/mastodon-websocket-protocol.fix create mode 100644 lib/pleroma/web/mastodon_api/websocket_plug.ex diff --git a/changelog.d/mastodon-websocket-protocol.fix b/changelog.d/mastodon-websocket-protocol.fix new file mode 100644 index 000000000..66dab16ed --- /dev/null +++ b/changelog.d/mastodon-websocket-protocol.fix @@ -0,0 +1 @@ +Echo Mastodon-style `Sec-WebSocket-Protocol` tokens in streaming WebSocket handshakes. diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 36e78e1e2..07b33f866 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.Endpoint do alias Pleroma.Config - socket("/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, - longpoll: false, + plug(Pleroma.Web.MastodonAPI.WebsocketPlug, + path: "/api/v1/streaming", websocket: [ path: "/", compress: false, @@ -169,8 +169,7 @@ defmodule Pleroma.Web.Endpoint do else: "pleroma_key" extra = - Config.get([__MODULE__, :extra_cookie_attrs]) - |> Enum.join(";") + Enum.join(Config.get([__MODULE__, :extra_cookie_attrs]), ";") # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 2b698bd5d..3dc862a5a 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -67,9 +67,10 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @impl Phoenix.Socket.Transport def handle_in({text, [opcode: :text]}, state) do - with {:ok, %{} = event} <- Jason.decode(text) do - handle_client_event(event, state) - else + case Jason.decode(text) do + {:ok, %{} = event} -> + handle_client_event(event, state) + _ -> Logger.error("#{__MODULE__} received non-JSON event: #{inspect(text)}") {:ok, state} @@ -85,11 +86,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do def handle_info({:render_with_user, view, template, item, topic}, state) do user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) - unless Streamer.filtered_by_user?(user, item) do + if Streamer.filtered_by_user?(user, item) do + {:ok, state} + else message = view.render(template, item, user, topic) {:push, {:text, message}, %{state | user: user}} - else - {:ok, state} end end @@ -253,7 +254,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do defp find_sec_websocket_protocol(sec_headers) do Enum.find_value(sec_headers, fn - {"sec-websocket-protocol", token} -> token + {"sec-websocket-protocol", protocols} -> protocols |> Plug.Conn.Utils.list() |> List.first() _ -> nil end) end diff --git a/lib/pleroma/web/mastodon_api/websocket_plug.ex b/lib/pleroma/web/mastodon_api/websocket_plug.ex new file mode 100644 index 000000000..58ee913b4 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/websocket_plug.ex @@ -0,0 +1,105 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.WebsocketPlug do + @moduledoc """ + A Phoenix 1.8 compatible WebSocket transport for Mastodon streaming. + + It mirrors Phoenix.Transports.WebSocket, but echoes a successfully authenticated + Mastodon-style Sec-WebSocket-Protocol token so browser clients accept the handshake. + """ + + @behaviour Plug + + import Plug.Conn + + alias Phoenix.Socket.Transport + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MastodonAPI.WebsocketHandler + + @connect_info_opts [:check_csrf] + + @impl Plug + def init(opts) do + path = String.split(Keyword.fetch!(opts, :path), "/", trim: true) + websocket = Keyword.fetch!(opts, :websocket) + config = Transport.load_config(websocket, Phoenix.Transports.WebSocket) + + {path, config} + end + + @impl Plug + def call(%{method: "GET", path_info: path} = conn, {path, opts}) do + conn + |> fetch_query_params() + |> Transport.code_reload(Endpoint, opts) + |> Transport.transport_log(opts[:transport_log]) + |> Transport.check_origin(WebsocketHandler, Endpoint, opts) + |> connect(opts) + end + + def call(%{path_info: path} = conn, {path, _opts}) do + conn + |> send_resp(400, "") + |> halt() + end + + def call(conn, _opts), do: conn + + defp connect(%{halted: true} = conn, _opts), do: conn + + defp connect(%{params: params} = conn, opts) do + keys = Keyword.get(opts, :connect_info, []) + + connect_info = + Transport.connect_info(conn, Endpoint, keys, Keyword.take(opts, @connect_info_opts)) + + config = %{ + endpoint: Endpoint, + transport: :websocket, + options: opts, + params: params, + connect_info: connect_info + } + + case WebsocketHandler.connect(config) do + {:ok, arg} -> + try do + conn + |> echo_sec_websocket_protocol() + |> WebSockAdapter.upgrade(WebsocketHandler, arg, opts) + |> halt() + rescue + e in WebSockAdapter.UpgradeError -> + conn + |> send_resp(400, e.message) + |> halt() + end + + :error -> + conn + |> send_resp(403, "") + |> halt() + + {:error, reason} -> + {m, f, args} = opts[:error_handler] + + halt(apply(m, f, [conn, reason | args])) + end + end + + defp echo_sec_websocket_protocol(conn) do + case get_req_header(conn, "sec-websocket-protocol") do + [protocols | _] -> + case Plug.Conn.Utils.list(protocols) do + [protocol | _] -> put_resp_header(conn, "sec-websocket-protocol", protocol) + nil -> conn + [] -> conn + end + + [] -> + conn + end + end +end diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index 078bb643c..de88e5002 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do alias Pleroma.Integration.WebsocketClient alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth @moduletag needs_streamer: true, capture_log: true @@ -31,6 +32,48 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do WebsocketClient.start_link(self(), path, headers) end + defp raw_websocket_handshake(qs, headers) do + uri = URI.parse(@path <> qs) + port = uri.port || 80 + path = uri.path <> if(uri.query, do: "?" <> uri.query, else: "") + + default_headers = [ + {"host", "#{uri.host}:#{port}"}, + {"upgrade", "websocket"}, + {"connection", "Upgrade"}, + {"sec-websocket-key", Base.encode64(:crypto.strong_rand_bytes(16))}, + {"sec-websocket-version", "13"} + ] + + request = [ + "GET #{path} HTTP/1.1\r\n", + Enum.map(default_headers ++ headers, fn {name, value} -> "#{name}: #{value}\r\n" end), + "\r\n" + ] + + with {:ok, socket} <- + :gen_tcp.connect(String.to_charlist(uri.host), port, [:binary, active: false], 1_000), + :ok <- :gen_tcp.send(socket, request), + {:ok, response} <- :gen_tcp.recv(socket, 0, 1_000) do + :gen_tcp.close(socket) + {:ok, parse_http_response(response)} + end + end + + defp parse_http_response(response) do + [headers | _] = String.split(response, "\r\n\r\n", parts: 2) + [status_line | header_lines] = String.split(headers, "\r\n") + [_, status | _] = String.split(status_line, " ") + + headers = + Enum.map(header_lines, fn line -> + [name, value] = String.split(line, ":", parts: 2) + {String.downcase(name), String.trim(value)} + end) + + %{status: String.to_integer(status), headers: headers} + end + defp decode_json(json) do with {:ok, %{"event" => event, "payload" => payload_text}} <- Jason.decode(json), {:ok, payload} <- Jason.decode(payload_text) do @@ -85,9 +128,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert json["payload"] assert {:ok, json} = Jason.decode(json["payload"]) - view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil) - |> atom_key_to_string() + view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil)) assert json == view_json end @@ -114,10 +155,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert json["payload"] assert {:ok, json} = Jason.decode(json["payload"]) - view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity, for: nil) - |> Jason.encode!() - |> Jason.decode!() + view_json = atom_key_to_string(StatusView.render("show.json", activity: activity, for: nil)) assert json == view_json end @@ -279,6 +317,34 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end + test "echoes the Sec-WebSocket-Protocol token in the handshake", %{token: token} do + assert {:ok, %{status: 101, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", token.token} + ]) + + assert {"sec-websocket-protocol", token.token} in headers + end + + test "echoes the selected Sec-WebSocket-Protocol token", %{token: token} do + assert {:ok, %{status: 101, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", "#{token.token}, phoenix"} + ]) + + assert {"sec-websocket-protocol", token.token} in headers + end + + test "does not echo an invalid Sec-WebSocket-Protocol token", %{token: token} do + assert {:ok, %{status: 401, headers: headers}} = + raw_websocket_handshake("?stream=user", [ + {"sec-websocket-protocol", "invalid"} + ]) + + refute {"sec-websocket-protocol", token.token} in headers + refute List.keymember?(headers, "sec-websocket-protocol", 0) + end + test "prefers sec-websocket-protocol token over query access_token", %{ token: token, user: user @@ -450,12 +516,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do assert {:ok, json} = Jason.decode(json["payload"]) view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", - activity: activity, - for: reading_user + atom_key_to_string( + StatusView.render("show.json", + activity: activity, + for: reading_user + ) ) - |> Jason.encode!() - |> Jason.decode!() assert json == view_json end @@ -478,12 +544,12 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do activity = Pleroma.Activity.normalize(activity) view_json = - Pleroma.Web.MastodonAPI.StatusView.render("show.json", - activity: activity, - for: reading_user + atom_key_to_string( + StatusView.render("show.json", + activity: activity, + for: reading_user + ) ) - |> Jason.encode!() - |> Jason.decode!() assert {:ok, %{"event" => "status.update", "payload" => ^view_json}} = decode_json(raw_json) end From b054c2aa42748fd3489ee463f4bfd43b075ec638 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 22 May 2026 22:11:37 +0400 Subject: [PATCH 07/20] Fix paged featured collection fetches --- changelog.d/featured-collection-page.fix | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 36 ++++- .../web/activity_pub/activity_pub_test.exs | 133 ++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 changelog.d/featured-collection-page.fix diff --git a/changelog.d/featured-collection-page.fix b/changelog.d/featured-collection-page.fix new file mode 100644 index 000000000..b611a4137 --- /dev/null +++ b/changelog.d/featured-collection-page.fix @@ -0,0 +1 @@ +Support fetching featured collection items from the first collection page diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0b513ee16..1e8bfe3dd 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1881,11 +1881,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end + @featured_collection_types ["OrderedCollection", "Collection"] + @featured_collection_page_types ["OrderedCollectionPage", "CollectionPage"] + @featured_collection_item_types @featured_collection_types ++ @featured_collection_page_types + def pin_data_from_featured_collection(%{ "type" => type, "orderedItems" => objects }) - when type in ["OrderedCollection", "Collection"] do + when type in @featured_collection_item_types do Map.new(objects, fn %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} object_ap_id when is_binary(object_ap_id) -> {object_ap_id, NaiveDateTime.utc_now()} @@ -1903,7 +1907,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def fetch_and_prepare_featured_from_ap_id(ap_id) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do - {:ok, pin_data_from_featured_collection(data)} + {:ok, prepare_featured_collection(data)} else e -> Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") @@ -1911,6 +1915,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do 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, _} -> diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 9aafc41a5..dd7a82a55 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -415,6 +415,105 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) end + test "fetches user featured collection from the first collection page" do + ap_id = "https://example.com/users/lain" + featured_url = "https://example.com/users/lain/collections/featured" + first_page_url = "#{featured_url}?page=true" + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> Jason.decode!() + |> Map.put("featured", featured_url) + |> Jason.encode!() + + object_id = Ecto.UUID.generate() + object_url = "https://example.com/objects/#{object_id}" + + featured_data = + Jason.encode!(%{ + "id" => featured_url, + "type" => "OrderedCollection", + "first" => first_page_url + }) + + first_page_data = + Jason.encode!(%{ + "id" => first_page_url, + "type" => "OrderedCollectionPage", + "partOf" => featured_url, + "orderedItems" => [object_url] + }) + + object_data = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{object_id}}", object_id) + |> String.replace("{{nickname}}", "lain") + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^ap_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^featured_url + } -> + %Tesla.Env{ + status: 200, + body: featured_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^first_page_url + } -> + %Tesla.Env{ + status: 200, + body: first_page_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + refute capture_log(fn -> + {:ok, user} = ActivityPub.make_user_from_ap_id(ap_id) + + assert_enqueued( + worker: Pleroma.Workers.RemoteFetcherWorker, + args: %{ + "op" => "fetch_remote", + "id" => object_url, + "depth" => 1 + } + ) + + Pleroma.Tests.ObanHelpers.perform_all() + + assert user.featured_address == featured_url + assert Map.has_key?(user.pinned_objects, object_url) + assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) + end) =~ "Could not parse featured collection" + end + test "fetches user birthday information from misskey" do user_id = "https://misskey.io/@mkljczk" @@ -2818,4 +2917,38 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do "first" => "https://social.example/users/alice/collections/featured?page=true" }) end + + test "fetch_and_prepare_featured_from_ap_id handles embedded first collection pages" do + featured_url = "https://social.example/users/alice/collections/featured" + first_page_url = "#{featured_url}?page=true" + object_url = "https://social.example/objects/1" + + featured_data = + Jason.encode!(%{ + "id" => featured_url, + "type" => "OrderedCollection", + "first" => %{ + "id" => first_page_url, + "type" => "OrderedCollectionPage", + "partOf" => featured_url, + "orderedItems" => [object_url] + } + }) + + Tesla.Mock.mock(fn + %{method: :get, url: ^featured_url} -> + %Tesla.Env{ + status: 200, + body: featured_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + refute capture_log(fn -> + assert {:ok, pinned_objects} = + ActivityPub.fetch_and_prepare_featured_from_ap_id(featured_url) + + assert Map.has_key?(pinned_objects, object_url) + end) =~ "Could not parse featured collection" + end end From cda6738309117a8ea95f8367175dbed3587e697b Mon Sep 17 00:00:00 2001 From: hj Date: Fri, 22 May 2026 20:47:52 +0000 Subject: [PATCH 08/20] Update priv/scrubbers/default.ex --- priv/scrubbers/default.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 8963d99e8..cc5eba027 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -105,7 +105,9 @@ defmodule Pleroma.HTML.Scrubber.Default do "mfm-sparkle", "mfm-rotate", "mfm-ruby", - "mfm-unixtime" + "mfm-unixtime", + # Exists in Akkoma but not Misskey? + "mfm-center" ]) Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) From 94a28d128605bc4e3328293a01585471c193f82b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 16 May 2026 00:31:02 +0200 Subject: [PATCH 09/20] RichMedia Backfill: Add cachex positive test --- test/pleroma/web/rich_media/backfill_test.exs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index 6d221fcf5..8b4834bf7 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -23,4 +23,26 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url}) end + + test "sets a warm_cache entry" do + url = "https://good.example.com/" + url_hash = Card.url_to_hash(url) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, + ^url_hash, + %Pleroma.Web.RichMedia.Card{url_hash: ^url_hash} -> + {:ok, true} + end) + + Backfill.run(%{"url" => url}) + end end From 678fe8a0642771e24d7b4d9b1df78578024351fe Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 20 May 2026 20:16:19 +0200 Subject: [PATCH 10/20] RichMedia: Add support for disabling wss streaming out on backfill --- .../web/mastodon_api/views/status_view.ex | 9 +-- lib/pleroma/web/rich_media/backfill.ex | 14 +++-- lib/pleroma/web/rich_media/card.ex | 16 ++++-- test/pleroma/web/rich_media/backfill_test.exs | 56 ++++++++++++++++++- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 09af60865..80d53adff 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -28,9 +28,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # This is a naive way to do this, just spawning a process per activity # to fetch the preview. However it should be fine considering # pagination is restricted to 40 activities at a time - defp fetch_rich_media_for_activities(activities) do + defp fetch_rich_media_for_activities(activities, opts) do Enum.each(activities, fn activity -> - Card.get_by_activity(activity) + Card.get_by_activity(activity, opts) end) end @@ -113,7 +113,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do activities = Enum.filter(opts.activities, & &1) # Start prefetching rich media before doing anything else - fetch_rich_media_for_activities(activities) + fetch_rich_media_for_activities(activities, opts) + replied_to_activities = get_replied_to_activities(activities) quoted_activities = get_quoted_activities(activities) @@ -362,7 +363,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" card = - case Card.get_by_activity(activity) do + case Card.get_by_activity(activity, opts) do %Card{} = result -> render("card.json", result) _ -> nil end diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex index 1cd90629f..607c5238a 100644 --- a/lib/pleroma/web/rich_media/backfill.ex +++ b/lib/pleroma/web/rich_media/backfill.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.RichMedia.Backfill do require Logger + @callback run(map()) :: :ok | Parser.parse_errors() | Helpers.get_errors() + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @stream_out_impl Pleroma.Config.get( [__MODULE__, :stream_out], @@ -26,11 +28,7 @@ defmodule Pleroma.Web.RichMedia.Backfill do {:ok, card} = Card.create(url, fields) maybe_schedule_expiration(url, fields) - - with %{"activity_id" => activity_id} <- args, - false <- is_nil(activity_id) do - stream_update(args) - end + maybe_update_stream(args) warm_cache(url_hash, card) :ok @@ -55,12 +53,16 @@ defmodule Pleroma.Web.RichMedia.Backfill do end end - defp stream_update(%{"activity_id" => activity_id}) do + defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) when is_binary(activity_id) do Pleroma.Activity.get_by_id(activity_id) |> Pleroma.Activity.normalize() |> @stream_out_impl.stream_out() end + # Streamer.stream_out returns noop when unsupported activity type is requested to be streamed. + # Do the same here for unwanted streaming + defp maybe_update_stream(_), do: :noop + defp warm_cache(key, val), do: @cachex.put(:rich_media_cache, key, val) defp negative_cache(key, ttl \\ :timer.minutes(15)), diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex index 6b4bb9555..86a4f577a 100644 --- a/lib/pleroma/web/rich_media/card.ex +++ b/lib/pleroma/web/rich_media/card.ex @@ -90,8 +90,12 @@ defmodule Pleroma.Web.RichMedia.Card do nil -> activity_id = Keyword.get(opts, :activity_id, nil) + # Nested opts, first layer comes from get_by_activity/2 as Keyword, second from API views/Federation as Map. + # Provide default Map when called directly. + opts = Keyword.get(opts, :opts, %{}) + stream = Map.get(opts, :stream, true) - RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id}) + RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id, "stream" => stream}) |> Oban.insert() nil @@ -112,9 +116,11 @@ defmodule Pleroma.Web.RichMedia.Card do end end - @spec get_by_activity(Activity.t()) :: t() | nil | :error + @spec get_by_activity(Activity.t(), %{}) :: t() | nil | :error + def get_by_activity(activity, opts \\ %{}) + # Fake/Draft activity - def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity) do + def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity, _opts) do with {_, true} <- {:config, @config_impl.get([:rich_media, :enabled])}, %Object{} = object <- Object.normalize(activity, fetch: false), url when not is_nil(url) <- HTML.extract_first_external_url_from_object(object) do @@ -138,13 +144,13 @@ defmodule Pleroma.Web.RichMedia.Card do end end - def get_by_activity(activity) do + def get_by_activity(activity, opts) do with %Object{} = object <- Object.normalize(activity, fetch: false), {_, nil} <- {:cached, get_cached_url(object, activity.id)} do nil else {:cached, url} -> - get_or_backfill_by_url(url, activity_id: activity.id) + get_or_backfill_by_url(url, activity_id: activity.id, opts: opts) _ -> :error diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index 8b4834bf7..f7e74c02a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -5,12 +5,20 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do use Pleroma.DataCase + alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card import Mox + import Pleroma.Factory - setup_all do: clear_config([:rich_media, :enabled], true) + setup do + clear_config([:rich_media, :enabled], true) + + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + + :ok + end test "sets a negative cache entry for an error" do url = "https://bad.example.com/" @@ -45,4 +53,50 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url}) end + + test "streams out update when stream == true" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) + + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, fn _ -> :ok end) + + Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => true}) + end + + test "does not stream out update when stream == false" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + + Pleroma.CachexMock + |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) + + Pleroma.Web.ActivityPub.ActivityPubMock + |> deny(:stream_out, 1) + + Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => false}) + end end From 5f55c9653c64ced6e1461224a942a303e82627d8 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Wed, 20 May 2026 20:18:47 +0200 Subject: [PATCH 11/20] RichMedia: Disable websockets backfill streaming in StatusView --- lib/pleroma/web/mastodon_api/views/status_view.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 80d53adff..cc807f603 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -28,7 +28,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # This is a naive way to do this, just spawning a process per activity # to fetch the preview. However it should be fine considering # pagination is restricted to 40 activities at a time + # Force disable Websockets streaming for backfill jobs, + # otherwise old posts can show up on timelines. defp fetch_rich_media_for_activities(activities, opts) do + opts = Map.put(opts, :stream, false) Enum.each(activities, fn activity -> Card.get_by_activity(activity, opts) end) @@ -362,8 +365,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" + # Force disable Websockets streaming for backfill jobs which the below call will create, + # otherwise old posts can show up on timelines. card = - case Card.get_by_activity(activity, opts) do + case Card.get_by_activity(activity, Map.put(opts, :stream, false)) do %Card{} = result -> render("card.json", result) _ -> nil end From ff7927e219f84ada9235353457f2c2497d336964 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Fri, 22 May 2026 14:53:28 +0200 Subject: [PATCH 12/20] changelog --- changelog.d/wss-necroposts.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/wss-necroposts.fix diff --git a/changelog.d/wss-necroposts.fix b/changelog.d/wss-necroposts.fix new file mode 100644 index 000000000..743f9087c --- /dev/null +++ b/changelog.d/wss-necroposts.fix @@ -0,0 +1 @@ +RichMedia: Fix backfill causing old posts to show up on timelines by disabling it in MastoAPI StatusView From 6ee40cb2ebe7a6666bedea3773710a3b555791ad Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 23 May 2026 19:43:44 +0200 Subject: [PATCH 13/20] RichMedia: Add StatusView backfill streaming tests --- test/pleroma/web/rich_media/backfill_test.exs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index f7e74c02a..a4b2d34fd 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card + alias Pleroma.Tests.ObanHelpers import Mox import Pleroma.Factory @@ -15,6 +16,7 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do setup do clear_config([:rich_media, :enabled], true) + Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) :ok @@ -99,4 +101,64 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => false}) end + + # NOTE: Below two MastoAPI tests cover almost the same code paths. + # index.json will always prefetch rich media, while show.json will try to get the card and + # fetch it when it isn't cached (both use Card.get_by_activity in the end). + # So if index.json doesn't fetch the rich media, show.json will when it renders the post, + # hence why index.json test will only call ActivityPub.stream_out twice, + # if streaming is re-enabled for in both. + test "does not stream out in MastoAPI StatusView index" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + # CommonAPI federation processing will stream out once as a new post + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, 1, fn _ -> :ok end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + ObanHelpers.perform_all() + + # Clear cache to force backfill below + Pleroma.Activity.HTML.invalidate_cache_for(activity.id) + Pleroma.Web.RichMedia.Card.delete(url) + + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{activities: [activity], as: :activity}) + ObanHelpers.perform_all() + end + + test "does not stream out in MastoAPI StatusView show" do + url = "https://example.com" + user = insert(:user) + + Tesla.Mock.mock(fn %{url: ^url} -> + {:ok, + %Tesla.Env{ + status: 200, + body: "" + }} + end) + + # CommonAPI federation processing will stream out once as a new post + Pleroma.Web.ActivityPub.ActivityPubMock + |> expect(:stream_out, 1, fn _ -> :ok end) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe #{url}"}) + ObanHelpers.perform_all() + + # Clear cache to force backfill below + Pleroma.Activity.HTML.invalidate_cache_for(activity.id) + Pleroma.Web.RichMedia.Card.delete(url) + + Pleroma.Web.MastodonAPI.StatusView.render("show.json", activity: activity) + ObanHelpers.perform_all() + end end From 0c0d6873306244ad98273a6ae661a4414cee875b Mon Sep 17 00:00:00 2001 From: Phantasm Date: Sat, 23 May 2026 19:49:22 +0200 Subject: [PATCH 14/20] credo, lint --- lib/pleroma/web/mastodon_api/views/status_view.ex | 1 + lib/pleroma/web/rich_media/backfill.ex | 3 ++- lib/pleroma/web/rich_media/card.ex | 11 +++++++++-- test/pleroma/web/rich_media/backfill_test.exs | 10 +++++++--- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index cc807f603..6949c5f2d 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -32,6 +32,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # otherwise old posts can show up on timelines. defp fetch_rich_media_for_activities(activities, opts) do opts = Map.put(opts, :stream, false) + Enum.each(activities, fn activity -> Card.get_by_activity(activity, opts) end) diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex index 607c5238a..a100b39ab 100644 --- a/lib/pleroma/web/rich_media/backfill.ex +++ b/lib/pleroma/web/rich_media/backfill.ex @@ -53,7 +53,8 @@ defmodule Pleroma.Web.RichMedia.Backfill do end end - defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) when is_binary(activity_id) do + defp maybe_update_stream(%{"activity_id" => activity_id, "stream" => true}) + when is_binary(activity_id) do Pleroma.Activity.get_by_id(activity_id) |> Pleroma.Activity.normalize() |> @stream_out_impl.stream_out() diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex index 86a4f577a..1e9e66ec1 100644 --- a/lib/pleroma/web/rich_media/card.ex +++ b/lib/pleroma/web/rich_media/card.ex @@ -90,12 +90,19 @@ defmodule Pleroma.Web.RichMedia.Card do nil -> activity_id = Keyword.get(opts, :activity_id, nil) - # Nested opts, first layer comes from get_by_activity/2 as Keyword, second from API views/Federation as Map. + + # Nested opts, first layer comes from get_by_activity/2 as Keyword, + # second from API views/Federation as Map. # Provide default Map when called directly. opts = Keyword.get(opts, :opts, %{}) stream = Map.get(opts, :stream, true) - RichMediaWorker.new(%{"op" => "backfill", "url" => url, "activity_id" => activity_id, "stream" => stream}) + RichMediaWorker.new(%{ + "op" => "backfill", + "url" => url, + "activity_id" => activity_id, + "stream" => stream + }) |> Oban.insert() nil diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index a4b2d34fd..a1949533a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -5,15 +5,15 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do use Pleroma.DataCase + alias Pleroma.Tests.ObanHelpers alias Pleroma.Web.CommonAPI alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card - alias Pleroma.Tests.ObanHelpers import Mox import Pleroma.Factory - setup do + setup do clear_config([:rich_media, :enabled], true) Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) @@ -131,7 +131,11 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do Pleroma.Activity.HTML.invalidate_cache_for(activity.id) Pleroma.Web.RichMedia.Card.delete(url) - Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{activities: [activity], as: :activity}) + Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ + activities: [activity], + as: :activity + }) + ObanHelpers.perform_all() end From 4c9fc6287e547d908ee0723996fc978653d4652d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 09:26:07 +0400 Subject: [PATCH 15/20] Remove unreachable WebSocket protocol match --- lib/pleroma/web/mastodon_api/websocket_plug.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/websocket_plug.ex b/lib/pleroma/web/mastodon_api/websocket_plug.ex index 58ee913b4..c5fb5b748 100644 --- a/lib/pleroma/web/mastodon_api/websocket_plug.ex +++ b/lib/pleroma/web/mastodon_api/websocket_plug.ex @@ -94,7 +94,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketPlug do [protocols | _] -> case Plug.Conn.Utils.list(protocols) do [protocol | _] -> put_resp_header(conn, "sec-websocket-protocol", protocol) - nil -> conn [] -> conn end From cdb0f103a8f7f141185e2bff6d686b047f368fa2 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 14:08:07 +0400 Subject: [PATCH 16/20] Tighten rich media backfill stream test --- test/pleroma/web/rich_media/backfill_test.exs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/rich_media/backfill_test.exs b/test/pleroma/web/rich_media/backfill_test.exs index a1949533a..071f9b48a 100644 --- a/test/pleroma/web/rich_media/backfill_test.exs +++ b/test/pleroma/web/rich_media/backfill_test.exs @@ -74,9 +74,12 @@ defmodule Pleroma.Web.RichMedia.BackfillTest do |> expect(:put, fn :rich_media_cache, _, _ -> {:ok, true} end) Pleroma.Web.ActivityPub.ActivityPubMock - |> expect(:stream_out, fn _ -> :ok end) + |> expect(:stream_out, fn %Pleroma.Activity{id: id} -> + assert id == activity.id + :ok + end) - Backfill.run(%{"url" => url, "activity_id" => "#{activity.data["id"]}", "stream" => true}) + Backfill.run(%{"url" => url, "activity_id" => activity.id, "stream" => true}) end test "does not stream out update when stream == false" do From 8a410bf49f1818ebf631020c55ba05012efcb8f7 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:04:53 +0400 Subject: [PATCH 17/20] Quote E2E entrypoint variables --- docker-entrypoint-e2e.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index c83c44a80..528a09619 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -5,12 +5,12 @@ SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs echo '-- Waiting for database...' -while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do +while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do sleep 1s done echo '-- Writing E2E config overrides...' -cat > $CONFIG_OVERRIDE_PATH < "$CONFIG_OVERRIDE_PATH" </dev/null; then - kill -TERM $PLEROMA_PID - wait $PLEROMA_PID || true + if kill -0 "$PLEROMA_PID" 2>/dev/null; then + kill -TERM "$PLEROMA_PID" + wait "$PLEROMA_PID" || true fi } @@ -48,23 +48,23 @@ for _i in $(seq 1 120); do sleep 1s done -if [ $api_ok != true ]; then +if [ "$api_ok" != true ]; then echo 'Timed out waiting for Pleroma API to become available' exit 1 fi -if [ ! -f $SEED_SENTINEL_PATH ]; then - if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then - echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...' - if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then +if [ ! -f "$SEED_SENTINEL_PATH" ]; then + if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then + echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' + if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then echo '-- User already exists or creation failed, ensuring admin + confirmed...' - /opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed + /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed fi else echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' fi - touch $SEED_SENTINEL_PATH + touch "$SEED_SENTINEL_PATH" fi -wait $PLEROMA_PID +wait "$PLEROMA_PID" From 54369c93d78d4431f10a44af9c8f28a416262f8e Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:05:21 +0400 Subject: [PATCH 18/20] Use E2E approval config key --- docker-entrypoint-e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index 528a09619..772ac7cec 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -19,7 +19,7 @@ config :pleroma, Pleroma.Captcha, config :pleroma, :instance, registrations_open: true, account_activation_required: false, - approval_required: false + account_approval_required: false EOF echo '-- Running migrations...' From e023ed7a5a450d909565cfd5451b4fb4ab7df4fe Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 25 May 2026 16:06:21 +0400 Subject: [PATCH 19/20] Seed E2E admin before API readiness --- docker-entrypoint-e2e.sh | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docker-entrypoint-e2e.sh b/docker-entrypoint-e2e.sh index 772ac7cec..d7aa332f7 100755 --- a/docker-entrypoint-e2e.sh +++ b/docker-entrypoint-e2e.sh @@ -25,6 +25,20 @@ EOF echo '-- Running migrations...' /opt/pleroma/bin/pleroma_ctl migrate +if [ ! -f "$SEED_SENTINEL_PATH" ]; then + if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then + echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' + if ! PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then + echo '-- User already exists or creation failed, ensuring admin + confirmed...' + PLEROMA_CTL_RPC_DISABLED=true /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed + fi + else + echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' + fi + + touch "$SEED_SENTINEL_PATH" +fi + echo '-- Starting!' /opt/pleroma/bin/pleroma start & PLEROMA_PID=$! @@ -53,18 +67,4 @@ if [ "$api_ok" != true ]; then exit 1 fi -if [ ! -f "$SEED_SENTINEL_PATH" ]; then - if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then - echo '-- Seeding admin user' "$E2E_ADMIN_USERNAME" '...' - if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then - echo '-- User already exists or creation failed, ensuring admin + confirmed...' - /opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed - fi - else - echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)' - fi - - touch "$SEED_SENTINEL_PATH" -fi - wait "$PLEROMA_PID" From 86dd9663fc3e1162d5747fc140774374dcc65896 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Mon, 1 Jun 2026 22:06:12 +0300 Subject: [PATCH 20/20] allow
(used by mfm) --- priv/scrubbers/default.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index cc5eba027..8e2d16650 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -46,6 +46,7 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_these_attributes(:b, ["lang"]) Meta.allow_tag_with_these_attributes(:blockquote, ["lang"]) Meta.allow_tag_with_these_attributes(:br, ["lang"]) + Meta.allow_tag_with_these_attributes(:center, []) Meta.allow_tag_with_these_attributes(:cite, ["lang"]) Meta.allow_tag_with_these_attributes(:code, ["lang"]) Meta.allow_tag_with_these_attributes(:del, ["lang"])