diff --git a/.gitignore b/.gitignore index 355cea069..d8e5ed553 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ pleroma.iml # asdf .tool-versions +# mise +mise.toml + # Editor temp files *~ *# diff --git a/.woodpecker/changelog.yaml b/.woodpecker/changelog.yaml index 4f38ce618..64062f17e 100644 --- a/.woodpecker/changelog.yaml +++ b/.woodpecker/changelog.yaml @@ -1,9 +1,19 @@ when: - event: pull_request +labels: + platform: linux/amd64 + +variables: + script_file_entrypoint: &script_file_entrypoint + - /bin/sh + - -c + - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh' + steps: check-changelog: image: docker.io/alpine:3.23 + entrypoint: *script_file_entrypoint commands: - apk add --no-cache git - sh ./tools/check-changelog diff --git a/.woodpecker/docker-combine.yaml b/.woodpecker/docker-combine.yaml new file mode 100644 index 000000000..6c91d26e9 --- /dev/null +++ b/.woodpecker/docker-combine.yaml @@ -0,0 +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] + 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 new file mode 100644 index 000000000..abc6bfa3b --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,96 @@ +when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**", "Dockerfile" ] + - event: tag + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: stable + +matrix: + platform: + - linux/amd64 + - linux/arm64 + +# This is needed for the when clauses below. +labels: + platform: ${platform} + memory: 'high' + +variables: + docker_variables: &docker_variables + repo: pleroma/pleroma + registry: git.pleroma.social + username: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + kaniko_image: &kaniko_image woodpeckerci/plugin-kaniko:2.3.1 + +steps: + docker-develop-amd64: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - evaluate: 'platform == "linux/amd64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + settings: + <<: *docker_variables + tags: + - latest-amd64 + - develop-amd64 + - ${CI_COMMIT_SHA:0:8}-amd64 + + docker-develop-arm64: + image: woodpeckerci/plugin-kaniko:2.3.1 + when: + - evaluate: 'platform == "linux/arm64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + settings: + <<: *docker_variables + tags: + - latest-arm64 + - develop-arm64 + - ${CI_COMMIT_SHA:0:8}-arm64 + + docker-stable-amd64: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' + settings: + <<: *docker_variables + tags: &amd64_tags + - latest-amd64 + - stable-amd64 + - ${CI_COMMIT_SHA:0:8}-amd64 + + docker-stable-tag-amd64: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""' + settings: + <<: *docker_variables + tags: + - <<: *amd64_tags + - ${CI_COMMIT_TAG}-amd64 + + docker-stable-arm64: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG == ""' + settings: + <<: *docker_variables + tags: &arm64_tags + - latest-arm64 + - stable-arm64 + - ${CI_COMMIT_SHA:0:8}-arm64 + + docker-stable-tag-arm64: + image: *kaniko_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable" && CI_COMMIT_TAG != ""' + settings: + <<: *docker_variables + tags: + - <<: *arm64_tags + - ${CI_COMMIT_TAG}-arm64 diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index b96d584ee..0ab7441a8 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -1,14 +1,24 @@ when: - event: pull_request - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] - event: push branch: ${CI_REPO_DEFAULT_BRANCH} - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + +labels: + platform: linux/amd64 + +variables: + script_file_entrypoint: &script_file_entrypoint + - /bin/sh + - -c + - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh' steps: mix-format: image: &elixir-image docker.io/elixir:1.15-alpine + entrypoint: *script_file_entrypoint failure: ignore commands: - | @@ -19,6 +29,7 @@ steps: credo: image: *elixir-image + entrypoint: *script_file_entrypoint failure: ignore environment: MIX_ENV: test @@ -55,6 +66,7 @@ steps: ensure-status: image: *elixir-image + entrypoint: *script_file_entrypoint commands: | if test -f fail.stamp; then echo "One or more previous steps fails. Failing workflow..." diff --git a/.woodpecker/otp-musl.yaml b/.woodpecker/otp-musl.yaml new file mode 100644 index 000000000..60558b893 --- /dev/null +++ b/.woodpecker/otp-musl.yaml @@ -0,0 +1,292 @@ +when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + - event: tag + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: stable + +matrix: + platform: + - linux/amd64 + - linux/arm + - linux/arm64 + +# This is needed for the when clauses below. +# When the platform clause is fixed, this might not be needed anymore +labels: + platform: ${platform} + +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' + build_cmds: &build_cmds + - apk add git build-base cmake file-dev openssl vips-dev zip + - echo "import Config" > config/prod.secret.exs + - mix local.hex --force + - mix local.rebar --force + - mix deps.get --only prod + - mkdir release + - export PLEROMA_BUILD_BRANCH=${CI_COMMIT_BRANCH} + - mix release --path release + build_image_amd64: &build_image_amd64 docker.io/hexpm/elixir-amd64:1.17.3-erlang-27.3.4.2-alpine-3.22.1 + build_image_arm: &build_image_arm docker.io/arm32v7/elixir:1.17.3-alpine + build_image_arm64: &build_image_arm64 docker.io/hexpm/elixir-arm64:1.17.3-erlang-27.3.4.2-alpine-3.22.1 + artifacts_uploader_image: &artifacts_uploader_image docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0 + artifacts_uploader_settings: &artifacts_uploader_settings + user: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + owner: 'pleroma' + env: &env + MIX_ENV: prod + VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS + +steps: + otp-develop-amd64-musl: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &amd64_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64-musl.zip release + + otp-stable-amd64-musl: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *amd64_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-amd64-musl: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-amd64-musl.zip release + + otp-develop-arm-musl: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &arm_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm-musl.zip release + + otp-stable-arm-musl: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *arm_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-arm-musl: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-arm-musl.zip release + + otp-develop-arm64-musl: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &arm64_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64-musl.zip release + + otp-stable-arm64-musl: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *arm64_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-arm64-musl: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-arm64-musl.zip release + + upload-artifacts-amd64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-amd64-musl + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64-musl + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + update: 'true' + + upload-latest-amd64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-amd64-musl + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-amd64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-amd64-musl + package_version: stable-${CI_COMMIT_SHA:0:8}-amd64-musl + file_source: ./stable-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-amd64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-amd64-musl + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-amd64-musl.zip + file_name: pleroma.zip + update: 'true' + + upload-artifacts-arm-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm-musl + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm-musl + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm-musl.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm-musl.zip + update: 'true' + + upload-latest-arm-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm-musl + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm-musl.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-arm-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm-musl + package_version: stable-${CI_COMMIT_SHA:0:8}-arm-musl + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm-musl.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-arm-musl.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-arm-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm-musl + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm-musl.zip + file_name: pleroma.zip + update: 'true' + + upload-artifacts-arm64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm64-musl + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64-musl + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + update: 'true' + + upload-latest-arm64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm64-musl + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-arm64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm64-musl + package_version: stable-${CI_COMMIT_SHA:0:8}-arm64-musl + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-arm64-musl: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm64-musl + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm64-musl.zip + file_name: pleroma.zip + update: 'true' diff --git a/.woodpecker/otp.yaml b/.woodpecker/otp.yaml new file mode 100644 index 000000000..9a33c228e --- /dev/null +++ b/.woodpecker/otp.yaml @@ -0,0 +1,293 @@ +when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] + - event: tag + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: stable + +matrix: + platform: + - linux/amd64 + - linux/arm + - linux/arm64 + +# This is needed for the when clauses below. +# When the platform clause is fixed, this might not be needed anymore +labels: + platform: ${platform} + +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' + build_cmds: &build_cmds + - apt-get update && apt-get install -y cmake libmagic-dev libvips-dev erlang-dev git build-essential zip + - echo "import Config" > config/prod.secret.exs + - mix local.hex --force + - mix local.rebar --force + - mix deps.get --only prod + - mkdir release + - export PLEROMA_BUILD_BRANCH=${CI_COMMIT_BRANCH} + - mix release --path release + build_image_amd64: &build_image_amd64 docker.io/hexpm/elixir-amd64:1.17.3-erlang-27.3.4.2-ubuntu-noble-20250716 + build_image_arm: &build_image_arm docker.io/arm32v7/elixir:1.17.3 + build_image_arm64: &build_image_arm64 docker.io/hexpm/elixir-arm64:1.17.3-erlang-27.3.4.2-ubuntu-noble-20250716 + artifacts_uploader_image: &artifacts_uploader_image docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0 + artifacts_uploader_settings: &artifacts_uploader_settings + user: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + owner: 'pleroma' + env: &env + MIX_ENV: prod + VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS + DEBIAN_FRONTEND: noninteractive + +steps: + otp-develop-amd64: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &amd64_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64.zip release + + otp-stable-amd64: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *amd64_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-amd64: + image: *build_image_amd64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-amd64.zip release + + otp-develop-arm: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &arm_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm.zip release + + otp-stable-arm: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *arm_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-arm: + image: *build_image_arm + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-arm.zip release + + otp-develop-arm64: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + environment: *env + commands: &arm64_build + - <<: *build_cmds + - zip -9rq ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64.zip release + + otp-stable-arm64: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual" && CI_COMMIT_BRANCH == "stable"' + environment: *env + commands: *arm64_build + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + otp-stable-tag-arm64: + image: *build_image_arm64 + entrypoint: *script_file_entrypoint + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + environment: *env + commands: + - <<: *build_cmds + - zip -9rq stable-${CI_COMMIT_SHA:0:8}-arm64.zip release + + upload-artifacts-amd64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-amd64 + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64 + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64.zip + update: 'true' + + upload-latest-amd64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-amd64 + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-amd64.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-amd64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-amd64 + package_version: stable-${CI_COMMIT_SHA:0:8}-amd64 + file_source: ./stable-${CI_COMMIT_SHA:0:8}-amd64.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-amd64.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-amd64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/amd64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-amd64 + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-amd64.zip + file_name: pleroma.zip + update: 'true' + + upload-artifacts-arm: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm.zip + update: 'true' + + upload-latest-arm: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-arm: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm + package_version: stable-${CI_COMMIT_SHA:0:8}-arm + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-arm.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-arm: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm.zip + file_name: pleroma.zip + update: 'true' + + upload-artifacts-arm64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm64 + package_version: ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64 + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64.zip + file_name: pleroma-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64.zip + update: 'true' + + upload-latest-arm64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "push" && CI_COMMIT_BRANCH == "${CI_REPO_DEFAULT_BRANCH}"' + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "manual"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-${CI_COMMIT_BRANCH}-arm64 + package_version: latest + file_source: ./${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:8}-arm64.zip + file_name: pleroma.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-artifacts-tag-arm64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm64 + package_version: stable-${CI_COMMIT_SHA:0:8}-arm64 + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm64.zip + file_name: pleroma-stable-${CI_COMMIT_SHA:0:8}-arm64.zip + update: 'true' + + # Tag events don't have CI_COMMIT_BRANCH set, hardcode stable + upload-latest-tag-arm64: + image: *artifacts_uploader_image + when: + - evaluate: 'platform == "linux/arm64" && CI_PIPELINE_EVENT == "tag"' + settings: + <<: *artifacts_uploader_settings + package_name: pleroma-otp-stable-arm64 + package_version: latest + file_source: ./stable-${CI_COMMIT_SHA:0:8}-arm64.zip + file_name: pleroma.zip + update: 'true' diff --git a/.woodpecker/unit-testing-elixir-1.15.yaml b/.woodpecker/unit-testing-elixir-1.15.yaml index 84046be41..a4a8fc266 100644 --- a/.woodpecker/unit-testing-elixir-1.15.yaml +++ b/.woodpecker/unit-testing-elixir-1.15.yaml @@ -1,16 +1,26 @@ when: - event: pull_request - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] - event: push branch: ${CI_REPO_DEFAULT_BRANCH} - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] depends_on: - lint +labels: + platform: linux/amd64 + +variables: + script_file_entrypoint: &script_file_entrypoint + - /bin/sh + - -c + - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh' + steps: unit-testing-elixir-1.15: image: elixir:1.15-alpine + entrypoint: *script_file_entrypoint environment: MIX_ENV: test DB_HOST: postgres diff --git a/.woodpecker/unit-testing-elixir-1.18.yaml b/.woodpecker/unit-testing-elixir-1.18.yaml index 8dcec62fb..9ad9eebc9 100644 --- a/.woodpecker/unit-testing-elixir-1.18.yaml +++ b/.woodpecker/unit-testing-elixir-1.18.yaml @@ -1,16 +1,26 @@ when: - event: pull_request - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + path: [ "**/*.ex", "**/*.eex", "**/*.exs", "mix.lock", ".woodpecker/**" ] - event: push branch: ${CI_REPO_DEFAULT_BRANCH} - path: [ "*.ex", "*.eex", "*.exs", "mix.lock", ".woodpecker/**" ] + 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 + entrypoint: *script_file_entrypoint environment: MIX_ENV: test DB_HOST: postgres diff --git a/CHANGELOG.md b/CHANGELOG.md index adc76c767..f03be3f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.10.2 + +### Security + +- ActivityPub: Fixed failed-signature inbox retry handling and signer identity checks to prevent spoofed remote activities from being processed + +## 2.10.1 + +### Changed + +- Move avatar_description and header_description fields to the account object +- Update Bandit to 1.10.4 +- No-op code correctness improvements detected by Elixir 1.19 compiler +- Downgrade Hackney to 1.20.1 +- Use a custom redirect handler to ensure MediaProxy redirects are followed with Hackney +- Update Hackney, the default HTTP client, to the latest release which supports Happy Eyeballs for improved IPv6 federation +- Paginate follow requests +- Moved Phoenix LiveDashboard to /pleroma/live_dashboard +- Add mute/block expiry to the relationship object +- Filter indexable activities before inserting indexing jobs into the queue. + +### Added + +- Allow assigning users to reports +- Allow fine-grained announce visibilities +- Add immutable tag on cache-control header for several endpoints that's serving the same exact things. +- Add reasonable defaults for :database_config_whitelist +- Support lists `exclusive` param +- Add v1/instance/domain_blocks endpoint +- Add /api/v2/instance profile fields limits info used by Mastodon +- Added Oban Web dashboard located at /pleroma/oban +- Add instructions on how to run a release in docker, to make it easier to run on older distros. + +### Fixed + +- Fix the daily email digest job which was not executing +- Encode custom emoji URLs in EmojiReact activity tags. +- Gopher: Fix Ranch listener not being stopped properly on Pleroma restart when database configuration is enabled +- Fix fetching Hubzilla Actors with alsoKnownAs as string +- Fix /phoenix/live_dashboard redirect not working when user added a path segment +- Fix 404 error codes for missing static files +- Fix OAuth app registration to accept `redirect_uris` as an array of strings (RFC 7591), while keeping backwards compatibility with string input. +- Correct old migrations for expiring activities and user access tokens. +- Federate `votersCount` correctly +- DB prune: Check if user follows hashtag with no objects before deletion +- Stop the rate limiter from crashing when run with wrong settings. +- Restore embeds route +- ReverseProxy: Recursively follow redirects until redirect_limit is reached +- Fix compilation with vips-8.18.0 with bumping to vix 0.36.0 + +### Removed + +- Docs: Removed outdated, incorrect, unmaintained and inappropriate installation documentation (Arch, NetBSD, NixOS) + ## 2.10 ### Security diff --git a/changelog.d/assign-users.add b/changelog.d/assign-users.add deleted file mode 100644 index f50ad94c6..000000000 --- a/changelog.d/assign-users.add +++ /dev/null @@ -1 +0,0 @@ -Allow assigning users to reports \ No newline at end of file diff --git a/changelog.d/avatar-description-mastodon-api.change b/changelog.d/avatar-description-mastodon-api.change deleted file mode 100644 index 6a454c01e..000000000 --- a/changelog.d/avatar-description-mastodon-api.change +++ /dev/null @@ -1 +0,0 @@ -Move avatar_description and header_description fields to the account object diff --git a/changelog.d/bandit.change b/changelog.d/bandit.change deleted file mode 100644 index 3831a02c2..000000000 --- a/changelog.d/bandit.change +++ /dev/null @@ -1 +0,0 @@ -Update Bandit to 1.10.4 diff --git a/changelog.d/bookmark-folders.ignore b/changelog.d/bookmark-folders.ignore deleted file mode 100644 index 8705ac00b..000000000 --- a/changelog.d/bookmark-folders.ignore +++ /dev/null @@ -1 +0,0 @@ -Various bookmark folders-related improvements diff --git a/changelog.d/boost-visibilities.add b/changelog.d/boost-visibilities.add deleted file mode 100644 index 317d9840d..000000000 --- a/changelog.d/boost-visibilities.add +++ /dev/null @@ -1 +0,0 @@ -Allow fine-grained announce visibilities diff --git a/changelog.d/cache-control-immutable.add b/changelog.d/cache-control-immutable.add deleted file mode 100644 index 516db67bf..000000000 --- a/changelog.d/cache-control-immutable.add +++ /dev/null @@ -1 +0,0 @@ -Add immutable tag on cache-control header for several endpoints that's serving the same exact things. \ No newline at end of file diff --git a/changelog.d/ci-artifacts.skip b/changelog.d/ci-artifacts.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/context-cleanup.skip b/changelog.d/context-cleanup.skip new file mode 100644 index 000000000..ae609602d --- /dev/null +++ b/changelog.d/context-cleanup.skip @@ -0,0 +1 @@ +litepub-0.1.jsonld cleanup diff --git a/changelog.d/credo-aliases-sort-fixes.skip b/changelog.d/credo-aliases-sort-fixes.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/database-config-whitelist.add b/changelog.d/database-config-whitelist.add deleted file mode 100644 index a78960c98..000000000 --- a/changelog.d/database-config-whitelist.add +++ /dev/null @@ -1 +0,0 @@ -Add reasonable defaults for :database_config_whitelist \ No newline at end of file diff --git a/changelog.d/elixir-1.19-cherrypicks.change b/changelog.d/elixir-1.19-cherrypicks.change deleted file mode 100644 index 7e56be008..000000000 --- a/changelog.d/elixir-1.19-cherrypicks.change +++ /dev/null @@ -1 +0,0 @@ -No-op code correctness improvements detected by Elixir 1.19 compiler diff --git a/changelog.d/email_digest.fix b/changelog.d/email_digest.fix deleted file mode 100644 index cd15874a2..000000000 --- a/changelog.d/email_digest.fix +++ /dev/null @@ -1 +0,0 @@ -Fix the daily email digest job which was not executing diff --git a/changelog.d/emoji-reaction-url-escape.fix b/changelog.d/emoji-reaction-url-escape.fix deleted file mode 100644 index c3a1c8823..000000000 --- a/changelog.d/emoji-reaction-url-escape.fix +++ /dev/null @@ -1 +0,0 @@ -Encode custom emoji URLs in EmojiReact activity tags. diff --git a/changelog.d/exclusive-lists.add b/changelog.d/exclusive-lists.add deleted file mode 100644 index bbd722f07..000000000 --- a/changelog.d/exclusive-lists.add +++ /dev/null @@ -1 +0,0 @@ -Support lists `exclusive` param diff --git a/changelog.d/gopher-genserver-crash-on-boot.fix b/changelog.d/gopher-genserver-crash-on-boot.fix deleted file mode 100644 index 3b51662be..000000000 --- a/changelog.d/gopher-genserver-crash-on-boot.fix +++ /dev/null @@ -1 +0,0 @@ -Gopher: Fix Ranch listener not being stopped properly on Pleroma restart when database configuration is enabled diff --git a/changelog.d/hackney-downgrade.change b/changelog.d/hackney-downgrade.change deleted file mode 100644 index a98710692..000000000 --- a/changelog.d/hackney-downgrade.change +++ /dev/null @@ -1 +0,0 @@ -Downgrade Hackney to 1.20.1 diff --git a/changelog.d/hackney-mediaproxy.change b/changelog.d/hackney-mediaproxy.change deleted file mode 100644 index 10dfb0775..000000000 --- a/changelog.d/hackney-mediaproxy.change +++ /dev/null @@ -1 +0,0 @@ -Use a custom redirect handler to ensure MediaProxy redirects are followed with Hackney diff --git a/changelog.d/hackney.change b/changelog.d/hackney.change deleted file mode 100644 index 3158cfc77..000000000 --- a/changelog.d/hackney.change +++ /dev/null @@ -1 +0,0 @@ -Update Hackney, the default HTTP client, to the latest release which supports Happy Eyeballs for improved IPv6 federation diff --git a/changelog.d/hubzilla-alsoknownas.fix b/changelog.d/hubzilla-alsoknownas.fix deleted file mode 100644 index 2a2969807..000000000 --- a/changelog.d/hubzilla-alsoknownas.fix +++ /dev/null @@ -1 +0,0 @@ -Fix fetching Hubzilla Actors with alsoKnownAs as string diff --git a/changelog.d/inappropriate-docs.remove b/changelog.d/inappropriate-docs.remove deleted file mode 100644 index 699c9186a..000000000 --- a/changelog.d/inappropriate-docs.remove +++ /dev/null @@ -1 +0,0 @@ -Docs: Removed outdated, incorrect, unmaintained and inappropriate installation documentation (Arch, NetBSD, NixOS) diff --git a/changelog.d/instance-domain-blocks.add b/changelog.d/instance-domain-blocks.add deleted file mode 100644 index 85f01c5c2..000000000 --- a/changelog.d/instance-domain-blocks.add +++ /dev/null @@ -1 +0,0 @@ -Add v1/instance/domain_blocks endpoint diff --git a/changelog.d/instance-profile-fields.add b/changelog.d/instance-profile-fields.add deleted file mode 100644 index 712bd68d9..000000000 --- a/changelog.d/instance-profile-fields.add +++ /dev/null @@ -1 +0,0 @@ -Add /api/v2/instance profile fields limits info used by Mastodon diff --git a/changelog.d/lint-warnings.skip b/changelog.d/lint-warnings.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/live-dashboard-redirect.fix b/changelog.d/live-dashboard-redirect.fix deleted file mode 100644 index 10588d89e..000000000 --- a/changelog.d/live-dashboard-redirect.fix +++ /dev/null @@ -1 +0,0 @@ -Fix /phoenix/live_dashboard redirect not working when user added a path segment diff --git a/changelog.d/map-side-effects.skip b/changelog.d/map-side-effects.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/mfm-backend.add b/changelog.d/mfm-backend.add new file mode 100644 index 000000000..f815c6828 --- /dev/null +++ b/changelog.d/mfm-backend.add @@ -0,0 +1 @@ +Add backend support for Misskey Markdown (MFM) posts diff --git a/changelog.d/missing-static-file.fix b/changelog.d/missing-static-file.fix deleted file mode 100644 index c7ef805aa..000000000 --- a/changelog.d/missing-static-file.fix +++ /dev/null @@ -1 +0,0 @@ -Fix 404 error codes for missing static files diff --git a/changelog.d/mix-exs-fix.skip b/changelog.d/mix-exs-fix.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/mix-exs-update.skip b/changelog.d/mix-exs-update.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/oauth-registration-redirect_uris.fix b/changelog.d/oauth-registration-redirect_uris.fix deleted file mode 100644 index 76ace55df..000000000 --- a/changelog.d/oauth-registration-redirect_uris.fix +++ /dev/null @@ -1 +0,0 @@ -Fix OAuth app registration to accept `redirect_uris` as an array of strings (RFC 7591), while keeping backwards compatibility with string input. diff --git a/changelog.d/oban-web.add b/changelog.d/oban-web.add deleted file mode 100644 index c59e2ebca..000000000 --- a/changelog.d/oban-web.add +++ /dev/null @@ -1 +0,0 @@ -Added Oban Web dashboard located at /pleroma/oban diff --git a/changelog.d/old-migrations.fix b/changelog.d/old-migrations.fix deleted file mode 100644 index 49566c896..000000000 --- a/changelog.d/old-migrations.fix +++ /dev/null @@ -1 +0,0 @@ -Correct old migrations for expiring activities and user access tokens. diff --git a/changelog.d/paginate-follow-requests.change b/changelog.d/paginate-follow-requests.change deleted file mode 100644 index 1a88995b7..000000000 --- a/changelog.d/paginate-follow-requests.change +++ /dev/null @@ -1 +0,0 @@ -Paginate follow requests diff --git a/changelog.d/phoenix-livedashboard-move.change b/changelog.d/phoenix-livedashboard-move.change deleted file mode 100644 index 116b1523a..000000000 --- a/changelog.d/phoenix-livedashboard-move.change +++ /dev/null @@ -1 +0,0 @@ -Moved Phoenix LiveDashboard to /pleroma/live_dashboard diff --git a/changelog.d/pleroma-fe-link.fix b/changelog.d/pleroma-fe-link.fix new file mode 100644 index 000000000..de93f86dd --- /dev/null +++ b/changelog.d/pleroma-fe-link.fix @@ -0,0 +1 @@ +Updated Pleroma-FE build URL after Forgejo migration diff --git a/changelog.d/plug-test-typo.skip b/changelog.d/plug-test-typo.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/poll-voters-count-inflation.fix b/changelog.d/poll-voters-count-inflation.fix new file mode 100644 index 000000000..7eae41f13 --- /dev/null +++ b/changelog.d/poll-voters-count-inflation.fix @@ -0,0 +1 @@ +Fix votersCount inflation when same voter picks multiple options diff --git a/changelog.d/poll-voters-count.fix b/changelog.d/poll-voters-count.fix deleted file mode 100644 index 2dbc81b5d..000000000 --- a/changelog.d/poll-voters-count.fix +++ /dev/null @@ -1 +0,0 @@ -Federate `votersCount` correctly diff --git a/changelog.d/prune-hashtag-follow-3376.fix b/changelog.d/prune-hashtag-follow-3376.fix deleted file mode 100644 index cdb4e9a79..000000000 --- a/changelog.d/prune-hashtag-follow-3376.fix +++ /dev/null @@ -1 +0,0 @@ -DB prune: Check if user follows hashtag with no objects before deletion diff --git a/changelog.d/rate-limiter-hardening.fix b/changelog.d/rate-limiter-hardening.fix deleted file mode 100644 index a3af8fcc4..000000000 --- a/changelog.d/rate-limiter-hardening.fix +++ /dev/null @@ -1 +0,0 @@ -Stop the rate limiter from crashing when run with wrong settings. diff --git a/changelog.d/reduce-flaky-tests.skip b/changelog.d/reduce-flaky-tests.skip deleted file mode 100644 index 0375762c0..000000000 --- a/changelog.d/reduce-flaky-tests.skip +++ /dev/null @@ -1 +0,0 @@ -Reduce the number of flaky tests by making them sync if they affect the global state, and silence noisy test output. diff --git a/changelog.d/relationship-expires-at.change b/changelog.d/relationship-expires-at.change deleted file mode 100644 index 286dba197..000000000 --- a/changelog.d/relationship-expires-at.change +++ /dev/null @@ -1 +0,0 @@ -Add mute/block expiry to the relationship object diff --git a/changelog.d/release-to-docker.add b/changelog.d/release-to-docker.add deleted file mode 100644 index 5fbf611a5..000000000 --- a/changelog.d/release-to-docker.add +++ /dev/null @@ -1 +0,0 @@ -Add instructions on how to run a release in docker, to make it easier to run on older distros. diff --git a/changelog.d/restore-embeds.fix b/changelog.d/restore-embeds.fix deleted file mode 100644 index 5a2a1c4fe..000000000 --- a/changelog.d/restore-embeds.fix +++ /dev/null @@ -1 +0,0 @@ -Restore embeds route diff --git a/changelog.d/reverseproxy-recursive-redirect.fix b/changelog.d/reverseproxy-recursive-redirect.fix deleted file mode 100644 index 744109fd6..000000000 --- a/changelog.d/reverseproxy-recursive-redirect.fix +++ /dev/null @@ -1 +0,0 @@ -ReverseProxy: Recursively follow redirects until redirect_limit is reached diff --git a/changelog.d/search-indexing.change b/changelog.d/search-indexing.change deleted file mode 100644 index 766934f3f..000000000 --- a/changelog.d/search-indexing.change +++ /dev/null @@ -1 +0,0 @@ -Filter indexable activities before inserting indexing jobs into the queue. diff --git a/changelog.d/search-indexing.skip b/changelog.d/search-indexing.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/twitter-api.skip b/changelog.d/twitter-api.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/update-comment.ignore b/changelog.d/update-comment.ignore deleted file mode 100644 index 733e813b3..000000000 --- a/changelog.d/update-comment.ignore +++ /dev/null @@ -1 +0,0 @@ -Update comment for prepare_object, rename prepare_outgoing diff --git a/changelog.d/user-view.ignore b/changelog.d/user-view.ignore deleted file mode 100644 index 37e9a7e09..000000000 --- a/changelog.d/user-view.ignore +++ /dev/null @@ -1 +0,0 @@ -Avoid code duplication in UserView diff --git a/changelog.d/vix-0.36.0.fix b/changelog.d/vix-0.36.0.fix deleted file mode 100644 index 43a8dd8f8..000000000 --- a/changelog.d/vix-0.36.0.fix +++ /dev/null @@ -1 +0,0 @@ -Fix compilation with vips-8.18.0 with bumping to vix 0.36.0 diff --git a/changelog.d/woodpecker-pr-pipeline.skip b/changelog.d/woodpecker-pr-pipeline.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/config/config.exs b/config/config.exs index 5bf2c5c2e..2d38e3ebe 100644 --- a/config/config.exs +++ b/config/config.exs @@ -203,7 +203,8 @@ config :pleroma, :instance, "text/plain", "text/html", "text/markdown", - "text/bbcode" + "text/bbcode", + "text/x.misskeymarkdown" ], autofollowed_nicknames: [], autofollowing_nicknames: [], @@ -775,7 +776,7 @@ config :pleroma, :frontends, "name" => "pleroma-fe", "git" => "https://git.pleroma.social/pleroma/pleroma-fe", "build_url" => - "https://git.pleroma.social/pleroma/pleroma-fe/-/jobs/artifacts/${ref}/download?job=build", + "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-fe-builds/${ref}/latest.zip", "ref" => "develop" }, "fedi-fe" => %{ diff --git a/config/description.exs b/config/description.exs index c388d17c3..6e4348907 100644 --- a/config/description.exs +++ b/config/description.exs @@ -815,7 +815,8 @@ config :pleroma, :config_description, [ "text/plain", "text/html", "text/markdown", - "text/bbcode" + "text/bbcode", + "text/x.misskeymarkdown" ] }, %{ @@ -1394,7 +1395,13 @@ config :pleroma, :config_description, [ label: "Post Content Type", type: {:dropdown, :atom}, description: "Default post formatting option", - suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"] + suggestions: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ] }, %{ key: :redirectRootNoLogin, diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 11d5af2fb..4bf2f6b95 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -127,6 +127,13 @@ defmodule Pleroma.Formatter do Earmark.as_html!(text, %Earmark.Options{compact_output: true, smartypants: false}) end + def markdown_to_html(text, opts) do + Earmark.as_html!( + text, + %Earmark.Options{compact_output: true, smartypants: false} |> Map.merge(opts) + ) + end + def html_escape({text, mentions, hashtags}, type) do {html_escape(text, type), mentions, hashtags} end @@ -135,6 +142,10 @@ defmodule Pleroma.Formatter do HTML.filter_tags(text) end + def html_escape(text, "text/x.misskeymarkdown") do + HTML.filter_tags(text) + end + def html_escape(text, "text/plain") do Regex.split(@link_regex, text, include_captures: true) |> Enum.map_every(2, fn chunk -> diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index e651d7d9d..e37a3fefe 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -75,8 +75,8 @@ defmodule Pleroma.Frontend do end defp download_build(frontend_info, dest) do - Logger.info("Downloading pre-built bundle for #{frontend_info["name"]}") url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) + Logger.info("Downloading pre-built bundle for #{frontend_info["name"]} from #{url}") with {:ok, %{status: 200, body: zip_body}} <- Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index f1e07a257..5e9314446 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -372,13 +372,21 @@ defmodule Pleroma.Object do option end) - voters = [actor | object.data["voters"] || []] |> Enum.uniq() + existing_voters = object.data["voters"] || [] + voters = [actor | existing_voters] |> Enum.uniq() + new_voter? = actor not in existing_voters + existing_voters_count = object.data["votersCount"] voters_count = - if Map.has_key?(object.data, "votersCount") do - object.data["votersCount"] + 1 - else - length(voters) + cond do + is_integer(existing_voters_count) and new_voter? -> + existing_voters_count + 1 + + is_integer(existing_voters_count) -> + existing_voters_count + + true -> + length(voters) end data = diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex index af2d35c8f..49400940f 100644 --- a/lib/pleroma/release_tasks.ex +++ b/lib/pleroma/release_tasks.ex @@ -5,7 +5,10 @@ defmodule Pleroma.ReleaseTasks do @repo Pleroma.Repo - def run(args) do + # TODO: Kept for some backwards compatibility with buggy pleroma_ctl, + # if a mismatch between pleroma_ctl and Pleroma accidentaly happens. + # Remove in the future. + def run(args) when is_binary(args) do [task | args] = String.split(args) case task do @@ -16,6 +19,20 @@ defmodule Pleroma.ReleaseTasks do end end + # HACK: Script arguments need to be received as a list, otherwise (quoted) arguments with + # whitespace will be broken. Previously the broken string form above was used, + # escaping in the shell does not help. + def run(args) when is_list(args) do + [task | args] = args + + case task do + "migrate" -> migrate(args) + "create" -> create() + "rollback" -> rollback(args) + task -> mix_task(task, args) + end + end + def find_module(task) do module_name = task diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 071d634db..0b513ee16 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1677,44 +1677,80 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do show_birthday = !!birthday - # if WebFinger request was already done, we probably have acct, otherwise - # we request WebFinger here - nickname = additional[:nickname_from_acct] || generate_nickname(data) + 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 - %{ - 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 - } + 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 = "#{username}@#{URI.parse(data["id"]).host}" + generated = generated_nickname(data) if Config.get([WebFinger, :update_nickname_on_user_fetch]) do - case WebFinger.finger(generated) do - {:ok, %{"subject" => "acct:" <> acct}} -> acct + case webfinger_nickname(data) do + acct when is_binary(acct) -> acct _ -> generated end else @@ -1794,9 +1830,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp collection_private(_data), do: {:ok, true} def user_data_from_user_object(data, additional \\ []) do - with {:ok, data} <- MRF.filter(data) do - {:ok, object_to_user_data(data, additional)} + 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 diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 4f1613a07..2bfff6968 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -348,7 +348,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do - Federator.incoming_ap_doc(%{ + Federator.incoming_failed_signature_ap_doc(%{ method: conn.method, req_headers: conn.req_headers, request_path: conn.request_path, diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex index c0626ce4d..844ad2c8f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -6,9 +6,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.HTML + alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI.Utils import Ecto.Changeset @@ -26,6 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do end field(:replies, {:array, ObjectValidators.ObjectID}, default: []) + field(:source, :map) end def cast_and_apply(data) do @@ -80,6 +84,113 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do def fix_attachments(data), do: data + defp remote_mention_resolver( + %{"id" => ap_id, "tag" => tags}, + "@" <> nickname = mention, + buffer, + opts, + acc + ) + when is_binary(ap_id) and is_list(tags) do + initial_host = + ap_id + |> URI.parse() + |> Map.get(:host) + + with mention_tag when not is_nil(mention_tag) <- + Enum.find(tags, &mention_tag?(&1, mention, initial_host)), + href when is_binary(href) <- mention_tag["href"], + %User{} = user <- User.get_cached_by_ap_id(href) do + link = Pleroma.Formatter.mention_from_user(user, opts) + {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, user})}} + else + _ -> {buffer, acc} + end + end + + defp remote_mention_resolver(_object, _mention, buffer, _opts, acc), do: {buffer, acc} + + defp mention_tag?(%{"type" => "Mention", "name" => name}, mention, initial_host) + when is_binary(name) do + name == mention || mention == "#{name}@#{initial_host}" + end + + defp mention_tag?(_tag, _mention, _initial_host), do: false + + defp scrub_content(%{"content" => content} = object) when is_binary(content) do + Map.put(object, "content", HTML.filter_tags(content)) + end + + defp scrub_content(object), do: object + + defp mfm_parse_limit do + min(Pleroma.Config.get([:instance, :limit]), Pleroma.Config.get([:instance, :remote_limit])) + end + + defp normalize_source(%{"source" => source} = object) when is_binary(source) do + object + |> Map.put("source", %{"content" => source}) + |> normalize_source() + end + + defp normalize_source(%{"source" => source} = object) when is_map(source) do + source = + case source["content"] do + content when is_binary(content) -> + if String.length(content) <= mfm_parse_limit() do + source + else + Map.delete(source, "content") + end + + nil -> + source + + _ -> + Map.delete(source, "content") + end + + Map.put(object, "source", source) + end + + defp normalize_source(object), do: object + + defp fix_misskey_content(%{"htmlMfm" => true, "content" => content} = object) + when is_binary(content) do + Map.put(object, "content", HTML.filter_tags(content)) + end + + defp fix_misskey_content(%{"htmlMfm" => true} = object), do: object + + defp fix_misskey_content( + %{"source" => %{"mediaType" => "text/x.misskeymarkdown", "content" => content}} = object + ) + when is_binary(content) do + mention_handler = fn nick, buffer, opts, acc -> + remote_mention_resolver(object, nick, buffer, opts, acc) + end + + {linked, _mentions, _tags} = + Utils.format_input(content, "text/x.misskeymarkdown", mention_handler: mention_handler) + + Map.put(object, "content", linked) + end + + defp fix_misskey_content(%{"source" => %{"mediaType" => "text/x.misskeymarkdown"}} = object), + do: scrub_content(object) + + defp fix_misskey_content(%{"_misskey_content" => content} = object) when is_binary(content) do + object + |> Map.put("source", %{ + "content" => content, + "mediaType" => "text/x.misskeymarkdown" + }) + |> Map.delete("_misskey_content") + |> fix_misskey_content() + end + + defp fix_misskey_content(object), do: object + defp fix(data) do data |> CommonFixes.fix_actor() @@ -88,6 +199,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do |> fix_tag() |> fix_replies() |> fix_attachments() + |> normalize_source() + |> fix_misskey_content() |> CommonFixes.fix_quote_url() |> CommonFixes.fix_likes() |> Transmogrifier.fix_emoji() diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex index 22cf0cc05..9b8580200 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -32,6 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do quote bind_quoted: binding() do field(:content, :string) field(:contentMap, ObjectValidators.ContentLanguageMap) + field(:htmlMfm, :boolean) field(:published, ObjectValidators.DateTime) field(:updated, ObjectValidators.DateTime) diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex index aab90235f..4c0d9dff7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -75,15 +75,40 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do end end - # For remote Updates, verify the host is the same. + # For remote Updates, verify the Actor is the same def validate_updating_rights_remote(cng) do with actor = get_field(cng, :actor), object = get_field(cng, :object), {:ok, object_id} <- ObjectValidators.ObjectID.cast(object), - actor_uri <- URI.parse(actor), - object_uri <- URI.parse(object_id), - true <- actor_uri.host == object_uri.host do - cng + entity <- + Object.normalize(object_id, fetch: false) || User.get_cached_by_ap_id(object_id) do + case entity do + # Actor must own Object to update it + %Object{} -> + if actor == entity.data["actor"] do + cng + else + cng + |> add_error(:object, "Can't be updated by this actor") + end + + # Actor must only be allowed to update itself + %User{} -> + if actor == entity.ap_id do + cng + else + cng + |> add_error(:object, "Can't be updated by this actor") + end + + nil -> + cng + |> add_error(:object, "Can't be updated by this actor") + + _ -> + cng + |> add_error(:object, "Update is neither for Object or Actor") + end else _e -> cng diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 43c0f456d..0af4ceaf5 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -120,7 +120,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do "https://www.w3.org/ns/activitystreams", "#{Endpoint.url()}/schemas/litepub-0.1.jsonld", %{ - "@language" => get_language(data) + "@language" => get_language(data), + "htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm" } ] } diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 16489663a..6072fff6b 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -317,6 +317,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do emoji = Map.merge(emoji, summary_emoji) + media_type = Utils.get_content_type(draft.params[:content_type]) {:ok, note_data, _meta} = Builder.note(draft) object = @@ -324,14 +325,18 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do |> Map.put("emoji", emoji) |> Map.put("source", %{ "content" => draft.status, - "mediaType" => Utils.get_content_type(draft.params[:content_type]) + "mediaType" => media_type }) + |> maybe_put("htmlMfm", true, media_type == "text/x.misskeymarkdown") |> Map.put("generator", draft.params[:generator]) |> Map.put("language", draft.language) %{draft | object: object} end + defp maybe_put(map, key, value, true), do: Map.put(map, key, value) + defp maybe_put(map, _key, _value, _condition), do: map + defp preview?(%__MODULE__{} = draft) do preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview]) %{draft | preview?: preview?} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 32572a721..26034d685 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -322,6 +322,14 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Formatter.linkify(options) end + def format_input(text, "text/x.misskeymarkdown", options) do + text + |> Formatter.markdown_to_html(%{breaks: true}) + |> safe_mfm_to_html() + |> Formatter.linkify(options) + |> Formatter.html_escape("text/x.misskeymarkdown") + end + def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) @@ -330,6 +338,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do |> Formatter.html_escape("text/html") end + defp safe_mfm_to_html(html) do + html + |> MfmParser.Parser.parse() + |> MfmParser.Encoder.to_html() + rescue + _ -> html + catch + _, _ -> html + end + def format_naive_asctime(date) do date |> DateTime.from_naive!("Etc/UTC") |> format_asctime end diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 676fc5137..90cd2e54a 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.Federator do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Workers.PublisherWorker alias Pleroma.Workers.ReceiverWorker + alias Pleroma.Workers.SignatureRetryWorker require Logger @@ -35,12 +36,21 @@ defmodule Pleroma.Web.Federator do end # Client API - def incoming_ap_doc(%{params: params, req_headers: req_headers}) do - ReceiverWorker.new( + 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_ap_doc", + "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 diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 3b4271227..f047804e2 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -71,12 +71,12 @@ defmodule Pleroma.Web.MastodonAPI.PollView do end) end + defp voters_count(%{data: %{"votersCount" => voters}}) when is_integer(voters), do: voters + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do length(voters) end - defp voters_count(%{data: %{"votersCount" => voters}}), do: voters - defp voters_count(_), do: 0 defp voted_and_own_votes(%{object: object} = params, options) do diff --git a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex index c6d531086..a688b1780 100644 --- a/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/web/plugs/mapped_signature_to_identity_plug.ex @@ -32,8 +32,8 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do # remove me once testsuite uses mapped capabilities instead of what we do now {:user, nil} -> Logger.debug("Failed to map identity from signature (lookup failure)") - Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}") - conn + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}") + assign(conn, :valid_signature, false) end end diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index e2c950967..3afbe138d 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -4,40 +4,37 @@ defmodule Pleroma.Workers.ReceiverWorker do alias Pleroma.Instances - alias Pleroma.Signature - alias Pleroma.User alias Pleroma.Web.Federator + alias Pleroma.Workers.SignatureRetryWorker use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity] @impl true + def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params} = args} = job) do + if signature_retry_job?(args) do + perform_signature_retry(job) + else + perform_incoming(params) + end + end - def perform(%Job{ - args: %{ - "op" => "incoming_ap_doc", - "method" => method, - "params" => params, - "req_headers" => req_headers, - "request_path" => request_path, - "query_string" => query_string - } - }) do - # Oban's serialization converts our tuple headers to lists. - # Revert it for the signature validation. - req_headers = Enum.into(req_headers, [], &List.to_tuple(&1)) + def perform(%Job{args: %{"op" => "incoming_ap_doc"} = args} = job) do + if signature_retry_job?(args) do + perform_signature_retry(job) + else + process_errors(:missing_incoming_ap_doc_params) + end + end - conn_data = %Plug.Conn{ - method: method, - params: params, - req_headers: req_headers, - request_path: request_path, - query_string: query_string - } + defp perform_signature_retry(%Job{args: args} = job) do + SignatureRetryWorker.perform(%Job{ + job + | args: Map.put(args, "op", "incoming_failed_signature_ap_doc") + }) + end - with {:ok, %User{}} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), - {:ok, _public_key} <- Signature.refetch_public_key(conn_data), - {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, - {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do + defp perform_incoming(params) do + with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do unless Instances.reachable?(params["actor"]) do domain = URI.parse(params["actor"]).host Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain})) @@ -49,17 +46,8 @@ defmodule Pleroma.Workers.ReceiverWorker do end end - def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params}}) do - with {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do - unless Instances.reachable?(params["actor"]) do - domain = URI.parse(params["actor"]).host - Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain})) - end - - {:ok, res} - else - e -> process_errors(e) - end + defp signature_retry_job?(args) do + Enum.any?(~w(method req_headers request_path query_string), &Map.has_key?(args, &1)) end @impl true @@ -85,10 +73,12 @@ defmodule Pleroma.Workers.ReceiverWorker do {:error, {:reject, _} = reason} -> {:cancel, reason} # HTTP Sigs {:signature, false} -> {:cancel, :invalid_signature} + {:same_actor, false} -> {:cancel, :actor_signature_mismatch} # Origin / URL validation failed somewhere possibly due to spoofing {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} # Unclear if this can be reached {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} + :missing_incoming_ap_doc_params -> {:cancel, :missing_incoming_ap_doc_params} # Catchall {:error, _} = e -> e e -> {:error, e} diff --git a/lib/pleroma/workers/signature_retry_worker.ex b/lib/pleroma/workers/signature_retry_worker.ex new file mode 100644 index 000000000..2c4c097dd --- /dev/null +++ b/lib/pleroma/workers/signature_retry_worker.ex @@ -0,0 +1,254 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.SignatureRetryWorker do + alias Pleroma.Instances + alias Pleroma.Signature + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Federator + alias Pleroma.Web.Plugs.MappedSignatureToIdentityPlug + + require Logger + + use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity] + + @impl true + def perform(%Job{ + args: %{ + "op" => "incoming_failed_signature_ap_doc", + "method" => method, + "params" => params, + "req_headers" => req_headers, + "request_path" => request_path, + "query_string" => query_string + } + }) + when is_binary(method) and is_map(params) and is_list(req_headers) and + is_binary(request_path) and is_binary(query_string) do + case normalize_req_headers(req_headers) do + {:ok, req_headers} -> + conn_data = %Plug.Conn{ + assigns: %{valid_signature: true}, + method: method, + params: params, + req_headers: req_headers, + request_path: request_path, + query_string: query_string + } + + signature_actor_result = signature_actor_id(conn_data) + + with actor_id = Utils.get_ap_id(params["actor"]), + {:signature_actor, {:ok, signature_actor_id}} <- + {:signature_actor, signature_actor_result}, + {:same_actor, true} <- {:same_actor, signature_actor_id == actor_id}, + {:ok, %User{}} <- User.get_or_fetch_by_ap_id(actor_id), + {:ok, _public_key} <- Signature.refetch_public_key(conn_data), + {:signature, true} <- {:signature, validate_signature(conn_data)}, + {:same_actor, true} <- {:same_actor, validate_same_actor(conn_data)}, + {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do + unless Instances.reachable?(params["actor"]) do + domain = URI.parse(params["actor"]).host + Oban.insert(Pleroma.Workers.ReachabilityWorker.new(%{"domain" => domain})) + end + + {:ok, res} + else + e -> process_errors(e, retry_log_context(params, request_path, signature_actor_result)) + end + + e -> + process_errors(e, retry_log_context(params, request_path, nil)) + end + end + + def perform(%Job{args: %{"op" => "incoming_failed_signature_ap_doc"} = args}) do + process_errors( + :missing_signature_retry_metadata, + retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil) + ) + end + + def perform(%Job{args: args}) when is_map(args) do + process_errors( + :missing_signature_retry_metadata, + retry_log_context(Map.get(args, "params"), Map.get(args, "request_path"), nil) + ) + end + + def perform(%Job{}), do: process_errors(:missing_signature_retry_metadata) + + @impl true + def timeout(%_{args: %{"timeout" => timeout}}), do: timeout + + def timeout(_job), do: :timer.seconds(5) + + defp normalize_req_headers(req_headers) do + req_headers + |> Enum.reduce_while({:ok, []}, fn + {key, value}, {:ok, acc} when is_binary(key) and is_binary(value) -> + {:cont, {:ok, [{key, value} | acc]}} + + [key, value], {:ok, acc} when is_binary(key) and is_binary(value) -> + {:cont, {:ok, [{key, value} | acc]}} + + _, _ -> + {:halt, {:error, :invalid_signature_retry_metadata}} + end) + |> case do + {:ok, headers} -> {:ok, Enum.reverse(headers)} + error -> error + end + end + + defp validate_same_actor(conn_data) do + case MappedSignatureToIdentityPlug.call(conn_data, []) do + %Plug.Conn{assigns: %{valid_signature: true}} -> + true + + _ -> + false + end + end + + defp validate_signature(conn_data) do + Signature.validate_signature(conn_data) + rescue + _ -> false + catch + _, _ -> false + end + + defp signature_actor_id(conn_data) do + Signature.get_actor_id(conn_data) + rescue + _ -> {:error, :invalid_signature} + catch + _, _ -> {:error, :invalid_signature} + end + + defp process_errors(errors, context \\ %{}) + + defp process_errors({:error, {:error, _} = error}, context), do: process_errors(error, context) + + defp process_errors(errors, context) do + result = + case errors do + # User fetch failures + {:error, :not_found} = reason -> + {:cancel, reason} + + {:error, :forbidden} = reason -> + {:cancel, reason} + + # Inactive user + {:error, {:user_active, false} = reason} -> + {:cancel, reason} + + # Validator will error and return a changeset error + # e.g., duplicate activities or if the object was deleted + {:error, {:validate, {:error, _changeset} = reason}} -> + {:cancel, reason} + + # Duplicate detection during Normalization + {:error, :already_present} -> + {:cancel, :already_present} + + # MRFs will return a reject + {:error, {:reject, _} = reason} -> + {:cancel, reason} + + # HTTP Sigs + {:signature_actor, {:error, _}} -> + {:cancel, :invalid_signature} + + {:signature, false} -> + {:cancel, :invalid_signature} + + {:same_actor, false} -> + {:cancel, :actor_signature_mismatch} + + # Origin / URL validation failed somewhere possibly due to spoofing + {:error, :origin_containment_failed} -> + {:cancel, :origin_containment_failed} + + # Unclear if this can be reached + {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> + {:cancel, reason} + + # Fail closed if the retry cannot reconstruct the original request. + :missing_signature_retry_metadata -> + {:cancel, :missing_signature_retry_metadata} + + {:error, :invalid_signature_retry_metadata} -> + {:cancel, :invalid_signature_retry_metadata} + + # Catchall + {:error, _} = e -> + e + + e -> + {:error, e} + end + + log_signature_retry_rejection(result, context) + result + end + + defp retry_log_context(params, request_path, signature_actor_result) when is_map(params) do + signature_actor = + case signature_actor_result do + {:ok, actor} when is_binary(actor) -> actor + actor when is_binary(actor) -> actor + _ -> nil + end + + %{ + activity_id: params["id"], + payload_actor: Utils.get_ap_id(params["actor"]), + request_path: request_path, + signature_actor: signature_actor, + type: params["type"] + } + end + + defp retry_log_context(_params, request_path, signature_actor_result) do + signature_actor = + case signature_actor_result do + {:ok, actor} when is_binary(actor) -> actor + actor when is_binary(actor) -> actor + _ -> nil + end + + %{ + activity_id: nil, + payload_actor: nil, + request_path: request_path, + signature_actor: signature_actor, + type: nil + } + end + + defp log_signature_retry_rejection({:cancel, reason}, context) + when reason in [ + :actor_signature_mismatch, + :invalid_signature, + :invalid_signature_retry_metadata, + :missing_signature_retry_metadata, + :origin_containment_failed + ] do + Logger.warning( + "Failed-signature inbox retry rejected " <> + "reason=#{inspect(reason)} " <> + "payload_actor=#{inspect(context[:payload_actor])} " <> + "signature_actor=#{inspect(context[:signature_actor])} " <> + "activity_id=#{inspect(context[:activity_id])} " <> + "type=#{inspect(context[:type])} " <> + "request_path=#{inspect(context[:request_path])}" + ) + end + + defp log_signature_retry_rejection(_result, _context), do: :ok +end diff --git a/mix.exs b/mix.exs index 5d84c6e09..88f98d54e 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.10.0"), + version: version("2.10.2"), elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), @@ -160,6 +160,9 @@ defmodule Pleroma.Mixfile do {:sweet_xml, "~> 0.7.5"}, {:earmark, "1.4.46"}, {:bbcode_pleroma, "~> 0.2.0"}, + {:mfm_parser, + git: "https://akkoma.dev/AkkomaGang/mfm-parser.git", + ref: "360a30267a847810a63ab48f606ba227b2ca05f0"}, {:cors_plug, "~> 2.0"}, {:web_push_encryption, "~> 0.3.1"}, {:swoosh, "~> 1.16.12"}, diff --git a/mix.lock b/mix.lock index dd5f65f11..976a856b0 100644 --- a/mix.lock +++ b/mix.lock @@ -79,6 +79,7 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mfm_parser": {:git, "https://akkoma.dev/AkkomaGang/mfm-parser.git", "360a30267a847810a63ab48f606ba227b2ca05f0", [ref: "360a30267a847810a63ab48f606ba227b2ca05f0"]}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 0defdc74e..342ef9944 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -82,12 +82,50 @@ defmodule Pleroma.HTML.Scrubber.Default do "recipients-inline", "quote-inline", "invisible", - "ellipsis" + "ellipsis", + "mfm-center", + "mfm-flip", + "mfm-font", + "mfm-blur", + "mfm-rotate", + "mfm-x2", + "mfm-x3", + "mfm-x4", + "mfm-position", + "mfm-scale", + "mfm-fg", + "mfm-bg", + "mfm-jelly", + "mfm-twitch", + "mfm-shake", + "mfm-spin", + "mfm-jump", + "mfm-bounce", + "mfm-rainbow", + "mfm-tada", + "mfm-sparkle" ]) Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) - Meta.allow_tag_with_these_attributes(:span, ["lang"]) + Meta.allow_tag_with_these_attributes(:span, [ + "lang", + "data-mfm-h", + "data-mfm-v", + "data-mfm-x", + "data-mfm-y", + "data-mfm-alternate", + "data-mfm-speed", + "data-mfm-deg", + "data-mfm-left", + "data-mfm-serif", + "data-mfm-monospace", + "data-mfm-cursive", + "data-mfm-fantasy", + "data-mfm-emoji", + "data-mfm-math", + "data-mfm-color" + ]) Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 3569165a4..4e82d4b01 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -4,46 +4,55 @@ "https://w3id.org/security/v1", "https://purl.archive.org/socialweb/webfinger", { - "Emoji": "toot:Emoji", + "as": "https://www.w3.org/ns/activitystreams#", + "ostatus": "http://ostatus.org#", + "schema": "http://schema.org#", + "vcard": "http://www.w3.org/2006/vcard/ns#", + + "fedibird": "http://fedibird.com/ns#", + "litepub": "http://litepub.social/ns#", + "sm": "http://smithereen.software/ns#", + "toot": "http://joinmastodon.org/ns#", + + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, "Hashtag": "as:Hashtag", - "PropertyValue": "schema:PropertyValue", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "quoteUrl": "as:quoteUrl", + "sensitive": "as:sensitive", + "atomUri": "ostatus:atomUri", "conversation": { "@id": "ostatus:conversation", "@type": "@id" }, - "discoverable": "toot:discoverable", - "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", - "capabilities": "litepub:capabilities", - "ostatus": "http://ostatus.org#", - "schema": "http://schema.org#", - "toot": "http://joinmastodon.org/ns#", - "fedibird": "http://fedibird.com/ns#", + + "PropertyValue": "schema:PropertyValue", "value": "schema:value", - "sensitive": "as:sensitive", - "litepub": "http://litepub.social/ns#", - "invisible": "litepub:invisible", + + "quoteUri": "fedibird:quoteUri", + + "capabilities": "litepub:capabilities", + "ChatMessage": "litepub:ChatMessage", "directMessage": "litepub:directMessage", + "EmojiReact": "litepub:EmojiReact", + "formerRepresentations": "litepub:formerRepresentations", + "invisible": "litepub:invisible", "listMessage": { "@id": "litepub:listMessage", "@type": "@id" }, - "quoteUrl": "as:quoteUrl", - "quoteUri": "fedibird:quoteUri", "oauthRegistrationEndpoint": { "@id": "litepub:oauthRegistrationEndpoint", "@type": "@id" }, - "EmojiReact": "litepub:EmojiReact", - "ChatMessage": "litepub:ChatMessage", - "alsoKnownAs": { - "@id": "as:alsoKnownAs", - "@type": "@id" - }, - "vcard": "http://www.w3.org/2006/vcard/ns#", - "formerRepresentations": "litepub:formerRepresentations", - "sm": "http://smithereen.software/ns#", - "nonAnonymous": "sm:nonAnonymous" + + "nonAnonymous": "sm:nonAnonymous", + + "discoverable": "toot:discoverable", + "Emoji": "toot:Emoji" } ] -} +} \ No newline at end of file diff --git a/rel/files/bin/pleroma_ctl b/rel/files/bin/pleroma_ctl index 6f0dba3a8..f5d3f321b 100755 --- a/rel/files/bin/pleroma_ctl +++ b/rel/files/bin/pleroma_ctl @@ -78,14 +78,19 @@ update() { RELEASE_ROOT=$(dirname "$SCRIPTPATH") uri="https://git.pleroma.social" - project_id="2" - project_branch="${BRANCH:-$(detect_branch)}" - flavour="${FLAVOUR:-$(detect_flavour)}" + project_name="pleroma" + package_base="${uri}/api/packages/${project_name}/generic" + if [ -n "$FULL_URI" ]; then + full_uri="$FULL_URI" + else + project_branch="${BRANCH:-$(detect_branch)}" + flavour="${FLAVOUR:-$(detect_flavour)}" + full_uri="${package_base}"/pleroma-otp-"${project_branch}"-"${flavour}"/latest/pleroma.zip + fi tmp="${TMP_DIR:-/tmp}" artifact="$tmp/pleroma.zip" - full_uri="${FULL_URI:-${uri}/api/v4/projects/${project_id}/jobs/artifacts/${project_branch}/download?job=${flavour}}" echo "Downloading the artifact from ${full_uri} to ${artifact}" - curl "$full_uri" -o "${artifact}" + curl -fL "$full_uri" -o "${artifact}" echo "Unpacking ${artifact} to ${tmp}" unzip -q "$artifact" -d "$tmp" echo "Copying files over to $RELEASE_ROOT" @@ -137,7 +142,14 @@ else SCRIPT=$(realpath "$0") SCRIPTPATH=$(dirname "$SCRIPT") - FULL_ARGS="$*" + # HACK: Script arguments need to be sent as an array to Mix tasks, otherwise they will break (quoted) arguments with whitespace. + # Previously it was sent as string, which would get split on whitespace on the task side. + # Encode as Elixir binary literals to avoid string escaping and interpolation issues. + PREPARED_ARGS="" + for arg in "$@"; do + bytes=$(printf '%s' "$arg" | od -An -v -tu1 | tr -s '[:space:]' ',' | sed 's/^,//; s/,$//') + PREPARED_ARGS="$PREPARED_ARGS <<$bytes>>," + done ACTION="$1" if [ $# -gt 0 ]; then @@ -154,8 +166,8 @@ else if [ "$ACTION" = "update" ]; then update "$@" elif [ "$ACTION" = "migrate" ] || [ "$ACTION" = "rollback" ] || [ "$ACTION" = "create" ] || [ "$ACTION $SUBACTION" = "instance gen" ] || [ "$PLEROMA_CTL_RPC_DISABLED" = true ]; then - "$SCRIPTPATH"/pleroma eval 'Pleroma.ReleaseTasks.run("'"$FULL_ARGS"'")' + "$SCRIPTPATH"/pleroma eval 'Pleroma.ReleaseTasks.run(['"${PREPARED_ARGS%%,}"'])' else - "$SCRIPTPATH"/pleroma rpc 'Pleroma.ReleaseTasks.run("'"$FULL_ARGS"'")' + "$SCRIPTPATH"/pleroma rpc 'Pleroma.ReleaseTasks.run(['"${PREPARED_ARGS%%,}"'])' fi fi diff --git a/test/mix/tasks/pleroma/config_test.exs b/test/mix/tasks/pleroma/config_test.exs index ef1adc235..f672d8c13 100644 --- a/test/mix/tasks/pleroma/config_test.exs +++ b/test/mix/tasks/pleroma/config_test.exs @@ -144,7 +144,13 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do quarantined_instances: [], managed_config: true, static_dir: "instance/static/", - allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"], + allowed_post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ], autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, diff --git a/test/pleroma/pleroma_ctl_test.exs b/test/pleroma/pleroma_ctl_test.exs new file mode 100644 index 000000000..d96396399 --- /dev/null +++ b/test/pleroma/pleroma_ctl_test.exs @@ -0,0 +1,293 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.PleromaCtlTest do + use ExUnit.Case, async: false + + @pleroma_ctl Path.expand("../../rel/files/bin/pleroma_ctl", __DIR__) + + setup do + tmp_dir = + Path.join(System.tmp_dir!(), "pleroma_ctl_test_#{System.unique_integer([:positive])}") + + release_root = Path.join(tmp_dir, "release") + bin_dir = Path.join(release_root, "bin") + + File.mkdir_p!(bin_dir) + File.cp!(@pleroma_ctl, Path.join(bin_dir, "pleroma_ctl")) + File.chmod!(Path.join(bin_dir, "pleroma_ctl"), 0o755) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + {:ok, tmp_dir: tmp_dir, release_root: release_root, bin_dir: bin_dir} + end + + test "update downloads branch-scoped latest OTP package", %{ + tmp_dir: tmp_dir, + bin_dir: bin_dir, + release_root: release_root + } do + stubs_dir = Path.join(tmp_dir, "stubs") + curl_args_path = Path.join(tmp_dir, "curl.args") + File.mkdir_p!(stubs_dir) + write_update_stubs(stubs_dir, curl_args_path, "unused", "glibc") + + update_tmp_dir = Path.join(tmp_dir, "update_tmp") + File.mkdir_p!(update_tmp_dir) + + {output, status} = + System.cmd( + Path.join(bin_dir, "pleroma_ctl"), + [ + "update", + "--branch", + "develop", + "--flavour", + "amd64", + "--tmp-dir", + update_tmp_dir, + "--no-rm" + ], + env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}], + stderr_to_stdout: true + ) + + assert status == 0, output + assert File.exists?(Path.join(release_root, "bin/marker")) + + assert ["-fL", url, "-o", artifact_path] = + curl_args_path + |> File.read!() + |> String.split("\n", trim: true) + + assert url == + "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-develop-amd64/latest/pleroma.zip" + + assert artifact_path == Path.join(update_tmp_dir, "pleroma.zip") + end + + test "update detects stable branch and local flavour", %{ + tmp_dir: tmp_dir, + bin_dir: bin_dir, + release_root: release_root + } do + stubs_dir = Path.join(tmp_dir, "stubs") + curl_args_path = Path.join(tmp_dir, "curl.args") + File.mkdir_p!(stubs_dir) + write_update_stubs(stubs_dir, curl_args_path, "x86_64", "glibc") + write_start_erl_data(release_root, "2.10.0") + + {output, status} = + System.cmd( + Path.join(bin_dir, "pleroma_ctl"), + ["update", "--tmp-dir", create_update_tmp_dir(tmp_dir), "--no-rm"], + env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}], + stderr_to_stdout: true + ) + + assert status == 0, output + + assert curl_url(curl_args_path) == + "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-amd64/latest/pleroma.zip" + end + + test "update detects develop branch and musl arm flavour", %{ + tmp_dir: tmp_dir, + bin_dir: bin_dir, + release_root: release_root + } do + stubs_dir = Path.join(tmp_dir, "stubs") + curl_args_path = Path.join(tmp_dir, "curl.args") + File.mkdir_p!(stubs_dir) + write_update_stubs(stubs_dir, curl_args_path, "armv7l", "musl") + write_start_erl_data(release_root, "2.10.0.develop") + + {output, status} = + System.cmd( + Path.join(bin_dir, "pleroma_ctl"), + ["update", "--tmp-dir", create_update_tmp_dir(tmp_dir), "--no-rm"], + env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}], + stderr_to_stdout: true + ) + + assert status == 0, output + + assert curl_url(curl_args_path) == + "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-develop-arm-musl/latest/pleroma.zip" + end + + test "update with zip URL bypasses branch and flavour detection", %{ + tmp_dir: tmp_dir, + bin_dir: bin_dir, + release_root: release_root + } do + stubs_dir = Path.join(tmp_dir, "stubs") + curl_args_path = Path.join(tmp_dir, "curl.args") + File.mkdir_p!(stubs_dir) + write_update_stubs(stubs_dir, curl_args_path, "unsupported-arch", "unsupported-libc") + write_start_erl_data(release_root, "2.10.0.custombranch") + + custom_url = "https://example.test/custom.zip" + + {output, status} = + System.cmd( + Path.join(bin_dir, "pleroma_ctl"), + [ + "update", + "--zip-url", + custom_url, + "--tmp-dir", + create_update_tmp_dir(tmp_dir), + "--no-rm" + ], + env: [{"PATH", stubs_dir <> ":" <> System.get_env("PATH", "")}], + stderr_to_stdout: true + ) + + assert status == 0, output + assert curl_url(curl_args_path) == custom_url + end + + test "passes arguments with spaces and Elixir string metacharacters", %{ + tmp_dir: tmp_dir, + bin_dir: bin_dir + } do + capture_path = Path.join(tmp_dir, "captured_args") + eval_path = Path.join(tmp_dir, "pleroma_ctl_eval.exs") + + write_executable(Path.join(bin_dir, "pleroma"), """ + #!/bin/sh + { + printf '%s\n' 'defmodule Pleroma.ReleaseTasks do' + printf '%s\n' ' def run(args), do: File.write!(System.fetch_env!("PLEROMA_CTL_CAPTURE"), :erlang.term_to_binary(args))' + printf '%s\n' 'end' + printf '%s\n' "$2" + } > "$PLEROMA_CTL_EVAL_FILE" + + exec elixir "$PLEROMA_CTL_EVAL_FILE" + """) + + {output, status} = + System.cmd( + Path.join(bin_dir, "pleroma_ctl"), + [ + "user", + "", + "has space", + ~s(has "quote"), + ~s(has \\ backslash), + ~S(#{:not_interpolated}) + ], + env: [ + {"PLEROMA_CTL_CAPTURE", capture_path}, + {"PLEROMA_CTL_EVAL_FILE", eval_path} + ], + stderr_to_stdout: true + ) + + assert status == 0, output + + assert capture_path + |> File.read!() + |> :erlang.binary_to_term() == [ + "user", + "", + "has space", + ~s(has "quote"), + ~s(has \\ backslash), + ~S(#{:not_interpolated}) + ] + end + + defp write_executable(path, contents) do + File.write!(path, contents) + File.chmod!(path, 0o755) + end + + defp write_start_erl_data(release_root, version) do + releases_dir = Path.join(release_root, "releases") + File.mkdir_p!(releases_dir) + File.write!(Path.join(releases_dir, "start_erl.data"), "erts-15.0 #{version}\n") + end + + defp create_update_tmp_dir(tmp_dir) do + update_tmp_dir = Path.join(tmp_dir, "update_tmp") + File.mkdir_p!(update_tmp_dir) + update_tmp_dir + end + + defp write_update_stubs(stubs_dir, curl_args_path, arch, libc) do + write_executable(Path.join(stubs_dir, "curl"), """ + #!/bin/sh + printf '%s\n' "$@" > "#{curl_args_path}" + + while [ $# -gt 0 ]; do + case "$1" in + -o) + artifact="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + : > "$artifact" + """) + + write_executable(Path.join(stubs_dir, "unzip"), """ + #!/bin/sh + while [ $# -gt 0 ]; do + case "$1" in + -d) + dest="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + mkdir -p "$dest/release/bin" + printf 'marker' > "$dest/release/bin/marker" + """) + + write_executable(Path.join(stubs_dir, "uname"), """ + #!/bin/sh + printf '%s\n' '#{arch}' + """) + + write_executable(Path.join(stubs_dir, "getconf"), getconf_stub(libc)) + + write_executable(Path.join(stubs_dir, "ldd"), """ + #!/bin/sh + printf '%s\n' 'musl libc (mock)' + """) + end + + defp getconf_stub("glibc") do + """ + #!/bin/sh + printf '%s\n' 'glibc 2.40' + """ + end + + defp getconf_stub(_libc) do + """ + #!/bin/sh + exit 1 + """ + end + + defp curl_url(curl_args_path) do + ["-fL", url, "-o", _artifact_path] = + curl_args_path + |> File.read!() + |> String.split("\n", trim: true) + + url + end +end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index b2533e9f1..de735cdb7 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -876,17 +876,17 @@ defmodule Pleroma.UserTest do 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 "for mastodon" do - ap_id = "a@mastodon.example" - {:ok, fetched_user} = User.get_or_fetch(ap_id) + 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 "for pleroma" do - ap_id = "a@pleroma.example" - {:ok, fetched_user} = User.get_or_fetch(ap_id) + 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" @@ -936,6 +936,89 @@ defmodule Pleroma.UserTest do 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) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index d5947186f..3988c3912 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint alias Pleroma.Workers.ReceiverWorker + alias Pleroma.Workers.SignatureRetryWorker import Pleroma.Factory @@ -36,6 +37,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do setup do: clear_config([:instance, :federating], true) + defp assign_valid_signature_for_actor(conn, %User{ap_id: actor_id}) do + assign_valid_signature_for_actor(conn, actor_id) + end + + defp assign_valid_signature_for_actor(conn, actor) do + actor_id = Utils.get_ap_id(actor) + + conn + |> assign(:valid_signature, true) + |> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"") + end + + defp expect_signature_retry_from(%User{} = signer) do + signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured") + + Tesla.Mock.mock(fn + %{url: url} when url == signer.ap_id -> + %Tesla.Env{ + status: 200, + body: Jason.encode!(signer_json), + headers: HttpRequestMock.activitypub_object_headers() + } + + env -> + apply(HttpRequestMock, :request, [env]) + end) + + Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end) + end + describe "/relay" do setup do: clear_config([:instance, :allow_relay]) @@ -688,7 +719,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) @@ -716,7 +747,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) @@ -726,6 +757,199 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert Activity.get_by_ap_id(data["id"]) end + test "does not create a forged post after failed signature retry", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + object_id = "https://two.com/objects/inbox-forged-note" + + data = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/inbox-forged-create", + "context" => "https://two.com/contexts/inbox-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "context" => "https://two.com/contexts/inbox-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + expect_signature_retry_from(alice) + + conn = + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") + |> post("/inbox", data) + + assert "ok" == json_response(conn, 200) + + assert [{:cancel, :actor_signature_mismatch}] = + ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker)) + + refute Activity.get_by_ap_id(data["id"]) + refute Object.get_by_ap_id(object_id) + end + + test "does not create a forged like after failed signature retry", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + + data = %{ + "type" => "Like", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/inbox-forged-like", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data["id"] + } + + expect_signature_retry_from(alice) + + conn = + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") + |> post("/inbox", data) + + assert "ok" == json_response(conn, 200) + + assert [{:cancel, :actor_signature_mismatch}] = + ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker)) + + refute Activity.get_by_ap_id(data["id"]) + end + + test "does not delete an object after failed signature retry", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + object_id = note.data["id"] + + data = %{ + "type" => "Delete", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/inbox-forged-delete", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => object_id + } + + expect_signature_retry_from(alice) + + conn = + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") + |> post("/inbox", data) + + assert "ok" == json_response(conn, 200) + + assert [{:cancel, :actor_signature_mismatch}] = + ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker)) + + refute Activity.get_by_ap_id(data["id"]) + assert %Object{data: %{"type" => "Note"}} = Object.get_by_ap_id(object_id) + end + + test "does not create a forged post signed by a different actor", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + object_id = "https://two.com/objects/inbox-signed-forged-note" + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/inbox-signed-forged-create", + "context" => "https://two.com/contexts/inbox-signed-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "context" => "https://two.com/contexts/inbox-signed-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + expect_signature_retry_from(alice) + + conn = + conn + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT") + |> put_req_header("digest", "SHA-256=fake-digest") + |> put_req_header( + "signature", + "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + ) + |> post("/inbox", data) + + assert conn.assigns.valid_signature == false + assert "ok" == json_response(conn, 200) + + assert [{:cancel, :actor_signature_mismatch}] = + ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker)) + + refute Activity.get_by_ap_id(data["id"]) + refute Object.get_by_ap_id(object_id) + end + + test "does not create a forged like signed by a different actor", %{conn: conn} do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Like", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/inbox-signed-forged-like", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data["id"] + } + + expect_signature_retry_from(alice) + + conn = + conn + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT") + |> put_req_header("digest", "SHA-256=fake-digest") + |> put_req_header( + "signature", + "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + ) + |> post("/inbox", data) + + assert conn.assigns.valid_signature == false + assert "ok" == json_response(conn, 200) + + assert [{:cancel, :actor_signature_mismatch}] = + ObanHelpers.perform(all_enqueued(worker: SignatureRetryWorker)) + + refute Activity.get_by_ap_id(data["id"]) + end + test "accept follow activity", %{conn: conn} do clear_config([:instance, :federating], true) relay = Relay.get_actor() @@ -742,7 +966,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert "ok" == conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(followed_relay) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", accept) |> json_response(200) @@ -822,16 +1046,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do test "Unknown activity types are discarded", %{conn: conn} do unknown_types = ["Poke", "Read", "Dazzle"] + actor = + insert(:user, local: false, ap_id: "https://unknown.mastodon.instance/users/somebody") + Enum.each(unknown_types, fn bad_type -> params = %{ "type" => bad_type, - "actor" => "https://unknown.mastodon.instance/users/somebody" + "actor" => actor.ap_id } |> Jason.encode!() conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(actor) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", params) |> json_response(400) @@ -900,7 +1127,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert "ok" == conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) |> json_response(200) @@ -921,7 +1148,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert "ok" == conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) |> json_response(200) @@ -990,7 +1217,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert "ok" == conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) |> json_response(200) @@ -1009,7 +1236,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert "ok" == conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", data) |> json_response(200) @@ -1040,7 +1267,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1061,7 +1288,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1082,7 +1309,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1106,7 +1333,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1133,7 +1360,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1163,7 +1390,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{recipient.nickname}/inbox", data) @@ -1228,7 +1455,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{recipient.nickname}/inbox", data) |> json_response(200) @@ -1318,7 +1545,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{reported_user.nickname}/inbox", data) |> json_response(200) @@ -1372,7 +1599,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{reported_user.nickname}/inbox", data) |> json_response(200) @@ -1405,7 +1632,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1428,7 +1655,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -1451,7 +1678,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn - |> assign(:valid_signature, true) + |> assign_valid_signature_for_actor(data["actor"]) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/inbox", data) @@ -2571,6 +2798,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do setup do: clear_config([:media_proxy]) setup do: clear_config([Pleroma.Upload]) + # majic's libmagic port is unavailable on local Darwin runs; Linux CI still runs this test. + @tag :skip_darwin test "POST /api/ap/upload_media", %{conn: conn} do user = insert(:user) diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs index c32811c5b..bf9c70fb6 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs @@ -149,6 +149,171 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note) end + test "a Misskey MFM note is rendered from source content" do + user = insert(:user, ap_id: "https://misskey.example/users/alice") + + note = %{ + "id" => "https://misskey.example/notes/1", + "type" => "Note", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => "original content", + "context" => Utils.generate_context_id(), + "source" => %{ + "content" => "$[spin.speed=1s mfm goes here] ", + "mediaType" => "text/x.misskeymarkdown" + } + } + + %{valid?: true, changes: %{content: content, source: source}} = + ArticleNotePageValidator.cast_and_validate(note) + + assert source["mediaType"] == "text/x.misskeymarkdown" + assert content =~ ~s(class="mfm-spin") + assert content =~ ~s(data-mfm-speed="1s") + assert content =~ "mfm goes here" + refute content =~ "original content" + refute content =~ " "https://misskey.example/notes/3", + "type" => "Note", + "actor" => remote_user.ap_id, + "attributedTo" => remote_user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => "original content", + "context" => Utils.generate_context_id(), + "tag" => [ + %{ + "type" => "Mention", + "name" => "@local_user", + "href" => local_user.ap_id + }, + %{ + "type" => "Mention", + "name" => "@uncached", + "href" => "https://misskey.example/users/uncached" + } + ], + "source" => %{ + "content" => "@local_user @uncached $[spin hello]", + "mediaType" => "text/x.misskeymarkdown" + } + } + + %{valid?: true, changes: %{content: content}} = + ArticleNotePageValidator.cast_and_validate(note) + + assert content =~ local_user.ap_id + assert content =~ "@uncached" + end + + test "a Misskey MFM note drops oversized source content instead of parsing it" do + user = insert(:user, ap_id: "https://misskey.example/users/oversized") + + note = %{ + "id" => "https://misskey.example/notes/4", + "type" => "Note", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => "safe fallback", + "context" => Utils.generate_context_id(), + "source" => %{ + "content" => String.duplicate("x", 5_001), + "mediaType" => "text/x.misskeymarkdown" + } + } + + %{valid?: true, changes: %{content: content, source: source}} = + ArticleNotePageValidator.cast_and_validate(note) + + assert content == "safe fallback" + refute Map.has_key?(source, "content") + end + + test "a note drops oversized non-MFM source content" do + user = insert(:user, ap_id: "https://example.com/users/source") + + note = %{ + "id" => "https://example.com/notes/1", + "type" => "Note", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => "regular content", + "context" => Utils.generate_context_id(), + "source" => %{ + "content" => String.duplicate("x", 5_001), + "mediaType" => "text/markdown" + } + } + + %{valid?: true, changes: %{source: source}} = ArticleNotePageValidator.cast_and_validate(note) + + assert source == %{"mediaType" => "text/markdown"} + end + + test "a Misskey MFM note with legacy _misskey_content is rendered" do + user = insert(:user, ap_id: "https://misskey.example/users/legacy") + + note = %{ + "id" => "https://misskey.example/notes/5", + "type" => "Note", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => "original content", + "context" => Utils.generate_context_id(), + "_misskey_content" => "$[spin legacy]" + } + + %{valid?: true, changes: %{content: content, source: source}} = + ArticleNotePageValidator.cast_and_validate(note) + + assert source == %{"content" => "$[spin legacy]", "mediaType" => "text/x.misskeymarkdown"} + assert content =~ ~s(class="mfm-spin") + assert content =~ "legacy" + end + + test "a Misskey MFM note with htmlMfm is scrubbed but not rendered from source content" do + user = insert(:user, ap_id: "https://misskey.example/users/bob") + + note = %{ + "id" => "https://misskey.example/notes/2", + "type" => "Note", + "actor" => user.ap_id, + "attributedTo" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "content" => + "already rendered", + "htmlMfm" => true, + "context" => Utils.generate_context_id(), + "source" => %{ + "content" => String.duplicate("x", 5_001), + "mediaType" => "text/x.misskeymarkdown" + } + } + + %{valid?: true, changes: %{content: content, htmlMfm: true, source: source}} = + ArticleNotePageValidator.cast_and_validate(note) + + assert content == "already renderedalert('xss')" + refute Map.has_key?(source, "content") + end + test "a Note with validated likes collection validates" do insert(:user, ap_id: "https://pol.social/users/mkljczk") diff --git a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs index 347c5b578..9dec315b3 100644 --- a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) end - test "returns an error if the object can't be updated by the actor", %{ + test "returns an error if the object can't be updated by the actor (different domain)", %{ valid_update: valid_update } do other_user = insert(:user, local: false) @@ -41,27 +41,72 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do assert {:error, _cng} = ObjectValidator.validate(update, []) end - test "validates as long as the object is same-origin with the actor", %{ + test "returns an error if the object can't be updated by the actor (same domain)", %{ + user: user, valid_update: valid_update } do - other_user = insert(:user) + user_ap_id = user.ap_id + user_domain = URI.parse(user_ap_id).host + other_user = insert(:user, local: false, domain: user_domain) update = valid_update |> Map.put("actor", other_user.ap_id) - assert {:ok, _update, []} = ObjectValidator.validate(update, []) + assert {:error, _cng} = ObjectValidator.validate(update, []) end - test "validates if the object is not of an Actor type" do - note = insert(:note) + test "validates if the object is not of an Actor type", %{user: user} do + note = insert(:note, user: user) updated_note = note.data |> Map.put("content", "edited content") - other_user = insert(:user) - {:ok, update, _} = Builder.update(other_user, updated_note) + {:ok, update, _} = Builder.update(user, updated_note) assert {:ok, _update, _} = ObjectValidator.validate(update, []) end + + test "returns an error if the remote update target is unknown" do + remote_user = insert(:user, local: false, ap_id: "https://example.com/users/alice") + + update = %{ + "type" => "Update", + "actor" => remote_user.ap_id, + "id" => "https://example.com/activities/update-unknown-object", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://example.com/objects/unknown", + "actor" => remote_user.ap_id, + "content" => "edited content", + "published" => "2024-07-25T13:33:31Z", + "updated" => "2024-07-25T13:34:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:error, %Ecto.Changeset{} = cng} = ObjectValidator.validate(update, local: false) + refute cng.valid? + assert Keyword.has_key?(cng.errors, :object) + end + + test "returns an error if the remote update target IRI is unknown" do + remote_user = insert(:user, local: false, ap_id: "https://example.com/users/alice") + + update = %{ + "type" => "Update", + "actor" => remote_user.ap_id, + "id" => "https://example.com/activities/update-unknown-object-iri", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => "https://example.com/objects/unknown-iri" + } + + assert {:error, %Ecto.Changeset{} = cng} = ObjectValidator.validate(update, local: false) + refute cng.valid? + assert Keyword.has_key?(cng.errors, :object) + end end describe "update note" do diff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs index 3b77f0867..93234a015 100644 --- a/test/pleroma/web/activity_pub/utils_test.exs +++ b/test/pleroma/web/activity_pub/utils_test.exs @@ -180,7 +180,8 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do "https://www.w3.org/ns/activitystreams", "http://localhost:4001/schemas/litepub-0.1.jsonld", %{ - "@language" => "und" + "@language" => "und", + "htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm" } ] } @@ -192,7 +193,8 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do "https://www.w3.org/ns/activitystreams", "http://localhost:4001/schemas/litepub-0.1.jsonld", %{ - "@language" => "pl" + "@language" => "pl", + "htmlMfm" => "https://w3id.org/fep/c16b#htmlMfm" } ] } diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 017fac696..ea1795c0b 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -709,6 +709,47 @@ defmodule Pleroma.Web.CommonAPITest do assert object.data["source"]["content"] == post end + test "it renders MFM posts and marks their ActivityPub representation" do + user = insert(:user) + + post = "

$[spin.speed=1s 13:37]

" + + {:ok, activity} = + CommonAPI.post(user, %{ + status: post, + content_type: "text/x.misskeymarkdown" + }) + + object = Object.normalize(activity, fetch: false) + + assert object.data["htmlMfm"] == true + + assert object.data["source"] == %{ + "content" => post, + "mediaType" => "text/x.misskeymarkdown" + } + + assert object.data["content"] =~ ~s(class="mfm-spin") + assert object.data["content"] =~ ~s(data-mfm-speed="1s") + assert object.data["content"] =~ "13:37" + refute object.data["content"] =~ "scrub-this" + end + + test "it falls back safely for malformed MFM" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "$[spin.speed=1s=boom malformed]", + content_type: "text/x.misskeymarkdown" + }) + + object = Object.normalize(activity, fetch: false) + + refute object.data["content"] =~ ~s(class="mfm-spin") + assert object.data["content"] =~ "malformed" + end + test "it does not allow replies to direct messages that are not direct messages themselves" do user = insert(:user) diff --git a/test/pleroma/web/mastodon_api/views/poll_view_test.exs b/test/pleroma/web/mastodon_api/views/poll_view_test.exs index 16281393d..6cb5934de 100644 --- a/test/pleroma/web/mastodon_api/views/poll_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/poll_view_test.exs @@ -180,4 +180,179 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do assert result[:pleroma][:non_anonymous] == true end + + test "prefers votersCount over voters list when both are present" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + assert object.data["votersCount"] == 1 + assert length(object.data["voters"]) == 1 + + object = %{ + object + | data: Map.put(object.data, "votersCount", 42) + } + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 42 + end + + test "falls back to voters list when votersCount is absent" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + assert length(object.data["voters"]) == 1 + + data = Map.delete(object.data, "votersCount") + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 1 + end + + test "returns 0 when both votersCount and voters are absent" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + data = + object.data + |> Map.delete("votersCount") + |> Map.delete("voters") + + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "returns 0 when voters list is empty" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Which flavor?", + poll: %{options: ["chocolate", "vanilla"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + data = + object.data + |> Map.delete("votersCount") + |> Map.put("voters", []) + + object = %{object | data: data} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "does not inflate votersCount when same voter picks multiple options" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick several", + poll: %{options: ["a", "b", "c"], expires_in: 20, multiple: true} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0, 2]) + + assert object.data["votersCount"] == 1 + assert length(object.data["voters"]) == 1 + end + + test "preserves votersCount from remote source when existing voter picks another option" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick several", + poll: %{options: ["a", "b"], expires_in: 20, multiple: true} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0, 1]) + + object = %{object | data: Map.put(object.data, "votersCount", 14)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 14 + end + + test "returns 0 when votersCount is explicitly 0" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick one", + poll: %{options: ["a", "b"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + object = %{object | data: Map.put(object.data, "votersCount", 0)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == 0 + end + + test "falls back to voters list when votersCount is nil" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "Pick one", + poll: %{options: ["a", "b"], expires_in: 20} + }) + + object = Object.normalize(activity, fetch: false) + + voter = insert(:user) + {:ok, _, object} = CommonAPI.vote(object, voter, [0]) + + object = %{object | data: Map.put(object.data, "votersCount", nil)} + + result = PollView.render("show.json", %{object: object}) + + assert result[:voters_count] == length(object.data["voters"]) + end end diff --git a/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs b/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs index 33eff1bc5..df713762c 100644 --- a/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs +++ b/test/pleroma/web/plugs/mapped_signature_to_identity_plug_test.exs @@ -47,13 +47,27 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlugTest do assert %{valid_signature: false} == conn.assigns end - @tag skip: "known breakage; the testsuite presently depends on it" test "it considers a mapped identity to be invalid when the identity cannot be found" do + actor = "http://niu.moe/users/rye" + conn = - build_conn(:post, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"}) - |> set_signature("http://niu.moe/users/rye") + build_conn(:post, "/doesntmattter", %{"actor" => actor}) + |> set_signature(actor) |> MappedSignatureToIdentityPlug.call(%{}) - assert %{valid_signature: false} == conn.assigns + assert conn.assigns.valid_signature == false + refute Map.has_key?(conn.assigns, :user) + end + + test "it considers a mapped identity to be invalid when embedded actor identity cannot be found" do + actor = "http://niu.moe/users/rye" + + conn = + build_conn(:post, "/doesntmattter", %{"actor" => %{"id" => actor}}) + |> set_signature(actor) + |> MappedSignatureToIdentityPlug.call(%{}) + + assert conn.assigns.valid_signature == false + refute Map.has_key?(conn.assigns, :user) end end diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 12abc1a27..ea05f38f1 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -11,9 +11,27 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.Federator alias Pleroma.Workers.ReceiverWorker + defp signature_headers_for(%User{} = signer) do + [ + {"host", "local.test"}, + {"date", "Thu, 25 Jul 2024 13:33:31 GMT"}, + {"digest", "SHA-256=fake-digest"}, + {"content-type", "application/activity+json"}, + { + "signature", + "keyId=\"#{signer.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + } + ] + end + + defp perform_incoming(params) do + ReceiverWorker.perform(%Oban.Job{ + args: %{"op" => "incoming_ap_doc", "params" => params} + }) + end + test "it does not retry MRF reject" do params = insert(:note).data @@ -81,16 +99,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do insert(:note_activity).data |> Map.put("actor", "https://springfield.social/users/bart") - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job) + assert {:cancel, {:error, :forbidden}} = perform_incoming(params) end test "when request returns a 404" do @@ -98,16 +107,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do insert(:note_activity).data |> Map.put("actor", "https://springfield.social/users/troymcclure") - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) + assert {:cancel, {:error, :not_found}} = perform_incoming(params) end test "when request returns a 410" do @@ -115,16 +115,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do insert(:note_activity).data |> Map.put("actor", "https://springfield.social/users/hankscorpio") - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) + assert {:cancel, {:error, :not_found}} = perform_incoming(params) end test "when user account is disabled" do @@ -138,86 +129,16 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do {:ok, %User{}} = User.set_activation(user, false) - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:cancel, {:user_active, false}} = ReceiverWorker.perform(oban_job) + assert {:cancel, {:user_active, false}} = perform_incoming(params) end end - test "it can validate the signature" do - Tesla.Mock.mock(fn - %{url: "https://phpc.social/users/denniskoch"} -> - %Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/denniskoch.json"), - headers: [{"content-type", "application/activity+json"}] - } - - %{url: "https://phpc.social/users/denniskoch/collections/featured"} -> - %Tesla.Env{ - status: 200, - headers: [{"content-type", "application/activity+json"}], - body: - File.read!("test/fixtures/users_mock/masto_featured.json") - |> String.replace("{{domain}}", "phpc.social") - |> String.replace("{{nickname}}", "denniskoch") - } - end) - - params = - File.read!("test/fixtures/receiver_worker_signature_activity.json") |> Jason.decode!() - - req_headers = [ - ["accept-encoding", "gzip"], - ["content-length", "5184"], - ["content-type", "application/activity+json"], - ["date", "Thu, 25 Jul 2024 13:33:31 GMT"], - ["digest", "SHA-256=ouge/6HP2/QryG6F3JNtZ6vzs/hSwMk67xdxe87eH7A="], - ["host", "bikeshed.party"], - [ - "signature", - "keyId=\"https://mastodon.social/users/bastianallgeier#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"ymE3vn5Iw50N6ukSp8oIuXJB5SBjGAGjBasdTDvn+ahZIzq2SIJfmVCsIIzyqIROnhWyQoTbavTclVojEqdaeOx+Ejz2wBnRBmhz5oemJLk4RnnCH0lwMWyzeY98YAvxi9Rq57Gojuv/1lBqyGa+rDzynyJpAMyFk17XIZpjMKuTNMCbjMDy76ILHqArykAIL/v1zxkgwxY/+ELzxqMpNqtZ+kQ29znNMUBB3eVZ/mNAHAz6o33Y9VKxM2jw+08vtuIZOusXyiHbRiaj2g5HtN2WBUw1MzzfRfHF2/yy7rcipobeoyk5RvP5SyHV3WrIeZ3iyoNfmv33y8fxllF0EA==\"" - ], - [ - "user-agent", - "http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-07-25; +https://mastodon.social/)" - ] - ] - - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: req_headers, - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job) - end - test "cancels due to origin containment" do params = insert(:note_activity).data |> Map.put("id", "https://notorigindomain.com/activity") - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) - - assert {:cancel, :origin_containment_failed} = ReceiverWorker.perform(oban_job) + assert {:cancel, :origin_containment_failed} = perform_incoming(params) end test "canceled due to deleted object" do @@ -233,16 +154,114 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do } end) - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: [], - request_path: "/inbox", - params: params, - query_string: "" - }) + assert {:cancel, _} = perform_incoming(params) + end - assert {:cancel, _} = ReceiverWorker.perform(oban_job) + test "delegates legacy failed-signature metadata jobs instead of processing them as trusted" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + object_id = "https://two.com/objects/legacy-forged-note" + + create = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/legacy-forged-create", + "context" => "https://two.com/contexts/legacy-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "context" => "https://two.com/contexts/legacy-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:cancel, :actor_signature_mismatch} = + ReceiverWorker.perform(%Oban.Job{ + args: %{ + "op" => "incoming_ap_doc", + "method" => "POST", + "params" => create, + "req_headers" => signature_headers_for(alice), + "request_path" => "/inbox", + "query_string" => "" + } + }) + + refute Pleroma.Activity.get_by_ap_id(create["id"]) + refute Pleroma.Object.get_by_ap_id(object_id) + end + + test "fails closed for the old persisted failed-signature job shape" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + object_id = "https://two.com/objects/old-shape-forged-note" + + create = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/old-shape-forged-create", + "context" => "https://two.com/contexts/old-shape-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "context" => "https://two.com/contexts/old-shape-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:cancel, :missing_signature_retry_metadata} = + ReceiverWorker.perform(%Oban.Job{ + args: %{ + "op" => "incoming_ap_doc", + "params" => create, + "req_headers" => signature_headers_for(alice), + "timeout" => 20_000 + } + }) + + refute Pleroma.Activity.get_by_ap_id(create["id"]) + refute Pleroma.Object.get_by_ap_id(object_id) + end + + test "fails closed for legacy retry jobs missing one metadata field" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + params = insert(:note_activity).data + + assert {:cancel, :missing_signature_retry_metadata} = + ReceiverWorker.perform(%Oban.Job{ + args: %{ + "op" => "incoming_ap_doc", + "method" => "POST", + "params" => params, + "req_headers" => signature_headers_for(alice), + "request_path" => "/inbox" + } + }) + end + + test "fails closed for malformed legacy metadata jobs without params" do + assert {:cancel, :missing_signature_retry_metadata} = + ReceiverWorker.perform(%Oban.Job{ + args: %{ + "op" => "incoming_ap_doc", + "req_headers" => [], + "timeout" => 20_000 + } + }) end describe "Server reachability:" do diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs new file mode 100644 index 000000000..94dd5f6c1 --- /dev/null +++ b/test/pleroma/workers/signature_retry_worker_test.exs @@ -0,0 +1,574 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.SignatureRetryWorkerTest do + use Pleroma.DataCase, async: false + use Oban.Testing, repo: Pleroma.Repo + + import ExUnit.CaptureLog + import Pleroma.Factory + + @moduletag capture_log: true + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Signature + alias Pleroma.User + alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Web.Federator + alias Pleroma.Workers.SignatureRetryWorker + + defp signature_headers_for(%User{} = signer) do + [ + {"host", "local.test"}, + {"date", "Thu, 25 Jul 2024 13:33:31 GMT"}, + {"digest", "SHA-256=fake-digest"}, + {"content-type", "application/activity+json"}, + { + "signature", + "keyId=\"#{signer.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + } + ] + end + + defp stub_actor_fetch(%User{} = signer) do + signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured") + + Tesla.Mock.mock(fn + %{url: url} when url == signer.ap_id -> + %Tesla.Env{ + status: 200, + body: Jason.encode!(signer_json), + headers: HttpRequestMock.activitypub_object_headers() + } + end) + end + + defp expect_signature_from(%User{} = signer) do + stub_actor_fetch(signer) + Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> true end) + end + + defp enqueue_failed_signature(params, signer) do + Federator.incoming_failed_signature_ap_doc(%{ + method: "POST", + req_headers: signature_headers_for(signer), + request_path: "/inbox", + params: params, + query_string: "" + }) + end + + defp failed_signature_job(params, req_headers, opts \\ []) do + %Oban.Job{ + args: %{ + "op" => "incoming_failed_signature_ap_doc", + "method" => Keyword.get(opts, :method, "POST"), + "req_headers" => req_headers, + "request_path" => Keyword.get(opts, :request_path, "/inbox"), + "params" => params, + "query_string" => Keyword.get(opts, :query_string, "") + } + } + end + + defp assert_mismatched_signature_cancelled(params, signer) do + assert {:ok, oban_job} = enqueue_failed_signature(params, signer) + + capture_log([level: :warning], fn -> + assert {:cancel, :actor_signature_mismatch} = SignatureRetryWorker.perform(oban_job) + end) + end + + test "Federator preserves request metadata for failed-signature retry jobs" do + params = insert(:note_activity).data + + req_headers = [ + {"host", "local.test"}, + {"signature", "keyId=\"https://one.com/users/alice#main-key\""} + ] + + assert {:ok, oban_job} = + Federator.incoming_failed_signature_ap_doc(%{ + method: "POST", + req_headers: req_headers, + request_path: "/inbox", + params: params, + query_string: "foo=bar" + }) + + assert oban_job.worker == "Pleroma.Workers.SignatureRetryWorker" + + assert %{ + "op" => "incoming_failed_signature_ap_doc", + "method" => "POST", + "req_headers" => ^req_headers, + "request_path" => "/inbox", + "params" => ^params, + "query_string" => "foo=bar" + } = oban_job.args + end + + test "cancels retry jobs without request metadata" do + params = insert(:note_activity).data + + log = + capture_log([level: :warning], fn -> + assert {:cancel, :missing_signature_retry_metadata} = + SignatureRetryWorker.perform(%Oban.Job{ + args: %{"op" => "incoming_failed_signature_ap_doc", "params" => params} + }) + end) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:missing_signature_retry_metadata" + assert log =~ "payload_actor=#{inspect(params["actor"])}" + assert log =~ "activity_id=#{inspect(params["id"])}" + assert log =~ "type=#{inspect(params["type"])}" + assert log =~ "request_path=nil" + end + + test "cancels retry jobs with malformed serialized request headers" do + params = insert(:note_activity).data + + log = + capture_log([level: :warning], fn -> + assert {:cancel, :invalid_signature_retry_metadata} = + SignatureRetryWorker.perform(failed_signature_job(params, [["signature"]])) + end) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:invalid_signature_retry_metadata" + assert log =~ "signature_actor=nil" + assert log =~ "request_path=\"/inbox\"" + end + + test "cancels retry jobs without a signature header" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + params = insert(:note_activity, user: alice).data + + log = + capture_log([level: :warning], fn -> + assert {:cancel, :invalid_signature} = + SignatureRetryWorker.perform( + failed_signature_job(params, [{"host", "local.test"}]) + ) + end) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:invalid_signature" + assert log =~ "payload_actor=#{inspect(params["actor"])}" + assert log =~ "signature_actor=nil" + assert log =~ "request_path=\"/inbox\"" + end + + test "cancels missing signature before fetching an unavailable payload actor" do + params = + insert(:note_activity).data + |> Map.put("actor", "https://unavailable.example/users/bob") + + assert {:cancel, :invalid_signature} = + SignatureRetryWorker.perform(failed_signature_job(params, [{"host", "local.test"}])) + end + + test "cancels signer mismatch before fetching an unavailable payload actor" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + + params = + insert(:note_activity).data + |> Map.put("actor", "https://unavailable.example/users/bob") + + assert {:cancel, :actor_signature_mismatch} = + SignatureRetryWorker.perform( + failed_signature_job(params, signature_headers_for(alice)) + ) + end + + test "cancels retry jobs with a signature header without keyId" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + params = insert(:note_activity, user: alice).data + + req_headers = [{"signature", "algorithm=\"rsa-sha256\",signature=\"fake-signature\""}] + + assert {:cancel, :invalid_signature} = + SignatureRetryWorker.perform(failed_signature_job(params, req_headers)) + end + + test "cancels retry jobs with an unparsable signature keyId" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + params = insert(:note_activity, user: alice).data + req_headers = [{"signature", "keyId=\"not an activitypub id\",signature=\"fake-signature\""}] + + assert {:cancel, :invalid_signature} = + SignatureRetryWorker.perform(failed_signature_job(params, req_headers)) + end + + test "cancels when the refetched key still cannot validate the signature" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + + create = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://one.com/activities/invalid-signature-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://one.com/objects/invalid-signature-note", + "actor" => alice.ap_id, + "attributedTo" => alice.ap_id, + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + stub_actor_fetch(alice) + + assert {:ok, oban_job} = enqueue_failed_signature(create, alice) + + log = + capture_log([level: :warning], fn -> + assert {:cancel, :invalid_signature} = SignatureRetryWorker.perform(oban_job) + end) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:invalid_signature" + assert log =~ "payload_actor=\"https://one.com/users/alice\"" + assert log =~ "signature_actor=\"https://one.com/users/alice\"" + assert log =~ "activity_id=\"https://one.com/activities/invalid-signature-create\"" + assert log =~ "type=\"Create\"" + assert log =~ "request_path=\"/inbox\"" + + refute Activity.get_by_ap_id(create["id"]) + end + + test "processes the activity after refetching a valid matching signature" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + + create = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://one.com/activities/valid-signature-create", + "context" => "https://one.com/contexts/valid-signature-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://one.com/objects/valid-signature-note", + "actor" => alice.ap_id, + "attributedTo" => alice.ap_id, + "context" => "https://one.com/contexts/valid-signature-create", + "content" => "valid post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + expect_signature_from(alice) + + assert {:ok, oban_job} = enqueue_failed_signature(create, alice) + assert {:ok, %Activity{}} = SignatureRetryWorker.perform(oban_job) + assert Activity.get_by_ap_id(create["id"]) + end + + test "processes the activity when a real signature validates with a query string" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + + create = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://one.com/activities/valid-query-signature-create", + "context" => "https://one.com/contexts/valid-query-signature-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://one.com/objects/valid-query-signature-note", + "actor" => alice.ap_id, + "attributedTo" => alice.ap_id, + "context" => "https://one.com/contexts/valid-query-signature-create", + "content" => "valid signed post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + stub_actor_fetch(alice) + + date = "Thu, 25 Jul 2024 13:33:31 GMT" + digest = "SHA-256=fake-digest" + + signature = + Signature.sign(alice, %{ + "(request-target)" => "post /inbox?foo=bar", + "content-type" => "application/activity+json", + date: date, + digest: digest, + host: "local.test" + }) + + req_headers = [ + ["host", "local.test"], + ["date", date], + ["digest", digest], + ["content-type", "application/activity+json"], + ["signature", signature] + ] + + assert {:ok, %Activity{}} = + SignatureRetryWorker.perform( + failed_signature_job(create, req_headers, query_string: "foo=bar") + ) + + assert Activity.get_by_ap_id(create["id"]) + end + + test "cancels when signature actor does not match payload actor" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + note = + insert(:note, + user: bob, + object_local: false, + data: %{"id" => "https://two.com/objects/malicious-update-note"} + ) + + update = %{ + "type" => "Update", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/malicious-update", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data + } + + assert_mismatched_signature_cancelled(update, alice) + end + + test "cancels signature actor mismatch through Federator-created jobs" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + note = + insert(:note, + user: bob, + object_local: false, + data: %{"id" => "https://two.com/objects/federator-malicious-note"} + ) + + update = %{ + "type" => "Update", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/federator-malicious-update", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data + } + + assert_mismatched_signature_cancelled(update, alice) + end + + test "cancels signature actor mismatch before processing a forged Create" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + create = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://two.com/objects/forged-note", + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert_mismatched_signature_cancelled(create, alice) + end + + test "cancels signature actor mismatch when payload actor is embedded" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + create = %{ + "type" => "Create", + "actor" => %{"id" => bob.ap_id}, + "id" => "https://two.com/activities/embedded-actor-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://two.com/objects/embedded-actor-forged-note", + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert_mismatched_signature_cancelled(create, alice) + end + + test "logs signature actor mismatch retry rejections" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + create = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/logged-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => "https://two.com/objects/logged-forged-note", + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + log = assert_mismatched_signature_cancelled(create, alice) + + assert log =~ "Failed-signature inbox retry rejected" + assert log =~ "reason=:actor_signature_mismatch" + assert log =~ "payload_actor=\"https://two.com/users/bob\"" + assert log =~ "signature_actor=\"https://one.com/users/alice\"" + assert log =~ "activity_id=\"https://two.com/activities/logged-forged-create\"" + assert log =~ "type=\"Create\"" + assert log =~ "request_path=\"/inbox\"" + end + + test "cancels signature actor mismatch before actually creating a forged post" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + object_id = "https://two.com/objects/actually-forged-note" + + create = %{ + "type" => "Create", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/actually-forged-create", + "context" => "https://two.com/contexts/actually-forged-create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "type" => "Note", + "id" => object_id, + "actor" => bob.ap_id, + "attributedTo" => bob.ap_id, + "context" => "https://two.com/contexts/actually-forged-create", + "content" => "forged post", + "published" => "2024-07-25T13:33:31Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert_mismatched_signature_cancelled(create, alice) + refute Object.get_by_ap_id(object_id) + end + + test "cancels signature actor mismatch before processing a forged Like" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + + like = %{ + "type" => "Like", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/forged-like", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data["id"] + } + + assert_mismatched_signature_cancelled(like, alice) + end + + test "cancels signature actor mismatch before actually creating a forged Like" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + + like = %{ + "type" => "Like", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/actually-forged-like", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data["id"] + } + + assert_mismatched_signature_cancelled(like, alice) + refute Activity.get_by_ap_id(like["id"]) + end + + test "cancels signature actor mismatch before processing a forged Announce" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + note = insert(:note) + + announce = %{ + "type" => "Announce", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/forged-announce", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => note.data["id"] + } + + assert_mismatched_signature_cancelled(announce, alice) + end + + test "cancels signature actor mismatch before processing a forged Follow" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + followed = insert(:user) + + follow = %{ + "type" => "Follow", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/forged-follow", + "to" => [followed.ap_id], + "cc" => [], + "object" => followed.ap_id + } + + assert_mismatched_signature_cancelled(follow, alice) + end + + test "cancels signature actor mismatch before processing a forged Undo" do + alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") + bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") + + undo = %{ + "type" => "Undo", + "actor" => bob.ap_id, + "id" => "https://two.com/activities/forged-undo", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => "https://two.com/activities/existing-bob-activity" + } + + assert_mismatched_signature_cancelled(undo, alice) + end +end