diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 000000000..865e7d782 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1,6 @@ +[ +{"lib/cachex.ex", "Unknown type: Spec.cache/0."}, +{"lib/pleroma/web/plugs/rate_limiter.ex", "The pattern can never match the type {:commit, _} | {:ignore, _}."}, +{"lib/pleroma/web/plugs/rate_limiter.ex", "Function get_scale/2 will never be called."}, +{"lib/pleroma/web/plugs/rate_limiter.ex", "Function initialize_buckets!/1 will never be called."} +] diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b0381d11..8f1839c42 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,13 +1,22 @@ image: git.pleroma.social:5050/pleroma/pleroma/ci-base variables: &global_variables + # Only used for the release + ELIXIR_VER: 1.12.3 POSTGRES_DB: pleroma_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres DB_HOST: postgres - DB_PORT: 5432 + DB_PORT: "5432" MIX_ENV: test +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + when: never + - if: $CI_COMMIT_BRANCH + cache: &global_cache_policy key: files: @@ -17,12 +26,15 @@ cache: &global_cache_policy - _build stages: + - check-changelog - build + - lint - test - benchmark - deploy - release - docker + - docker-combine before_script: - echo $MIX_ENV @@ -32,24 +44,61 @@ before_script: after_script: - rm -rf _build/*/lib/pleroma -build: +check-changelog: + stage: check-changelog + image: alpine + rules: + - if: $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == 'pleroma/pleroma' && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == 'weblate-extract' + when: never + - if: $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == 'pleroma/pleroma' && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == 'weblate' + when: never + - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" + before_script: '' + after_script: '' + cache: {} + script: + - apk add git + - sh ./tools/check-changelog + +.build_changes_policy: + rules: + - changes: + - ".gitlab-ci.yml" + - "**/*.ex" + - "**/*.exs" + - "mix.lock" + +.using-ci-base: + tags: + - amd64 + +build-1.12.3: + extends: + - .build_changes_policy + - .using-ci-base stage: build - only: - changes: &build_changes_policy - - ".gitlab-ci.yml" - - "**/*.ex" - - "**/*.exs" - - "mix.lock" + script: + - mix compile --force + +build-1.15.7-otp-25: + extends: + - .build_changes_policy + - .using-ci-base + stage: build + image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.15 + allow_failure: true script: - mix compile --force spec-build: - stage: test - only: - changes: - - ".gitlab-ci.yml" - - "lib/pleroma/web/api_spec/**/*.ex" - - "lib/pleroma/web/api_spec.ex" + extends: + - .using-ci-base + stage: build + rules: + - changes: + - ".gitlab-ci.yml" + - "lib/pleroma/web/api_spec/**/*.ex" + - "lib/pleroma/web/api_spec.ex" artifacts: paths: - spec.json @@ -57,6 +106,8 @@ spec-build: - mix pleroma.openapi_spec spec.json benchmark: + extends: + - .using-ci-base stage: benchmark when: manual variables: @@ -70,19 +121,19 @@ benchmark: - mix ecto.migrate - mix pleroma.load_testing -unit-testing: +unit-testing-1.12.3: + extends: + - .build_changes_policy + - .using-ci-base stage: test - only: - changes: *build_changes_policy cache: &testing_cache_policy <<: *global_cache_policy policy: pull - - services: + services: &testing_services - name: postgres:13-alpine alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - script: + script: &testing_script - mix ecto.create - mix ecto.migrate - mix test --cover --preload-modules @@ -93,48 +144,39 @@ unit-testing: coverage_format: cobertura path: coverage.xml -unit-testing-erratic: +unit-testing-1.15.7-otp-25: + extends: + - .build_changes_policy + - .using-ci-base + stage: test + image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.15-otp25 + allow_failure: true + cache: *testing_cache_policy + services: *testing_services + script: *testing_script + +unit-testing-1.12-erratic: + extends: + - .build_changes_policy + - .using-ci-base stage: test retry: 2 allow_failure: true - only: - changes: *build_changes_policy - cache: &testing_cache_policy - <<: *global_cache_policy - policy: pull - - services: - - name: postgres:13-alpine - alias: postgres - command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] + cache: *testing_cache_policy + services: *testing_services script: - mix ecto.create - mix ecto.migrate - mix test --only=erratic -# Removed to fix CI issue. In this early state it wasn't adding much value anyway. -# TODO Fix and reinstate federated testing -# federated-testing: -# stage: test -# cache: *testing_cache_policy -# services: -# - name: minibikini/postgres-with-rum:12 -# alias: postgres -# command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] -# script: -# - mix deps.get -# - mix ecto.create -# - mix ecto.migrate -# - epmd -daemon -# - mix test --trace --only federated - -unit-testing-rum: +unit-testing-1.12-rum: + extends: + - .build_changes_policy + - .using-ci-base stage: test - only: - changes: *build_changes_policy cache: *testing_cache_policy services: - - name: minibikini/postgres-with-rum:12 + - name: git.pleroma.social:5050/pleroma/pleroma/postgres-with-rum-13 alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] variables: @@ -146,11 +188,10 @@ unit-testing-rum: - "mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" - mix test --preload-modules -lint: - image: ¤t_elixir elixir:1.12-alpine - stage: test - only: - changes: *build_changes_policy +formatting-1.13: + extends: .build_changes_policy + image: &formatting_elixir elixir:1.13-alpine + stage: lint cache: *testing_cache_policy before_script: ¤t_bfr_script - apk update @@ -161,25 +202,38 @@ lint: script: - mix format --check-formatted -analysis: - stage: test - only: - changes: *build_changes_policy - cache: *testing_cache_policy - script: - - mix credo --strict --only=warnings,todo,fixme,consistency,readability - -cycles: - image: *current_elixir - stage: test - only: - changes: *build_changes_policy +cycles-1.13: + extends: .build_changes_policy + image: *formatting_elixir + stage: lint cache: {} before_script: *current_bfr_script script: - mix compile - mix xref graph --format cycles --label compile | awk '{print $0} END{exit ($0 != "No cycles found")}' +analysis: + extends: + - .build_changes_policy + - .using-ci-base + stage: lint + cache: *testing_cache_policy + script: + - mix credo --strict --only=warnings,todo,fixme,consistency,readability + +dialyzer: + extends: + - .build_changes_policy + - .using-ci-base + stage: lint + allow_failure: true + when: manual + cache: *testing_cache_policy + tags: + - feld + script: + - mix dialyzer + docs-deploy: stage: deploy cache: *testing_cache_policy @@ -190,7 +244,7 @@ docs-deploy: before_script: - apk add curl script: - - curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" https://git.pleroma.social/api/v4/projects/673/trigger/pipeline + - curl --fail-with-body -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" https://git.pleroma.social/api/v4/projects/673/trigger/pipeline review_app: image: alpine:3.9 stage: deploy @@ -231,7 +285,7 @@ spec-deploy: before_script: - apk add curl script: - - curl -X POST -F"token=$API_DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" -F"variables[JOB_REF]=$CI_JOB_ID" https://git.pleroma.social/api/v4/projects/1130/trigger/pipeline + - curl --fail-with-body -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" -F"variables[JOB_REF]=$CI_JOB_ID" https://git.pleroma.social/api/v4/projects/1130/trigger/pipeline stop_review_app: @@ -254,7 +308,7 @@ stop_review_app: amd64: stage: release - image: elixir:1.11.4 + image: elixir:$ELIXIR_VER only: &release-only - stable@pleroma/pleroma - develop@pleroma/pleroma @@ -278,8 +332,9 @@ amd64: - deps variables: &release-variables MIX_ENV: prod + VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS before_script: &before-release - - apt-get update && apt-get install -y cmake libmagic-dev + - apt-get update && apt-get install -y cmake libmagic-dev libvips-dev erlang-dev - echo "import Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force @@ -294,13 +349,13 @@ amd64-musl: stage: release artifacts: *release-artifacts only: *release-only - image: elixir:1.11.4-alpine + image: elixir:$ELIXIR_VER-alpine tags: - amd64 cache: *release-cache variables: *release-variables before_script: &before-release-musl - - apk add git build-base cmake file-dev openssl + - apk add git build-base cmake file-dev openssl vips-dev - echo "import Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force @@ -312,7 +367,7 @@ arm: only: *release-only tags: - arm32-specified - image: arm32v7/elixir:1.11.4 + image: arm32v7/elixir:$ELIXIR_VER cache: *release-cache variables: *release-variables before_script: *before-release @@ -324,7 +379,7 @@ arm-musl: only: *release-only tags: - arm32-specified - image: arm32v7/elixir:1.11.4-alpine + image: arm32v7/elixir:$ELIXIR_VER-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl @@ -336,7 +391,7 @@ arm64: only: *release-only tags: - arm - image: arm64v8/elixir:1.11.4 + image: arm64v8/elixir:$ELIXIR_VER cache: *release-cache variables: *release-variables before_script: *before-release @@ -348,110 +403,173 @@ arm64-musl: only: *release-only tags: - arm - image: arm64v8/elixir:1.11.4-alpine + image: arm64v8/elixir:$ELIXIR_VER-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl script: *release -docker: +.kaniko: stage: docker - image: docker:latest + image: + name: gcr.io/kaniko-project/executor:debug + entrypoint: [""] cache: {} dependencies: [] - variables: &docker-variables - DOCKER_DRIVER: overlay2 - DOCKER_HOST: unix:///var/run/docker.sock - IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - IMAGE_TAG_SLUG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG - IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest - IMAGE_TAG_LATEST_STABLE: $CI_REGISTRY_IMAGE:latest-stable - DOCKER_BUILDX_URL: https://github.com/docker/buildx/releases/download/v0.6.3/buildx-v0.6.3.linux-amd64 - DOCKER_BUILDX_HASH: 980e6b9655f971991fbbb5fd6cd19f1672386195 - before_script: &before-docker - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - - docker pull $IMAGE_TAG_SLUG || true + before_script: &before-kaniko - export CI_JOB_TIMESTAMP=$(date --utc -Iseconds) - export CI_VCS_REF=$CI_COMMIT_SHORT_SHA - allow_failure: true - script: - - mkdir -p /root/.docker/cli-plugins - - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx - - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - - chmod +x ~/.docker/cli-plugins/docker-buildx - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - docker buildx create --name mbuilder --driver docker-container --use - - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST . - tags: - - dind + - export IMAGE_TAG=$CI_REGISTRY_IMAGE/$BUILD_ARCH_IMG_SUFFIX:$CI_COMMIT_SHORT_SHA + - export IMAGE_TAG_SLUG=$CI_REGISTRY_IMAGE/$BUILD_ARCH_IMG_SUFFIX:$CI_COMMIT_REF_SLUG + - export IMAGE_TAG_LATEST=$CI_REGISTRY_IMAGE/$BUILD_ARCH_IMG_SUFFIX:latest + - export IMAGE_TAG_LATEST_STABLE=$CI_REGISTRY_IMAGE/$BUILD_ARCH_IMG_SUFFIX:latest-stable + - mkdir -p /kaniko/.docker + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json + +.kaniko-latest: + extends: .kaniko only: - develop@pleroma/pleroma - -docker-stable: - stage: docker - image: docker:latest - cache: {} - dependencies: [] - variables: *docker-variables - before_script: *before-docker - allow_failure: true script: - - mkdir -p /root/.docker/cli-plugins - - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx - - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - - chmod +x ~/.docker/cli-plugins/docker-buildx - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - docker buildx create --name mbuilder --driver docker-container --use - - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG -t $IMAGE_TAG_LATEST_STABLE . - tags: - - dind + - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --custom-platform=$BUILD_ARCH --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP --build-arg ELIXIR_IMG=$ELIXIR_IMG --destination $IMAGE_TAG --destination $IMAGE_TAG_SLUG --destination $IMAGE_TAG_LATEST + +.kaniko-stable: + extends: .kaniko only: - stable@pleroma/pleroma + script: + - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --custom-platform=$BUILD_ARCH --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP --build-arg ELIXIR_IMG=$ELIXIR_IMG --destination $IMAGE_TAG --destination $IMAGE_TAG_SLUG --destination $IMAGE_TAG_LATEST_STABLE -docker-release: - stage: docker - image: docker:latest - cache: {} - dependencies: [] - variables: *docker-variables - before_script: *before-docker - allow_failure: true - script: - script: - - mkdir -p /root/.docker/cli-plugins - - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx - - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - - chmod +x ~/.docker/cli-plugins/docker-buildx - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - docker buildx create --name mbuilder --driver docker-container --use - - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . - tags: - - dind +.kaniko-release: + extends: .kaniko only: - /^release/.*$/@pleroma/pleroma + script: + - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --custom-platform=$BUILD_ARCH --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP --build-arg ELIXIR_IMG=$ELIXIR_IMG --destination $IMAGE_TAG --destination $IMAGE_TAG_SLUG -docker-adhoc: - stage: docker - image: docker:latest - cache: {} - dependencies: [] - variables: *docker-variables - before_script: *before-docker - allow_failure: true - script: - script: - - mkdir -p /root/.docker/cli-plugins - - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx - - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - - chmod +x ~/.docker/cli-plugins/docker-buildx - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - docker buildx create --name mbuilder --driver docker-container --use - - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --cache-from $IMAGE_TAG_SLUG --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP -t $IMAGE_TAG -t $IMAGE_TAG_SLUG . - tags: - - dind +.kaniko-adhoc: + extends: .kaniko only: - /^build-docker/.*$/@pleroma/pleroma + script: + - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --custom-platform=$BUILD_ARCH --build-arg VCS_REF=$CI_VCS_REF --build-arg BUILD_DATE=$CI_JOB_TIMESTAMP --build-arg ELIXIR_IMG=$ELIXIR_IMG --destination $IMAGE_TAG --destination $IMAGE_TAG_SLUG + +.kaniko:linux/amd64: + variables: + BUILD_ARCH: linux/amd64 + BUILD_ARCH_IMG_SUFFIX: linux-amd64 + ELIXIR_IMG: hexpm/elixir + tags: + - amd64 + +.kaniko:linux/arm64: + variables: + BUILD_ARCH: linux/arm64/v8 + BUILD_ARCH_IMG_SUFFIX: linux-arm64-v8 + ELIXIR_IMG: hexpm/elixir + tags: + - arm + +.kaniko:linux/arm: + variables: + BUILD_ARCH: linux/arm/v7 + BUILD_ARCH_IMG_SUFFIX: linux-arm-v7 + ELIXIR_IMG: git.pleroma.social:5050/pleroma/ci-image/elixir-linux-arm-v7 + tags: + - arm32-specified + +kaniko-latest:linux/amd64: + extends: + - .kaniko-latest + - .kaniko:linux/amd64 + +kaniko-latest:linux/arm64: + extends: + - .kaniko-latest + - .kaniko:linux/arm64 + +kaniko-latest:linux/arm: + extends: + - .kaniko-latest + - .kaniko:linux/arm + +kaniko-stable:linux/amd64: + extends: + - .kaniko-stable + - .kaniko:linux/amd64 + +kaniko-stable:linux/arm64: + extends: + - .kaniko-stable + - .kaniko:linux/arm64 + +kaniko-stable:linux/arm: + extends: + - .kaniko-stable + - .kaniko:linux/arm + +kaniko-release:linux/amd64: + extends: + - .kaniko-release + - .kaniko:linux/amd64 + +kaniko-release:linux/arm64: + extends: + - .kaniko-release + - .kaniko:linux/arm64 + +kaniko-release:linux/arm: + extends: + - .kaniko-release + - .kaniko:linux/arm + +.docker-combine: + stage: docker-combine + image: docker:cli + cache: {} + before_script: + - 'BUILD_ARCHES="linux-amd64 linux-arm64-v8 linux-arm-v7"' + - export IMAGE_TAG=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA + - export IMAGE_TAG_SLUG=$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + - export IMAGE_TAG_LATEST=$CI_REGISTRY_IMAGE:latest + - export IMAGE_TAG_LATEST_STABLE=$CI_REGISTRY_IMAGE:latest-stable + - 'IMAGES=; for arch in $BUILD_ARCHES; do IMAGES="$IMAGES $CI_REGISTRY_IMAGE/$arch:$CI_COMMIT_SHORT_SHA"; done' + - 'IMAGES_SLUG=; for arch in $BUILD_ARCHES; do IMAGES_SLUG="$IMAGES_SLUG $CI_REGISTRY_IMAGE/$arch:$CI_COMMIT_REF_SLUG"; done' + - 'IMAGES_LATEST=; for arch in $BUILD_ARCHES; do IMAGES_LATEST="$IMAGES_LATEST $CI_REGISTRY_IMAGE/$arch:latest"; done' + - 'IMAGES_LATEST_STABLE=; for arch in $BUILD_ARCHES; do IMAGES_LATEST_STABLE="$IMAGES_LATEST_STABLE $CI_REGISTRY_IMAGE/$arch:latest"; done' + - mkdir -p ~/.docker + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > ~/.docker/config.json + +docker-combine:latest: + extends: .docker-combine + only: + - develop@pleroma/pleroma + script: + - 'docker manifest create $IMAGE_TAG $IMAGES' + - 'docker manifest push $IMAGE_TAG' + - 'docker manifest create $IMAGE_TAG_SLUG $IMAGES_SLUG' + - 'docker manifest push $IMAGE_TAG_SLUG' + - 'docker manifest create $IMAGE_TAG_LATEST $IMAGES_LATEST' + - 'docker manifest push $IMAGE_TAG_LATEST' + +docker-combine:stable: + extends: .docker-combine + only: + - stable@pleroma/pleroma + script: + - 'docker manifest create $IMAGE_TAG $IMAGES' + - 'docker manifest push $IMAGE_TAG' + - 'docker manifest create $IMAGE_TAG_SLUG $IMAGES_SLUG' + - 'docker manifest push $IMAGE_TAG_SLUG' + - 'docker manifest create $IMAGE_TAG_LATEST_STABLE $IMAGES_LATEST_STABLE' + - 'docker manifest push $IMAGE_TAG_LATEST_STABLE' + +docker-combine:release: + extends: .docker-combine + only: + - /^release/.*$/@pleroma/pleroma + script: + - 'docker manifest create $IMAGE_TAG $IMAGES' + - 'docker manifest push $IMAGE_TAG' + - 'docker manifest create $IMAGE_TAG_SLUG $IMAGES_SLUG' + - 'docker manifest push $IMAGE_TAG_SLUG' diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 000000000..641d9cfd8 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,10 @@ +### Checklist +- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `.`. + + `` can be anything, but we recommend using a more or less unique identifier to avoid collisions, such as the branch name. + + `` can be `add`, `change`, `remove`, `fix`, `security` or `skip`. `skip` is only used if there is no user-visible change in the MR (for example, only editing comments in the code). Otherwise, choose a type that corresponds to your change. + + In the file, write the changelog entry. For example, if an MR adds group functionality, we can create a file named `group.add` and write `Add group functionality` in it. + + If one changelog entry is not enough, you may add more. But that might mean you can split it into two MRs. Only use more than one changelog entry if you really need to (for example, when one change in the code fix two different bugs, or when refactoring). diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md index 9638d6d11..e57556e6c 100644 --- a/.gitlab/merge_request_templates/Release.md +++ b/.gitlab/merge_request_templates/Release.md @@ -1,6 +1,6 @@ ### Release checklist * [ ] Bump version in `mix.exs` -* [ ] Compile a changelog +* [ ] Compile a changelog with the `tools/collect-changelog` script * [ ] Create an MR with an announcement to pleroma.social #### post-merge * [ ] Tag the release on the merge commit diff --git a/.rgignore b/.rgignore new file mode 100644 index 000000000..975056b6d --- /dev/null +++ b/.rgignore @@ -0,0 +1 @@ +priv/static diff --git a/CHANGELOG.md b/CHANGELOG.md index c15546608..e071fe693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,125 @@ 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/). -## Unreleased - +## 2.6.1 ### Changed +- - Document maximum supported version of Erlang & Elixir ### Added +- [docs] add frontends management documentation ### Fixed +- TwitterAPI: Return proper error when healthcheck is disabled +- Fix eblurhash and elixir-captcha not using system cflags +## 2.6.0 +### Security +- Preload: Make generated JSON html-safe. It already was html safe because it only consists of config data that is base64 encoded, but this will keep it safe it that ever changes. +- CommonAPI: Prevent users from accessing media of other users by creating a status with reused attachment ID +- Disable XML entity resolution completely to fix a dos vulnerability + +### Added +- Support for Image activities, namely from Hubzilla +- Add OAuth scope descriptions +- Allow lang attribute in status text +- OnlyMedia Upload Filter +- Implement MRF policy to reject or delist according to emojis +- (hardening) Add no_new_privs=yes to OpenRC service files +- Implement quotes +- Add unified streaming endpoint + +### Fixed - rel="me" was missing its cache +- MediaProxy responses now return a sandbox CSP header +- Filter context activities using Visibility.visible_for_user? +- UploadedMedia: Add missing disposition_type to Content-Disposition +- fix not being able to fetch flash file from remote instance +- Fix abnormal behaviour when refetching a poll +- Allow non-HTTP(s) URIs in "url" fields for compatibility with "FEP-fffd: Proxy Objects" +- Fix opengraph and twitter card meta tags +- ForceMentionsInContent: fix double mentions for Mastodon/Misskey posts +- OEmbed HTML tags are now filtered +- Restrict attachments to only uploaded files only +- Fix error 404 when deleting status of a banned user +- Fix config ownership in dockerfile to pass restriction test +- Fix user fetch completely broken if featured collection is not in a supported form +- Correctly handle the situation when a poll has both "anyOf" and "oneOf" but one of them being empty +- Fix handling report from a deactivated user +- Prevent using the .json format to bypass authorized fetch mode +- Fix mentioning punycode domains when using Markdown +- Show more informative errors when profile exceeds char limits ### Removed +- BREAKING: Support for passwords generated with `crypt(3)` (Gnu Social migration artifact) +- remove BBS/SSH feature, replaced by an external bridge. +- Remove a few unused indexes. +- Cleanup OStatus-era user upgrades and ap_enabled indicator +- Deprecate Pleroma's audio scrobbling + +## 2.5.4 + +## Security +- Fix XML External Entity (XXE) loading vulnerability allowing to fetch arbitrary files from the server's filesystem + +## 2.5.3 + +### Security +- Emoji pack loader sanitizes pack names +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories + +## 2.5.5 + +## Security +- Prevent users from accessing media of other users by creating a status with reused attachment ID + +## 2.5.4 + +## Security +- Fix XML External Entity (XXE) loading vulnerability allowing to fetch arbitrary files from the server's filesystem + +## 2.5.3 + +### Security +- Emoji pack loader sanitizes pack names +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories + +## 2.5.2 + +### Security +- `/proxy` endpoint now sets a Content-Security-Policy (sandbox) +- WebSocket endpoint now respects unauthenticated restrictions for streams of public posts +- OEmbed HTML tags are now filtered + +### Changed +- docs: Be more explicit about the level of compatibility of OTP releases +- Set default background worker timeout to 15 minutes + +### Fixed +- Atom/RSS formatting (HTML truncation, published, missing summary) +- Remove `static_fe` pipeline for `/users/:nickname/feed` +- Stop oban from retrying if validating errors occur when processing incoming data +- Make sure object refetching as used by already received polls follows MRF rules + +### Removed +- BREAKING: Support for passwords generated with `crypt(3)` (Gnu Social migration artifact) + +## 2.5.1 + +### Added +- Allow customizing instance languages + +### Fixed +- Security: uploading HTTP endpoint can no longer create directories in the upload dir (internal APIs, like backup, still can do it.) +- ~ character in urls in Markdown posts are handled properly +- Exiftool upload filter will now ignore SVG files +- Fix `block_from_stranger` setting +- Fix rel="me" +- Docker images will now run properly +- Fix improper content being cached in report content +- Notification filter on object content will not operate on the ones that inherently have no content +- ZWNJ and double dots in links are parsed properly for Plain-text posts +- OTP releases will work on systems with a newer libcrypt +- Errors when running Exiftool.ReadDescription filter will not be filled into the image description ## 2.5.0 - 2022-12-23 @@ -676,7 +784,7 @@ switched to a new configuration mechanism, however it was not officially removed - Rate limiter crashes when there is no explicitly specified ip in the config - 500 errors when no `Accept` header is present if Static-FE is enabled - Instance panel not being updated immediately due to wrong `Cache-Control` headers -- Statuses posted with BBCode/Markdown having unncessary newlines in Pleroma-FE +- Statuses posted with BBCode/Markdown having unnecessary newlines in Pleroma-FE - OTP: Fix some settings not being migrated to in-database config properly - No `Cache-Control` headers on attachment/media proxy requests - Character limit enforcement being off by 1 @@ -996,10 +1104,10 @@ curl -Lo ./bin/pleroma_ctl 'https://git.pleroma.social/pleroma/pleroma/raw/devel - Reverse Proxy limiting `max_body_length` was incorrectly defined and only checked `Content-Length` headers which may not be sufficient in some circumstances ### Added -- Expiring/ephemeral activites. All activities can have expires_at value set, which controls when they should be deleted automatically. +- Expiring/ephemeral activities. All activities can have expires_at value set, which controls when they should be deleted automatically. - Mastodon API: in post_status, the expires_in parameter lets you set the number of seconds until an activity expires. It must be at least one hour. - Mastodon API: all status JSON responses contain a `pleroma.expires_at` item which states when an activity will expire. The value is only shown to the user who created the activity. To everyone else it's empty. -- Configuration: `ActivityExpiration.enabled` controls whether expired activites will get deleted at the appropriate time. Enabled by default. +- Configuration: `ActivityExpiration.enabled` controls whether expired activities will get deleted at the appropriate time. Enabled by default. - Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data. - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) - MRF: Support for excluding specific domains from Transparency. diff --git a/Dockerfile b/Dockerfile index 8c3ff3ac5..69c3509de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,16 @@ -ARG ELIXIR_VER=1.11.4 +ARG ELIXIR_IMG=hexpm/elixir +ARG ELIXIR_VER=1.12.3 ARG ERLANG_VER=24.2.1 ARG ALPINE_VER=3.17.0 -FROM hexpm/elixir:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} as build +FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} as build COPY . . ENV MIX_ENV=prod +ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS -RUN apk add git gcc g++ musl-dev make cmake file-dev &&\ +RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ echo "import Config" > config/prod.secret.exs &&\ mix local.hex --force &&\ mix local.rebar --force &&\ @@ -36,7 +38,7 @@ ARG HOME=/opt/pleroma ARG DATA=/var/lib/pleroma RUN apk update &&\ - apk add exiftool ffmpeg imagemagick libmagic ncurses postgresql-client &&\ + apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ @@ -48,7 +50,7 @@ USER pleroma COPY --from=build --chown=pleroma:0 /release ${HOME} -COPY ./config/docker.exs /etc/pleroma/config.exs +COPY --chown=pleroma --chmod=640 ./config/docker.exs /etc/pleroma/config.exs COPY ./docker-entrypoint.sh ${HOME} EXPOSE 4000 diff --git a/README.md b/README.md index 62f8fdc64..2837b6ef8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ If your platform is not supported, or you just want to be able to edit the sourc - [OpenBSD (fi)](https://docs-develop.pleroma.social/backend/installation/openbsd_fi/) ### OS/Distro packages -Currently Pleroma is packaged for [YunoHost](https://yunohost.org) and [NixOS](https://nixos.org). If you want to package Pleroma for any OS/Distros, we can guide you through the process on our [community channels](#community-channels). If you want to change default options in your Pleroma package, please **discuss it with us first**. +Currently Pleroma is packaged for [YunoHost](https://yunohost.org), [NixOS](https://nixos.org), [Gentoo through GURU](https://gentoo.org/) and [Archlinux through AUR](https://aur.archlinux.org/packages/pleroma). You may find more at . +If you want to package Pleroma for any OS/Distros, we can guide you through the process on our [community channels](#community-channels). If you want to change default options in your Pleroma package, please **discuss it with us first**. ### Docker While we don’t provide docker files, other people have written very good ones. Take a look at or . diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/benchmarks/mix/tasks/pleroma/benchmark.ex similarity index 93% rename from lib/mix/tasks/pleroma/benchmark.ex rename to benchmarks/mix/tasks/pleroma/benchmark.ex index f32492169..42b28478d 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/benchmarks/mix/tasks/pleroma/benchmark.ex @@ -3,8 +3,20 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Benchmark do - import Mix.Pleroma + @shortdoc "Benchmarks" + @moduledoc """ + Benchmark tasks available: + + adapters + render_timeline + search + tag + + MIX_ENV=benchmark mix pleroma.benchmark adapters + """ + use Mix.Task + import Mix.Pleroma def run(["search"]) do start_pleroma() @@ -63,7 +75,7 @@ def run(["render_timeline", nickname | _] = args) do Benchee.run( %{ - "Standart rendering" => fn activities -> + "Standard rendering" => fn activities -> Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ activities: activities, for: user, diff --git a/changelog.d/2.6.0-mergeback.skip b/changelog.d/2.6.0-mergeback.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/2.6.1-mergeback.skip b/changelog.d/2.6.1-mergeback.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/3900.change b/changelog.d/3900.change new file mode 100644 index 000000000..fe0cc2fbf --- /dev/null +++ b/changelog.d/3900.change @@ -0,0 +1 @@ +Update to Phoenix 1.7 diff --git a/changelog.d/3987.fix b/changelog.d/3987.fix new file mode 100644 index 000000000..5d578cc09 --- /dev/null +++ b/changelog.d/3987.fix @@ -0,0 +1 @@ +Remove checking ImageMagick's commands for Pleroma.Upload.Filter.AnalyzeMetadata diff --git a/changelog.d/account-rendering-auth-check.fix b/changelog.d/account-rendering-auth-check.fix new file mode 100644 index 000000000..12f68e454 --- /dev/null +++ b/changelog.d/account-rendering-auth-check.fix @@ -0,0 +1 @@ +Fix authentication check on account rendering when bio is defined diff --git a/changelog.d/add-outbox.fix b/changelog.d/add-outbox.fix new file mode 100644 index 000000000..f3de5338d --- /dev/null +++ b/changelog.d/add-outbox.fix @@ -0,0 +1 @@ +ap userview: add outbox field. diff --git a/changelog.d/anonymous-exception-else.fix b/changelog.d/anonymous-exception-else.fix new file mode 100644 index 000000000..38d5d1be5 --- /dev/null +++ b/changelog.d/anonymous-exception-else.fix @@ -0,0 +1 @@ +Fix #strip_report_status_data diff --git a/changelog.d/api-docs.skip b/changelog.d/api-docs.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/authorize-interaction.add b/changelog.d/authorize-interaction.add new file mode 100644 index 000000000..8692209e1 --- /dev/null +++ b/changelog.d/authorize-interaction.add @@ -0,0 +1 @@ +Support /authorize-interaction route used by Mastodon \ No newline at end of file diff --git a/changelog.d/bad_inbox_request.change b/changelog.d/bad_inbox_request.change new file mode 100644 index 000000000..b81f60638 --- /dev/null +++ b/changelog.d/bad_inbox_request.change @@ -0,0 +1 @@ +Invalid activities delivered to the inbox will be rejected with a 400 Bad Request diff --git a/changelog.d/bare_uri_test.skip b/changelog.d/bare_uri_test.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/benchee.skip b/changelog.d/benchee.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/blurhash.change b/changelog.d/blurhash.change new file mode 100644 index 000000000..4276eb164 --- /dev/null +++ b/changelog.d/blurhash.change @@ -0,0 +1 @@ +Replace eblurhash with rinpatch_blurhash. This also removes a dependency on ImageMagick. diff --git a/changelog.d/build-release-with-local-libvips.skip b/changelog.d/build-release-with-local-libvips.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/chat-attachment-empty-array.fix b/changelog.d/chat-attachment-empty-array.fix new file mode 100644 index 000000000..7d98c9dd2 --- /dev/null +++ b/changelog.d/chat-attachment-empty-array.fix @@ -0,0 +1 @@ +ChatMessage: Tolerate attachment field set to an empty array diff --git a/changelog.d/deprecations.skip b/changelog.d/deprecations.skip new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/changelog.d/deprecations.skip @@ -0,0 +1 @@ + diff --git a/changelog.d/deprecations2.skip b/changelog.d/deprecations2.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/deps-bump-2024-01-25.skip b/changelog.d/deps-bump-2024-01-25.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/dialyzer.skip b/changelog.d/dialyzer.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/dialyzer2.skip b/changelog.d/dialyzer2.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/dialyzer3.skip b/changelog.d/dialyzer3.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/digest_emails.fix b/changelog.d/digest_emails.fix new file mode 100644 index 000000000..335a24464 --- /dev/null +++ b/changelog.d/digest_emails.fix @@ -0,0 +1 @@ +Fix the processing of email digest jobs. diff --git a/changelog.d/doc-fix.skip b/changelog.d/doc-fix.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/docs-max-elixir-erlang.change b/changelog.d/docs-max-elixir-erlang.change new file mode 100644 index 000000000..a58b7fc17 --- /dev/null +++ b/changelog.d/docs-max-elixir-erlang.change @@ -0,0 +1 @@ +- Document maximum supported version of Erlang & Elixir diff --git a/changelog.d/emoji-download-paginate.fix b/changelog.d/emoji-download-paginate.fix new file mode 100644 index 000000000..e31a63380 --- /dev/null +++ b/changelog.d/emoji-download-paginate.fix @@ -0,0 +1 @@ +When downloading remote emojis packs, account for pagination \ No newline at end of file diff --git a/changelog.d/emoji-use-v1.fix b/changelog.d/emoji-use-v1.fix new file mode 100644 index 000000000..ccc96b377 --- /dev/null +++ b/changelog.d/emoji-use-v1.fix @@ -0,0 +1 @@ +Make remote emoji packs API use specifically the V1 URL. Akkoma does not understand it without V1, and it works either way with normal pleroma, so no reason to not do this \ No newline at end of file diff --git a/changelog.d/exile-bsds.skip b/changelog.d/exile-bsds.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/exile-macos.skip b/changelog.d/exile-macos.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/exile.skip b/changelog.d/exile.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/favicon.add b/changelog.d/favicon.add new file mode 100644 index 000000000..cf12395e7 --- /dev/null +++ b/changelog.d/favicon.add @@ -0,0 +1 @@ +Add support for configuring favicon, embed favicon and PWA manifest in server-generated meta diff --git a/changelog.d/federation_status-access.change b/changelog.d/federation_status-access.change new file mode 100644 index 000000000..952254476 --- /dev/null +++ b/changelog.d/federation_status-access.change @@ -0,0 +1 @@ +- Make `/api/v1/pleroma/federation_status` publicly available diff --git a/changelog.d/federator-modules.remove b/changelog.d/federator-modules.remove new file mode 100644 index 000000000..6ff71d107 --- /dev/null +++ b/changelog.d/federator-modules.remove @@ -0,0 +1 @@ +Removed support for multiple federator modules as we only support ActivityPub diff --git a/changelog.d/federator.skip b/changelog.d/federator.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/finch_redirects.fix b/changelog.d/finch_redirects.fix new file mode 100644 index 000000000..c25beaba4 --- /dev/null +++ b/changelog.d/finch_redirects.fix @@ -0,0 +1 @@ +Following HTTP Redirects when the HTTP Adapter is Finch diff --git a/changelog.d/fix-dockerfile.skip b/changelog.d/fix-dockerfile.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/fix-duplicate-inbox-deliveries.fix b/changelog.d/fix-duplicate-inbox-deliveries.fix new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/fix-otp-comparison.skip b/changelog.d/fix-otp-comparison.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/fix-tests.skip b/changelog.d/fix-tests.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/frontend-management.add b/changelog.d/frontend-management.add new file mode 100644 index 000000000..b85cddd96 --- /dev/null +++ b/changelog.d/frontend-management.add @@ -0,0 +1 @@ +[docs] add frontends management documentation diff --git a/changelog.d/generate-unset-user-keys-migration.skip b/changelog.d/generate-unset-user-keys-migration.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/group-actor.add b/changelog.d/group-actor.add new file mode 100644 index 000000000..2f614b3d8 --- /dev/null +++ b/changelog.d/group-actor.add @@ -0,0 +1 @@ +Implement group actors diff --git a/changelog.d/handle_object_fetch_failures.change b/changelog.d/handle_object_fetch_failures.change new file mode 100644 index 000000000..ae44e6f4b --- /dev/null +++ b/changelog.d/handle_object_fetch_failures.change @@ -0,0 +1 @@ +Remote object fetch failures will prevent the object fetch job from retrying if the object request returns 401, 403, 404, 410, or exceeds the maximum thread depth. diff --git a/changelog.d/healthcheck-disabled-error.fix b/changelog.d/healthcheck-disabled-error.fix new file mode 100644 index 000000000..984384a52 --- /dev/null +++ b/changelog.d/healthcheck-disabled-error.fix @@ -0,0 +1 @@ +TwitterAPI: Return proper error when healthcheck is disabled diff --git a/changelog.d/instance-defdelegates.skip b/changelog.d/instance-defdelegates.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/instance-v2.add b/changelog.d/instance-v2.add new file mode 100644 index 000000000..4dd7ce8c0 --- /dev/null +++ b/changelog.d/instance-v2.add @@ -0,0 +1 @@ +Implement /api/v2/instance route \ No newline at end of file diff --git a/changelog.d/last_status_at.change b/changelog.d/last_status_at.change new file mode 100644 index 000000000..5417aff30 --- /dev/null +++ b/changelog.d/last_status_at.change @@ -0,0 +1 @@ +- Change AccountView `last_status_at` from a datetime to a date (as done in Mastodon 3.1.0) \ No newline at end of file diff --git a/changelog.d/loading-order-test-fix.skip b/changelog.d/loading-order-test-fix.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/local-webfinger.fix b/changelog.d/local-webfinger.fix new file mode 100644 index 000000000..d99056efd --- /dev/null +++ b/changelog.d/local-webfinger.fix @@ -0,0 +1 @@ +Use correct domain for fqn and InstanceView \ No newline at end of file diff --git a/changelog.d/mastodon_directory.fix b/changelog.d/mastodon_directory.fix new file mode 100644 index 000000000..937c8f864 --- /dev/null +++ b/changelog.d/mastodon_directory.fix @@ -0,0 +1 @@ +Mastodon API /api/v1/directory: Fix listing directory contents when not authenticated diff --git a/changelog.d/meilisearch.add b/changelog.d/meilisearch.add new file mode 100644 index 000000000..4856eea2e --- /dev/null +++ b/changelog.d/meilisearch.add @@ -0,0 +1 @@ +Add meilisearch, make search engines pluggable diff --git a/changelog.d/migration-fix.skip b/changelog.d/migration-fix.skip new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/changelog.d/migration-fix.skip @@ -0,0 +1 @@ + diff --git a/changelog.d/mrf-regex-error.fix b/changelog.d/mrf-regex-error.fix new file mode 100644 index 000000000..2c43bc04a --- /dev/null +++ b/changelog.d/mrf-regex-error.fix @@ -0,0 +1 @@ +MRF: Log sensible error for subdomains_regex diff --git a/changelog.d/mrf-steal-emoji-extname.fix b/changelog.d/mrf-steal-emoji-extname.fix new file mode 100644 index 000000000..197aa9b9e --- /dev/null +++ b/changelog.d/mrf-steal-emoji-extname.fix @@ -0,0 +1 @@ +MRF.StealEmojiPolicy: Properly add fallback extension to filenames missing one diff --git a/changelog.d/mrf_hashtags.fix b/changelog.d/mrf_hashtags.fix new file mode 100644 index 000000000..c44c2376b --- /dev/null +++ b/changelog.d/mrf_hashtags.fix @@ -0,0 +1 @@ +Federated timeline removal of hashtags via MRF HashtagPolicy diff --git a/changelog.d/nil-content-map.fix b/changelog.d/nil-content-map.fix new file mode 100644 index 000000000..d4943bf74 --- /dev/null +++ b/changelog.d/nil-content-map.fix @@ -0,0 +1 @@ +Support objects with a null contentMap (firefish) diff --git a/changelog.d/no-async-with-clear-config.skip b/changelog.d/no-async-with-clear-config.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/opengraph-rich-media-proxy.add b/changelog.d/opengraph-rich-media-proxy.add new file mode 100644 index 000000000..2b2fc657d --- /dev/null +++ b/changelog.d/opengraph-rich-media-proxy.add @@ -0,0 +1 @@ +Add media proxy to opengraph rich media cards diff --git a/changelog.d/optimistic-inbox.change b/changelog.d/optimistic-inbox.change new file mode 100644 index 000000000..2cf1ce92c --- /dev/null +++ b/changelog.d/optimistic-inbox.change @@ -0,0 +1 @@ +Optimistic Inbox reduces the processing overhead of incoming activities without instantly verifiable signatures. diff --git a/changelog.d/otp26.add b/changelog.d/otp26.add new file mode 100644 index 000000000..b019afdf3 --- /dev/null +++ b/changelog.d/otp26.add @@ -0,0 +1 @@ +Support for Erlang OTP 26 diff --git a/changelog.d/prioritize-direct-recipients.add b/changelog.d/prioritize-direct-recipients.add new file mode 100644 index 000000000..4efc94c68 --- /dev/null +++ b/changelog.d/prioritize-direct-recipients.add @@ -0,0 +1 @@ +- Prioritize mentioned recipients (i.e., those that are not just followers) when federating. diff --git a/changelog.d/promex.change b/changelog.d/promex.change new file mode 100644 index 000000000..6c1571c54 --- /dev/null +++ b/changelog.d/promex.change @@ -0,0 +1 @@ +Change the prometheus library to PromEx. diff --git a/changelog.d/publisher_discard.change b/changelog.d/publisher_discard.change new file mode 100644 index 000000000..85e530d8d --- /dev/null +++ b/changelog.d/publisher_discard.change @@ -0,0 +1 @@ +Activity publishing failures will prevent the job from retrying if the publishing request returns a 403 or 410 diff --git a/changelog.d/publisher_log.change b/changelog.d/publisher_log.change new file mode 100644 index 000000000..3f85f5a1e --- /dev/null +++ b/changelog.d/publisher_log.change @@ -0,0 +1 @@ +Publisher errors will now emit logs indicating the inbox that was not available for delivery. diff --git a/changelog.d/qtfaststart.fix b/changelog.d/qtfaststart.fix new file mode 100644 index 000000000..66d2569f2 --- /dev/null +++ b/changelog.d/qtfaststart.fix @@ -0,0 +1 @@ +MediaProxy Preview failures prevented when encountering certain video files diff --git a/changelog.d/quotes-count.skip b/changelog.d/quotes-count.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/reachability.change b/changelog.d/reachability.change new file mode 100644 index 000000000..06f63272b --- /dev/null +++ b/changelog.d/reachability.change @@ -0,0 +1 @@ +Reduce the reachability timestamp update to a single upsert query diff --git a/changelog.d/scrobble-url.add b/changelog.d/scrobble-url.add new file mode 100644 index 000000000..24bdeed89 --- /dev/null +++ b/changelog.d/scrobble-url.add @@ -0,0 +1 @@ +Adds the capability to add a URL to a scrobble (optional field) diff --git a/changelog.d/scrubbers-html4-GtS.add b/changelog.d/scrubbers-html4-GtS.add new file mode 100644 index 000000000..7f99dbb25 --- /dev/null +++ b/changelog.d/scrubbers-html4-GtS.add @@ -0,0 +1 @@ +- scrubbers/default: Add more formatting elements from HTML4 / GoToSocial (acronym, bdo, big, cite, dfn, ins, kbd, q, samp, s, tt, var, wbr) diff --git a/changelog.d/system-cflags.fix b/changelog.d/system-cflags.fix new file mode 100644 index 000000000..84de5ad57 --- /dev/null +++ b/changelog.d/system-cflags.fix @@ -0,0 +1 @@ +- Fix eblurhash and elixir-captcha not using system cflags diff --git a/changelog.d/testsecrets.skip b/changelog.d/testsecrets.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/typo.skip b/changelog.d/typo.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/vips.change b/changelog.d/vips.change new file mode 100644 index 000000000..ee18cd34b --- /dev/null +++ b/changelog.d/vips.change @@ -0,0 +1 @@ +Change mediaproxy previews to use vips to generate thumbnails instead of ImageMagick diff --git a/changelog.d/web_push.fix b/changelog.d/web_push.fix new file mode 100644 index 000000000..cf933e2d4 --- /dev/null +++ b/changelog.d/web_push.fix @@ -0,0 +1 @@ +Fix web push notifications not successfully delivering diff --git a/ci/build_and_push.sh b/ci/build_and_push.sh deleted file mode 100755 index 484cc2643..000000000 --- a/ci/build_and_push.sh +++ /dev/null @@ -1 +0,0 @@ -docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:latest --push . diff --git a/ci/Dockerfile b/ci/elixir-1.12/Dockerfile similarity index 93% rename from ci/Dockerfile rename to ci/elixir-1.12/Dockerfile index ca28b7029..a2b566873 100644 --- a/ci/Dockerfile +++ b/ci/elixir-1.12/Dockerfile @@ -1,4 +1,4 @@ -FROM elixir:1.11.4 +FROM elixir:1.12.3 # Single RUN statement, otherwise intermediate images are created # https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#run diff --git a/ci/elixir-1.12/build_and_push.sh b/ci/elixir-1.12/build_and_push.sh new file mode 100755 index 000000000..508262ed8 --- /dev/null +++ b/ci/elixir-1.12/build_and_push.sh @@ -0,0 +1 @@ +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.12 --push . diff --git a/ci/elixir-1.15-otp25/Dockerfile b/ci/elixir-1.15-otp25/Dockerfile new file mode 100644 index 000000000..3335c6e36 --- /dev/null +++ b/ci/elixir-1.15-otp25/Dockerfile @@ -0,0 +1,8 @@ +FROM elixir:1.15.7-otp-25 + +# Single RUN statement, otherwise intermediate images are created +# https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#run +RUN apt-get update &&\ + apt-get install -y libmagic-dev cmake libimage-exiftool-perl ffmpeg &&\ + mix local.hex --force &&\ + mix local.rebar --force diff --git a/ci/elixir-1.15-otp25/build_and_push.sh b/ci/elixir-1.15-otp25/build_and_push.sh new file mode 100755 index 000000000..06fe74f34 --- /dev/null +++ b/ci/elixir-1.15-otp25/build_and_push.sh @@ -0,0 +1 @@ +docker buildx build --platform linux/amd64 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.15-otp25 --push . diff --git a/ci/postgres-with-rum-13/Dockerfile b/ci/postgres-with-rum-13/Dockerfile new file mode 100644 index 000000000..dc727df1d --- /dev/null +++ b/ci/postgres-with-rum-13/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:13-bullseye + +RUN apt-get update && apt-get install -y postgresql-13-rum/bullseye-pgdg diff --git a/ci/postgres-with-rum-13/build_and_push.sh b/ci/postgres-with-rum-13/build_and_push.sh new file mode 100755 index 000000000..c437b64a7 --- /dev/null +++ b/ci/postgres-with-rum-13/build_and_push.sh @@ -0,0 +1 @@ +docker buildx build --platform linux/amd64,linux/arm64 -t git.pleroma.social:5050/pleroma/pleroma/postgres-with-rum-13:latest --push . diff --git a/config/benchmark.exs b/config/benchmark.exs index 870ead150..d30c95946 100644 --- a/config/benchmark.exs +++ b/config/benchmark.exs @@ -14,7 +14,7 @@ method: Pleroma.Captcha.Mock # Print only warnings and errors during test -config :logger, level: :warn +config :logger, level: :warning config :pleroma, :auth, oauth_consumer_strategies: [] @@ -79,6 +79,10 @@ config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock +config :pleroma, Pleroma.Application, + background_migrators: false, + streamer_registry: false + if File.exists?("./config/benchmark.secret.exs") do import_config "benchmark.secret.exs" else diff --git a/config/config.exs b/config/config.exs index e41ec2f91..bb17ab145 100644 --- a/config/config.exs +++ b/config/config.exs @@ -110,17 +110,6 @@ "xmpp" ] -websocket_config = [ - path: "/websocket", - serializer: [ - {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, - {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} - ], - timeout: 60_000, - transport_log: false, - compress: false -] - # Configures the endpoint config :pleroma, Pleroma.Web.Endpoint, url: [host: "localhost"], @@ -130,10 +119,7 @@ {:_, [ {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, - {"/websocket", Phoenix.Endpoint.CowboyWebSocket, - {Phoenix.Transports.WebSocket, - {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, websocket_config}}}, - {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}} + {:_, Plug.Cowboy.Handler, {Pleroma.Web.Endpoint, []}} ]} ] ], @@ -185,6 +171,7 @@ short_description: "", background_image: "/images/city.jpg", instance_thumbnail: "/instance/thumbnail.jpeg", + favicon: "/favicon.png", limit: 5_000, description_limit: 5_000, remote_limit: 100_000, @@ -205,9 +192,6 @@ federating: true, federation_incoming_replies_max_depth: 100, federation_reachability_timeout_days: 7, - federation_publisher_modules: [ - Pleroma.Web.ActivityPub.Publisher - ], allow_relay: true, public: true, quarantined_instances: [], @@ -360,6 +344,8 @@ icons: [ %{ src: "/static/logo.svg", + sizes: "144x144", + purpose: "any", type: "image/svg+xml" } ], @@ -408,6 +394,12 @@ federated_timeline_removal: [], replace: [] +config :pleroma, :mrf_emoji, + remove_url: [], + remove_shortcode: [], + federated_timeline_removal_url: [], + federated_timeline_removal_shortcode: [] + config :pleroma, :mrf_hashtag, sensitive: ["nsfw"], reject: [], @@ -428,6 +420,8 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil +config :pleroma, :mrf_inline_quote, template: "RT: {url}" + config :pleroma, :rich_media, enabled: true, ignore_hosts: [], @@ -583,7 +577,8 @@ remote_fetcher: 2, attachments_cleanup: 1, new_users_digest: 1, - mute_expire: 5 + mute_expire: 5, + search_indexing: 10 ], plugins: [Oban.Plugins.Pruner], crontab: [ @@ -594,7 +589,8 @@ config :pleroma, :workers, retries: [ federator_incoming: 5, - federator_outgoing: 5 + federator_outgoing: 5, + search_indexing: 2 ] config :pleroma, Pleroma.Formatter, @@ -617,9 +613,6 @@ base: System.get_env("LDAP_BASE") || "dc=example,dc=com", uid: System.get_env("LDAP_UID") || "cn" -config :esshd, - enabled: false - oauth_consumer_strategies = System.get_env("OAUTH_CONSUMER_STRATEGIES") |> to_string() @@ -655,12 +648,26 @@ config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: false -config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, - enabled: false, - auth: false, - ip_whitelist: [], - path: "/api/pleroma/app_metrics", - format: :text +config :pleroma, Pleroma.PromEx, + disabled: false, + manual_metrics_start_delay: :no_delay, + drop_metrics_groups: [], + grafana: [ + host: System.get_env("GRAFANA_HOST", "http://localhost:3000"), + auth_token: System.get_env("GRAFANA_TOKEN"), + upload_dashboards_on_start: false, + folder_name: "BEAM", + annotate_app_lifecycle: true + ], + metrics_server: [ + port: 4021, + path: "/metrics", + protocol: :http, + pool_size: 5, + cowboy_opts: [], + auth_strategy: :none + ], + datasource: "Prometheus" config :pleroma, Pleroma.ScheduledActivity, daily_user_limit: 25, @@ -855,7 +862,11 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false config :pleroma, :mrf, - policies: [Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, Pleroma.Web.ActivityPub.MRF.TagPolicy], + policies: [ + Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, + Pleroma.Web.ActivityPub.MRF.TagPolicy, + Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy + ], transparency: true, transparency_exclusions: [] @@ -874,15 +885,34 @@ config :pleroma, Pleroma.User.Backup, purge_after_days: 30, limit_days: 7, - dir: nil + dir: nil, + process_wait_time: 30_000, + process_chunk_size: 100 config :pleroma, ConcurrentLimiter, [ {Pleroma.Web.RichMedia.Helpers, [max_running: 5, max_waiting: 5]}, - {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]} + {Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, [max_running: 5, max_waiting: 5]}, + {Pleroma.Search, [max_running: 30, max_waiting: 50]} ] config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fetch: true +config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +config :pleroma, Pleroma.Search.Meilisearch, + url: "http://127.0.0.1:7700/", + private_key: nil, + initial_indexing_chunk_size: 100_000 + +config :pleroma, Pleroma.Application, + background_migrators: true, + internal_fetch: true, + load_custom_modules: true, + max_restarts: 3, + streamer_registry: true + +config :pleroma, Pleroma.Uploaders.Uploader, timeout: 30_000 + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs index 78dc8770d..78e7710cb 100644 --- a/config/description.exs +++ b/config/description.exs @@ -987,6 +987,12 @@ "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", suggestions: ["/instance/thumbnail.jpeg"] }, + %{ + key: :favicon, + type: {:string, :image}, + description: "Favicon of the instance", + suggestions: ["/favicon.png"] + }, %{ key: :show_reactions, type: :boolean, @@ -1181,7 +1187,7 @@ type: [:atom, :tuple, :module], description: "Where logs will be sent, :console - send logs to stdout, { ExSyslogger, :ex_syslogger } - to syslog, Quack.Logger - to Slack.", - suggestions: [:console, {ExSyslogger, :ex_syslogger}, Quack.Logger] + suggestions: [:console, {ExSyslogger, :ex_syslogger}] } ] }, @@ -1196,7 +1202,7 @@ key: :level, type: {:dropdown, :atom}, description: "Log level", - suggestions: [:debug, :info, :warn, :error] + suggestions: [:debug, :info, :warning, :error] }, %{ key: :ident, @@ -1229,7 +1235,7 @@ key: :level, type: {:dropdown, :atom}, description: "Log level", - suggestions: [:debug, :info, :warn, :error] + suggestions: [:debug, :info, :warning, :error] }, %{ key: :format, @@ -1438,7 +1444,7 @@ label: "Subject line behavior", type: :string, description: "Allows changing the default behaviour of subject lines in replies. - `email`: copy and preprend re:, as in email, + `email`: copy and prepend re:, as in email, `masto`: copy verbatim, as in Mastodon, `noop`: don't copy the subject.", suggestions: ["email", "masto", "noop"] @@ -1931,7 +1937,7 @@ key: :log, type: {:dropdown, :atom}, description: "Logs verbose mode", - suggestions: [false, :error, :warn, :info, :debug] + suggestions: [false, :error, :warning, :info, :debug] }, %{ key: :queues, @@ -2628,45 +2634,6 @@ } ] }, - %{ - group: :esshd, - label: "ESSHD", - type: :group, - description: - "Before enabling this you must add :esshd to mix.exs as one of the extra_applications " <> - "and generate host keys in your priv dir with ssh-keygen -m PEM -N \"\" -b 2048 -t rsa -f ssh_host_rsa_key", - children: [ - %{ - key: :enabled, - type: :boolean, - description: "Enables SSH" - }, - %{ - key: :priv_dir, - type: :string, - description: "Dir with SSH keys", - suggestions: ["/some/path/ssh_keys"] - }, - %{ - key: :handler, - type: :string, - description: "Handler module", - suggestions: ["Pleroma.BBS.Handler"] - }, - %{ - key: :port, - type: :integer, - description: "Port to connect", - suggestions: [10_022] - }, - %{ - key: :password_authenticator, - type: :string, - description: "Authenticator module", - suggestions: ["Pleroma.BBS.Authenticator"] - } - ] - }, %{ group: :mime, label: "Mime Types", @@ -3129,7 +3096,7 @@ key: :max_waiting, type: :integer, description: - "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errrors when a new request is made", + "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errors when a new request is made", suggestions: [10] }, %{ @@ -3395,7 +3362,7 @@ %{ key: :purge_after_days, type: :integer, - description: "Remove backup achives after N days", + description: "Remove backup archives after N days", suggestions: [30] }, %{ @@ -3403,6 +3370,21 @@ type: :integer, description: "Limit user to export not more often than once per N days", suggestions: [7] + }, + %{ + key: :process_wait_time, + type: :integer, + label: "Process Wait Time", + description: + "The amount of time to wait for backup to report progress, in milliseconds. If no progress is received from the backup job for that much time, terminate it and deem it failed.", + suggestions: [30_000] + }, + %{ + key: :process_chunk_size, + type: :integer, + label: "Process Chunk Size", + description: "The number of activities to fetch in the backup job for each chunk.", + suggestions: [100] } ] }, @@ -3490,5 +3472,48 @@ ] } ] + }, + %{ + group: :pleroma, + key: Pleroma.Search, + type: :group, + description: "General search settings.", + children: [ + %{ + key: :module, + type: :keyword, + description: "Selected search module.", + suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Search.Meilisearch, + type: :group, + description: "Meilisearch settings.", + children: [ + %{ + key: :url, + type: :string, + description: "Meilisearch URL.", + suggestion: ["http://127.0.0.1:7700/"] + }, + %{ + key: :private_key, + type: :string, + description: + "Private key for meilisearch authentication, or `nil` to disable private key authentication.", + suggestion: [nil] + }, + %{ + key: :initial_indexing_chunk_size, + type: :int, + description: + "Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <> + " since there's a limit on maximum insert size", + suggestion: [100_000] + } + ] } ] diff --git a/config/test.exs b/config/test.exs index 78303eb1d..9d752bdf8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -16,7 +16,7 @@ # Print only warnings and errors during test config :logger, :console, - level: :warn, + level: :warning, format: "\n[$level] $message\n" config :pleroma, :auth, oauth_consumer_strategies: [] @@ -133,10 +133,47 @@ ap_streamer: Pleroma.Web.ActivityPub.ActivityPubMock, logger: Pleroma.LoggerMock +config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", private_key: nil + # Reduce recompilation time # https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects config :phoenix, :plug_init_mode, :runtime +config :pleroma, :config_impl, Pleroma.UnstubbedConfigMock + +config :pleroma, Pleroma.PromEx, disabled: true + +# Mox definitions. Only read during compile time. +config :pleroma, Pleroma.User.Backup, config_impl: Pleroma.UnstubbedConfigMock +config :pleroma, Pleroma.Uploaders.S3, ex_aws_impl: Pleroma.Uploaders.S3.ExAwsMock +config :pleroma, Pleroma.Uploaders.S3, config_impl: Pleroma.UnstubbedConfigMock +config :pleroma, Pleroma.Upload, config_impl: Pleroma.UnstubbedConfigMock +config :pleroma, Pleroma.ScheduledActivity, config_impl: Pleroma.UnstubbedConfigMock +config :pleroma, Pleroma.Web.RichMedia.Helpers, config_impl: Pleroma.StaticStubbedConfigMock + +peer_module = + if String.to_integer(System.otp_release()) >= 25 do + :peer + else + :slave + end + +config :pleroma, Pleroma.Cluster, peer_module: peer_module + +config :pleroma, Pleroma.Application, + background_migrators: false, + internal_fetch: false, + load_custom_modules: false, + max_restarts: 100, + streamer_registry: false, + test_http_pools: true + +config :pleroma, Pleroma.Uploaders.Uploader, timeout: 1_000 + +config :pleroma, Pleroma.Emoji.Loader, test_emoji: true + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md index fc9f3cbd5..7c167ec5d 100644 --- a/docs/administration/CLI_tasks/config.md +++ b/docs/administration/CLI_tasks/config.md @@ -1,4 +1,4 @@ -# Transfering the config to/from the database +# Transferring the config to/from the database {! backend/administration/CLI_tasks/general_cli_task_info.include !} @@ -34,7 +34,7 @@ Options: -- `` - where to save migrated config. E.g. `--path=/tmp`. If file saved into non standart folder, you must manually copy file into directory where Pleroma can read it. For OTP install path will be `PLEROMA_CONFIG_PATH` or `/etc/pleroma`. For installation from source - `config` directory in the pleroma folder. +- `` - where to save migrated config. E.g. `--path=/tmp`. If file saved into non-standard folder, you must manually copy file into directory where Pleroma can read it. For OTP install path will be `PLEROMA_CONFIG_PATH` or `/etc/pleroma`. For installation from source - `config` directory in the pleroma folder. - `` - environment, for which is migrated config. By default is `prod`. - To delete transferred settings from database optional flag `-d` can be used diff --git a/docs/administration/backup.md b/docs/administration/backup.md index 5f279ab97..93325e702 100644 --- a/docs/administration/backup.md +++ b/docs/administration/backup.md @@ -31,7 +31,7 @@ 1. Optionally you can remove the users of your instance. This will trigger delete requests for their accounts and posts. Note that this is 'best effort' and doesn't mean that all traces of your instance will be gone from the fediverse. * You can do this from the admin-FE where you can select all local users and delete the accounts using the *Moderate multiple users* dropdown. - * You can also list local users and delete them individualy using the CLI tasks for [Managing users](./CLI_tasks/user.md). + * You can also list local users and delete them individually using the CLI tasks for [Managing users](./CLI_tasks/user.md). 2. Stop the Pleroma service `systemctl stop pleroma` 3. Disable pleroma from systemd `systemctl disable pleroma` 4. Remove the files and folders you created during installation (see installation guide). This includes the pleroma, nginx and systemd files and folders. diff --git a/docs/administration/frontends-management.md b/docs/administration/frontends-management.md new file mode 100644 index 000000000..f982c4bca --- /dev/null +++ b/docs/administration/frontends-management.md @@ -0,0 +1,71 @@ +# Managing installed frontends + +Pleroma lets you install multiple frontends including multiple versions of same frontend. Right now it's only possible to switch which frontend is the default, but in the future it would be possible for user to select which frontend they prefer to use. + +As of 2.6.0 there are two ways of managing frontends - through PleromaFE's Admin Dashboard (preferred, easier method) or through AdminFE (clunky but also works on versions older than 2.6.0). + +!!! note + Managing frontends through UI requires [in-database configuration](../configuration/howto_database_config.md) to be enabled (default on newer instances but might be off on older ones). + +## How it works + +When installing frontends, it creates a folder in [static directory](../configuration/static_dir.md) that follows this pattern: `/frontends/${front-end name}/${front-end version}/`, puts contents of the built frontend in there. Then when accessing the server backend checks what front-end name and version are set to be default and serves index.html and assets from appropriate path. + +!!! warning + + If you've been putting your frontend build directly into static dir as an antiquated way of serving custom frontend, this system will not work and will still serve the custom index.html you put in there. You can still serve custom frontend builds if you put your build into `/frontends/$name/$version` instead and set the "default frontend" fields appropriately. + +Currently, there is no backup system, i.e. when installing `master` version it _will_ overwrite installed `master` version, for now if you want to keep previous version you should back it up manually, i.e. running `cp -r ./frontends/pleroma-fe/master ./frontends/pleroma-fe/master_old` in your static dir. + +## Managing front-ends through Admin Dashboard + +Open up Admin Dashboard (gauge icon in top bar, same as where link to AdminFE was),__ +![location of Admin Dashboard icon](../assets/admin_dash_location.png) +switch to "Front-ends" tab. +![screenshot of Front-ends tab](../assets/frontends_tab.png) +This page is designed to be self-explanatory and easy to use, while avoiding issues and pitfalls of AdminFE, but it's also early in development, everything is subject to change. + +!!! warning + This goes without saying, but if you set default frontend to anything except >2.6.0 version of PleromaFE you'll lose the access to Admin Dashboard and will have to use AdminFE to get it back. See below on how to use AdminFE. + +### Limitations + +Currently the list of available for install frontends is essentially hard-coded in backend's configuration, each providing only one version, with exception for PleromaFE which overrides 'pleroma-fe' to also include `develop` version. There is no way to manually install build with a URL (coming soon) nor add more available frontends to the repository (it's broken). + +There is also no way to tell if there is an update available or not, for now you should watch for [announcements](https://pleroma.social/announcements/) of new PleromaFE stable releases to see if there is new stable version. For `develop` version it's up to you whether you want to follow the development process or just reinstall it periodically hoping for new stuff. + +## Using AdminFE to manage frontends + +Access AdminFE either directly by going to `/pleroma/admin` of your instance or by opening Admin Dashboard and clicking the link at the bottom of the window +![link to open old AdminFE](../assets/old_adminfe_link.png) + + +Go to Settings -> Frontend. + +### Installing front-ends + +At the very top of the page there's a list of available frontends and button to install custom front-end + +!!! tip + Remember to click "Submit" in bottom right corner to save your changes! + +!!! bug + **Available Frontends** section lets you _install_ frontends but **NOT** update/reinstall them. It's only useful for installing a frontend once. + +Due to aforementioned bug, preferred way of installing frontends in AdminFE is by clicking the "Install another frontend" +![screenshot of admin-fe with instructions on how to install a frontend](../assets/way_to_install_frontends.png) +and filling in the fields. Unfortunately AdminFE does not provide the raw data necessary for you to fill those fields, so your best bet is to see what backend returns in browser's devtools or refer to the [source code](https://git.pleroma.social/pleroma/pleroma/-/blob/develop/config/config.exs?ref_type=heads#L742-791). For the most part, only **Name**, **Ref** (i.e. version) and **Build URL** fields are required, although some frontends might also require **Build Directory** to work. + +For pleroma-fe you can use either `master` or `develop` refs, or potentially any ref in GitLab that has artifacts for `build` job, but that's outside scope of this document. + +### Selecting default frontend + +Scroll page waaaaay down, search for "Frontends" section, subtitled "Installed frontends management", change the name and reference of the "Primary" frontend. +![screenshot of admin-fe with instructions on how to install a frontend](../assets/primary_frontend_section.png) + + +!!! danger + If you change "Admin" frontend name/reference you risk losing access to AdminFE as well. + +!!! warning + Don't put anything into the "Available" section as it will break the list of available frontends completely, including the "add another frontend" button. If you accidentally put something in there, click the trashbin icon next to "Available" to reset it and restore the frontends list. diff --git a/docs/assets/admin_dash_location.png b/docs/assets/admin_dash_location.png new file mode 100644 index 000000000..4e1d110e7 Binary files /dev/null and b/docs/assets/admin_dash_location.png differ diff --git a/docs/assets/frontends_tab.png b/docs/assets/frontends_tab.png new file mode 100644 index 000000000..f7c66adab Binary files /dev/null and b/docs/assets/frontends_tab.png differ diff --git a/docs/assets/old_adminfe_link.png b/docs/assets/old_adminfe_link.png new file mode 100644 index 000000000..5ea6a486c Binary files /dev/null and b/docs/assets/old_adminfe_link.png differ diff --git a/docs/assets/primary_frontend_section.png b/docs/assets/primary_frontend_section.png new file mode 100644 index 000000000..14c3de41b Binary files /dev/null and b/docs/assets/primary_frontend_section.png differ diff --git a/docs/assets/way_to_install_frontends.png b/docs/assets/way_to_install_frontends.png new file mode 100644 index 000000000..a90ff2b5d Binary files /dev/null and b/docs/assets/way_to_install_frontends.png differ diff --git a/docs/clients.md b/docs/clients.md index 31d2d27c3..ad7eb7807 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -3,12 +3,6 @@ Note: Additional clients may be working but theses are officially supporting Ple Feel free to contact us to be added to this list! ## Desktop -### Roma for Desktop -- Homepage: -- Source Code: -- Platforms: Windows, Mac, Linux -- Features: MastoAPI, Streaming Ready - ### Social - Source Code: - Contact: [@brainblasted@social.libre.fi](https://social.libre.fi/users/brainblasted) @@ -19,7 +13,14 @@ Feel free to contact us to be added to this list! ### Whalebird - Homepage: - Source Code: -- Contact: [@h3poteto@pleroma.io](https://pleroma.io/users/h3poteto) +- Contact: [@whalebird@pleroma.io](https://pleroma.io/users/whalebird) +- Platforms: Windows, Mac, Linux +- Features: MastoAPI, Streaming Ready + +### Fedistar +- Homepage: +- Source Code: +- Contact: [@fedistar@pleroma.io](https://pleroma.io/users/fedistar) - Platforms: Windows, Mac, Linux - Features: MastoAPI, Streaming Ready diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index bbdf30a0f..7bba7b26e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -154,12 +154,14 @@ To add configuration to your config file, you can copy it from the base config. * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections. + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled deletions. * `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines. * `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. Local accounts, locked accounts, and users with "#nobot" in their bio are respected and excluded from being followed. * `Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy`: Drops follow requests from followbots. Users can still allow bots to follow them by first following the bot. * `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)). * `Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent`: Forces every mentioned user to be reflected in the post content. + * `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Forces quote post URLs to be reflected in the message content inline. + * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions) * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. @@ -261,6 +263,14 @@ Notes: * `follower_nickname`: The name of the bot account to use for following newly discovered users. Using `followbot` or similar is strongly suggested. +#### :mrf_emoji +* `remove_url`: A list of patterns which result in emoji whose URL matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `remove_shortcode`: A list of patterns which result in emoji whose shortcode matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `federated_timeline_removal_url`: A list of patterns which result in message with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). +* `federated_timeline_removal_shortcode`: A list of patterns which result in message with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. Each pattern can be a string or a [regular expression](https://hexdocs.pm/elixir/Regex.html). + +#### :mrf_inline_quote +* `template`: The template to append to the post. `{url}` will be replaced with the actual link to the quoted post. Default: `RT: {url}` ### :activitypub * `unfollow_blocked`: Whether blocks result in people getting unfollowed @@ -496,7 +506,7 @@ config :pleroma, :rate_limit, Means that: 1. In 60 seconds, 15 authentication attempts can be performed from the same IP address. -2. In 1 second, 10 search requests can be performed from the same IP adress by unauthenticated users, while authenticated users can perform 30 search requests per second. +2. In 1 second, 10 search requests can be performed from the same IP address by unauthenticated users, while authenticated users can perform 30 search requests per second. Supported rate limiters: @@ -671,6 +681,12 @@ This filter reads the ImageDescription and iptc:Caption-Abstract fields with Exi No specific configuration. +#### Pleroma.Upload.Filter.OnlyMedia + +This filter rejects uploads that are not identified with Content-Type matching audio/\*, image/\*, or video/\* + +No specific configuration. + #### Pleroma.Upload.Filter.Mogrify * `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`. @@ -873,21 +889,8 @@ This will probably take a long time. ### BBS / SSH access -To enable simple command line interface accessible over ssh, add a setting like this to your configuration file: - -```exs -app_dir = File.cwd! -priv_dir = Path.join([app_dir, "priv/ssh_keys"]) - -config :esshd, - enabled: true, - priv_dir: priv_dir, - handler: "Pleroma.BBS.Handler", - port: 10_022, - password_authenticator: "Pleroma.BBS.Authenticator" -``` - -Feel free to adjust the priv_dir and port number. Then you will have to create the key for the keys (in the example `priv/ssh_keys`) and create the host keys with `ssh-keygen -m PEM -N "" -b 2048 -t rsa -f ssh_host_rsa_key`. After restarting, you should be able to connect to your Pleroma instance with `ssh username@server -p $PORT` +This feature has been removed from Pleroma core. +However, a client has been made and is available at https://git.pleroma.social/Duponin/sshocial. ### :gopher * `enabled`: Enables the gopher interface @@ -1078,7 +1081,7 @@ config :pleroma, Pleroma.Formatter, ## :configurable_from_database -Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. +Boolean, enables/disables in-database configuration. Read [Transferring the config to/from the database](../administration/CLI_tasks/config.md) for more information. ## :database_config_whitelist @@ -1139,7 +1142,7 @@ Control favicons for instances. !!! note Requires enabled email -* `:purge_after_days` an integer, remove backup achives after N days. +* `:purge_after_days` an integer, remove backup achieves after N days. * `:limit_days` an integer, limit user to export not more often than once per N days. * `:dir` a string with a path to backup temporary directory or `nil` to let Pleroma choose temporary directory in the following order: 1. the directory named by the TMPDIR environment variable diff --git a/docs/configuration/custom_emoji.md b/docs/configuration/custom_emoji.md index 1648840fd..19250cf80 100644 --- a/docs/configuration/custom_emoji.md +++ b/docs/configuration/custom_emoji.md @@ -29,7 +29,7 @@ foo, /emoji/custom/foo.png The files should be PNG (APNG is okay with `.png` for `image/png` Content-type) and under 50kb for compatibility with mastodon. -Default file extentions and locations for emojis are set in `config.exs`. To use different locations or file-extentions, add the `shortcode_globs` to your secrets file (`prod.secret.exs` or `dev.secret.exs`) and edit it. Note that not all fediverse-software will show emojis with other file extentions: +Default file extensions and locations for emojis are set in `config.exs`. To use different locations or file-extensions, add the `shortcode_globs` to your secrets file (`prod.secret.exs` or `dev.secret.exs`) and edit it. Note that not all fediverse-software will show emojis with other file extensions: ```elixir config :pleroma, :emoji, shortcode_globs: ["/emoji/custom/**/*.png", "/emoji/custom/**/*.gif"] ``` diff --git a/docs/configuration/hardening.md b/docs/configuration/hardening.md index d3bfc4e4a..cc46d1ff9 100644 --- a/docs/configuration/hardening.md +++ b/docs/configuration/hardening.md @@ -62,6 +62,20 @@ An additional “Expect-CT” header will be sent with the configured `ct_max_ag If you click on a link, your browser’s request to the other site will include from where it is coming from. The “Referrer policy” header tells the browser how and if it should send this information. (see [Referrer policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy)) +### Uploaded media and media proxy + +It is STRONGLY RECOMMENDED to serve both the locally-uploaded media and the media proxy from another domain than the domain that Pleroma runs on, if applicable. + +```elixir +config :pleroma, :media_proxy, + base_url: "https://some.other.domain" + +config :pleroma, Pleroma.Upload, + base_url: "https://some.other.domain/media" +``` + +See `installation/pleroma-mediaproxy.nginx` for examples on how to configure your media proxy. + ## systemd A systemd unit example is provided at `installation/pleroma.service`. diff --git a/docs/configuration/i2p.md b/docs/configuration/i2p.md index 8c5207d67..17dd9b0cb 100644 --- a/docs/configuration/i2p.md +++ b/docs/configuration/i2p.md @@ -1,4 +1,4 @@ -# I2P Federation and Accessability +# I2P Federation and Accessibility This guide is going to focus on the Pleroma federation aspect. The actual installation is neatly explained in the official documentation, and more likely to remain up-to-date. It might be added to this guide if there will be a need for that. diff --git a/docs/configuration/onion_federation.md b/docs/configuration/onion_federation.md index 37673211a..8a8137251 100644 --- a/docs/configuration/onion_federation.md +++ b/docs/configuration/onion_federation.md @@ -29,7 +29,7 @@ HiddenServiceDir /var/lib/tor/pleroma_hidden_service/ HiddenServicePort 80 127.0.0.1:8099 HiddenServiceVersion 3 # Remove if Tor version is below 0.3 ( tor --version ) ``` -Restart Tor to generate an adress: +Restart Tor to generate an address: ``` systemctl restart tor@default.service ``` diff --git a/docs/configuration/optimizing_beam.md b/docs/configuration/optimizing_beam.md index e336bd36c..5e81cd003 100644 --- a/docs/configuration/optimizing_beam.md +++ b/docs/configuration/optimizing_beam.md @@ -1,6 +1,6 @@ # Optimizing the BEAM -Pleroma is built upon the Erlang/OTP VM known as BEAM. The BEAM VM is highly optimized for latency, but this has drawbacks in environments without dedicated hardware. One of the tricks used by the BEAM VM is [busy waiting](https://en.wikipedia.org/wiki/Busy_waiting). This allows the application to pretend to be busy working so the OS kernel does not pause the application process and switch to another process waiting for the CPU to execute its workload. It does this by spinning for a period of time which inflates the apparent CPU usage of the application so it is immediately ready to execute another task. This can be observed with utilities like **top(1)** which will show consistently high CPU usage for the process. Switching between procesess is a rather expensive operation and also clears CPU caches further affecting latency and performance. The goal of busy waiting is to avoid this penalty. +Pleroma is built upon the Erlang/OTP VM known as BEAM. The BEAM VM is highly optimized for latency, but this has drawbacks in environments without dedicated hardware. One of the tricks used by the BEAM VM is [busy waiting](https://en.wikipedia.org/wiki/Busy_waiting). This allows the application to pretend to be busy working so the OS kernel does not pause the application process and switch to another process waiting for the CPU to execute its workload. It does this by spinning for a period of time which inflates the apparent CPU usage of the application so it is immediately ready to execute another task. This can be observed with utilities like **top(1)** which will show consistently high CPU usage for the process. Switching between processes is a rather expensive operation and also clears CPU caches further affecting latency and performance. The goal of busy waiting is to avoid this penalty. This strategy is very successful in making a performant and responsive application, but is not desirable on Virtual Machines or hardware with few CPU cores. Pleroma instances are often deployed on the same server as the required PostgreSQL database which can lead to situations where the Pleroma application is holding the CPU in a busy-wait loop and as a result the database cannot process requests in a timely manner. The fewer CPUs available, the more this problem is exacerbated. The latency is further amplified by the OS being installed on a Virtual Machine as the Hypervisor uses CPU time-slicing to pause the entire OS and switch between other tasks. diff --git a/docs/configuration/postgresql.md b/docs/configuration/postgresql.md index e251eb83b..56f1c60dc 100644 --- a/docs/configuration/postgresql.md +++ b/docs/configuration/postgresql.md @@ -22,7 +22,7 @@ config :pleroma, Pleroma.Repo, ] ``` -A more detailed explaination of the issue can be found at . +A more detailed explanation of the issue can be found at . ## Example configurations diff --git a/docs/configuration/search.md b/docs/configuration/search.md new file mode 100644 index 000000000..0316c9bf4 --- /dev/null +++ b/docs/configuration/search.md @@ -0,0 +1,123 @@ +# Configuring search + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Built-in search + +To use built-in search that has no external dependencies, set the search module to `Pleroma.Activity`: + +> config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch + +While it has no external dependencies, it has problems with performance and relevancy. + +## Meilisearch + +Note that it's quite a bit more memory hungry than PostgreSQL (around 4-5G for ~1.2 million +posts while idle and up to 7G while indexing initially). The disk usage for this additional index is also +around 4 gigabytes. Like [RUM](./cheatsheet.md#rum-indexing-for-full-text-search) indexes, it offers considerably +higher performance and ordering by timestamp in a reasonable amount of time. +Additionally, the search results seem to be more accurate. + +Due to high memory usage, it may be best to set it up on a different machine, if running pleroma on a low-resource +computer, and use private key authentication to secure the remote search instance. + +To use [meilisearch](https://www.meilisearch.com/), set the search module to `Pleroma.Search.Meilisearch`: + +> config :pleroma, Pleroma.Search, module: Pleroma.Search.Meilisearch + +You then need to set the address of the meilisearch instance, and optionally the private key for authentication. You might +also want to change the `initial_indexing_chunk_size` to be smaller if you're server is not very powerful, but not higher than `100_000`, +because meilisearch will refuse to process it if it's too big. However, in general you want this to be as big as possible, because meilisearch +indexes faster when it can process many posts in a single batch. + +> config :pleroma, Pleroma.Search.Meilisearch, +> url: "http://127.0.0.1:7700/", +> private_key: "private key", +> initial_indexing_chunk_size: 100_000 + +Information about setting up meilisearch can be found in the +[official documentation](https://docs.meilisearch.com/learn/getting_started/installation.html). +You probably want to start it with `MEILI_NO_ANALYTICS=true` environment variable to disable analytics. +At least version 0.25.0 is required, but you are strongly advised to use at least 0.26.0, as it introduces +the `--enable-auto-batching` option which drastically improves performance. Without this option, the search +is hardly usable on a somewhat big instance. + +### Private key authentication (optional) + +To set the private key, use the `MEILI_MASTER_KEY` environment variable when starting. After setting the _master key_, +you have to get the _private key_, which is actually used for authentication. + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch show-keys + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch show-keys + ``` + +You will see a "Default Admin API Key", this is the key you actually put into your configuration file. + +### Initial indexing + +After setting up the configuration, you'll want to index all of your already existing posts. Only public posts are indexed. You'll only +have to do it one time, but it might take a while, depending on the amount of posts your instance has seen. This is also a fairly RAM +consuming process for `meilisearch`, and it will take a lot of RAM when running if you have a lot of posts (seems to be around 5G for ~1.2 +million posts while idle and up to 7G while indexing initially, but your experience may be different). + +The sequence of actions is as follows: + +1. First, change the configuration to use `Pleroma.Search.Meilisearch` as the search backend +2. Restart your instance, at this point it can be used while the search indexing is running, though search won't return anything +3. Start the initial indexing process (as described below with `index`), + and wait until the task says it sent everything from the database to index +4. Wait until everything is actually indexed (by checking with `stats` as described below), + at this point you don't have to do anything, just wait a while. + +To start the initial indexing, run the `index` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch index + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch index + ``` + +This will show you the total amount of posts to index, and then show you the amount of posts indexed currently, until the numbers eventually +become the same. The posts are indexed in big batches and meilisearch will take some time to actually index them, even after you have +inserted all the posts into it. Depending on the amount of posts, this may be as long as several hours. To get information about the status +of indexing and how many posts have actually been indexed, use the `stats` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch stats + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch stats + ``` + +### Clearing the index + +In case you need to clear the index (for example, to re-index from scratch, if that needs to happen for some reason), you can +use the `clear` command: + +=== "OTP" + ```sh + ./bin/pleroma_ctl search.meilisearch clear + ``` + +=== "From Source" + ```sh + mix pleroma.search.meilisearch clear + ``` + +This will clear **all** the posts from the search index. Note, that deleted posts are also removed from index by the instance itself, so +there is no need to actually clear the whole index, unless you want **all** of it gone. That said, the index does not hold any information +that cannot be re-created from the database, it should also generally be a lot smaller than the size of your database. Still, the size +depends on the amount of text in posts. diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index f6e9f7d2a..182a760fa 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -303,7 +303,7 @@ Removes the user(s) from follower recommendations. ## `GET /api/v1/pleroma/admin/users/:nickname_or_id` -### Retrive the details of a user +### Retrieve the details of a user - Params: - `nickname` or `id` @@ -313,7 +313,7 @@ Removes the user(s) from follower recommendations. ## `GET /api/v1/pleroma/admin/users/:nickname_or_id/statuses` -### Retrive user's latest statuses +### Retrieve user's latest statuses - Params: - `nickname` or `id` @@ -337,7 +337,7 @@ Removes the user(s) from follower recommendations. ## `GET /api/v1/pleroma/admin/instances/:instance/statuses` -### Retrive instance's latest statuses +### Retrieve instance's latest statuses - Params: - `instance`: instance name @@ -377,7 +377,7 @@ It may take some time. ## `GET /api/v1/pleroma/admin/statuses` -### Retrives all latest statuses +### Retrieves all latest statuses - Params: - *optional* `page_size`: number of statuses to return (default is `20`) @@ -541,7 +541,7 @@ Response: ## `PATCH /api/v1/pleroma/admin/users/force_password_reset` -### Force passord reset for a user with a given nickname +### Force password reset for a user with a given nickname - Params: - `nicknames` @@ -1585,6 +1585,7 @@ Returns the content of the document "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", "git": "https://git.pleroma.social/pleroma/fedi-fe", "installed": true, + "installed_refs": ["master"], "name": "fedi-fe", "ref": "master" }, @@ -1592,6 +1593,7 @@ Returns the content of the document "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", "git": "https://git.pleroma.social/lambadalambda/kenoma", "installed": false, + "installed_refs": [], "name": "kenoma", "ref": "master" } diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 4007c63c8..2937b2301 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -1,6 +1,6 @@ # Differences in Mastodon API responses from vanilla Mastodon -A Pleroma instance can be identified by " (compatible; Pleroma )" present in `version` field in response from `/api/v1/instance` +A Pleroma instance can be identified by " (compatible; Pleroma )" present in `version` field in response from `/api/v1/instance` and `/api/v2/instance` ## Flake IDs @@ -39,6 +39,7 @@ Has these additional fields under the `pleroma` object: - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. - `parent_visible`: If the parent of this post is visible to the user or not. - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. +- `quotes_count`: the count of status quotes. The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes: @@ -304,19 +305,27 @@ Has these additional parameters (which are the same as in Pleroma-API): `GET /api/v1/instance` has additional fields - `max_toot_chars`: The maximum characters per post +- `max_media_attachments`: Maximum number of post media attachments - `chat_limit`: The maximum characters per chat message - `description_limit`: The maximum characters per image description - `poll_limits`: The limits of polls +- `shout_limit`: The maximum characters per Shoutbox message - `upload_limit`: The maximum upload file size - `avatar_upload_limit`: The same for avatars - `background_upload_limit`: The same for backgrounds - `banner_upload_limit`: The same for banners - `background_image`: A background image that frontends can use +- `pleroma.metadata.account_activation_required`: Whether users are required to confirm their emails before signing in +- `pleroma.metadata.birthday_required`: Whether users are required to provide their birth day when signing in +- `pleroma.metadata.birthday_min_age`: The minimum user age (in days) - `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.federation`: The federation restrictions of this instance - `pleroma.metadata.fields_limits`: A list of values detailing the length and count limitation for various instance-configurable fields. - `pleroma.metadata.post_formats`: A list of the allowed post format types -- `vapid_public_key`: The public key needed for push messages +- `pleroma.stats.mau`: Monthly active user count +- `pleroma.vapid_public_key`: The public key needed for push messages + +In, `GET /api/v2/instance` Pleroma-specific fields are all moved into `pleroma` object. `max_toot_chars`, `poll_limits` and `upload_limit` are replaced with their MastoAPI counterparts. ## Push Subscription @@ -357,6 +366,122 @@ The message payload consist of: - `follower_count`: follower count - `following_count`: following count +### Authenticating via `sec-websocket-protocol` header + +Pleroma allows to authenticate via the `sec-websocket-protocol` header, for example, if your access token is `your-access-token`, you can authenticate using the following: + +``` +sec-websocket-protocol: your-access-token +``` + +### Authenticating after connection via `pleroma:authenticate` event + +Pleroma allows to authenticate after connection is established, via the `pleroma:authenticate` event. For example, if your access token is `your-access-token`, you can send the following after the connection is established: + +``` +{"type": "pleroma:authenticate", "token": "your-access-token"} +``` + +### Response to client-sent events + +Pleroma will respond to client-sent events that it recognizes. Supported event types are: + +- `subscribe` +- `unsubscribe` +- `pleroma:authenticate` + +The reply will be in the following format: + +``` +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"\", \"result\": \"\", \"error\": \"\"}" +} +``` + +Result of the action can be either `success`, `ignored` or `error`. If it is `error`, the `error` property will contain the error code. Otherwise, the `error` property will not be present. Below are some examples: + +``` +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"pleroma:authenticate\", \"result\": \"success\"}" +} + +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"subscribe\", \"result\": \"ignored\"}" +} + +{ + "event": "pleroma:respond", + "payload": "{\"type\": \"unsubscribe\", \"result\": \"error\", \"error\": \"bad_topic\"}" +} +``` + +If the sent event is not of a type that Pleroma supports, it will not reply. + +### The `stream` attribute of a server-sent event + +Technically, this is in Mastodon, but its documentation does nothing to specify its format. + +This attribute appears on every event type except `pleroma:respond` and `delete`. It helps clients determine where they should display the new statuses. + +The value of the attribute is an array containing one or two elements. The first element is the type of the stream. The second is the identifier related to that specific stream, if applicable. + +For the following stream types, there is a second element in the array: + +- `list`: The second element is the id of the list, as a string. +- `hashtag`: The second element is the name of the hashtag. +- `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance. + +For all other stream types, there is no second element. + +Some examples of valid `stream` values: + +- `["list", "1"]`: List of id 1. +- `["hashtag", "mew"]`: The hashtag #mew. +- `["user:notifications"]`: Notifications for the current user. +- `["user"]`: Home timeline. +- `["public:remote", "mew.moe"]`: Public posts from the instance mew.moe . + +### The unified streaming endpoint + +If you do not specify a stream to connect to when requesting `/api/v1/streaming`, you will enter a connection that subscribes to no streams. After the connection is established, you can authenticate and then subscribe to different streams. + +### List of supported streams + +Below is a list of supported streams by Pleroma. To make a single-stream WebSocket connection, append the string specified in "Query style" to the streaming endpoint url. +To subscribe to a stream after the connection is established, merge the JSON object specified in "Subscribe style" with `{"type": "subscribe"}`. To unsubscribe, merge it with `{"type": "unsubscribe"}`. + +For example, to receive updates on the list 1, you can connect to `/api/v1/streaming/?stream=list&list=1`, or send + +``` +{"type": "subscribe", "stream": "list", "list": "1"} +``` + +upon establishing the websocket connection. + +To unsubscribe to list 1, send + +``` +{"type": "unsubscribe", "stream": "list", "list": "1"} +``` + +Note that if you specify a stream that requires a logged-in user in the query string (for example, `user` or `list`), you have to specify the access token when you are trying to establish the connection, i.e. in the query string or via the `sec-websocket-protocol` header. + +- `list` + - Query style: `?stream=list&list=` + - Subscribe style: `{"stream": "list", "list": ""}` +- `public`, `public:local`, `public:media`, `public:local:media`, `user`, `user:pleroma_chat`, `user:notifications`, `direct` + - Query style: `?stream=` + - Subscribe style: `{"stream": ""}` +- `hashtag` + - Query style: `?stream=hashtag&tag=` + - Subscribe style: `{"stream": "hashtag", "tag": ""}` +- `public:remote`, `public:remote:media` + - Query style: `?stream=&instance=` + - Subscribe style: `{"stream": "", "instance": ""}` + ## User muting and thread muting Both user muting and thread muting can be done for only a certain time by adding an `expires_in` parameter to the API calls and giving the expiration time in seconds. diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index 47fcb7479..060af5c14 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -129,7 +129,7 @@ The `/api/v1/pleroma/*` path is backwards compatible with `/api/pleroma/*` (`/ap * method: `GET` * Authentication: required * OAuth scope: `write:security` -* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}` +* Response: JSON. Returns `{"codes": codes}` when successful, otherwise HTTP 422 `{"error": "[error message]"}` ## `/api/v1/pleroma/admin/` See [Admin-API](admin_api.md) @@ -251,6 +251,15 @@ See [Admin-API](admin_api.md) ] ``` + +## `/api/v1/pleroma/accounts/:id/endorsements` +### Returns users endorsed by a user +* Method `GET` +* Authentication: not required +* Params: + * `id`: the id of the account for whom to return results +* Response: JSON, returns a list of Mastodon Account entities + ## `/api/v1/pleroma/accounts/update_*` ### Set and clear account avatar, banner, and background @@ -266,6 +275,14 @@ See [Admin-API](admin_api.md) * Authentication: not required * Response: 204 No Content +## `/api/v1/pleroma/statuses/:id/quotes` +### Gets quotes for a given status +* Method `GET` +* Authentication: not required +* Params: + * `id`: the id of the status +* Response: JSON, returns a list of Mastodon Status entities + ## `/api/v1/pleroma/mascot` ### Gets user mascot image * Method `GET` @@ -372,6 +389,15 @@ See [Admin-API](admin_api.md) * `alias`: the nickname of the alias to delete, e.g. `foo@example.org`. * Response: JSON. Returns `{"status": "success"}` if the change was successful, `{"error": "[error message]"}` otherwise +## `/api/v1/pleroma/remote_interaction` +## Interact with profile or status from remote account +* Metod `POST` +* Authentication: not required +* Params: + * `ap_id`: Profile or status ActivityPub ID + * `profile`: Remote profile webfinger +* Response: JSON. Returns `{"url": "[redirect url]"}` on success, `{"error": "[error message]"}` otherwise + # Pleroma Conversations Pleroma Conversations have the same general structure that Mastodon Conversations have. The behavior differs in the following ways when using these endpoints: @@ -382,7 +408,7 @@ Pleroma Conversations have the same general structure that Mastodon Conversation Conversations have the additional field `recipients` under the `pleroma` key. This holds a list of all the accounts that will receive a message in this conversation. -The status posting endpoint takes an additional parameter, `in_reply_to_conversation_id`, which, when set, will set the visiblity to direct and address only the people who are the recipients of that Conversation. +The status posting endpoint takes an additional parameter, `in_reply_to_conversation_id`, which, when set, will set the visibility to direct and address only the people who are the recipients of that Conversation. ⚠ Conversation IDs can be found in direct messages with the `pleroma.direct_conversation_id` key, do not confuse it with `pleroma.conversation_id`. @@ -577,6 +603,9 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa 404 if the pack does not exist ## `GET /api/v1/pleroma/accounts/:id/scrobbles` + +Audio scrobbling in Pleroma is **deprecated**. + ### Requests a list of current and recent Listen activities for an account * Method `GET` * Authentication: not required @@ -598,6 +627,9 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa ``` ## `POST /api/v1/pleroma/scrobble` + +Audio scrobbling in Pleroma is **deprecated**. + ### Creates a new Listen activity for an account * Method `POST` * Authentication: required diff --git a/docs/development/ap_extensions.md b/docs/development/ap_extensions.md index 3d1caeb3e..75c8a7b54 100644 --- a/docs/development/ap_extensions.md +++ b/docs/development/ap_extensions.md @@ -20,16 +20,16 @@ Content-Type: multipart/form-data Parameters: - (required) `file`: The file being uploaded -- (optionnal) `description`: A plain-text description of the media, for accessibility purposes. +- (optional) `description`: A plain-text description of the media, for accessibility purposes. Response: HTTP 201 Created with the object into the body, no `Location` header provided as it doesn't have an `id` -The object given in the reponse should then be inserted into an Object's `attachment` field. +The object given in the response should then be inserted into an Object's `attachment` field. ## ChatMessages `ChatMessage`s are the messages sent in 1-on-1 chats. They are similar to -`Note`s, but the addresing is done by having a single AP actor in the `to` +`Note`s, but the addressing is done by having a single AP actor in the `to` field. Addressing multiple actors is not allowed. These messages are always private, there is no public version of them. They are created with a `Create` activity. diff --git a/docs/development/setting_up_pleroma_dev.md b/docs/development/setting_up_pleroma_dev.md index 8da761d62..24f358e4a 100644 --- a/docs/development/setting_up_pleroma_dev.md +++ b/docs/development/setting_up_pleroma_dev.md @@ -15,7 +15,7 @@ Pleroma requires some adjustments from the defaults for running the instance loc 2. Change the dev.secret.exs * Change the scheme in `config :pleroma, Pleroma.Web.Endpoint` to http (see examples below) * If you want to change other settings, you can do that too -3. You can now start the server `mix phx.server`. Once it's build and started, you can access the instance on `http://:` (e.g.http://localhost:4000 ) and should be able to do everything locally you normaly can. +3. You can now start the server `mix phx.server`. Once it's build and started, you can access the instance on `http://:` (e.g.http://localhost:4000 ) and should be able to do everything locally you normally can. Example config to change the scheme to http. Change the port if you want to run on another port. ```elixir @@ -38,7 +38,7 @@ config :logger, :console, ## Testing -1. Create a `test.secret.exs` file with the content as shown below +1. Create a `config/test.secret.exs` file with the content as shown below 2. Create the database user and test database. 1. You can use the `config/setup_db.psql` as a template. Copy the file if you want and change the database name, user and password to the values for the test-database (e.g. 'pleroma_local_test' for database and user). Then run this file like you did during installation. 2. The tests will try to create the Database, so we'll have to allow our test-database user to create databases, `sudo -Hu postgres psql -c "ALTER USER pleroma_local_test WITH CREATEDB;"` diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index c37ff0c63..7154bca48 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -183,6 +183,9 @@ server { ... } ``` +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. * Enable and start nginx: diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index 285743d56..f7d722ef9 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -173,6 +173,11 @@ sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/ple ``` * Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) + +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + * Enable and start nginx: ```shell diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 4e52b2155..b61e4addd 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -4,7 +4,7 @@ ## Installation -This guide will assume you are on Debian 11 (“bullseye”) or later. This guide should also work with Ubuntu 18.04 (“Bionic Beaver”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -s $SHELL -c 'command'` instead. +This guide will assume you are on Debian 12 (“bookworm”) or later. This guide should also work with Ubuntu 22.04 (“jammy”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -s $SHELL -c 'command'` instead. {! backend/installation/generic_dependencies.include !} @@ -136,6 +136,11 @@ sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/ple ``` * Before starting nginx edit the configuration and change it to your needs (e.g. change servername, change cert paths) + +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + * Enable and start nginx: ```shell diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 3736e857f..1424ad7f4 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -1,11 +1,14 @@ # Pleromaの入れ方 + +Note: This article is potentially outdated because at this time we may not have people who can speak this language well enough to update it. To see the up-to-date version, which may have significant differences or important caveats of the installation process, look up the English version. + ## 日本語訳について この記事は [Installing on Debian based distributions](Installing on Debian based distributions) の日本語訳です。何かがおかしいと思ったら、原文を見てください。 ## インストール -このガイドはDebian Stretchを利用することを想定しています。Ubuntu 16.04や18.04でもおそらく動作します。また、ユーザはrootもしくはsudoにより管理者権限を持っていることを前提とします。もし、以下の操作をrootユーザで行う場合は、 `sudo` を無視してください。ただし、`sudo -Hu pleroma` のようにユーザを指定している場合には `su -s $SHELL -c 'command'` を代わりに使ってください。 +このガイドはDebian Bookwormを利用することを想定しています。Ubuntu 22.04でもおそらく動作します。また、ユーザはrootもしくはsudoにより管理者権限を持っていることを前提とします。もし、以下の操作をrootユーザで行う場合は、 `sudo` を無視してください。ただし、`sudo -Hu pleroma` のようにユーザを指定している場合には `su -s $SHELL -c 'command'` を代わりに使ってください。 ### 必要なソフトウェア diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 9cbe0f203..02513daf2 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -9,7 +9,7 @@ This document was written for FreeBSD 12.1, but should be work on future release This assumes the target system has `pkg(8)`. ``` -# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh cmake +# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh cmake vips ``` Copy the rc.d scripts to the right directory: @@ -41,6 +41,7 @@ Create a user for Pleroma: ``` # pw add user pleroma -m # echo 'export LC_ALL="en_US.UTF-8"' >> /home/pleroma/.profile +# echo 'export VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS' >> /home/pleroma/.profile # su -l pleroma ``` @@ -173,6 +174,10 @@ Edit the defaults of `/usr/local/etc/nginx/sites-available/pleroma.nginx`: * Change `ssl_certificate_key` to `/var/db/acme/certs/example.tld/example.tld.key`. * Change all references of `example.tld` to your instance's domain name. +#### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + ## Creating a startup script for Pleroma Pleroma will need to compile when it initially starts, which typically takes a longer diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include index dcaacfdfd..aebf21e7c 100644 --- a/docs/installation/generic_dependencies.include +++ b/docs/installation/generic_dependencies.include @@ -1,11 +1,11 @@ ## Required dependencies -* PostgreSQL 9.6+ -* Elixir 1.10+ -* Erlang OTP 22.2+ +* PostgreSQL >=9.6 +* Elixir >=1.11.0 <1.15 +* Erlang OTP >=22.2.0 (supported: <27) * git * file / libmagic -* gcc (clang might also work) +* gcc or clang * GNU make * CMake diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index 36882c8c8..dc47d27f8 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -1,6 +1,8 @@ -# Installing on Gentoo GNU/Linux +# Manual install on Gentoo GNU/Linux -{! backend/installation/otp_vs_from_source_source.include !} +{! backend/installation/otp_vs_from_source.include !} + +This guide covers a manual from-source installation. To use the gentoo package, please check the [packaged installation guide for gentoo](./gentoo_otp_en.md). ## Installation @@ -57,7 +59,7 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i If you would not like to install the optional packages, remove them from this line. -If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, strech a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. +If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, stretch a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. ### Install PostgreSQL @@ -102,7 +104,7 @@ Not only does this make it much easier to deploy changes you make, as you can co * Add a new system user for the Pleroma service and set up default directories: -Remove `,wheel` if you do not want this user to be able to use `sudo`, however note that being able to `sudo` as the `pleroma` user will make finishing the insallation and common maintenence tasks somewhat easier: +Remove `,wheel` if you do not want this user to be able to use `sudo`, however note that being able to `sudo` as the `pleroma` user will make finishing the installation and common maintenance tasks somewhat easier: ```shell # useradd -m -G users,wheel -s /bin/bash pleroma @@ -227,6 +229,10 @@ Replace all instances of `example.tld` with your instance's public URL. If for w Pay special attention to the line that begins with `ssl_ecdh_curve`. It is stongly advised to comment that line out so that OpenSSL will use its full capabilities, and it is also possible you are running OpenSSL 1.0.2 necessitating that you do this. +* (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + * Enable and start nginx: ```shell diff --git a/docs/installation/gentoo_otp_en.md b/docs/installation/gentoo_otp_en.md new file mode 100644 index 000000000..20d8835da --- /dev/null +++ b/docs/installation/gentoo_otp_en.md @@ -0,0 +1,207 @@ +# Packaged install on Gentoo Linux + +{! backend/installation/otp_vs_from_source.include !} + +This guide covers installation via Gentoo provided packaging. A [manual installation guide for gentoo](./gentoo_en.md) is also available. + +## Installation + +This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.gentoo.org/wiki/Sudo). Lines that begin with `#` indicate that they should be run as the superuser. Lines using `$` should be run as the indicated user, e.g. `pleroma$` should be run as the `pleroma` user. + +{! backend/installation/generic_dependencies.include !} + +### Installing a cron daemon + +Gentoo quite pointedly does not come with a cron daemon installed, and as such it is recommended you install one to automate certbot renewals and to allow other system administration tasks to be run automatically. Gentoo has [a whole wide world of cron options](https://wiki.gentoo.org/wiki/Cron) but if you just want A Cron That Works, `emerge --ask virtual/cron` will install the default cron implementation (probably cronie) which will work just fine. For the purpouses of this guide, we will be doing just that. + +### Required ebuilds + +* `www-apps/pleroma` + +#### Optional ebuilds used in this guide + +* `www-servers/nginx` (preferred, example configs for other reverse proxies can be found in the repo) +* `app-crypt/certbot` (or any other ACME client for Let’s Encrypt certificates) +* `app-crypt/certbot-nginx` (nginx certbot plugin that allows use of the all-powerful `--nginx` flag on certbot) +* `media-gfx/imagemagick` +* `media-video/ffmpeg` +* `media-libs/exiftool` + +### Prepare the system + +* If you haven't yet done so, add the [Gentoo User Repository (GURU)](https://wiki.gentoo.org/wiki/Project:GURU), where the `www-apps/pleroma` ebuild currently lives at: +```shell + # eselect repository enable guru +``` + +* Ensure that you have the latest copy of the Gentoo and GURU ebuilds if you have not synced them yet: + +```shell + # emaint sync -a +``` + + +* Emerge all required the required and suggested software in one go: + +```shell + # emerge --ask www-apps/pleroma www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx +``` + +If you would not like to install the optional packages, remove them from this line. + +If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, stretch a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. + +### Setup PostgreSQL + +[Gentoo Wiki article](https://wiki.gentoo.org/wiki/PostgreSQL) as well as [PostgreSQL QuickStart](https://wiki.gentoo.org/wiki/PostgreSQL/QuickStart) might be worth a quick glance, as the way Gentoo handles postgres is slightly unusual, with built in capability to have two different databases running for testing and live or whatever other purpouse. While it is still straightforward to install, it does mean that the version numbers used in this guide might change for future updates, so keep an eye out for the output you get from `emerge` to ensure you are using the correct ones. + +* Initialize the database cluster + +The output from emerging postgresql should give you a command for initializing the postgres database. The default slot should be indicated in this command, ensure that it matches the command below. + +```shell + # emerge --config dev-db/postgresql:11 +``` + +### Install media / graphics packages (optional) + +See [Optional software packages needed for specific functionality](optional/media_graphics_packages.md) for details. + +```shell +# emerge --ask media-video/ffmpeg media-gfx/imagemagick media-libs/exiftool +``` + +### Setup PleromaBE + +* Generate the configuration: + +```shell + # pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" +``` + +* Create the PostgreSQL database + +```shell + # sudo -u postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" +``` + +* Now run the database migration: + +```shell + # pleroma_ctl migrate +``` + +* Optional: If you have installed RUM indexes (`dev-db/rum`) you also need to run: +``` + # sudo -Hu pleroma "pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" +``` + +* Now you can start Pleroma already and add it in the default runlevel + +```shell + # rc-service pleroma start + # rc-update add pleroma default +``` + +It probably won't work over the public internet quite yet, however, as we still need to set up a web server to proxy to the pleroma application, as well as configure SSL. + +### Finalize installation + +Assuming you want to open your newly installed federated social network to, well, the federation, you should run nginx or some other webserver/proxy in front of Pleroma. It is also a good idea to set up Pleroma to run as a system service. + +#### Nginx + +* Install nginx, if not already done: + +```shell + # emerge --ask www-servers/nginx +``` + +* Create directories for available and enabled sites: + +```shell + # mkdir -p /etc/nginx/sites-{available,enabled} +``` + +* Append the following line at the end of the `http` block in `/etc/nginx/nginx.conf`: + +```Nginx +include sites-enabled/*; +``` + +* Setup your SSL cert, using your method of choice or certbot. If using certbot, install it if you haven't already: + +```shell + # emerge --ask app-crypt/certbot app-crypt/certbot-nginx +``` + +and then set it up: + +```shell + # mkdir -p /var/lib/letsencrypt/ + # certbot certonly --email -d --standalone +``` + +If that doesn't work the first time, add `--dry-run` to further attempts to avoid being ratelimited as you identify the issue, and do not remove it until the dry run succeeds. If that doesn’t work, make sure, that nginx is not already running. If it still doesn’t work, try setting up nginx first (change ssl “on” to “off” and try again). Often the answer to issues with certbot is to use the `--nginx` flag once you have nginx up and running. + +If you are using any additional subdomains, such as for a media proxy, you can re-run the same command with the subdomain in question. When it comes time to renew later, you will not need to run multiple times for each domain, one renew will handle it. + +--- + +* Copy the example nginx configuration and activate it: + +```shell + # cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/ + # ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +``` + +* Take some time to ensure that your nginx config is correct + +Replace all instances of `example.tld` with your instance's public URL. If for whatever reason you made changes to the port that your pleroma app runs on, be sure that is reflected in your configuration. + +Pay special attention to the line that begins with `ssl_ecdh_curve`. It is stongly advised to comment that line out so that OpenSSL will use its full capabilities, and it is also possible you are running OpenSSL 1.0.2 necessitating that you do this. + +* Enable and start nginx: + +```shell + # rc-update add nginx default + # /etc/init.d/nginx start +``` + +If you are using certbot, it is HIGHLY recommend you set up a cron job that renews your certificate, and that you install the suggested `certbot-nginx` plugin. If you don't do these things, you only have yourself to blame when your instance breaks suddenly because you forgot about it. + +First, ensure that the command you will be installing into your crontab works. + +```shell + # /usr/bin/certbot renew --nginx +``` + +Assuming not much time has passed since you got certbot working a few steps ago, you should get a message for all domains you installed certificates for saying `Cert not yet due for renewal`. + +Now, run crontab as a superuser with `crontab -e` or `sudo crontab -e` as appropriate, and add the following line to your cron: + +```cron +0 0 1 * * /usr/bin/certbot renew --nginx +``` + +This will run certbot on the first of the month at midnight. If you'd rather run more frequently, it's not a bad idea, feel free to go for it. + +#### Other webserver/proxies + +If you would like to use other webservers or proxies, there are example configurations for some popular alternatives in `/opt/pleroma/installation/`. You can, of course, check out [the Gentoo wiki](https://wiki.gentoo.org) for more information on installing and configuring said alternatives. + +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +pleroma$ pleroma_ctl user new --admin +``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/migrating_from_source_otp_en.md b/docs/installation/migrating_from_source_otp_en.md index f6f23400a..798862566 100644 --- a/docs/installation/migrating_from_source_otp_en.md +++ b/docs/installation/migrating_from_source_otp_en.md @@ -86,26 +86,26 @@ export FLAVOUR="amd64-musl" # Clone the release build into a temporary directory and unpack it # Replace `stable` with `unstable` if you want to run the unstable branch -su pleroma -s $SHELL -lc " +sudo -Hu pleroma " curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip unzip /tmp/pleroma.zip -d /tmp/ " # Move the release to the home directory and delete temporary files -su pleroma -s $SHELL -lc " +sudo -Hu pleroma " mv /tmp/release/* ~pleroma/ rmdir /tmp/release rm /tmp/pleroma.zip " # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "./bin/pleroma daemon" +sudo -Hu pleroma "./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance # Stop the instance -su pleroma -s $SHELL -lc "./bin/pleroma stop" +sudo -Hu pleroma "./bin/pleroma stop" ``` ## Setting up a system service diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 41b3b0072..2ade7df98 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -123,6 +123,10 @@ Edit the defaults: * Change `ssl_certificate_key` to `/etc/nginx/tls/key`. * Change `example.tld` to your instance's domain name. +### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + ## Configuring acme.sh We'll be using acme.sh in Stateless Mode for TLS certificate renewal. diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index c80c8f678..e58e144d2 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -62,7 +62,7 @@ rcctl start postgresql To check that it started properly and didn't fail right after starting, you can run `ps aux | grep postgres`, there should be multiple lines of output. #### httpd -httpd will have three fuctions: +httpd will have three functions: * redirect requests trying to reach the instance over http to the https URL * serve a robots.txt file @@ -195,6 +195,10 @@ rcctl enable relayd rcctl start relayd ``` +##### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + #### pf Enabling and configuring pf is highly recommended. In /etc/pf.conf, insert the following configuration: @@ -221,7 +225,7 @@ pass in quick on $if inet6 proto icmp6 to ($if) icmp6-type { echoreq unreach par pass in quick on $if proto tcp to ($if) port { http https } # relayd/httpd pass in quick on $if proto tcp from $authorized_ssh_clients to ($if) port ssh ``` -Replace ** by your server's network interface name (which you can get with ifconfig). Consider replacing the content of the authorized\_ssh\_clients macro by, for exemple, your home IP address, to avoid SSH connection attempts from bots. +Replace ** by your server's network interface name (which you can get with ifconfig). Consider replacing the content of the authorized\_ssh\_clients macro by, for example, your home IP address, to avoid SSH connection attempts from bots. Check pf's configuration by running `pfctl -nf /etc/pf.conf`, load it with `pfctl -f /etc/pf.conf` and enable pf at boot with `rcctl enable pf`. diff --git a/docs/installation/openbsd_fi.md b/docs/installation/openbsd_fi.md index 3c40b2d1a..73aca3a6f 100644 --- a/docs/installation/openbsd_fi.md +++ b/docs/installation/openbsd_fi.md @@ -1,5 +1,7 @@ # Pleroman asennus OpenBSD:llä +Note: This article is potentially outdated because at this time we may not have people who can speak this language well enough to update it. To see the up-to-date version, which may have significant differences or important caveats of the installation process, look up the English version. + Tarvitset: * Oman domainin * OpenBSD 6.3 -serverin diff --git a/docs/installation/optional/media_graphics_packages.md b/docs/installation/optional/media_graphics_packages.md index de402d1c4..ad01d47d1 100644 --- a/docs/installation/optional/media_graphics_packages.md +++ b/docs/installation/optional/media_graphics_packages.md @@ -1,9 +1,10 @@ # Optional software packages needed for specific functionality For specific Pleroma functionality (which is disabled by default) some or all of the below packages are required: - * `ImageMagic` - * `ffmpeg` - * `exiftool` + +* `ImageMagic` +* `ffmpeg` +* `exiftool` Please refer to documentation in `docs/installation` on how to install them on specific OS. @@ -14,20 +15,23 @@ Note: the packages are not required with the current default settings of Pleroma `ImageMagick` is a set of tools to create, edit, compose, or convert bitmap images. It is required for the following Pleroma features: - * `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Plaroma.Upload/filters` in `config/config.exs`) - * Media preview proxy for still images (related config: `media_preview_proxy/enabled` in `config/config.exs`) + +* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* Media preview proxy for still images (related config: `media_preview_proxy/enabled` in `config/config.exs`) ## `ffmpeg` `ffmpeg` is software to record, convert and stream audio and video. It is required for the following Pleroma features: - * Media preview proxy for videos (related config: `media_preview_proxy/enabled` in `config/config.exs`) + +* Media preview proxy for videos (related config: `media_preview_proxy/enabled` in `config/config.exs`) ## `exiftool` `exiftool` is media files metadata reader/writer. It is required for the following Pleroma features: - * `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) - * `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) + +* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 8c02201e6..86efa27f8 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -2,15 +2,16 @@ {! backend/installation/otp_vs_from_source.include !} -This guide covers a installation using an OTP release. To install Pleroma from source, please check out the corresponding guide for your distro. +This guide covers a installation using OTP releases as built by the Pleroma project, it is meant as a fallback to distribution packages/recipes which are the preferred installation method. +To install Pleroma from source, please check out the corresponding guide for your distro. ## Pre-requisites -* A machine running Linux with GNU (e.g. Debian, Ubuntu) or musl (e.g. Alpine) libc and `x86_64`, `aarch64` or `armv7l` CPU, you have root access to. If you are not sure if it's compatible see [Detecting flavour section](#detecting-flavour) below +* A machine you have root access to running Debian GNU/Linux or compatible (eg. Ubuntu), or Alpine on `x86_64`, `aarch64` or `armv7l` CPU. If you are not sure what you are running see [Detecting flavour section](#detecting-flavour) below * A (sub)domain pointed to the machine -You will be running commands as root. If you aren't root already, please elevate your privileges by executing `sudo su`/`su`. +You will be running commands as root. If you aren't root already, please elevate your privileges by executing `sudo -i`/`su`. -While in theory OTP releases are possbile to install on any compatible machine, for the sake of simplicity this guide focuses only on Debian/Ubuntu and Alpine. +Similarly to other binaries, OTP releases tend to be only compatible with the distro they are built on, as such this guide focuses only on Debian/Ubuntu and Alpine. ### Detecting flavour @@ -19,7 +20,7 @@ Paste the following into the shell: arch="$(uname -m)";if [ "$arch" = "x86_64" ];then arch="amd64";elif [ "$arch" = "armv7l" ];then arch="arm";elif [ "$arch" = "aarch64" ];then arch="arm64";else echo "Unsupported arch: $arch">&2;fi;if getconf GNU_LIBC_VERSION>/dev/null;then libc_postfix="";elif [ "$(ldd 2>&1|head -c 9)" = "musl libc" ];then libc_postfix="-musl";elif [ "$(find /lib/libc.musl*|wc -l)" ];then libc_postfix="-musl";else echo "Unsupported libc">&2;fi;echo "$arch$libc_postfix" ``` -If your platform is supported the output will contain the flavour string, you will need it later. If not, this just means that we don't build releases for your platform, you can still try installing from source. +This should give your flavour string. If not this just means that we don't build releases for your platform, you can still try installing from source. ### Installing the required packages @@ -114,13 +115,13 @@ adduser --system --shell /bin/false --home /opt/pleroma pleroma export FLAVOUR="amd64-musl" # Clone the release build into a temporary directory and unpack it -su pleroma -s $SHELL -lc " +sudo -Hu pleroma " curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip unzip /tmp/pleroma.zip -d /tmp/ " # Move the release to the home directory and delete temporary files -su pleroma -s $SHELL -lc " +sudo -Hu pleroma " mv /tmp/release/* /opt/pleroma rmdir /tmp/release rm /tmp/pleroma.zip @@ -141,25 +142,25 @@ mkdir -p /etc/pleroma chown -R pleroma /etc/pleroma # Run the config generator -su pleroma -s $SHELL -lc "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" +sudo -Hu pleroma "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" # Create the postgres database -su postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" +sudo -u postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" # Create the database schema -su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" +sudo -Hu pleroma "./bin/pleroma_ctl migrate" # If you have installed RUM indexes uncommend and run -# su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" +# sudo -Hu pleroma "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "./bin/pleroma daemon" +sudo -Hu pleroma "./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance # Stop the instance -su pleroma -s $SHELL -lc "./bin/pleroma stop" +sudo -Hu pleroma "./bin/pleroma stop" ``` ### Setting up nginx and getting Let's Encrypt SSL certificaties @@ -197,6 +198,10 @@ $EDITOR path-to-nginx-config # Verify that the config is valid nginx -t ``` +#### (Strongly recommended) serve media on another domain + +Refer to the [Hardening your instance](../configuration/hardening.md) document on how to serve media on another domain. We STRONGLY RECOMMEND you to do this to minimize attack vectors. + #### Start nginx === "Alpine" @@ -233,7 +238,7 @@ At this point if you open your (sub)domain in a browser you should see a 502 err systemctl enable pleroma ``` -If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors. +If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errors. Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new). diff --git a/docs/installation/otp_vs_from_source.include b/docs/installation/otp_vs_from_source.include index 63e837a53..6c7820275 100644 --- a/docs/installation/otp_vs_from_source.include +++ b/docs/installation/otp_vs_from_source.include @@ -1,3 +1,8 @@ -## OTP releases vs from-source installations +## Packaged (OTP) installation vs Manual (from-source) installations -There are two ways to install Pleroma. You can use OTP releases or do a from-source installation. OTP releases are as close as you can get to binary releases with Erlang/Elixir. The release is self-contained, and provides everything needed to boot it, it is easily administered via the provided shell script to open up a remote console, start/stop/restart the release, start in the background, send remote commands, and more. With from source installations you install Pleroma from source, meaning you have to install certain dependencies like Erlang+Elixir and compile Pleroma yourself. +There is multiple ways to install Pleroma. +
+
Distro-provided packages
This is the recommended method, where you can get the strongest compatibility guarantees and the best dependency-management
+
Pleroma-provided OTP binaries
Intended as fallback for Alpine/Debian-compatible systems lacking a proper Pleroma package, they are heavier than proper distro packages as they also contain Erlang/Elixir and can break after system updates
+
Manual from-source installation
Needs build-dependencies to be installed and manual updates+rebuilds. Allows for easier source-customisations.
+
diff --git a/docs/installation/otp_vs_from_source_source.include b/docs/installation/otp_vs_from_source_source.include index 63482b69d..9f7134229 100644 --- a/docs/installation/otp_vs_from_source_source.include +++ b/docs/installation/otp_vs_from_source_source.include @@ -1,3 +1,3 @@ {! backend/installation/otp_vs_from_source.include !} -This guide covers a from-source installation. To install using OTP releases, please check out [the OTP guide](./otp_en.md). +This guide covers a manual from-source installation. To install using OTP releases, please check for the presence of a distro package, failing that you can use [Pleroma-provided OTP binaries](./otp_en.md). diff --git a/installation/init.d/pleroma b/installation/init.d/pleroma index 384536f7e..cb6635a0b 100755 --- a/installation/init.d/pleroma +++ b/installation/init.d/pleroma @@ -8,6 +8,7 @@ pidfile="/var/run/pleroma.pid" directory=/opt/pleroma healthcheck_delay=60 healthcheck_timer=30 +no_new_privs="yes" : ${pleroma_port:-4000} diff --git a/installation/pleroma-mediaproxy.nginx b/installation/pleroma-mediaproxy.nginx new file mode 100644 index 000000000..6f2edf212 --- /dev/null +++ b/installation/pleroma-mediaproxy.nginx @@ -0,0 +1,97 @@ +# This file is for those who want to serve uploaded media and media proxy over +# another domain. This is STRONGLY RECOMMENDED. +# This is meant to be used ALONG WITH `pleroma.nginx`. + +# If this is a new instance, replace the `location ~ ^/(media|proxy)` section in +# `pleroma.nginx` with the following to completely disable access to media from the main domain: +# location ~ ^/(media|proxy) { +# return 404; +# } +# +# If you are configuring an existing instance to use another domain +# for media, you will want to keep redirecting all existing local media to the new domain +# so already-uploaded media will not break. +# Replace the `location ~ ^/(media|proxy)` section in `pleroma.nginx` with the following: +# +# location /media { +# return 301 https://some.other.domain$request_uri; +# } +# +# location /proxy { +# return 404; +# } + +server { + server_name some.other.domain; + + listen 80; + listen [::]:80; + + # Uncomment this if you need to use the 'webroot' method with certbot. Make sure + # that the directory exists and that it is accessible by the webserver. If you followed + # the guide, you already ran 'mkdir -p /var/lib/letsencrypt' to create the folder. + # You may need to load this file with the ssl server block commented out, run certbot + # to get the certificate, and then uncomment it. + # + # location ~ /\.well-known/acme-challenge { + # root /var/lib/letsencrypt/; + # } + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + server_name some.other.domain; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + ssl_trusted_certificate /etc/letsencrypt/live/some.other.domain/chain.pem; + ssl_certificate /etc/letsencrypt/live/some.other.domain/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/some.other.domain/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + ssl_prefer_server_ciphers off; + # In case of an old server with an OpenSSL version of 1.0.2 or below, + # leave only prime256v1 or comment out the following line. + ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1; + ssl_stapling on; + ssl_stapling_verify on; + + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + # the nginx default is 1m, not enough for large media uploads + client_max_body_size 16m; + ignore_invalid_headers off; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + location / { return 404; } + + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_cache_valid 200 206 301 304 1h; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_pass http://phoenix; + } +} diff --git a/installation/pleroma-mongooseim.cfg b/installation/pleroma-mongooseim.cfg index 3ecba5641..6b568fd03 100755 --- a/installation/pleroma-mongooseim.cfg +++ b/installation/pleroma-mongooseim.cfg @@ -204,7 +204,7 @@ ]} ]}, - %% Following HTTP API is deprected, the new one abouve should be used instead + %% Following HTTP API is deprecated, the new one above should be used instead { {5288, "127.0.0.1"} , ejabberd_cowboy, [ {num_acceptors, 10}, @@ -824,7 +824,7 @@ %% Enable archivization for private messages (default) % {pm, [ - %% Top-level options can be overriden here if needed, for example: + %% Top-level options can be overridden here if needed, for example: % {async_writer, false} % ]}, @@ -834,7 +834,7 @@ %% % {muc, [ % {host, "muc.@HOST@"} - %% As with pm, top-level options can be overriden for MUC archive + %% As with pm, top-level options can be overridden for MUC archive % ]}, % %% Do not use a element (by default stanzaid is used) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index ed560c177..93ee57dc3 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -193,7 +193,7 @@ def run(["set_text_search_config", tsconfig]) do "ALTER DATABASE #{db} SET default_text_search_config = '#{tsconfig}';" ) - # non-exist config will not raise excpetion but only give >0 messages + # non-exist config will not raise exception but only give >0 messages if length(msg) > 0 do shell_info("Error: #{inspect(msg, pretty: true)}") else diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index aea9c8ac5..53cac0b94 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -30,7 +30,7 @@ def run(["test", nickname | opts]) do shell_info("Digest email have been sent to #{nickname} (#{user.email})") else _ -> - shell_info("Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}") + shell_info("Couldn't find any mentions for #{nickname} since #{last_digest_emailed_at}") end end end diff --git a/lib/mix/tasks/pleroma/ecto/rollback.ex b/lib/mix/tasks/pleroma/ecto/rollback.ex index 3d78eaec4..121890f39 100644 --- a/lib/mix/tasks/pleroma/ecto/rollback.ex +++ b/lib/mix/tasks/pleroma/ecto/rollback.ex @@ -61,7 +61,7 @@ def run(args \\ []) do Logger.configure(level: :info) if opts[:env] == "test" do - Logger.info("Rollback succesfully") + Logger.info("Rollback successfully") else {:ok, _, _} = Ecto.Migrator.with_repo(Pleroma.Repo, &Ecto.Migrator.run(&1, path, :down, opts)) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 5c93f19ff..0dc30549c 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -266,12 +266,20 @@ def run(["gen" | rest]) do config_dir = Path.dirname(config_path) psql_dir = Path.dirname(psql_path) + # Note: Distros requiring group read (0o750) on those directories should + # pre-create the directories. [config_dir, psql_dir, static_dir, uploads_dir] |> Enum.reject(&File.exists?/1) - |> Enum.map(&File.mkdir_p!/1) + |> Enum.each(fn dir -> + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + end) shell_info("Writing config to #{config_path}.") + # Sadly no fchmod(2) equivalent in Elixir… + File.touch!(config_path) + File.chmod!(config_path, 0o640) File.write(config_path, result_config) shell_info("Writing the postgres script to #{psql_path}.") File.write(psql_path, result_psql) @@ -284,14 +292,13 @@ def run(["gen" | rest]) do if db_configurable? do shell_info( - " Please transfer your config to the database after running database migrations. Refer to \"Transfering the config to/from the database\" section of the docs for more information." + " Please transfer your config to the database after running database migrations. Refer to \"Transferring the config to/from the database\" section of the docs for more information." ) end else shell_error( "The task would have overwritten the following files:\n" <> - (Enum.map(will_overwrite, &"- #{&1}\n") |> Enum.join("")) <> - "Rerun with `--force` to overwrite them." + Enum.map_join(will_overwrite, &"- #{&1}\n") <> "Rerun with `--force` to overwrite them." ) end end @@ -345,6 +352,4 @@ defp upload_filters(filters) when is_map(filters) do enabled_filters end - - defp upload_filters(_), do: [] end diff --git a/lib/mix/tasks/pleroma/search/meilisearch.ex b/lib/mix/tasks/pleroma/search/meilisearch.ex new file mode 100644 index 000000000..8379a0c25 --- /dev/null +++ b/lib/mix/tasks/pleroma/search/meilisearch.ex @@ -0,0 +1,145 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Search.Meilisearch do + require Pleroma.Constants + + import Mix.Pleroma + import Ecto.Query + + import Pleroma.Search.Meilisearch, + only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete: 1] + + def run(["index"]) do + start_pleroma() + Pleroma.HTML.compile_scrubbers() + + meili_version = + ( + {:ok, result} = meili_get("/version") + + result["pkgVersion"] + ) + + # The ranking rule syntax was changed but nothing about that is mentioned in the changelog + if not Version.match?(meili_version, ">= 0.25.0") do + raise "Meilisearch <0.24.0 not supported" + end + + {:ok, _} = + meili_post( + "/indexes/objects/settings/ranking-rules", + [ + "published:desc", + "words", + "exactness", + "proximity", + "typo", + "attribute", + "sort" + ] + ) + + {:ok, _} = + meili_post( + "/indexes/objects/settings/searchable-attributes", + [ + "content" + ] + ) + + IO.puts("Created indices. Starting to insert posts.") + + chunk_size = Pleroma.Config.get([Pleroma.Search.Meilisearch, :initial_indexing_chunk_size]) + + Pleroma.Repo.transaction( + fn -> + query = + from(Pleroma.Object, + # Only index public and unlisted posts which are notes and have some text + where: + fragment("data->>'type' = 'Note'") and + (fragment("data->'to' \\? ?", ^Pleroma.Constants.as_public()) or + fragment("data->'cc' \\? ?", ^Pleroma.Constants.as_public())), + order_by: [desc: fragment("data->'published'")] + ) + + count = query |> Pleroma.Repo.aggregate(:count, :data) + IO.puts("Entries to index: #{count}") + + Pleroma.Repo.stream( + query, + timeout: :infinity + ) + |> Stream.map(&Pleroma.Search.Meilisearch.object_to_search_data/1) + |> Stream.filter(fn o -> not is_nil(o) end) + |> Stream.chunk_every(chunk_size) + |> Stream.transform(0, fn objects, acc -> + new_acc = acc + Enum.count(objects) + + # Reset to the beginning of the line and rewrite it + IO.write("\r") + IO.write("Indexed #{new_acc} entries") + + {[objects], new_acc} + end) + |> Stream.each(fn objects -> + result = + meili_put( + "/indexes/objects/documents", + objects + ) + + with {:ok, res} <- result do + if not Map.has_key?(res, "uid") do + IO.puts("\nFailed to index: #{inspect(result)}") + end + else + e -> IO.puts("\nFailed to index due to network error: #{inspect(e)}") + end + end) + |> Stream.run() + end, + timeout: :infinity + ) + + IO.write("\n") + end + + def run(["clear"]) do + start_pleroma() + + meili_delete("/indexes/objects/documents") + end + + def run(["show-keys", master_key]) do + start_pleroma() + + endpoint = Pleroma.Config.get([Pleroma.Search.Meilisearch, :url]) + + {:ok, result} = + Pleroma.HTTP.get( + Path.join(endpoint, "/keys"), + [{"Authorization", "Bearer #{master_key}"}] + ) + + decoded = Jason.decode!(result.body) + + if decoded["results"] do + Enum.each(decoded["results"], fn %{"description" => desc, "key" => key} -> + IO.puts("#{desc}: #{key}") + end) + else + IO.puts("Error fetching the keys, check the master key is correct: #{inspect(decoded)}") + end + end + + def run(["stats"]) do + start_pleroma() + + {:ok, result} = meili_get("/indexes/objects/stats") + IO.puts("Number of entries: #{result["numberOfDocuments"]}") + IO.puts("Indexing? #{result["isIndexing"]}") + end +end diff --git a/lib/phoenix/transports/web_socket/raw.ex b/lib/phoenix/transports/web_socket/raw.ex index 8cf9c32a2..cf4fda79f 100644 --- a/lib/phoenix/transports/web_socket/raw.ex +++ b/lib/phoenix/transports/web_socket/raw.ex @@ -26,7 +26,6 @@ def init(%Plug.Conn{method: "GET"} = conn, {endpoint, handler, transport}) do conn |> fetch_query_params |> Transport.transport_log(opts[:transport_log]) - |> Transport.force_ssl(handler, endpoint, opts) |> Transport.check_origin(handler, endpoint, opts) case conn do diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 3556aaf9e..8a512dc57 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -368,7 +368,7 @@ def restrict_deactivated_users(query) do ) end - defdelegate search(user, query, options \\ []), to: Pleroma.Activity.Search + defdelegate search(user, query, options \\ []), to: Pleroma.Search.DatabaseSearch def direct_conversation_id(activity, for_user) do alias Pleroma.Conversation.Participation diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index 81c44ac05..d770b9ff3 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Activity.Queries do import Ecto.Query, only: [from: 2, where: 3] - @type query :: Ecto.Queryable.t() | Activity.t() + @type query :: Ecto.Queryable.t() | Pleroma.Activity.t() alias Pleroma.Activity alias Pleroma.User diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index d97c5e728..5a3c710e8 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -23,19 +23,21 @@ defmodule Pleroma.Announcement do timestamps(type: :utc_datetime) end - def change(struct, params \\ %{}) do - struct - |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at, :rendered]) + @doc "Generates changeset for %Pleroma.Announcement{}" + @spec changeset(%__MODULE__{}, map()) :: %Ecto.Changeset{} + def changeset(announcement \\ %__MODULE__{}, params \\ %{data: %{}}) do + announcement + |> cast(validate_params(announcement, params), [:data, :starts_at, :ends_at, :rendered]) |> validate_required([:data]) end - defp validate_params(struct, params) do + defp validate_params(announcement, params) do base_data = %{ "content" => "", "all_day" => false } - |> Map.merge((struct && struct.data) || %{}) + |> Map.merge((announcement && announcement.data) || %{}) merged_data = Map.merge(base_data, params.data) @@ -61,13 +63,13 @@ def add_rendered_properties(params) do end def add(params) do - changeset = change(%__MODULE__{}, params) + changeset = changeset(%__MODULE__{}, params) Repo.insert(changeset) end def update(announcement, params) do - changeset = change(announcement, params) + changeset = changeset(announcement, params) Repo.update(changeset) end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index e68a3c57e..de668052f 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -14,7 +14,6 @@ defmodule Pleroma.Application do @name Mix.Project.config()[:name] @version Mix.Project.config()[:version] @repository Mix.Project.config()[:source_url] - @mix_env Mix.env() def name, do: @name def version, do: @version @@ -54,7 +53,6 @@ def start(_type, _args) do Config.DeprecationWarnings.warn() Pleroma.Web.Plugs.HTTPSecurityPlug.warn_if_disabled() Pleroma.ApplicationRequirements.verify!() - setup_instrumenters() load_custom_modules() Pleroma.Docs.JSON.compile() limiters_setup() @@ -91,6 +89,7 @@ def start(_type, _args) do # Define workers and child supervisors to be supervised children = [ + Pleroma.PromEx, Pleroma.Repo, Config.TransferTask, Pleroma.Emoji, @@ -98,7 +97,7 @@ def start(_type, _args) do {Task.Supervisor, name: Pleroma.TaskSupervisor} ] ++ cachex_children() ++ - http_children(adapter, @mix_env) ++ + http_children(adapter) ++ [ Pleroma.Stats, Pleroma.JobQueueMonitor, @@ -106,8 +105,9 @@ def start(_type, _args) do {Oban, Config.get(Oban)}, Pleroma.Web.Endpoint ] ++ - task_children(@mix_env) ++ - dont_run_in_test(@mix_env) ++ + task_children() ++ + streamer_registry() ++ + background_migrators() ++ shout_child(shout_enabled?()) ++ [Pleroma.Gopher.Server] @@ -116,12 +116,7 @@ def start(_type, _args) do # If we have a lot of caches, default max_restarts can cause test # resets to fail. # Go for the default 3 unless we're in test - max_restarts = - if @mix_env == :test do - 100 - else - 3 - end + max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts] opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts] result = Supervisor.start_link(children, opts) @@ -138,7 +133,7 @@ defp set_postgres_server_version do num else e -> - Logger.warn( + Logger.warning( "Could not get the postgres version: #{inspect(e)}.\nSetting the default value of 9.6" ) @@ -159,7 +154,7 @@ def load_custom_modules do raise "Invalid custom modules" {:ok, modules, _warnings} -> - if @mix_env != :test do + if Application.get_env(:pleroma, __MODULE__)[:load_custom_modules] do Enum.each(modules, fn mod -> Logger.info("Custom module loaded: #{inspect(mod)}") end) @@ -170,29 +165,6 @@ def load_custom_modules do end end - defp setup_instrumenters do - require Prometheus.Registry - - if Application.get_env(:prometheus, Pleroma.Repo.Instrumenter) do - :ok = - :telemetry.attach( - "prometheus-ecto", - [:pleroma, :repo, :query], - &Pleroma.Repo.Instrumenter.handle_event/4, - %{} - ) - - Pleroma.Repo.Instrumenter.setup() - end - - Pleroma.Web.Endpoint.MetricsExporter.setup() - Pleroma.Web.Endpoint.PipelineInstrumenter.setup() - - # Note: disabled until prometheus-phx is integrated into prometheus-phoenix: - # Pleroma.Web.Endpoint.Instrumenter.setup() - PrometheusPhx.setup() - end - defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), @@ -236,24 +208,30 @@ def build_cachex(type, opts), defp shout_enabled?, do: Config.get([:shout, :enabled]) - defp dont_run_in_test(env) when env in [:test, :benchmark], do: [] - - defp dont_run_in_test(_) do - [ - {Registry, - [ - name: Pleroma.Web.Streamer.registry(), - keys: :duplicate, - partitions: System.schedulers_online() - ]} - ] ++ background_migrators() + defp streamer_registry do + if Application.get_env(:pleroma, __MODULE__)[:streamer_registry] do + [ + {Registry, + [ + name: Pleroma.Web.Streamer.registry(), + keys: :duplicate, + partitions: System.schedulers_online() + ]} + ] + else + [] + end end defp background_migrators do - [ - Pleroma.Migrators.HashtagsTableMigrator, - Pleroma.Migrators.ContextObjectsDeletionMigrator - ] + if Application.get_env(:pleroma, __MODULE__)[:background_migrators] do + [ + Pleroma.Migrators.HashtagsTableMigrator, + Pleroma.Migrators.ContextObjectsDeletionMigrator + ] + else + [] + end end defp shout_child(true) do @@ -265,37 +243,43 @@ defp shout_child(true) do defp shout_child(_), do: [] - defp task_children(:test) do - [ + defp task_children do + children = [ %{ id: :web_push_init, start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, restart: :temporary } ] - end - defp task_children(_) do - [ - %{ - id: :web_push_init, - start: {Task, :start_link, [&Pleroma.Web.Push.init/0]}, - restart: :temporary - }, - %{ - id: :internal_fetch_init, - start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]}, - restart: :temporary - } - ] + if Application.get_env(:pleroma, __MODULE__)[:internal_fetch] do + children ++ + [ + %{ + id: :internal_fetch_init, + start: {Task, :start_link, [&Pleroma.Web.ActivityPub.InternalFetchActor.init/0]}, + restart: :temporary + } + ] + else + children + end end # start hackney and gun pools in tests - defp http_children(_, :test) do - http_children(Tesla.Adapter.Hackney, nil) ++ http_children(Tesla.Adapter.Gun, nil) + defp http_children(adapter) do + if Application.get_env(:pleroma, __MODULE__)[:test_http_pools] do + http_children_hackney() ++ http_children_gun() + else + cond do + match?(Tesla.Adapter.Hackney, adapter) -> http_children_hackney() + match?(Tesla.Adapter.Gun, adapter) -> http_children_gun() + true -> [] + end + end end - defp http_children(Tesla.Adapter.Hackney, _) do + defp http_children_hackney do pools = [:federation, :media] pools = @@ -311,18 +295,20 @@ defp http_children(Tesla.Adapter.Hackney, _) do end end - defp http_children(Tesla.Adapter.Gun, _) do + defp http_children_gun do Pleroma.Gun.ConnectionPool.children() ++ [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] end - defp http_children(_, _), do: [] - @spec limiters_setup() :: :ok def limiters_setup do config = Config.get(ConcurrentLimiter, []) - [Pleroma.Web.RichMedia.Helpers, Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] + [ + Pleroma.Web.RichMedia.Helpers, + Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy, + Pleroma.Search + ] |> Enum.each(fn module -> mod_config = Keyword.get(config, module, []) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 44b1c1705..819245481 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -7,7 +7,10 @@ defmodule Pleroma.ApplicationRequirements do The module represents the collection of validations to runs before start server. """ - defmodule VerifyError, do: defexception([:message]) + defmodule VerifyError do + defexception([:message]) + @type t :: %__MODULE__{} + end alias Pleroma.Config alias Pleroma.Helpers.MediaHelper @@ -34,7 +37,7 @@ defp handle_result({:error, message}), do: raise(VerifyError, message: message) defp check_welcome_message_config!(:ok) do if Pleroma.Config.get([:welcome, :email, :enabled], false) and not Pleroma.Emails.Mailer.enabled?() do - Logger.warn(""" + Logger.warning(""" To send welcome emails, you need to enable the mailer. Welcome emails will NOT be sent with the current config. @@ -53,7 +56,7 @@ defp check_welcome_message_config!(result), do: result def check_confirmation_accounts!(:ok) do if Pleroma.Config.get([:instance, :account_activation_required]) && not Pleroma.Emails.Mailer.enabled?() do - Logger.warn(""" + Logger.warning(""" Account activation is required, but the mailer is disabled. Users will NOT be able to confirm their accounts with this config. Either disable account activation or enable the mailer. @@ -168,8 +171,6 @@ defp check_system_commands!(:ok) do check_filter(Pleroma.Upload.Filter.Exiftool.ReadDescription, "exiftool"), check_filter(Pleroma.Upload.Filter.Mogrify, "mogrify"), check_filter(Pleroma.Upload.Filter.Mogrifun, "mogrify"), - check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "mogrify"), - check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "convert"), check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "ffprobe") ] @@ -195,8 +196,6 @@ defp check_system_commands!(:ok) do end end - defp check_system_commands!(result), do: result - defp check_repo_pool_size!(:ok) do if Pleroma.Config.get([Pleroma.Repo, :pool_size], 10) != 10 and not Pleroma.Config.get([:dangerzone, :override_repo_pool_size], false) do diff --git a/lib/pleroma/bbs/authenticator.ex b/lib/pleroma/bbs/authenticator.ex deleted file mode 100644 index 0f7543ff5..000000000 --- a/lib/pleroma/bbs/authenticator.ex +++ /dev/null @@ -1,20 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.BBS.Authenticator do - use Sshd.PasswordAuthenticator - alias Pleroma.User - alias Pleroma.Web.Plugs.AuthenticationPlug - - def authenticate(username, password) do - username = to_string(username) - password = to_string(password) - - with %User{} = user <- User.get_by_nickname(username) do - AuthenticationPlug.checkpw(password, user.password_hash) - else - _e -> false - end - end -end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex deleted file mode 100644 index 27799338f..000000000 --- a/lib/pleroma/bbs/handler.ex +++ /dev/null @@ -1,246 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.BBS.Handler do - use Sshd.ShellHandler - alias Pleroma.Activity - alias Pleroma.HTML - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - - def on_shell(username, _pubkey, _ip, _port) do - :ok = IO.puts("Welcome to #{Pleroma.Config.get([:instance, :name])}!") - user = Pleroma.User.get_cached_by_nickname(to_string(username)) - Logger.debug("#{inspect(user)}") - loop(run_state(user: user)) - end - - def on_connect(username, ip, port, method) do - Logger.debug(fn -> - """ - Incoming SSH shell #{inspect(self())} requested for #{username} from #{inspect(ip)}:#{inspect(port)} using #{inspect(method)} - """ - end) - end - - def on_disconnect(username, ip, port) do - Logger.debug(fn -> - "Disconnecting SSH shell for #{username} from #{inspect(ip)}:#{inspect(port)}" - end) - end - - defp loop(state) do - self_pid = self() - counter = state.counter - prefix = state.prefix - user = state.user - - input = spawn(fn -> io_get(self_pid, prefix, counter, user.nickname) end) - wait_input(state, input) - end - - def puts_activity(activity) do - status = Pleroma.Web.MastodonAPI.StatusView.render("show.json", %{activity: activity}) - - IO.puts("-- #{status.id} by #{status.account.display_name} (#{status.account.acct})") - - status.content - |> String.split("
") - |> Enum.map(&HTML.strip_tags/1) - |> Enum.map(&HtmlEntities.decode/1) - |> Enum.map(&IO.puts/1) - end - - def puts_notification(activity, user) do - notification = - Pleroma.Web.MastodonAPI.NotificationView.render("show.json", %{ - notification: activity, - for: user - }) - - IO.puts( - "== (#{notification.type}) #{notification.status.id} by #{notification.account.display_name} (#{notification.account.acct})" - ) - - notification.status.content - |> String.split("
") - |> Enum.map(&HTML.strip_tags/1) - |> Enum.map(&HtmlEntities.decode/1) - |> (fn x -> - case x do - [content] -> - "> " <> content - - [head | _tail] -> - # "> " <> hd <> "..." - head - |> String.slice(1, 80) - |> (fn x -> "> " <> x <> "..." end).() - end - end).() - |> IO.puts() - - IO.puts("") - end - - def handle_command(state, "help") do - IO.puts("Available commands:") - IO.puts("help - This help") - IO.puts("home - Show the home timeline") - IO.puts("p - Post the given text") - IO.puts("r - Reply to the post with the given id") - IO.puts("t - Show a thread from the given id") - IO.puts("n - Show notifications") - IO.puts("n read - Mark all notifactions as read") - IO.puts("f - Favourites the post with the given id") - IO.puts("R - Repeat the post with the given id") - IO.puts("quit - Quit") - - state - end - - def handle_command(%{user: user} = state, "r " <> text) do - text = String.trim(text) - [activity_id, rest] = String.split(text, " ", parts: 2) - - with %Activity{} <- Activity.get_by_id(activity_id), - {:ok, _activity} <- - CommonAPI.post(user, %{status: rest, in_reply_to_status_id: activity_id}) do - IO.puts("Replied!") - else - _e -> IO.puts("Could not reply...") - end - - state - end - - def handle_command(%{user: user} = state, "t " <> activity_id) do - with %Activity{} = activity <- Activity.get_by_id(activity_id) do - activities = - ActivityPub.fetch_activities_for_context(activity.data["context"], %{ - blocking_user: user, - user: user, - exclude_id: activity.id - }) - - case activities do - [] -> - activity_id - |> Activity.get_by_id() - |> puts_activity() - - _ -> - activities - |> Enum.reverse() - |> Enum.each(&puts_activity/1) - end - else - _e -> IO.puts("Could not show this thread...") - end - - state - end - - def handle_command(%{user: user} = state, "n read") do - Pleroma.Notification.clear(user) - IO.puts("All notifications were marked as read") - - state - end - - def handle_command(%{user: user} = state, "n") do - user - |> Pleroma.Web.MastodonAPI.MastodonAPI.get_notifications(%{}) - |> Enum.each(&puts_notification(&1, user)) - - state - end - - def handle_command(%{user: user} = state, "p " <> text) do - text = String.trim(text) - - with {:ok, activity} <- CommonAPI.post(user, %{status: text}) do - IO.puts("Posted! ID: #{activity.id}") - else - _e -> IO.puts("Could not post...") - end - - state - end - - def handle_command(%{user: user} = state, "f " <> id) do - id = String.trim(id) - - with %Activity{} = activity <- Activity.get_by_id(id), - {:ok, _activity} <- CommonAPI.favorite(user, activity) do - IO.puts("Favourited!") - else - _e -> IO.puts("Could not Favourite...") - end - - state - end - - def handle_command(state, "home") do - user = state.user - - params = - %{} - |> Map.put(:type, ["Create"]) - |> Map.put(:blocking_user, user) - |> Map.put(:muting_user, user) - |> Map.put(:user, user) - - activities = - [user.ap_id | Pleroma.User.following(user)] - |> ActivityPub.fetch_activities(params) - - Enum.each(activities, fn activity -> - puts_activity(activity) - end) - - state - end - - def handle_command(state, command) do - IO.puts("Unknown command '#{command}'") - state - end - - defp wait_input(state, input) do - receive do - {:input, ^input, "quit\n"} -> - IO.puts("Exiting...") - - {:input, ^input, code} when is_binary(code) -> - code = String.trim(code) - - state = handle_command(state, code) - - loop(%{state | counter: state.counter + 1}) - - {:input, ^input, {:error, :interrupted}} -> - IO.puts("Caught Ctrl+C...") - loop(%{state | counter: state.counter + 1}) - - {:input, ^input, msg} -> - :ok = Logger.warn("received unknown message: #{inspect(msg)}") - loop(%{state | counter: state.counter + 1}) - end - end - - defp run_state(opts) do - %{prefix: "pleroma", counter: 1, user: opts[:user]} - end - - defp io_get(pid, prefix, counter, username) do - prompt = prompt(prefix, counter, username) - send(pid, {:input, self(), IO.gets(:stdio, prompt)}) - end - - defp prompt(prefix, counter, username) do - prompt = "#{username}@#{prefix}:#{counter}>" - prompt <> " " - end -end diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex index 187749e86..b83d72446 100644 --- a/lib/pleroma/bookmark.ex +++ b/lib/pleroma/bookmark.ex @@ -22,8 +22,8 @@ defmodule Pleroma.Bookmark do timestamps() end - @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: - {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec create(Ecto.UUID.t(), Ecto.UUID.t()) :: + {:ok, Bookmark.t()} | {:error, Ecto.Changeset.t()} def create(user_id, activity_id) do attrs = %{ user_id: user_id, @@ -37,7 +37,7 @@ def create(user_id, activity_id) do |> Repo.insert() end - @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() + @spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t() def for_user_query(user_id) do Bookmark |> where(user_id: ^user_id) @@ -52,8 +52,8 @@ def get(user_id, activity_id) do |> Repo.one() end - @spec destroy(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t()) :: - {:ok, Bookmark.t()} | {:error, Changeset.t()} + @spec destroy(Ecto.UUID.t(), Ecto.UUID.t()) :: + {:ok, Bookmark.t()} | {:error, Ecto.Changeset.t()} def destroy(user_id, activity_id) do from(b in Bookmark, where: b.user_id == ^user_id, diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index e786e28b9..c4987d4fd 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -29,7 +29,7 @@ def new do @impl Service def validate(_token, captcha, answer_data) do - # Here the token is unsed, because the unencrypted captcha answer is just passed to method + # Here the token is unused, because the unencrypted captcha answer is just passed to method if not is_nil(captcha) and :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data), do: :ok, diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index fe32ec08c..5c4dbc1ff 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -42,7 +42,7 @@ def changeset(struct, params) do |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end - @spec get_by_user_and_id(User.t(), FlakeId.Ecto.CompatType.t()) :: + @spec get_by_user_and_id(User.t(), Ecto.UUID.t()) :: {:ok, t()} | {:error, :not_found} def get_by_user_and_id(%User{id: user_id}, id) do from(c in __MODULE__, @@ -52,17 +52,17 @@ def get_by_user_and_id(%User{id: user_id}, id) do |> Repo.find_resource() end - @spec get_by_id(FlakeId.Ecto.CompatType.t()) :: t() | nil + @spec get_by_id(Ecto.UUID.t()) :: t() | nil def get_by_id(id) do Repo.get(__MODULE__, id) end - @spec get(FlakeId.Ecto.CompatType.t(), String.t()) :: t() | nil + @spec get(Ecto.UUID.t(), String.t()) :: t() | nil def get(user_id, recipient) do Repo.get_by(__MODULE__, user_id: user_id, recipient: recipient) end - @spec get_or_create(FlakeId.Ecto.CompatType.t(), String.t()) :: + @spec get_or_create(Ecto.UUID.t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def get_or_create(user_id, recipient) do %__MODULE__{} @@ -75,7 +75,7 @@ def get_or_create(user_id, recipient) do ) end - @spec bump_or_create(FlakeId.Ecto.CompatType.t(), String.t()) :: + @spec bump_or_create(Ecto.UUID.t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def bump_or_create(user_id, recipient) do %__MODULE__{} @@ -87,7 +87,7 @@ def bump_or_create(user_id, recipient) do ) end - @spec for_user_query(FlakeId.Ecto.CompatType.t()) :: Ecto.Query.t() + @spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t() def for_user_query(user_id) do from(c in Chat, where: c.user_id == ^user_id, diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index b53b15d95..a77923264 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -24,7 +24,7 @@ def check_exiftool_filter do filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) if Pleroma.Upload.Filter.Exiftool in filters do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later: @@ -63,7 +63,7 @@ def check_simple_policy_tuples do |> Enum.any?(fn {_, v} -> Enum.any?(v, &is_binary/1) end) if has_strings do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using strings in the SimplePolicy configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: @@ -121,7 +121,7 @@ def check_quarantined_instances_tuples do has_strings = Config.get([:instance, :quarantined_instances]) |> Enum.any?(&is_binary/1) if has_strings do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using strings in the quarantined_instances configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: @@ -158,7 +158,7 @@ def check_transparency_exclusions_tuples do has_strings = Config.get([:mrf, :transparency_exclusions]) |> Enum.any?(&is_binary/1) if has_strings do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using strings in the transparency_exclusions configuration instead of tuples. They should work for now, but you are advised to change to the new configuration to prevent possible issues later: @@ -172,7 +172,7 @@ def check_transparency_exclusions_tuples do ``` config :pleroma, :mrf, - transparency_exclusions: [{"instance.tld", "Reason to exlude transparency"}] + transparency_exclusions: [{"instance.tld", "Reason to exclude transparency"}] ``` """) @@ -193,7 +193,7 @@ def check_transparency_exclusions_tuples do def check_hellthread_threshold do if Config.get([:mrf_hellthread, :threshold]) do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! You are using the old configuration mechanism for the hellthread filter. Please check config.md. """) @@ -213,7 +213,7 @@ def warn do check_gun_pool_options(), check_activity_expiration_config(), check_remote_ip_plug_name(), - check_uploders_s3_public_endpoint(), + check_uploaders_s3_public_endpoint(), check_old_chat_shoutbox(), check_quarantined_instances_tuples(), check_transparency_exclusions_tuples(), @@ -274,7 +274,7 @@ def move_namespace_and_warn(config_map, warning_preface) do if warning == "" do :ok else - Logger.warn(warning_preface <> warning) + Logger.warning(warning_preface <> warning) :error end end @@ -284,7 +284,7 @@ def check_media_proxy_whitelist_config do whitelist = Config.get([:media_proxy, :whitelist]) if Enum.any?(whitelist, &(not String.starts_with?(&1, "http"))) do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using old format (only domain) for MediaProxy whitelist option. Setting should work for now, but you are advised to change format to scheme with port to prevent possible issues later. """) @@ -299,7 +299,7 @@ def check_gun_pool_options do pool_config = Config.get(:connections_pool) if timeout = pool_config[:await_up_timeout] do - Logger.warn(""" + Logger.warning(""" !!!DEPRECATION WARNING!!! Your config is using old setting `config :pleroma, :connections_pool, await_up_timeout`. Please change to `config :pleroma, :connections_pool, connect_timeout` to ensure compatibility with future releases. """) @@ -331,7 +331,7 @@ def check_gun_pool_options do "\n* `:timeout` options in #{pool_name} pool is now `:recv_timeout`" end) - Logger.warn(Enum.join([warning_preface | pool_warnings])) + Logger.warning(Enum.join([warning_preface | pool_warnings])) Config.put(:pools, updated_config) :error @@ -372,8 +372,8 @@ def check_remote_ip_plug_name do ) end - @spec check_uploders_s3_public_endpoint() :: :ok | nil - def check_uploders_s3_public_endpoint do + @spec check_uploaders_s3_public_endpoint() :: :ok | nil + def check_uploaders_s3_public_endpoint do s3_config = Pleroma.Config.get([Pleroma.Uploaders.S3]) use_old_config = Keyword.has_key?(s3_config, :public_endpoint) diff --git a/lib/pleroma/config/getting.ex b/lib/pleroma/config/getting.ex index f9b66bba6..ec93fd02a 100644 --- a/lib/pleroma/config/getting.ex +++ b/lib/pleroma/config/getting.ex @@ -5,4 +5,11 @@ defmodule Pleroma.Config.Getting do @callback get(any()) :: any() @callback get(any(), any()) :: any() + + def get(key), do: get(key, nil) + def get(key, default), do: impl().get(key, default) + + def impl do + Application.get_env(:pleroma, :config_impl, Pleroma.Config) + end end diff --git a/lib/pleroma/config/oban.ex b/lib/pleroma/config/oban.ex index 483d2bb79..836f0c1a7 100644 --- a/lib/pleroma/config/oban.ex +++ b/lib/pleroma/config/oban.ex @@ -23,7 +23,7 @@ def warn do You are using old workers in Oban crontab settings, which were removed. Please, remove setting from crontab in your config file (prod.secret.exs): #{inspect(setting)} """ - |> Logger.warn() + |> Logger.warning() List.delete(acc, setting) else diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex index 91e5f1a54..9ec0f975e 100644 --- a/lib/pleroma/config/release_runtime_provider.ex +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -20,6 +20,20 @@ def load(config, opts) do with_runtime_config = if File.exists?(config_path) do + # + %File.Stat{mode: mode} = File.lstat!(config_path) + + if Bitwise.band(mode, 0o007) > 0 do + raise "Configuration at #{config_path} has world-permissions, execute the following: chmod o= #{config_path}" + end + + if Bitwise.band(mode, 0o020) > 0 do + raise "Configuration at #{config_path} has group-wise write permissions, execute the following: chmod g-w #{config_path}" + end + + # Note: Elixir doesn't provides a getuid(2) + # so cannot forbid group-read only when config is owned by us + runtime_config = Config.Reader.read!(config_path) with_defaults diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 44a984019..91885347f 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -55,8 +55,7 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do started_applications = Application.started_applications() - # TODO: some problem with prometheus after restart! - reject = [nil, :prometheus, :postgrex] + reject = [nil, :postgrex] reject = if restart_pleroma? do @@ -145,7 +144,7 @@ defp update({group, key, value, merged}) do error_msg = "updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{inspect(value)} error: #{inspect(error)}" - Logger.warn(error_msg) + Logger.warning(error_msg) nil end @@ -179,12 +178,12 @@ defp restart(started_applications, app, _) do :ok = Application.start(app) else nil -> - Logger.warn("#{app} is not started.") + Logger.warning("#{app} is not started.") error -> error |> inspect() - |> Logger.warn() + |> Logger.warning() end end diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index 846cede04..e28fcb124 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -54,7 +54,7 @@ def get_by_group_and_key(group, key) do @spec get_by_params(map()) :: ConfigDB.t() | nil def get_by_params(%{group: _, key: _} = params), do: Repo.get_by(ConfigDB, params) - @spec changeset(ConfigDB.t(), map()) :: Changeset.t() + @spec changeset(ConfigDB.t(), map()) :: Ecto.Changeset.t() def changeset(config, params \\ %{}) do config |> cast(params, [:key, :group, :value]) @@ -138,7 +138,7 @@ defp deep_merge(_key, value1, value2) do end end - @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Ecto.Changeset.t()} def update_or_create(params) do params = Map.put(params, :value, to_elixir_types(params[:value])) search_opts = Map.take(params, [:group, :key]) @@ -175,7 +175,7 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do end) end - @spec delete(ConfigDB.t() | map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + @spec delete(ConfigDB.t() | map()) :: {:ok, ConfigDB.t()} | {:error, Ecto.Changeset.t()} def delete(%ConfigDB{} = config), do: Repo.delete(config) def delete(params) do diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index cfb405218..d814b4931 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -42,6 +42,18 @@ defmodule Pleroma.Constants do ] ) + const(status_object_types, + do: [ + "Note", + "Question", + "Audio", + "Video", + "Event", + "Article", + "Page" + ] + ) + const(updatable_object_types, do: [ "Note", @@ -64,9 +76,34 @@ defmodule Pleroma.Constants do ] ) + const(allowed_user_actor_types, + do: [ + "Person", + "Service", + "Group" + ] + ) + # basic regex, just there to weed out potential mistakes # https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 const(mime_regex, do: ~r/^[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+\/[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+(; .*)?$/ ) + + const(upload_object_types, do: ["Document", "Image"]) + + const(activity_json_canonical_mime_type, + do: "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + ) + + const(activity_json_mime_types, + do: [ + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + "application/activity+json" + ] + ) + + const(public_streams, + do: ["public", "public:local", "public:media", "public:local:media"] + ) end diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex index 42028aa51..0be609a22 100644 --- a/lib/pleroma/conversation.ex +++ b/lib/pleroma/conversation.ex @@ -57,7 +57,7 @@ def maybe_create_recipientships(participation, activity) do 3. Bump all relevant participations to 'unread' """ def create_or_bump_for(activity, opts \\ []) do - with true <- Pleroma.Web.ActivityPub.Visibility.is_direct?(activity), + with true <- Pleroma.Web.ActivityPub.Visibility.direct?(activity), "Create" <- activity.data["type"], %Object{} = object <- Object.normalize(activity, fetch: false), true <- object.data["type"] in ["Note", "Question"], diff --git a/lib/pleroma/data_migration.ex b/lib/pleroma/data_migration.ex index 8451678fc..be4bf6489 100644 --- a/lib/pleroma/data_migration.ex +++ b/lib/pleroma/data_migration.ex @@ -12,6 +12,8 @@ defmodule Pleroma.DataMigration do import Ecto.Changeset import Ecto.Query + @type t :: %__MODULE__{} + schema "data_migrations" do field(:name, :string) field(:state, State, default: :pending) diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index 6508f1947..93b19484f 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -15,8 +15,10 @@ def list_behaviour_implementations(behaviour) do :code.all_loaded() |> Enum.filter(fn {module, _} -> # This shouldn't be needed as all modules are expected to have module_info/1, - # but in test enviroments some transient modules `:elixir_compiler_XX` + # but in test environments some transient modules `:elixir_compiler_XX` # are loaded for some reason (where XX is a random integer). + Code.ensure_loaded(module) + if function_exported?(module, :module_info, 1) do module.module_info(:attributes) |> Keyword.get_values(:behaviour) diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index 05f46f39b..f69854935 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -18,7 +18,7 @@ def compile do :persistent_term.put(@term, Pleroma.Docs.Generator.convert_to_strings(descriptions)) end - @spec compiled_descriptions :: Map.t() + @spec compiled_descriptions :: map() def compiled_descriptions do :persistent_term.get(@term) end diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex index a4890b489..b346b39d6 100644 --- a/lib/pleroma/ecto_enums.ex +++ b/lib/pleroma/ecto_enums.ex @@ -27,3 +27,11 @@ failed: 4, manual: 5 ) + +defenum(Pleroma.User.Backup.State, + pending: 1, + running: 2, + complete: 3, + failed: 4, + invalid: 5 +) diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex new file mode 100644 index 000000000..a1af8faa1 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/bare_uri.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.BareUri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + parsed = URI.parse(uri) + + if is_nil(parsed.scheme) do + :error + else + {:ok, uri} + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index dd65d56ae..21bcb0111 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -24,6 +24,8 @@ defmodule Pleroma.Emoji do defstruct [:code, :file, :tags, :safe_code, :safe_file] + @type t :: %__MODULE__{} + @doc "Build emoji struct" def build({code, file, tags}) do %__MODULE__{ @@ -49,10 +51,12 @@ def reload do end @doc "Returns the path of the emoji `name`." - @spec get(String.t()) :: String.t() | nil + @spec get(String.t()) :: Pleroma.Emoji.t() | nil def get(name) do + name = maybe_strip_name(name) + case :ets.lookup(@ets, name) do - [{_, path}] -> path + [{_, emoji}] -> emoji _ -> nil end end @@ -134,10 +138,61 @@ defp update_emojis(emojis) do emojis = emojis ++ regional_indicators for emoji <- emojis do - def is_unicode_emoji?(unquote(emoji)), do: true + def unicode?(unquote(emoji)), do: true end - def is_unicode_emoji?(_), do: false + def unicode?(_), do: false + + @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/ + + def custom?(s) when is_binary(s), do: Regex.match?(@emoji_regex, s) + + def custom?(_), do: false + + def maybe_strip_name(name) when is_binary(name), do: String.trim(name, ":") + + def maybe_strip_name(name), do: name + + def maybe_quote(name) when is_binary(name) do + if unicode?(name) do + name + else + if String.starts_with?(name, ":") do + name + else + ":#{name}:" + end + end + end + + def maybe_quote(name), do: name + + def emoji_url(%{"type" => "EmojiReact", "content" => _, "tag" => []}), do: nil + + def emoji_url(%{"type" => "EmojiReact", "content" => emoji, "tag" => tags}) do + emoji = maybe_strip_name(emoji) + + tag = + tags + |> Enum.find(fn tag -> + tag["type"] == "Emoji" && !is_nil(tag["name"]) && tag["name"] == emoji + end) + + if is_nil(tag) do + nil + else + tag + |> Map.get("icon") + |> Map.get("url") + end + end + + def emoji_url(_), do: nil + + def emoji_name_with_instance(name, url) do + url = url |> URI.parse() |> Map.get(:host) + "#{name}@#{url}" + end emoji_qualification_map = emojis diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex index 97d4b8f70..b6e544323 100644 --- a/lib/pleroma/emoji/loader.ex +++ b/lib/pleroma/emoji/loader.ex @@ -15,8 +15,6 @@ defmodule Pleroma.Emoji.Loader do require Logger - @mix_env Mix.env() - @type pattern :: Regex.t() | module() | String.t() @type patterns :: pattern() | [pattern()] @type group_patterns :: keyword(patterns()) @@ -59,7 +57,7 @@ def load do Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}") if not Enum.empty?(files) do - Logger.warn( + Logger.warning( "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{Enum.join(files, ", ")}" ) end @@ -79,7 +77,7 @@ def load do # for testing emoji.txt entries we do not want exposed in normal operation test_emoji = - if @mix_env == :test do + if Application.get_env(:pleroma, __MODULE__)[:test_emoji] do load_from_file("test/config/emoji.txt", emoji_groups) else [] diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index a361ea200..85f7e1877 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -209,7 +209,9 @@ def list_remote(opts) do with :ok <- validate_shareable_packs_available(uri) do uri - |> URI.merge("/api/pleroma/emoji/packs?page=#{opts[:page]}&page_size=#{opts[:page_size]}") + |> URI.merge( + "/api/v1/pleroma/emoji/packs?page=#{opts[:page]}&page_size=#{opts[:page_size]}" + ) |> http_get() end end @@ -249,8 +251,12 @@ def download(name, url, as) do uri = url |> String.trim() |> URI.parse() with :ok <- validate_shareable_packs_available(uri), + {:ok, %{"files_count" => files_count}} <- + uri |> URI.merge("/api/v1/pleroma/emoji/pack?name=#{name}&page_size=0") |> http_get(), {:ok, remote_pack} <- - uri |> URI.merge("/api/pleroma/emoji/pack?name=#{name}") |> http_get(), + uri + |> URI.merge("/api/v1/pleroma/emoji/pack?name=#{name}&page_size=#{files_count}") + |> http_get(), {:ok, %{sha: sha, url: url} = pack_info} <- fetch_pack_info(remote_pack, uri, name), {:ok, archive} <- download_archive(url, sha), pack <- copy_as(remote_pack, as || name), @@ -285,6 +291,7 @@ def update_metadata(name, data) do @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} def load_pack(name) do + name = Path.basename(name) pack_file = Path.join([emoji_path(), name, "pack.json"]) with {:ok, _} <- File.stat(pack_file), @@ -591,7 +598,7 @@ defp fetch_pack_info(remote_pack, uri, name) do {:ok, %{ sha: sha, - url: URI.merge(uri, "/api/pleroma/emoji/packs/archive?name=#{name}") |> to_string() + url: URI.merge(uri, "/api/v1/pleroma/emoji/packs/archive?name=#{name}") |> to_string() }} %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index a46c3e381..11d5af2fb 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -124,7 +124,7 @@ def mentions_escape(text, options \\ []) do end def markdown_to_html(text) do - Earmark.as_html!(text, %Earmark.Options{compact_output: true}) + Earmark.as_html!(text, %Earmark.Options{compact_output: true, smartypants: false}) end def html_escape({text, mentions, hashtags}, type) do diff --git a/lib/pleroma/gopher/server.ex b/lib/pleroma/gopher/server.ex index 0fde0adcf..54245c9fa 100644 --- a/lib/pleroma/gopher/server.ex +++ b/lib/pleroma/gopher/server.ex @@ -114,7 +114,7 @@ def response("/main/all") do def response("/notices/" <> id) do with %Activity{} = activity <- Activity.get_by_id(id), - true <- Visibility.is_public?(activity) do + true <- Visibility.public?(activity) do activities = ActivityPub.fetch_activities_for_context(activity.data["context"]) |> render_activities diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 7c5785def..804cd11c7 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -56,7 +56,7 @@ defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do {:ok, conn, protocol} else error -> - Logger.warn( + Logger.warning( "Opening proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" ) @@ -90,7 +90,7 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do {:ok, conn, protocol} else error -> - Logger.warn( + Logger.warning( "Opening socks proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" ) @@ -106,7 +106,7 @@ defp do_open(%URI{host: host, port: port} = uri, opts) do {:ok, conn, protocol} else error -> - Logger.warn( + Logger.warning( "Opening connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" ) diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex index efd5c9fb8..35e7f4b2e 100644 --- a/lib/pleroma/gun/connection_pool/reclaimer.ex +++ b/lib/pleroma/gun/connection_pool/reclaimer.ex @@ -9,7 +9,7 @@ defp registry, do: Pleroma.Gun.ConnectionPool def start_monitor do pid = - case :gen_server.start(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do + case GenServer.start_link(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do {:ok, pid} -> pid diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex index d26a70be3..b2be4ff87 100644 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -21,7 +21,7 @@ def init(_opts) do def start_worker(opts, retry \\ false) do case DynamicSupervisor.start_child(__MODULE__, {Pleroma.Gun.ConnectionPool.Worker, opts}) do {:error, :max_children} -> - if retry or free_pool() == :error do + if Enum.any?([retry, free_pool()], &match?(&1, :error)) do :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) {:error, :pool_full} else diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 24c845fcd..1a414b37f 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -8,11 +8,12 @@ defmodule Pleroma.Helpers.MediaHelper do """ alias Pleroma.HTTP + alias Vix.Vips.Operation require Logger def missing_dependencies do - Enum.reduce([imagemagick: "convert", ffmpeg: "ffmpeg"], [], fn {sym, executable}, acc -> + Enum.reduce([ffmpeg: "ffmpeg"], [], fn {sym, executable}, acc -> if Pleroma.Utils.command_available?(executable) do acc else @@ -22,141 +23,48 @@ def missing_dependencies do end def image_resize(url, options) do - with executable when is_binary(executable) <- System.find_executable("convert"), - {:ok, args} <- prepare_image_resize_args(options), - {:ok, env} <- HTTP.get(url, [], pool: :media), - {:ok, fifo_path} <- mkfifo() do - args = List.flatten([fifo_path, args]) - run_fifo(fifo_path, env, executable, args) + with {:ok, env} <- HTTP.get(url, [], pool: :media), + {:ok, resized} <- + Operation.thumbnail_buffer(env.body, options.max_width, + height: options.max_height, + size: :VIPS_SIZE_DOWN + ) do + if options[:format] == "png" do + Operation.pngsave_buffer(resized, Q: options[:quality]) + else + Operation.jpegsave_buffer(resized, Q: options[:quality], interlace: true) + end else - nil -> {:error, {:convert, :command_not_found}} {:error, _} = error -> error end end - defp prepare_image_resize_args( - %{max_width: max_width, max_height: max_height, format: "png"} = options - ) do - quality = options[:quality] || 85 - resize = Enum.join([max_width, "x", max_height, ">"]) - - args = [ - "-resize", - resize, - "-quality", - to_string(quality), - "png:-" - ] - - {:ok, args} - end - - defp prepare_image_resize_args(%{max_width: max_width, max_height: max_height} = options) do - quality = options[:quality] || 85 - resize = Enum.join([max_width, "x", max_height, ">"]) - - args = [ - "-interlace", - "Plane", - "-resize", - resize, - "-quality", - to_string(quality), - "jpg:-" - ] - - {:ok, args} - end - - defp prepare_image_resize_args(_), do: {:error, :missing_options} - # Note: video thumbnail is intentionally not resized (always has original dimensions) def video_framegrab(url) do with executable when is_binary(executable) <- System.find_executable("ffmpeg"), {:ok, env} <- HTTP.get(url, [], pool: :media), - {:ok, fifo_path} <- mkfifo(), - args = [ - "-y", - "-i", - fifo_path, - "-vframes", - "1", - "-f", - "mjpeg", - "-loglevel", - "error", - "-" - ] do - run_fifo(fifo_path, env, executable, args) + {:ok, pid} <- StringIO.open(env.body) do + body_stream = IO.binstream(pid, 1) + + Exile.stream!( + [ + executable, + "-i", + "pipe:0", + "-vframes", + "1", + "-f", + "mjpeg", + "pipe:1" + ], + input: body_stream, + ignore_epipe: true, + stderr: :disable + ) + |> Enum.into(<<>>) else nil -> {:error, {:ffmpeg, :command_not_found}} {:error, _} = error -> error end end - - defp run_fifo(fifo_path, env, executable, args) do - pid = - Port.open({:spawn_executable, executable}, [ - :use_stdio, - :stream, - :exit_status, - :binary, - args: args - ]) - - fifo = Port.open(to_charlist(fifo_path), [:eof, :binary, :stream, :out]) - fix = Pleroma.Helpers.QtFastStart.fix(env.body) - true = Port.command(fifo, fix) - :erlang.port_close(fifo) - loop_recv(pid) - after - File.rm(fifo_path) - end - - defp mkfifo do - path = Path.join(System.tmp_dir!(), "pleroma-media-preview-pipe-#{Ecto.UUID.generate()}") - - case System.cmd("mkfifo", [path]) do - {_, 0} -> - spawn(fifo_guard(path)) - {:ok, path} - - {_, err} -> - {:error, {:fifo_failed, err}} - end - end - - defp fifo_guard(path) do - pid = self() - - fn -> - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, _} -> - File.rm(path) - end - end - end - - defp loop_recv(pid) do - loop_recv(pid, <<>>) - end - - defp loop_recv(pid, acc) do - receive do - {^pid, {:data, data}} -> - loop_recv(pid, acc <> data) - - {^pid, {:exit_status, 0}} -> - {:ok, acc} - - {^pid, {:exit_status, status}} -> - {:error, status} - after - 5000 -> - :erlang.port_close(pid) - {:error, :timeout} - end - end end diff --git a/lib/pleroma/helpers/qt_fast_start.ex b/lib/pleroma/helpers/qt_fast_start.ex index 5711c7162..0b200b234 100644 --- a/lib/pleroma/helpers/qt_fast_start.ex +++ b/lib/pleroma/helpers/qt_fast_start.ex @@ -40,16 +40,21 @@ defp fix( got_mdat, acc ) do - full_size = (size - 8) * 8 - <> = rest + try do + full_size = (size - 8) * 8 + <> = rest - acc = [ - {fourcc, pos, pos + size, size, - <>} - | acc - ] + acc = [ + {fourcc, pos, pos + size, size, + <>} + | acc + ] - fix(rest, pos + size, got_moov || fourcc == "moov", got_mdat || fourcc == "mdat", acc) + fix(rest, pos + size, got_moov || fourcc == "moov", got_mdat || fourcc == "mdat", acc) + rescue + _ -> + :abort + end end defp fix(<<>>, _pos, _, _, acc) do @@ -121,9 +126,15 @@ defp rewrite_entries( <>, acc ) do - rewrite_entries(unquote(size), offset, rest, [ - acc | <> - ]) + rewrite_entries( + unquote(size), + offset, + rest, + acc ++ + [ + <> + ] + ) end end diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index d41061538..eec61cf14 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -106,6 +106,10 @@ defp adapter_middlewares(Tesla.Adapter.Gun) do [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.ConnectionPool] end + defp adapter_middlewares({Tesla.Adapter.Finch, _}) do + [Tesla.Middleware.FollowRedirects] + end + defp adapter_middlewares(_) do if Pleroma.Config.get(:env) == :test do # Emulate redirects in test env, which are handled by adapters in other environments diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 252a6aba5..dcb27a29d 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -15,8 +15,8 @@ defmodule Pleroma.HTTP.AdapterHelper do require Logger @type proxy :: - {Connection.host(), pos_integer()} - | {Connection.proxy_type(), Connection.host(), pos_integer()} + {host(), pos_integer()} + | {proxy_type(), host(), pos_integer()} @callback options(keyword(), URI.t()) :: keyword() @@ -70,15 +70,15 @@ def parse_proxy(proxy) when is_binary(proxy) do {:ok, parse_host(host), port} else {_, _} -> - Logger.warn("Parsing port failed #{inspect(proxy)}") + Logger.warning("Parsing port failed #{inspect(proxy)}") {:error, :invalid_proxy_port} :error -> - Logger.warn("Parsing port failed #{inspect(proxy)}") + Logger.warning("Parsing port failed #{inspect(proxy)}") {:error, :invalid_proxy_port} _ -> - Logger.warn("Parsing proxy failed #{inspect(proxy)}") + Logger.warning("Parsing proxy failed #{inspect(proxy)}") {:error, :invalid_proxy} end end @@ -88,7 +88,7 @@ def parse_proxy(proxy) when is_tuple(proxy) do {:ok, type, parse_host(host), port} else _ -> - Logger.warn("Parsing proxy failed #{inspect(proxy)}") + Logger.warning("Parsing proxy failed #{inspect(proxy)}") {:error, :invalid_proxy} end end diff --git a/lib/pleroma/http/web_push.ex b/lib/pleroma/http/web_push.ex index ca399b6c8..888079c1e 100644 --- a/lib/pleroma/http/web_push.ex +++ b/lib/pleroma/http/web_push.ex @@ -6,7 +6,11 @@ defmodule Pleroma.HTTP.WebPush do @moduledoc false def post(url, payload, headers, options \\ []) do - list_headers = Map.to_list(headers) + list_headers = + headers + |> Map.to_list() + |> Kernel.++([{"content-type", "octet-stream"}]) + Pleroma.HTTP.post(url, payload, list_headers, options) end end diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 782948f83..b6d83f591 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -7,16 +7,15 @@ defmodule Pleroma.Instances do alias Pleroma.Instances.Instance - def filter_reachable(urls_or_hosts), do: Instance.filter_reachable(urls_or_hosts) + defdelegate filter_reachable(urls_or_hosts), to: Instance - def reachable?(url_or_host), do: Instance.reachable?(url_or_host) + defdelegate reachable?(url_or_host), to: Instance - def set_reachable(url_or_host), do: Instance.set_reachable(url_or_host) + defdelegate set_reachable(url_or_host), to: Instance - def set_unreachable(url_or_host, unreachable_since \\ nil), - do: Instance.set_unreachable(url_or_host, unreachable_since) + defdelegate set_unreachable(url_or_host, unreachable_since \\ nil), to: Instance - def get_consistently_unreachable, do: Instance.get_consistently_unreachable() + defdelegate get_consistently_unreachable, to: Instance def set_consistently_unreachable(url_or_host), do: set_unreachable(url_or_host, reachability_datetime_threshold()) diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index a5529ad44..c497a4fb7 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Instances.Instance do alias Pleroma.Instances alias Pleroma.Instances.Instance + alias Pleroma.Maps alias Pleroma.Repo alias Pleroma.User alias Pleroma.Workers.BackgroundWorker @@ -24,6 +25,14 @@ defmodule Pleroma.Instances.Instance do field(:favicon, :string) field(:favicon_updated_at, :naive_datetime) + embeds_one :metadata, Pleroma.Instances.Metadata, primary_key: false do + field(:software_name, :string) + field(:software_version, :string) + field(:software_repository, :string) + end + + field(:metadata_updated_at, :utc_datetime) + timestamps() end @@ -31,11 +40,17 @@ defmodule Pleroma.Instances.Instance do def changeset(struct, params \\ %{}) do struct - |> cast(params, [:host, :unreachable_since, :favicon, :favicon_updated_at]) + |> cast(params, __schema__(:fields) -- [:metadata]) + |> cast_embed(:metadata, with: &metadata_changeset/2) |> validate_required([:host]) |> unique_constraint(:host) end + def metadata_changeset(struct, params \\ %{}) do + struct + |> cast(params, [:software_name, :software_version, :software_repository]) + end + def filter_reachable([]), do: %{} def filter_reachable(urls_or_hosts) when is_list(urls_or_hosts) do @@ -82,13 +97,9 @@ def reachable?(url_or_host) when is_binary(url_or_host) do def reachable?(url_or_host) when is_binary(url_or_host), do: true def set_reachable(url_or_host) when is_binary(url_or_host) do - with host <- host(url_or_host), - %Instance{} = existing_record <- Repo.get_by(Instance, %{host: host}) do - {:ok, _instance} = - existing_record - |> changeset(%{unreachable_since: nil}) - |> Repo.update() - end + %Instance{host: host(url_or_host)} + |> changeset(%{unreachable_since: nil}) + |> Repo.insert(on_conflict: {:replace, [:unreachable_since]}, conflict_target: :host) end def set_reachable(_), do: {:error, nil} @@ -162,7 +173,7 @@ def get_or_update_favicon(%URI{host: host} = instance_uri) do end rescue e -> - Logger.warn("Instance.get_or_update_favicon(\"#{host}\") error: #{inspect(e)}") + Logger.warning("Instance.get_or_update_favicon(\"#{host}\") error: #{inspect(e)}") nil end @@ -190,7 +201,7 @@ defp scrape_favicon(%URI{} = instance_uri) do end rescue e -> - Logger.warn( + Logger.warning( "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") error: #{inspect(e)}" ) @@ -198,6 +209,89 @@ defp scrape_favicon(%URI{} = instance_uri) do end end + def get_or_update_metadata(%URI{host: host} = instance_uri) do + existing_record = Repo.get_by(Instance, %{host: host}) + now = NaiveDateTime.utc_now() + + if existing_record && existing_record.metadata_updated_at && + NaiveDateTime.diff(now, existing_record.metadata_updated_at) < 86_400 do + existing_record.metadata + else + metadata = scrape_metadata(instance_uri) + + if existing_record do + existing_record + |> changeset(%{metadata: metadata, metadata_updated_at: now}) + |> Repo.update() + else + %Instance{} + |> changeset(%{host: host, metadata: metadata, metadata_updated_at: now}) + |> Repo.insert() + end + + metadata + end + end + + defp get_nodeinfo_uri(well_known) do + links = Map.get(well_known, "links", []) + + nodeinfo21 = + Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1"))["href"] + + nodeinfo20 = + Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0"))["href"] + + cond do + is_binary(nodeinfo21) -> {:ok, nodeinfo21} + is_binary(nodeinfo20) -> {:ok, nodeinfo20} + true -> {:error, :no_links} + end + end + + defp scrape_metadata(%URI{} = instance_uri) do + try do + with {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{body: well_known_body}} <- + instance_uri + |> URI.merge("/.well-known/nodeinfo") + |> to_string() + |> Pleroma.HTTP.get([{"accept", "application/json"}]), + {:ok, well_known_json} <- Jason.decode(well_known_body), + {:ok, nodeinfo_uri} <- get_nodeinfo_uri(well_known_json), + {:ok, %Tesla.Env{body: nodeinfo_body}} <- + Pleroma.HTTP.get(nodeinfo_uri, [{"accept", "application/json"}]), + {:ok, nodeinfo} <- Jason.decode(nodeinfo_body) do + # Can extract more metadata from NodeInfo but need to be careful about it's size, + # can't just dump the entire thing + software = Map.get(nodeinfo, "software", %{}) + + %{ + software_name: software["name"], + software_version: software["version"] + } + |> Maps.put_if_present(:software_repository, software["repository"]) + else + {:reachable, false} -> + Logger.debug( + "Instance.scrape_metadata(\"#{to_string(instance_uri)}\") ignored unreachable host" + ) + + nil + + _ -> + nil + end + rescue + e -> + Logger.warning( + "Instance.scrape_metadata(\"#{to_string(instance_uri)}\") error: #{inspect(e)}" + ) + + nil + end + end + @doc """ Deletes all users from an instance in a background task, thus also deleting all of those users' activities and notifications. diff --git a/lib/pleroma/maintenance.ex b/lib/pleroma/maintenance.ex index eb5a6ef42..1e39b03e6 100644 --- a/lib/pleroma/maintenance.ex +++ b/lib/pleroma/maintenance.ex @@ -20,7 +20,7 @@ def vacuum(args) do "full" -> Logger.info("Running VACUUM FULL.") - Logger.warn( + Logger.warning( "Re-packing your entire database may take a while and will consume extra disk space during the process." ) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index dca4bfa6f..bd4dd2f1d 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -100,7 +100,7 @@ def query do |> where([_o, hashtags_objects], is_nil(hashtags_objects.object_id)) end - @spec transfer_object_hashtags(Map.t()) :: {:noop | :ok | :error, integer()} + @spec transfer_object_hashtags(map()) :: {:noop | :ok | :error, integer()} defp transfer_object_hashtags(object) do embedded_tags = if Map.has_key?(object, :tag), do: object.tag, else: object.data["tag"] hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags}) diff --git a/lib/pleroma/migrators/support/base_migrator.ex b/lib/pleroma/migrators/support/base_migrator.ex index 3bcd59fd0..76a5d4590 100644 --- a/lib/pleroma/migrators/support/base_migrator.ex +++ b/lib/pleroma/migrators/support/base_migrator.ex @@ -73,7 +73,7 @@ def handle_continue(:init_state, _state) do data_migration.state == :manual or data_migration.name in manual_migrations -> message = "Data migration is in manual execution or manual fix mode." update_status(:manual, message) - Logger.warn("#{__MODULE__}: #{message}") + Logger.warning("#{__MODULE__}: #{message}") data_migration.state == :complete -> on_complete(data_migration) @@ -109,7 +109,7 @@ def handle_info(:perform, state) do Putting data migration to manual fix mode. Try running `#{__MODULE__}.retry_failed/0`. """ - Logger.warn("#{__MODULE__}: #{message}") + Logger.warning("#{__MODULE__}: #{message}") update_status(:manual, message) on_complete(data_migration()) @@ -125,7 +125,7 @@ def handle_info(:perform, state) do defp on_complete(data_migration) do if data_migration.feature_lock || feature_state() == :disabled do - Logger.warn( + Logger.warning( "#{__MODULE__}: migration complete but feature is locked; consider enabling." ) @@ -188,10 +188,11 @@ defp update_status(status, message \\ nil) do end defp fault_rate do - with failures_count when is_integer(failures_count) <- failures_count() do + with failures_count when is_integer(failures_count) <- failures_count(), + true <- failures_count > 0 do failures_count / Enum.max([get_stat(:affected_count, 0), 1]) else - _ -> :error + _ -> 0 end end diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 7203423e2..5c3ca58b0 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -121,7 +121,7 @@ defp prepare_log_data(%{actor: actor, action: action} = attrs) do defp prepare_log_data(attrs), do: attrs - @spec insert_log(log_params()) :: {:ok, ModerationLog} | {:error, any} + @spec insert_log(log_params()) :: {:ok, ModerationLog.t()} | {:error, any} def insert_log(%{actor: %User{}, subject: subjects, permission: permission} = attrs) do data = attrs @@ -248,7 +248,8 @@ def insert_log(%{actor: %User{} = actor, action: "chat_message_delete", subject_ |> insert_log_entry_with_message() end - @spec insert_log_entry_with_message(ModerationLog) :: {:ok, ModerationLog} | {:error, any} + @spec insert_log_entry_with_message(ModerationLog.t()) :: + {:ok, ModerationLog.t()} | {:error, any} defp insert_log_entry_with_message(entry) do entry.data["message"] |> put_in(get_log_entry_message(entry)) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 38accae5d..55b646b12 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -177,7 +177,10 @@ def normalize(ap_id, options) when is_binary(ap_id) do ap_id Keyword.get(options, :fetch) -> - Fetcher.fetch_object_from_id!(ap_id, options) + case Fetcher.fetch_object_from_id(ap_id, options) do + {:ok, object} -> object + _ -> nil + end true -> get_cached_by_ap_id(ap_id) @@ -239,17 +242,17 @@ def delete(%Object{data: %{"id" => id}} = object) do {:ok, _} <- invalid_object_cache(object) do cleanup_attachments( Config.get([:instance, :cleanup_attachments]), - %{"object" => object} + object ) {:ok, object, deleted_activity} end end - @spec cleanup_attachments(boolean(), %{required(:object) => map()}) :: + @spec cleanup_attachments(boolean(), Object.t()) :: {:ok, Oban.Job.t() | nil} - def cleanup_attachments(true, %{"object" => _} = params) do - AttachmentsCleanupWorker.enqueue("cleanup_attachments", params) + def cleanup_attachments(true, %Object{} = object) do + AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{"object" => object}) end def cleanup_attachments(_, _), do: {:ok, nil} @@ -328,6 +331,52 @@ def decrease_replies_count(ap_id) do end end + def increase_quotes_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{quotesCount}', + (coalesce((?->>'quotesCount')::int, 0) + 1)::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + + def decrease_quotes_count(ap_id) do + Object + |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) + |> update([o], + set: [ + data: + fragment( + """ + safe_jsonb_set(?, '{quotesCount}', + (greatest(0, (?->>'quotesCount')::int - 1))::varchar::jsonb, true) + """, + o.data, + o.data + ) + ] + ) + |> Repo.update_all([]) + |> case do + {1, [object]} -> set_cache(object) + _ -> {:error, "Not found"} + end + end + def increase_vote_count(ap_id, name, actor) do with %Object{} = object <- Object.normalize(ap_id, fetch: false), "Question" <- object.data["type"] do @@ -425,4 +474,30 @@ def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do end def object_data_hashtags(_), do: [] + + def get_emoji_reactions(object) do + reactions = object.data["reactions"] + + if is_list(reactions) or is_map(reactions) do + reactions + |> Enum.map(fn + [_emoji, users, _maybe_url] = item when is_list(users) -> + item + + [emoji, users] when is_list(users) -> + [emoji, users, nil] + + # This case is here to process the Map situation, which will happen + # only with the legacy two-value format. + {emoji, users} when is_list(users) -> + [emoji, users, nil] + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + else + [] + end + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index a9a9eeeed..af5642af4 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -8,77 +8,30 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment - alias Pleroma.Repo alias Pleroma.Signature alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator require Logger require Pleroma.Constants - defp touch_changeset(changeset) do - updated_at = - NaiveDateTime.utc_now() - |> NaiveDateTime.truncate(:second) - - Ecto.Changeset.put_change(changeset, :updated_at, updated_at) - end - - defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do - has_history? = fn - %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> true - _ -> false - end - - internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields()) - - remote_history_exists? = has_history?.(new_data) - - # If the remote history exists, we treat that as the only source of truth. - new_data = - if has_history?.(old_data) and not remote_history_exists? do - Map.put(new_data, "formerRepresentations", old_data["formerRepresentations"]) - else - new_data - end - - # If the remote does not have history information, we need to manage it ourselves - new_data = - if not remote_history_exists? do - changed? = - Pleroma.Constants.status_updatable_fields() - |> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end) - - %{updated_object: updated_object} = - new_data - |> Object.Updater.maybe_update_history(old_data, - updated: changed?, - use_history_in_new_object?: false - ) - - updated_object - else - new_data - end - - Map.merge(new_data, internal_fields) - end - - defp maybe_reinject_internal_fields(_, new_data), do: new_data - @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} - defp reinject_object(%Object{data: %{"type" => "Question"}} = object, new_data) do + defp reinject_object(%Object{data: %{}} = object, new_data) do Logger.debug("Reinjecting object #{new_data["id"]}") - with data <- maybe_reinject_internal_fields(object, new_data), - {:ok, data, _} <- ObjectValidator.validate(data, %{}), - changeset <- Object.change(object, %{data: data}), - changeset <- touch_changeset(changeset), - {:ok, object} <- Repo.insert_or_update(changeset), - {:ok, object} <- Object.set_cache(object) do - {:ok, object} + with {:ok, new_data, _} <- ObjectValidator.validate(new_data, %{}), + {:ok, new_data} <- MRF.filter(new_data), + {:ok, new_object, _} <- + Object.Updater.do_update_and_invalidate_cache( + object, + new_data, + _touch_changeset? = true + ) do + {:ok, new_object} else e -> Logger.error("Error while processing object: #{inspect(e)}") @@ -86,20 +39,11 @@ defp reinject_object(%Object{data: %{"type" => "Question"}} = object, new_data) end end - defp reinject_object(%Object{} = object, new_data) do - Logger.debug("Reinjecting object #{new_data["id"]}") - - with new_data <- Transmogrifier.fix_object(new_data), - data <- maybe_reinject_internal_fields(object, new_data), - changeset <- Object.change(object, %{data: data}), - changeset <- touch_changeset(changeset), - {:ok, object} <- Repo.insert_or_update(changeset), - {:ok, object} <- Object.set_cache(object) do + defp reinject_object(_, new_data) do + with {:ok, object, _} <- Pipeline.common_pipeline(new_data, local: false) do {:ok, object} else - e -> - Logger.error("Error while processing object: #{inspect(e)}") - {:error, e} + e -> e end end @@ -128,20 +72,25 @@ def fetch_object_from_id(id, options \\ []) do {:object, data, Object.normalize(activity, fetch: false)} do {:ok, object} else - {:allowed_depth, false} -> - {:error, "Max thread distance exceeded."} + {:allowed_depth, false} = e -> + log_fetch_error(id, e) + {:error, :allowed_depth} - {:containment, _} -> - {:error, "Object containment failed."} + {:containment, reason} = e -> + log_fetch_error(id, e) + {:error, reason} - {:transmogrifier, {:error, {:reject, e}}} -> - {:reject, e} + {:transmogrifier, {:error, {:reject, reason}}} = e -> + log_fetch_error(id, e) + {:reject, reason} - {:transmogrifier, {:reject, e}} -> - {:reject, e} + {:transmogrifier, {:reject, reason}} = e -> + log_fetch_error(id, e) + {:reject, reason} - {:transmogrifier, _} = e -> - {:error, e} + {:transmogrifier, reason} = e -> + log_fetch_error(id, e) + {:error, reason} {:object, data, nil} -> reinject_object(%Object{}, data) @@ -152,14 +101,21 @@ def fetch_object_from_id(id, options \\ []) do {:fetch_object, %Object{} = object} -> {:ok, object} - {:fetch, {:error, error}} -> - {:error, error} + {:fetch, {:error, reason}} = e -> + log_fetch_error(id, e) + {:error, reason} e -> - e + log_fetch_error(id, e) + {:error, e} end end + defp log_fetch_error(id, error) do + Logger.metadata(object: id) + Logger.error("Object rejected while fetching #{id} #{inspect(error)}") + end + defp prepare_activity_params(data) do %{ "type" => "Create", @@ -173,26 +129,6 @@ defp prepare_activity_params(data) do |> Maps.put_if_present("bcc", data["bcc"]) end - def fetch_object_from_id!(id, options \\ []) do - with {:ok, object} <- fetch_object_from_id(id, options) do - object - else - {:error, %Tesla.Mock.Error{}} -> - nil - - {:error, "Object has been deleted"} -> - nil - - {:reject, reason} -> - Logger.info("Rejected #{id} while fetching: #{inspect(reason)}") - nil - - e -> - Logger.error("Error while fetching #{id}: #{inspect(e)}") - nil - end - end - defp make_signature(id, date) do uri = URI.parse(id) @@ -283,8 +219,11 @@ defp get_object(id) do {:error, {:content_type, nil}} end + {:ok, %{status: code}} when code in [401, 403] -> + {:error, :forbidden} + {:ok, %{status: code}} when code in [404, 410] -> - {:error, "Object has been deleted"} + {:error, :not_found} {:error, e} -> {:error, e} diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index ab38d3ed2..b1e4870ba 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -5,6 +5,9 @@ defmodule Pleroma.Object.Updater do require Pleroma.Constants + alias Pleroma.Object + alias Pleroma.Repo + def update_content_fields(orig_object_data, updated_object) do Pleroma.Constants.status_updatable_fields() |> Enum.reduce( @@ -97,12 +100,14 @@ def maybe_update_history( end defp maybe_update_poll(to_be_updated, updated_object) do - choice_key = fn data -> - if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf" + choice_key = fn + %{"anyOf" => [_ | _]} -> "anyOf" + %{"oneOf" => [_ | _]} -> "oneOf" + _ -> nil end with true <- to_be_updated["type"] == "Question", - key <- choice_key.(updated_object), + key when not is_nil(key) <- choice_key.(updated_object), true <- key == choice_key.(to_be_updated), orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])), new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])), @@ -237,4 +242,49 @@ def do_with_history(object, fun) do {:history_items, e} -> e end end + + defp maybe_touch_changeset(changeset, true) do + updated_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + + Ecto.Changeset.put_change(changeset, :updated_at, updated_at) + end + + defp maybe_touch_changeset(changeset, _), do: changeset + + def do_update_and_invalidate_cache(orig_object, updated_object, touch_changeset? \\ false) do + orig_object_ap_id = updated_object["id"] + orig_object_data = orig_object.data + + %{ + updated_data: updated_object_data, + updated: updated, + used_history_in_new_object?: used_history_in_new_object? + } = make_new_object_data_from_update_object(orig_object_data, updated_object) + + changeset = + orig_object + |> Repo.preload(:hashtags) + |> Object.change(%{data: updated_object_data}) + |> maybe_touch_changeset(touch_changeset?) + + with {:ok, new_object} <- Repo.update(changeset), + {:ok, _} <- Object.invalid_object_cache(new_object), + {:ok, _} <- Object.set_cache(new_object), + # The metadata/utils.ex uses the object id for the cache. + {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do + if used_history_in_new_object? do + with create_activity when not is_nil(create_activity) <- + Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id), + {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do + nil + else + _ -> nil + end + end + + {:ok, new_object, updated} + end + end end diff --git a/lib/pleroma/prom_ex.ex b/lib/pleroma/prom_ex.ex new file mode 100644 index 000000000..6608708b7 --- /dev/null +++ b/lib/pleroma/prom_ex.ex @@ -0,0 +1,49 @@ +defmodule Pleroma.PromEx do + use PromEx, otp_app: :pleroma + + alias PromEx.Plugins + + @impl true + def plugins do + [ + # PromEx built in plugins + Plugins.Application, + Plugins.Beam, + {Plugins.Phoenix, router: Pleroma.Web.Router, endpoint: Pleroma.Web.Endpoint}, + Plugins.Ecto, + Plugins.Oban + # Plugins.PhoenixLiveView, + # Plugins.Absinthe, + # Plugins.Broadway, + + # Add your own PromEx metrics plugins + # Pleroma.Users.PromExPlugin + ] + end + + @impl true + def dashboard_assigns do + [ + datasource_id: Pleroma.Config.get([Pleroma.PromEx, :datasource]), + default_selected_interval: "30s" + ] + end + + @impl true + def dashboards do + [ + # PromEx built in Grafana dashboards + {:prom_ex, "application.json"}, + {:prom_ex, "beam.json"}, + {:prom_ex, "phoenix.json"}, + {:prom_ex, "ecto.json"}, + {:prom_ex, "oban.json"} + # {:prom_ex, "phoenix_live_view.json"}, + # {:prom_ex, "absinthe.json"}, + # {:prom_ex, "broadway.json"}, + + # Add your dashboard definitions here with the format: {:otp_app, "path_in_priv"} + # {:pleroma, "/grafana_dashboards/user_metrics.json"} + ] + end +end diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex index f9e8d1948..bcfcd1243 100644 --- a/lib/pleroma/release_tasks.ex +++ b/lib/pleroma/release_tasks.ex @@ -55,12 +55,6 @@ def create do {:error, term} when is_binary(term) -> IO.puts(:stderr, "The database for #{inspect(@repo)} couldn't be created: #{term}") - - {:error, term} -> - IO.puts( - :stderr, - "The database for #{inspect(@repo)} couldn't be created: #{inspect(term)}" - ) end end end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 515b0c1ff..a50a59b3b 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -11,8 +11,6 @@ defmodule Pleroma.Repo do import Ecto.Query require Logger - defmodule Instrumenter, do: use(Prometheus.EctoInstrumenter) - @doc """ Dynamically loads the repository url from the DATABASE_URL environment variable. diff --git a/lib/pleroma/report_note.ex b/lib/pleroma/report_note.ex index f2ad76fa8..f59e5451b 100644 --- a/lib/pleroma/report_note.ex +++ b/lib/pleroma/report_note.ex @@ -23,8 +23,8 @@ defmodule Pleroma.ReportNote do timestamps() end - @spec create(FlakeId.Ecto.CompatType.t(), FlakeId.Ecto.CompatType.t(), String.t()) :: - {:ok, ReportNote.t()} | {:error, Changeset.t()} + @spec create(Ecto.UUID.t(), Ecto.UUID.t(), String.t()) :: + {:ok, ReportNote.t()} | {:error, Ecto.Changeset.t()} def create(user_id, activity_id, content) do attrs = %{ user_id: user_id, @@ -38,8 +38,8 @@ def create(user_id, activity_id, content) do |> Repo.insert() end - @spec destroy(FlakeId.Ecto.CompatType.t()) :: - {:ok, ReportNote.t()} | {:error, Changeset.t()} + @spec destroy(Ecto.UUID.t()) :: + {:ok, ReportNote.t()} | {:error, Ecto.Changeset.t()} def destroy(id) do from(r in ReportNote, where: r.id == ^id) |> Repo.one() diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 2248c2713..cc4530010 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -81,16 +81,16 @@ def default_cache_control_header, do: @default_cache_control_header import Plug.Conn @type option() :: - {:max_read_duration, :timer.time() | :infinity} + {:max_read_duration, non_neg_integer() | :infinity} | {:max_body_length, non_neg_integer() | :infinity} - | {:failed_request_ttl, :timer.time() | :infinity} - | {:http, []} + | {:failed_request_ttl, non_neg_integer() | :infinity} + | {:http, keyword()} | {:req_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]} - | {:inline_content_types, boolean() | [String.t()]} + | {:inline_content_types, boolean() | list(String.t())} | {:redirect_on_failure, boolean()} - @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t() + @spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t() def call(_conn, _url, _opts \\ []) def call(conn = %{method: method}, url, opts) when method in @methods do @@ -192,7 +192,7 @@ defp response(conn, client, url, status, headers, opts) do halt(conn) {:error, error, conn} -> - Logger.warn( + Logger.warning( "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}" ) @@ -388,8 +388,6 @@ defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and defp body_size_constraint(_, _), do: :ok - defp check_read_duration(nil = _duration, max), do: check_read_duration(@max_read_duration, max) - defp check_read_duration(duration, max) when is_integer(duration) and is_integer(max) and max > 0 do if duration > max do @@ -407,10 +405,6 @@ defp increase_read_duration({previous_duration, started}) {:ok, previous_duration + duration} end - defp increase_read_duration(_) do - {:ok, :no_duration_limit, :no_duration_limit} - end - defp client, do: Pleroma.ReverseProxy.Client.Wrapper defp track_failed_url(url, error, opts) do diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex index a7be58512..63c6cb45b 100644 --- a/lib/pleroma/scheduled_activity.ex +++ b/lib/pleroma/scheduled_activity.ex @@ -6,7 +6,6 @@ defmodule Pleroma.ScheduledActivity do use Ecto.Schema alias Ecto.Multi - alias Pleroma.Config alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.User @@ -20,6 +19,8 @@ defmodule Pleroma.ScheduledActivity do @min_offset :timer.minutes(5) + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) + schema "scheduled_activities" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:scheduled_at, :naive_datetime) @@ -40,7 +41,11 @@ defp with_media_attachments( %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset ) when is_list(media_ids) do - media_attachments = Utils.attachments_from_ids(%{media_ids: media_ids}) + media_attachments = + Utils.attachments_from_ids( + %{media_ids: media_ids}, + User.get_cached_by_id(changeset.data.user_id) + ) params = params @@ -83,7 +88,7 @@ def exceeds_daily_user_limit?(user_id, scheduled_at) do |> where([sa], type(sa.scheduled_at, :date) == type(^scheduled_at, :date)) |> select([sa], count(sa.id)) |> Repo.one() - |> Kernel.>=(Config.get([ScheduledActivity, :daily_user_limit])) + |> Kernel.>=(@config_impl.get([ScheduledActivity, :daily_user_limit])) end def exceeds_total_user_limit?(user_id) do @@ -91,7 +96,7 @@ def exceeds_total_user_limit?(user_id) do |> where(user_id: ^user_id) |> select([sa], count(sa.id)) |> Repo.one() - |> Kernel.>=(Config.get([ScheduledActivity, :total_user_limit])) + |> Kernel.>=(@config_impl.get([ScheduledActivity, :total_user_limit])) end def far_enough?(scheduled_at) when is_binary(scheduled_at) do @@ -119,7 +124,7 @@ def new(%User{} = user, attrs) do def create(%User{} = user, attrs) do Multi.new() |> Multi.insert(:scheduled_activity, new(user, attrs)) - |> maybe_add_jobs(Config.get([ScheduledActivity, :enabled])) + |> maybe_add_jobs(@config_impl.get([ScheduledActivity, :enabled])) |> Repo.transaction() |> transaction_response end diff --git a/lib/pleroma/search.ex b/lib/pleroma/search.ex new file mode 100644 index 000000000..3b266e59b --- /dev/null +++ b/lib/pleroma/search.ex @@ -0,0 +1,17 @@ +defmodule Pleroma.Search do + alias Pleroma.Workers.SearchIndexingWorker + + def add_to_index(%Pleroma.Activity{id: activity_id}) do + SearchIndexingWorker.enqueue("add_to_index", %{"activity" => activity_id}) + end + + def remove_from_index(%Pleroma.Object{id: object_id}) do + SearchIndexingWorker.enqueue("remove_from_index", %{"object" => object_id}) + end + + def search(query, options) do + search_module = Pleroma.Config.get([Pleroma.Search, :module], Pleroma.Activity) + + search_module.search(options[:for_user], query, options) + end +end diff --git a/lib/pleroma/activity/search.ex b/lib/pleroma/search/database_search.ex similarity index 88% rename from lib/pleroma/activity/search.ex rename to lib/pleroma/search/database_search.ex index 0b9b24aa4..c6311e0c7 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/search/database_search.ex @@ -1,9 +1,10 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors +# Copyright © 2017-2021 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Activity.Search do +defmodule Pleroma.Search.DatabaseSearch do alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Object.Fetcher alias Pleroma.Pagination alias Pleroma.User @@ -13,8 +14,11 @@ defmodule Pleroma.Activity.Search do import Ecto.Query + @behaviour Pleroma.Search.SearchBackend + + @impl true def search(user, search_query, options \\ []) do - index_type = if Pleroma.Config.get([:database, :rum_enabled]), do: :rum, else: :gin + index_type = if Config.get([:database, :rum_enabled]), do: :rum, else: :gin limit = Enum.min([Keyword.get(options, :limit), 40]) offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) @@ -45,6 +49,12 @@ def search(user, search_query, options \\ []) do end end + @impl true + def add_to_index(_activity), do: :ok + + @impl true + def remove_from_index(_object), do: :ok + def maybe_restrict_author(query, %User{} = author) do Activity.Queries.by_author(query, author) end @@ -136,8 +146,8 @@ defp query_with(q, :rum, search_query, :websearch) do ) end - defp maybe_restrict_local(q, user) do - limit = Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) + def maybe_restrict_local(q, user) do + limit = Config.get([:instance, :limit_to_local_content], :unauthenticated) case {limit, user} do {:all, _} -> restrict_local(q) @@ -149,7 +159,7 @@ defp maybe_restrict_local(q, user) do defp restrict_local(q), do: where(q, local: true) - defp maybe_fetch(activities, user, search_query) do + def maybe_fetch(activities, user, search_query) do with true <- Regex.match?(~r/https?:/, search_query), {:ok, object} <- Fetcher.fetch_object_from_id(search_query), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), diff --git a/lib/pleroma/search/meilisearch.ex b/lib/pleroma/search/meilisearch.ex new file mode 100644 index 000000000..2bff663e8 --- /dev/null +++ b/lib/pleroma/search/meilisearch.ex @@ -0,0 +1,181 @@ +defmodule Pleroma.Search.Meilisearch do + require Logger + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.Config.Getting, as: Config + + import Pleroma.Search.DatabaseSearch + import Ecto.Query + + @behaviour Pleroma.Search.SearchBackend + + defp meili_headers do + private_key = Config.get([Pleroma.Search.Meilisearch, :private_key]) + + [{"Content-Type", "application/json"}] ++ + if is_nil(private_key), do: [], else: [{"Authorization", "Bearer #{private_key}"}] + end + + def meili_get(path) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.get( + Path.join(endpoint, path), + meili_headers() + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_post(path, params) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.post( + Path.join(endpoint, path), + Jason.encode!(params), + meili_headers() + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_put(path, params) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + result = + Pleroma.HTTP.request( + :put, + Path.join(endpoint, path), + Jason.encode!(params), + meili_headers(), + [] + ) + + with {:ok, res} <- result do + {:ok, Jason.decode!(res.body)} + end + end + + def meili_delete(path) do + endpoint = Config.get([Pleroma.Search.Meilisearch, :url]) + + with {:ok, _} <- + Pleroma.HTTP.request( + :delete, + Path.join(endpoint, path), + "", + meili_headers(), + [] + ) do + :ok + else + _ -> {:error, "Could not remove from index"} + end + end + + @impl true + def search(user, query, options \\ []) do + limit = Enum.min([Keyword.get(options, :limit), 40]) + offset = Keyword.get(options, :offset, 0) + author = Keyword.get(options, :author) + + res = + meili_post( + "/indexes/objects/search", + %{q: query, offset: offset, limit: limit} + ) + + with {:ok, result} <- res do + hits = result["hits"] |> Enum.map(& &1["ap"]) + + try do + hits + |> Activity.create_by_object_ap_id() + |> Activity.with_preloaded_object() + |> Activity.restrict_deactivated_users() + |> maybe_restrict_local(user) + |> maybe_restrict_author(author) + |> maybe_restrict_blocked(user) + |> maybe_fetch(user, query) + |> order_by([object: obj], desc: obj.data["published"]) + |> Pleroma.Repo.all() + rescue + _ -> maybe_fetch([], user, query) + end + end + end + + def object_to_search_data(object) do + # Only index public or unlisted Notes + if not is_nil(object) and object.data["type"] == "Note" and + not is_nil(object.data["content"]) and + (Pleroma.Constants.as_public() in object.data["to"] or + Pleroma.Constants.as_public() in object.data["cc"]) and + object.data["content"] not in ["", "."] do + data = object.data + + content_str = + case data["content"] do + [nil | rest] -> to_string(rest) + str -> str + end + + content = + with {:ok, scrubbed} <- + FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing), + trimmed <- String.trim(scrubbed) do + trimmed + end + + # Make sure we have a non-empty string + if content != "" do + {:ok, published, _} = DateTime.from_iso8601(data["published"]) + + %{ + id: object.id, + content: content, + ap: data["id"], + published: published |> DateTime.to_unix() + } + end + end + end + + @impl true + def add_to_index(activity) do + maybe_search_data = object_to_search_data(activity.object) + + if activity.data["type"] == "Create" and maybe_search_data do + result = + meili_put( + "/indexes/objects/documents", + [maybe_search_data] + ) + + with {:ok, %{"status" => "enqueued"}} <- result do + # Added successfully + :ok + else + _ -> + # There was an error, report it + Logger.error("Failed to add activity #{activity.id} to index: #{inspect(result)}") + {:error, result} + end + else + # The post isn't something we can search, that's ok + :ok + end + end + + @impl true + def remove_from_index(object) do + meili_delete("/indexes/objects/documents/#{object.id}") + end +end diff --git a/lib/pleroma/search/search_backend.ex b/lib/pleroma/search/search_backend.ex new file mode 100644 index 000000000..68bc48cec --- /dev/null +++ b/lib/pleroma/search/search_backend.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.Search.SearchBackend do + @doc """ + Search statuses with a query, restricting to only those the user should have access to. + """ + @callback search(user :: Pleroma.User.t(), query :: String.t(), options :: [any()]) :: [ + Pleroma.Activity.t() + ] + + @doc """ + Add the object associated with the activity to the search index. + + The whole activity is passed, to allow filtering on things such as scope. + """ + @callback add_to_index(activity :: Pleroma.Activity.t()) :: :ok | {:error, any()} + + @doc """ + Remove the object from the index. + + Just the object, as opposed to the whole activity, is passed, since the object + is what contains the actual content and there is no need for filtering when removing + from index. + """ + @callback remove_from_index(object :: Pleroma.Object.t()) :: :ok | {:error, any()} +end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 5cfdae051..8fd422a6e 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -27,7 +27,7 @@ def key_id_to_actor_id(key_id) do _ -> case Pleroma.Web.WebFinger.finger(maybe_ap_id) do - %{"ap_id" => ap_id} -> {:ok, ap_id} + {:ok, %{"ap_id" => ap_id}} -> {:ok, ap_id} _ -> {:error, maybe_ap_id} end end diff --git a/lib/pleroma/telemetry/logger.ex b/lib/pleroma/telemetry/logger.ex index 384c70fbc..92d395394 100644 --- a/lib/pleroma/telemetry/logger.ex +++ b/lib/pleroma/telemetry/logger.ex @@ -70,7 +70,7 @@ def handle_event( %{key: key}, _ ) do - Logger.warn(fn -> + Logger.warning(fn -> "Pool worker for #{key}: Client #{inspect(client_pid)} died before releasing the connection with #{inspect(reason)}" end) end diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 4aee9326f..e6c484548 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -34,7 +34,6 @@ defmodule Pleroma.Upload do """ alias Ecto.UUID - alias Pleroma.Config alias Pleroma.Maps alias Pleroma.Web.ActivityPub.Utils require Logger @@ -52,6 +51,7 @@ defmodule Pleroma.Upload do | {:size_limit, nil | non_neg_integer()} | {:uploader, module()} | {:filters, [module()]} + | {:actor, String.t()} @type t :: %__MODULE__{ id: String.t(), @@ -76,6 +76,8 @@ defmodule Pleroma.Upload do :path ] + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) + defp get_description(upload) do case {upload.description, Pleroma.Config.get([Pleroma.Upload, :default_description])} do {description, _} when is_binary(description) -> description @@ -85,7 +87,7 @@ defp get_description(upload) do end end - @spec store(source, options :: [option()]) :: {:ok, Map.t()} | {:error, any()} + @spec store(source, options :: [option()]) :: {:ok, map()} | {:error, any()} @doc "Store a file. If using a `Plug.Upload{}` as the source, be sure to use `Majic.Plug` to ensure its content_type and filename is correct." def store(upload, opts \\ []) do opts = get_opts(opts) @@ -174,7 +176,7 @@ defp prepare_upload(%Plug.Upload{} = file, opts) do defp prepare_upload(%{img: "data:image/" <> image_data}, opts) do parsed = Regex.named_captures(~r/(?jpeg|png|gif);base64,(?.*)/, image_data) data = Base.decode64!(parsed["data"], ignore: :whitespace) - hash = Base.encode16(:crypto.hash(:sha256, data), lower: true) + hash = Base.encode16(:crypto.hash(:sha256, data), case: :upper) with :ok <- check_binary_size(data, opts.size_limit), tmp_path <- tempfile_for_image(data), @@ -244,18 +246,18 @@ defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do defp url_from_spec(_upload, _base_url, {:url, url}), do: url def base_url do - uploader = Config.get([Pleroma.Upload, :uploader]) - upload_base_url = Config.get([Pleroma.Upload, :base_url]) - public_endpoint = Config.get([uploader, :public_endpoint]) + uploader = @config_impl.get([Pleroma.Upload, :uploader]) + upload_base_url = @config_impl.get([Pleroma.Upload, :base_url]) + public_endpoint = @config_impl.get([uploader, :public_endpoint]) case uploader do Pleroma.Uploaders.Local -> upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" Pleroma.Uploaders.S3 -> - bucket = Config.get([Pleroma.Uploaders.S3, :bucket]) - truncated_namespace = Config.get([Pleroma.Uploaders.S3, :truncated_namespace]) - namespace = Config.get([Pleroma.Uploaders.S3, :bucket_namespace]) + bucket = @config_impl.get([Pleroma.Uploaders.S3, :bucket]) + truncated_namespace = @config_impl.get([Pleroma.Uploaders.S3, :truncated_namespace]) + namespace = @config_impl.get([Pleroma.Uploaders.S3, :bucket_namespace]) bucket_with_namespace = cond do diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex index 717f06621..809bc6e70 100644 --- a/lib/pleroma/upload/filter.ex +++ b/lib/pleroma/upload/filter.ex @@ -38,9 +38,9 @@ def filter([filter | rest], upload) do {:ok, :noop} -> filter(rest, upload) - error -> - Logger.error("#{__MODULE__}: Filter #{filter} failed: #{inspect(error)}") - error + {:error, e} -> + Logger.error("#{__MODULE__}: Filter #{filter} failed: #{inspect(e)}") + {:error, e} end end end diff --git a/lib/pleroma/upload/filter/analyze_metadata.ex b/lib/pleroma/upload/filter/analyze_metadata.ex index 9a76a998b..7ee643277 100644 --- a/lib/pleroma/upload/filter/analyze_metadata.ex +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -8,27 +8,28 @@ defmodule Pleroma.Upload.Filter.AnalyzeMetadata do """ require Logger + alias Vix.Vips.Image + alias Vix.Vips.Operation + @behaviour Pleroma.Upload.Filter @spec filter(Pleroma.Upload.t()) :: {:ok, :filtered, Pleroma.Upload.t()} | {:ok, :noop} | {:error, String.t()} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) do try do - image = - file - |> Mogrify.open() - |> Mogrify.verbose() + {:ok, image} = Image.new_from_file(file) + {width, height} = {Image.width(image), Image.height(image)} upload = upload - |> Map.put(:width, image.width) - |> Map.put(:height, image.height) - |> Map.put(:blurhash, get_blurhash(file)) + |> Map.put(:width, width) + |> Map.put(:height, height) + |> Map.put(:blurhash, get_blurhash(image)) {:ok, :filtered, upload} rescue e in ErlangError -> - Logger.warn("#{__MODULE__}: #{inspect(e)}") + Logger.warning("#{__MODULE__}: #{inspect(e)}") {:ok, :noop} end end @@ -45,7 +46,7 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "video" <> _} = upload) {:ok, :filtered, upload} rescue e in ErlangError -> - Logger.warn("#{__MODULE__}: #{inspect(e)}") + Logger.warning("#{__MODULE__}: #{inspect(e)}") {:ok, :noop} end end @@ -53,7 +54,7 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "video" <> _} = upload) def filter(_), do: {:ok, :noop} defp get_blurhash(file) do - with {:ok, blurhash} <- :eblurhash.magick(file) do + with {:ok, blurhash} <- vips_blurhash(file) do blurhash else _ -> nil @@ -77,7 +78,28 @@ defp media_dimensions(file) do %{width: width, height: height} else nil -> {:error, {:ffprobe, :command_not_found}} - {:error, _} = error -> error + error -> {:error, error} + end + end + + defp vips_blurhash(%Vix.Vips.Image{} = image) do + with {:ok, resized_image} <- Operation.thumbnail_image(image, 100), + {height, width} <- {Image.height(resized_image), Image.width(resized_image)}, + max <- max(height, width), + {x, y} <- {max(round(width * 5 / max), 1), max(round(height * 5 / max), 1)} do + {:ok, rgb} = + if Image.has_alpha?(resized_image) do + # remove alpha channel + resized_image + |> Operation.extract_band!(0, n: 3) + |> Image.write_to_binary() + else + Image.write_to_binary(resized_image) + end + + Blurhash.encode(rgb, width, height, x, y) + else + _ -> nil end end end diff --git a/lib/pleroma/upload/filter/exiftool/read_description.ex b/lib/pleroma/upload/filter/exiftool/read_description.ex index 543b22031..8c1ed82f8 100644 --- a/lib/pleroma/upload/filter/exiftool/read_description.ex +++ b/lib/pleroma/upload/filter/exiftool/read_description.ex @@ -10,8 +10,6 @@ defmodule Pleroma.Upload.Filter.Exiftool.ReadDescription do """ @behaviour Pleroma.Upload.Filter - @spec filter(Pleroma.Upload.t()) :: {:ok, any()} | {:error, String.t()} - def filter(%Pleroma.Upload{description: description}) when is_binary(description), do: {:ok, :noop} diff --git a/lib/pleroma/upload/filter/only_media.ex b/lib/pleroma/upload/filter/only_media.ex new file mode 100644 index 000000000..a9caeba67 --- /dev/null +++ b/lib/pleroma/upload/filter/only_media.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.OnlyMedia do + @behaviour Pleroma.Upload.Filter + alias Pleroma.Upload + + def filter(%Upload{content_type: content_type}) do + [type, _subtype] = String.split(content_type, "/") + + if type in ["image", "video", "audio"] do + {:ok, :noop} + else + {:error, "Disallowed content-type: #{content_type}"} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex index 19287c532..7b32bd8a5 100644 --- a/lib/pleroma/uploaders/s3.ex +++ b/lib/pleroma/uploaders/s3.ex @@ -6,7 +6,8 @@ defmodule Pleroma.Uploaders.S3 do @behaviour Pleroma.Uploaders.Uploader require Logger - alias Pleroma.Config + @ex_aws_impl Application.compile_env(:pleroma, [__MODULE__, :ex_aws_impl], ExAws) + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) # The file name is re-encoded with S3's constraints here to comply with previous # links with less strict filenames @@ -22,7 +23,7 @@ def get_file(file) do @impl true def put_file(%Pleroma.Upload{} = upload) do - config = Config.get([__MODULE__]) + config = @config_impl.get([__MODULE__]) bucket = Keyword.get(config, :bucket) streaming = Keyword.get(config, :streaming_enabled) @@ -56,7 +57,7 @@ def put_file(%Pleroma.Upload{} = upload) do ]) end - case ExAws.request(op) do + case @ex_aws_impl.request(op) do {:ok, _} -> {:ok, {:file, s3_name}} @@ -69,9 +70,9 @@ def put_file(%Pleroma.Upload{} = upload) do @impl true def delete_file(file) do [__MODULE__, :bucket] - |> Config.get() + |> @config_impl.get() |> ExAws.S3.delete_object(file) - |> ExAws.request() + |> @ex_aws_impl.request() |> case do {:ok, %{status_code: 204}} -> :ok error -> {:error, inspect(error)} @@ -83,3 +84,7 @@ def strict_encode(name) do String.replace(name, @regex, "-") end end + +defmodule Pleroma.Uploaders.S3.ExAwsAPI do + @callback request(op :: ExAws.Operation.t()) :: {:ok, ExAws.Operation.t()} | {:error, term()} +end diff --git a/lib/pleroma/uploaders/uploader.ex b/lib/pleroma/uploaders/uploader.ex index 77f6f02dd..3396fe06a 100644 --- a/lib/pleroma/uploaders/uploader.ex +++ b/lib/pleroma/uploaders/uploader.ex @@ -5,8 +5,6 @@ defmodule Pleroma.Uploaders.Uploader do import Pleroma.Web.Gettext - @mix_env Mix.env() - @moduledoc """ Defines the contract to put and get an uploaded file to any backend. """ @@ -40,7 +38,7 @@ defmodule Pleroma.Uploaders.Uploader do @callback delete_file(file :: String.t()) :: :ok | {:error, String.t()} - @callback http_callback(Plug.Conn.t(), Map.t()) :: + @callback http_callback(Plug.Conn.t(), map()) :: {:ok, Plug.Conn.t()} | {:ok, Plug.Conn.t(), file_spec()} | {:error, Plug.Conn.t(), String.t()} @@ -75,10 +73,5 @@ defp handle_callback(uploader, upload) do end end - defp callback_timeout do - case @mix_env do - :test -> 1_000 - _ -> 30_000 - end - end + defp callback_timeout, do: Application.get_env(:pleroma, __MODULE__)[:timeout] end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f6e30555c..0773434c5 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -39,6 +39,7 @@ defmodule Pleroma.User do alias Pleroma.Workers.BackgroundWorker require Logger + require Pleroma.Constants @type t :: %__MODULE__{} @type account_status :: @@ -124,7 +125,6 @@ defmodule Pleroma.User do field(:domain_blocks, {:array, :string}, default: []) field(:is_active, :boolean, default: true) field(:no_rich_text, :boolean, default: false) - field(:ap_enabled, :boolean, default: false) field(:is_moderator, :boolean, default: false) field(:is_admin, :boolean, default: false) field(:show_role, :boolean, default: true) @@ -488,7 +488,6 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :nickname, :public_key, :avatar, - :ap_enabled, :banner, :is_locked, :last_refreshed_at, @@ -581,7 +580,7 @@ def update_changeset(struct, params \\ %{}) do |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) - |> validate_inclusion(:actor_type, ["Person", "Service"]) + |> validate_inclusion(:actor_type, Pleroma.Constants.allowed_user_actor_types()) |> put_fields() |> put_emoji() |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) @@ -673,7 +672,7 @@ def update_as_admin_changeset(struct, params) do |> validate_inclusion(:actor_type, ["Person", "Service"]) end - @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} + @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def update_as_admin(user, params) do params = Map.put(params, "password_confirmation", params["password"]) changeset = update_as_admin_changeset(user, params) @@ -694,7 +693,7 @@ def password_update_changeset(struct, params) do |> put_change(:password_reset_pending, false) end - @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} + @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def reset_password(%User{} = user, params) do reset_password(user, user, params) end @@ -1012,7 +1011,7 @@ def maybe_send_confirmation_email(%User{is_confirmed: false, email: email} = use def maybe_send_confirmation_email(_), do: {:ok, :noop} - @spec send_confirmation_email(Uset.t()) :: User.t() + @spec send_confirmation_email(User.t()) :: User.t() def send_confirmation_email(%User{} = user) do user |> Pleroma.Emails.UserEmail.account_confirmation_email() @@ -1049,7 +1048,8 @@ def needs_update?(%User{local: false} = user) do def needs_update?(_), do: true - @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} + @spec maybe_direct_follow(User.t(), User.t()) :: + {:ok, User.t(), User.t()} | {:error, String.t()} # "Locked" (self-locked) users demand explicit authorization of follow requests def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do @@ -1061,11 +1061,7 @@ def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do end def maybe_direct_follow(%User{} = follower, %User{} = followed) do - if not ap_enabled?(followed) do - follow(follower, followed) - else - {:ok, follower, followed} - end + {:ok, follower, followed} end @doc "A mass follow for local users. Respects blocks in both directions but does not create activities." @@ -1566,7 +1562,7 @@ def unmute(muter_id, mutee_id) do unmute(muter, mutee) else {who, result} = error -> - Logger.warn( + Logger.warning( "User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}" ) @@ -1788,14 +1784,17 @@ def set_activation_async(user, status \\ true) do BackgroundWorker.enqueue("user_activation", %{"user_id" => user.id, "status" => status}) end - @spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + @spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def set_activation(users, status) when is_list(users) do Repo.transaction(fn -> - for user <- users, do: set_activation(user, status) + for user <- users do + {:ok, user} = set_activation(user, status) + user + end end) end - @spec set_activation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + @spec set_activation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def set_activation(%User{} = user, status) do with {:ok, user} <- set_activation_status(user, status) do user @@ -1873,7 +1872,7 @@ def update_notification_settings(%User{} = user, settings) do |> update_and_set_cache() end - @spec purge_user_changeset(User.t()) :: Changeset.t() + @spec purge_user_changeset(User.t()) :: Ecto.Changeset.t() def purge_user_changeset(user) do # "Right to be forgotten" # https://gdpr.eu/right-to-be-forgotten/ @@ -1898,7 +1897,6 @@ def purge_user_changeset(user) do confirmation_token: nil, domain_blocks: [], is_active: false, - ap_enabled: false, is_moderator: false, is_admin: false, mascot: nil, @@ -2143,7 +2141,7 @@ def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do def public_key(_), do: {:error, "key not found"} def get_public_key_for_ap_id(ap_id) do - with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), + with %User{} = user <- get_cached_by_ap_id(ap_id), {:ok, public_key} <- public_key(user) do {:ok, public_key} else @@ -2151,10 +2149,6 @@ def get_public_key_for_ap_id(ap_id) do end end - def ap_enabled?(%User{local: true}), do: true - def ap_enabled?(%User{ap_enabled: ap_enabled}), do: ap_enabled - def ap_enabled?(_), do: false - @doc "Gets or fetch a user by uri or nickname." @spec get_or_fetch(String.t()) :: {:ok, User.t()} | {:error, String.t()} def get_or_fetch("http://" <> _host = uri), do: get_or_fetch_by_ap_id(uri) @@ -2263,7 +2257,7 @@ def full_nickname(%User{} = user) do if String.contains?(user.nickname, "@") do user.nickname else - %{host: host} = URI.parse(user.ap_id) + host = Pleroma.Web.WebFinger.host() user.nickname <> "@" <> host end end @@ -2369,7 +2363,7 @@ def touch_last_digest_emailed_at(user) do updated_user end - @spec set_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()} + @spec set_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def set_confirmation(%User{} = user, bool) do user |> confirmation_changeset(set_confirmation: bool) @@ -2413,9 +2407,9 @@ defp put_password_hash( defp put_password_hash(changeset), do: changeset - def is_internal_user?(%User{nickname: nil}), do: true - def is_internal_user?(%User{local: true, nickname: "internal." <> _}), do: true - def is_internal_user?(_), do: false + def internal?(%User{nickname: nil}), do: true + def internal?(%User{local: true, nickname: "internal." <> _}), do: true + def internal?(_), do: false # A hack because user delete activities have a fake id for whatever reason # TODO: Get rid of this @@ -2547,7 +2541,7 @@ def mascot_update(user, url) do |> update_and_set_cache() end - @spec confirmation_changeset(User.t(), keyword()) :: Changeset.t() + @spec confirmation_changeset(User.t(), keyword()) :: Ecto.Changeset.t() def confirmation_changeset(user, set_confirmation: confirmed?) do params = if confirmed? do @@ -2565,9 +2559,9 @@ def confirmation_changeset(user, set_confirmation: confirmed?) do cast(user, params, [:is_confirmed, :confirmation_token]) end - @spec approval_changeset(User.t(), keyword()) :: Changeset.t() - def approval_changeset(user, set_approval: approved?) do - cast(user, %{is_approved: approved?}, [:is_approved]) + @spec approval_changeset(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() + def approval_changeset(changeset, set_approval: approved?) do + cast(changeset, %{is_approved: approved?}, [:is_approved]) end @spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()} @@ -2692,6 +2686,8 @@ def update_last_active_at(%__MODULE__{local: true} = user) do |> update_and_set_cache() end + def update_last_active_at(user), do: user + def active_user_count(days \\ 30) do active_after = Timex.shift(NaiveDateTime.utc_now(), days: -days) diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index 9df010605..b7f00bbf7 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -9,28 +9,36 @@ defmodule Pleroma.User.Backup do import Ecto.Query import Pleroma.Web.Gettext + require Logger require Pleroma.Constants alias Pleroma.Activity alias Pleroma.Bookmark alias Pleroma.Repo alias Pleroma.User + alias Pleroma.User.Backup.State alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Workers.BackupWorker + @type t :: %__MODULE__{} + schema "backups" do field(:content_type, :string) field(:file_name, :string) field(:file_size, :integer, default: 0) field(:processed, :boolean, default: false) + field(:state, State, default: :invalid) + field(:processed_number, :integer, default: 0) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) + def create(user, admin_id \\ nil) do with :ok <- validate_limit(user, admin_id), {:ok, backup} <- user |> new() |> Repo.insert() do @@ -46,7 +54,8 @@ def new(user) do %__MODULE__{ user_id: user.id, content_type: "application/zip", - file_name: name + file_name: name, + state: :pending } end @@ -109,30 +118,100 @@ def remove_outdated(%__MODULE__{id: latest_id, user_id: user_id}) do def get(id), do: Repo.get(__MODULE__, id) - def process(%__MODULE__{} = backup) do - with {:ok, zip_file} <- export(backup), - {:ok, %{size: size}} <- File.stat(zip_file), - {:ok, _upload} <- upload(backup, zip_file) do - backup - |> cast(%{file_size: size, processed: true}, [:file_size, :processed]) - |> Repo.update() + defp set_state(backup, state, processed_number \\ nil) do + struct = + %{state: state} + |> Pleroma.Maps.put_if_present(:processed_number, processed_number) + + backup + |> cast(struct, [:state, :processed_number]) + |> Repo.update() + end + + def process( + %__MODULE__{} = backup, + processor_module \\ __MODULE__.Processor + ) do + set_state(backup, :running, 0) + + current_pid = self() + + task = + Task.Supervisor.async_nolink( + Pleroma.TaskSupervisor, + processor_module, + :do_process, + [backup, current_pid] + ) + + wait_backup(backup, backup.processed_number, task) + end + + defp wait_backup(backup, current_processed, task) do + wait_time = @config_impl.get([__MODULE__, :process_wait_time]) + + receive do + {:progress, new_processed} -> + total_processed = current_processed + new_processed + + set_state(backup, :running, total_processed) + wait_backup(backup, total_processed, task) + + {:DOWN, _ref, _proc, _pid, reason} -> + backup = get(backup.id) + + if reason != :normal do + Logger.error("Backup #{backup.id} process ended abnormally: #{inspect(reason)}") + + {:ok, backup} = set_state(backup, :failed) + + cleanup(backup) + + {:error, + %{ + backup: backup, + reason: :exit, + details: reason + }} + else + {:ok, backup} + end + after + wait_time -> + Logger.error( + "Backup #{backup.id} timed out after no response for #{wait_time}ms, terminating" + ) + + Task.Supervisor.terminate_child(Pleroma.TaskSupervisor, task.pid) + + {:ok, backup} = set_state(backup, :failed) + + cleanup(backup) + + {:error, + %{ + backup: backup, + reason: :timeout + }} end end @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] - def export(%__MODULE__{} = backup) do + @spec export(Pleroma.User.Backup.t(), pid()) :: {:ok, String.t()} | :error + def export(%__MODULE__{} = backup, caller_pid) do backup = Repo.preload(backup, :user) - name = String.trim_trailing(backup.file_name, ".zip") - dir = dir(name) + dir = backup_tempdir(backup) with :ok <- File.mkdir(dir), - :ok <- actor(dir, backup.user), - :ok <- statuses(dir, backup.user), - :ok <- likes(dir, backup.user), - :ok <- bookmarks(dir, backup.user), - {:ok, zip_path} <- :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: dir), + :ok <- actor(dir, backup.user, caller_pid), + :ok <- statuses(dir, backup.user, caller_pid), + :ok <- likes(dir, backup.user, caller_pid), + :ok <- bookmarks(dir, backup.user, caller_pid), + {:ok, zip_path} <- :zip.create(backup.file_name, @files, cwd: dir), {:ok, _} <- File.rm_rf(dir) do - {:ok, to_string(zip_path)} + {:ok, zip_path} + else + _ -> :error end end @@ -157,11 +236,12 @@ def upload(%__MODULE__{} = backup, zip_path) do end end - defp actor(dir, user) do + defp actor(dir, user, caller_pid) do with {:ok, json} <- UserView.render("user.json", %{user: user}) |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) |> Jason.encode() do + send(caller_pid, {:progress, 1}) File.write(Path.join(dir, "actor.json"), json) end end @@ -180,47 +260,80 @@ defp write_header(file, name) do ) end - defp write(query, dir, name, fun) do + defp should_report?(num, chunk_size), do: rem(num, chunk_size) == 0 + + defp backup_tempdir(backup) do + name = String.trim_trailing(backup.file_name, ".zip") + dir(name) + end + + defp cleanup(backup) do + dir = backup_tempdir(backup) + File.rm_rf(dir) + end + + defp write(query, dir, name, fun, caller_pid) do path = Path.join(dir, "#{name}.json") + chunk_size = Pleroma.Config.get([__MODULE__, :process_chunk_size]) + with {:ok, file} <- File.open(path, [:write, :utf8]), :ok <- write_header(file, name) do total = query - |> Pleroma.Repo.chunk_stream(100) + |> Pleroma.Repo.chunk_stream(chunk_size, _returns_as = :one, timeout: :infinity) |> Enum.reduce(0, fn i, acc -> - with {:ok, data} <- fun.(i), + with {:ok, data} <- + (try do + fun.(i) + rescue + e -> {:error, e} + end), {:ok, str} <- Jason.encode(data), :ok <- IO.write(file, str <> ",\n") do + if should_report?(acc + 1, chunk_size) do + send(caller_pid, {:progress, chunk_size}) + end + acc + 1 else - _ -> acc + {:error, e} -> + Logger.warning( + "Error processing backup item: #{inspect(e)}\n The item is: #{inspect(i)}" + ) + + acc + + _ -> + acc end end) + send(caller_pid, {:progress, rem(total, chunk_size)}) + with :ok <- :file.pwrite(file, {:eof, -2}, "\n],\n \"totalItems\": #{total}}") do File.close(file) end end end - defp bookmarks(dir, %{id: user_id} = _user) do + defp bookmarks(dir, %{id: user_id} = _user, caller_pid) do Bookmark |> where(user_id: ^user_id) |> join(:inner, [b], activity in assoc(b, :activity)) |> select([b, a], %{id: b.id, object: fragment("(?)->>'object'", a.data)}) - |> write(dir, "bookmarks", fn a -> {:ok, a.object} end) + |> write(dir, "bookmarks", fn a -> {:ok, a.object} end, caller_pid) end - defp likes(dir, user) do + defp likes(dir, user, caller_pid) do user.ap_id |> Activity.Queries.by_actor() |> Activity.Queries.by_type("Like") |> select([like], %{id: like.id, object: fragment("(?)->>'object'", like.data)}) - |> write(dir, "likes", fn a -> {:ok, a.object} end) + |> write(dir, "likes", fn a -> {:ok, a.object} end, caller_pid) end - defp statuses(dir, user) do + defp statuses(dir, user, caller_pid) do opts = %{} |> Map.put(:type, ["Create", "Announce"]) @@ -233,10 +346,49 @@ defp statuses(dir, user) do ] |> Enum.concat() |> ActivityPub.fetch_activities_query(opts) - |> write(dir, "outbox", fn a -> - with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do - {:ok, Map.delete(activity, "@context")} - end - end) + |> write( + dir, + "outbox", + fn a -> + with {:ok, activity} <- Transmogrifier.prepare_outgoing(a.data) do + {:ok, Map.delete(activity, "@context")} + end + end, + caller_pid + ) + end +end + +defmodule Pleroma.User.Backup.ProcessorAPI do + @callback do_process(%Pleroma.User.Backup{}, pid()) :: + {:ok, %Pleroma.User.Backup{}} | {:error, any()} +end + +defmodule Pleroma.User.Backup.Processor do + @behaviour Pleroma.User.Backup.ProcessorAPI + + alias Pleroma.Repo + alias Pleroma.User.Backup + + import Ecto.Changeset + + @impl true + def do_process(backup, current_pid) do + with {:ok, zip_file} <- Backup.export(backup, current_pid), + {:ok, %{size: size}} <- File.stat(zip_file), + {:ok, _upload} <- Backup.upload(backup, zip_file) do + backup + |> cast( + %{ + file_size: size, + processed: true, + state: :complete + }, + [:file_size, :processed, :state] + ) + |> Repo.update() + else + e -> {:error, e} + end end end diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 3e090cac0..cd9586452 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -22,7 +22,7 @@ defmodule Pleroma.User.Query do - pass non empty string - e.g. Pleroma.User.Query.build(%{email: "email@example.com"}) - *contains criteria* - - add field to @containns_criteria list + - add field to @contains_criteria list - pass values list - e.g. Pleroma.User.Query.build(%{ap_id: ["http://ap_id1", "http://ap_id2"]}) """ @@ -71,7 +71,7 @@ defmodule Pleroma.User.Query do @equal_criteria [:email] @contains_criteria [:ap_id, :nickname] - @spec build(Query.t(), criteria()) :: Query.t() + @spec build(Ecto.Query.t(), criteria()) :: Ecto.Query.t() def build(query \\ base_query(), criteria) do prepare_query(query, criteria) end diff --git a/lib/pleroma/user_invite_token.ex b/lib/pleroma/user_invite_token.ex index b242a8848..4bfb3a6a7 100644 --- a/lib/pleroma/user_invite_token.ex +++ b/lib/pleroma/user_invite_token.ex @@ -64,7 +64,7 @@ def update_invite!(invite, changes) do end @spec update_invite(UserInviteToken.t(), map()) :: - {:ok, UserInviteToken.t()} | {:error, Changeset.t()} + {:ok, UserInviteToken.t()} | {:error, Ecto.Changeset.t()} def update_invite(invite, changes) do change(invite, changes) |> Repo.update() end diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index fbecf3129..82fcc1cdd 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -14,6 +14,8 @@ defmodule Pleroma.UserRelationship do alias Pleroma.User alias Pleroma.UserRelationship + @type t :: %__MODULE__{} + schema "user_relationships" do belongs_to(:source, User, type: FlakeId.Ecto.CompatType) belongs_to(:target, User, type: FlakeId.Ecto.CompatType) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index aee41b0fe..7a8b176cd 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -136,7 +136,7 @@ def view do namespace: Pleroma.Web # Import convenience functions from controllers - import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] + import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1] import Pleroma.Web.ErrorHelpers import Pleroma.Web.Gettext diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b9206b4da..2017c696d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -74,29 +74,40 @@ defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil( defp check_remote_limit(_), do: true def increase_note_count_if_public(actor, object) do - if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} + if public?(object), do: User.increase_note_count(actor), else: {:ok, actor} end def decrease_note_count_if_public(actor, object) do - if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} + if public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} end def update_last_status_at_if_public(actor, object) do - if is_public?(object), do: User.update_last_status_at(actor), else: {:ok, actor} + if public?(object), do: User.update_last_status_at(actor), else: {:ok, actor} end defp increase_replies_count_if_reply(%{ "object" => %{"inReplyTo" => reply_ap_id} = object, "type" => "Create" }) do - if is_public?(object) do + if public?(object) do Object.increase_replies_count(reply_ap_id) end end defp increase_replies_count_if_reply(_create_data), do: :noop - @object_types ~w[ChatMessage Question Answer Audio Video Event Article Note Page] + defp increase_quotes_count_if_quote(%{ + "object" => %{"quoteUrl" => quote_ap_id} = object, + "type" => "Create" + }) do + if public?(object) do + Object.increase_quotes_count(quote_ap_id) + end + end + + defp increase_quotes_count_if_quote(_create_data), do: :noop + + @object_types ~w[ChatMessage Question Answer Audio Video Image Event Article Note Page] @impl true def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do @@ -140,6 +151,9 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) end) + # Add local posts to search index + if local, do: Pleroma.Search.add_to_index(activity) + {:ok, activity} else %Activity{} = activity -> @@ -299,11 +313,13 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param with {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, _ <- increase_replies_count_if_reply(create_data), + _ <- increase_quotes_count_if_quote(create_data), {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), {:ok, _actor} <- update_last_status_at_if_public(actor, activity), _ <- notify_and_stream(activity), :ok <- maybe_schedule_poll_notifications(activity), + :ok <- maybe_handle_group_posts(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -455,6 +471,7 @@ def fetch_activities_for_context_query(context, opts) do |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) + |> restrict_unauthenticated(opts[:user]) |> restrict_blocked(opts) |> restrict_blockers_visibility(opts) |> restrict_recipients(recipients, opts[:user]) @@ -482,7 +499,7 @@ def fetch_activities_for_context(context, opts \\ %{}) do end @spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) :: - FlakeId.Ecto.CompatType.t() | nil + Ecto.UUID.t() | nil def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do context |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) @@ -1215,6 +1232,35 @@ defp restrict_filtered(query, %{blocking_user: %User{} = user}) do defp restrict_filtered(query, _), do: query + defp restrict_unauthenticated(query, nil) do + local = Config.restrict_unauthenticated_access?(:activities, :local) + remote = Config.restrict_unauthenticated_access?(:activities, :remote) + + cond do + local and remote -> + from(activity in query, where: false) + + local -> + from(activity in query, where: activity.local == false) + + remote -> + from(activity in query, where: activity.local == true) + + true -> + query + end + end + + defp restrict_unauthenticated(query, _), do: query + + defp restrict_quote_url(query, %{quote_url: quote_url}) do + from([_activity, object] in query, + where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url) + ) + end + + defp restrict_quote_url(query, _), do: query + defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query defp exclude_poll_votes(query, _) do @@ -1377,6 +1423,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> restrict_announce_object_actor(opts) |> restrict_filtered(opts) + |> restrict_quote_url(opts) |> maybe_restrict_deactivated_users(opts) |> exclude_poll_votes(opts) |> exclude_chat_messages(opts) @@ -1453,13 +1500,22 @@ def fetch_activities_bounded( @spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()} def upload(file, opts \\ []) do - with {:ok, data} <- Upload.store(file, opts) do + with {:ok, data} <- Upload.store(sanitize_upload_file(file), opts) do obj_data = Maps.put_if_present(data, "actor", opts[:actor]) Repo.insert(%Object{data: obj_data}) end end + defp sanitize_upload_file(%Plug.Upload{filename: filename} = upload) when is_binary(filename) do + %Plug.Upload{ + upload + | filename: Path.basename(filename) + } + end + + defp sanitize_upload_file(upload), do: upload + @spec get_actor_url(any()) :: binary() | nil defp get_actor_url(url) when is_binary(url), do: url defp get_actor_url(%{"href" => href}) when is_binary(href), do: href @@ -1538,7 +1594,6 @@ defp object_to_user_data(data, additional) do %{ ap_id: data["id"], uri: get_actor_url(data["url"]), - ap_enabled: true, banner: normalize_image(data["image"]), fields: fields, emoji: emojis, @@ -1643,9 +1698,7 @@ defp collection_private(%{"first" => first}) do Fetcher.fetch_and_contain_remote_object_from_id(first) do {:ok, false} else - {:error, {:ok, %{status: code}}} when code in [401, 403] -> {:ok, true} - {:error, _} = e -> e - e -> {:error, e} + {:error, _} -> {:ok, true} end end @@ -1659,7 +1712,7 @@ def user_data_from_user_object(data, additional \\ []) do end end - def fetch_and_prepare_user_from_ap_id(ap_id, additional \\ []) do + defp fetch_and_prepare_user_from_ap_id(ap_id, additional) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), {:ok, data} <- user_data_from_user_object(data, additional) do {:ok, maybe_update_follow_information(data)} @@ -1712,6 +1765,11 @@ def pin_data_from_featured_collection(%{ end) end + def pin_data_from_featured_collection(obj) do + Logger.error("Could not parse featured collection #{inspect(obj)}") + %{} + end + def fetch_and_prepare_featured_from_ap_id(nil) do {:ok, %{}} end @@ -1742,24 +1800,20 @@ def pinned_fetch_task(%{pinned_objects: pins}) do def make_user_from_ap_id(ap_id, additional \\ []) do user = User.get_cached_by_ap_id(ap_id) - if user && !User.ap_enabled?(user) do - Transmogrifier.upgrade_user_from_ap_id(ap_id) - else - with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do - {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) + with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do + {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) - if user do - user - |> User.remote_user_changeset(data) - |> User.update_and_set_cache() - else - maybe_handle_clashing_nickname(data) + if user do + user + |> User.remote_user_changeset(data) + |> User.update_and_set_cache() + else + maybe_handle_clashing_nickname(data) - data - |> User.remote_user_changeset() - |> Repo.insert() - |> User.set_cache() - end + data + |> User.remote_user_changeset() + |> Repo.insert() + |> User.set_cache() end 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 1357c379c..e38a94966 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -273,12 +273,17 @@ def outbox(conn, %{"nickname" => nickname}) do end def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do - with %User{} = recipient <- User.get_cached_by_nickname(nickname), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(params["actor"]), + with %User{is_active: true} = recipient <- User.get_cached_by_nickname(nickname), + {:ok, %User{is_active: true} = actor} <- User.get_or_fetch_by_ap_id(params["actor"]), true <- Utils.recipient_in_message(recipient, actor, params), params <- Utils.maybe_splice_recipient(recipient.ap_id, params) do Federator.incoming_ap_doc(params) json(conn, "ok") + else + _ -> + conn + |> put_status(:bad_request) + |> json("Invalid request.") end end @@ -287,10 +292,9 @@ def inbox(%{assigns: %{valid_signature: true}} = conn, params) do json(conn, "ok") end - def inbox(%{assigns: %{valid_signature: false}} = conn, _params) do - conn - |> put_status(:bad_request) - |> json("Invalid HTTP Signature") + def inbox(%{assigns: %{valid_signature: false}, req_headers: req_headers} = conn, params) do + Federator.incoming_ap_doc(%{req_headers: req_headers, params: params}) + json(conn, "ok") end # POST /relay/inbox -or- POST /internal/fetch/inbox @@ -476,7 +480,7 @@ def update_outbox( |> json(message) e -> - Logger.warn(fn -> "AP C2S: #{inspect(e)}" end) + Logger.warning(fn -> "AP C2S: #{inspect(e)}" end) conn |> put_status(:bad_request) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 532047599..2a1e56278 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do This module encodes our addressing policies and general shape of our objects. """ + alias Pleroma.Activity alias Pleroma.Emoji alias Pleroma.Object alias Pleroma.User @@ -16,6 +17,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft + alias Pleroma.Web.Endpoint require Pleroma.Constants @@ -54,13 +56,87 @@ def follow(follower, followed) do {:ok, data, []} end + defp unicode_emoji_react(_object, data, emoji) do + data + |> Map.put("content", emoji) + |> Map.put("type", "EmojiReact") + end + + defp add_emoji_content(data, emoji, url) do + tag = [ + %{ + "id" => url, + "type" => "Emoji", + "name" => Emoji.maybe_quote(emoji), + "icon" => %{ + "type" => "Image", + "url" => url + } + } + ] + + data + |> Map.put("content", Emoji.maybe_quote(emoji)) + |> Map.put("type", "EmojiReact") + |> Map.put("tag", tag) + end + + defp remote_custom_emoji_react( + %{data: %{"reactions" => existing_reactions}}, + data, + emoji + ) do + [emoji_code, instance] = String.split(Emoji.maybe_strip_name(emoji), "@") + + matching_reaction = + Enum.find( + existing_reactions, + fn [name, _, url] -> + if url != nil do + url = URI.parse(url) + url.host == instance && name == emoji_code + end + end + ) + + if matching_reaction do + [name, _, url] = matching_reaction + add_emoji_content(data, name, url) + else + {:error, "Could not react"} + end + end + + defp remote_custom_emoji_react(_object, _data, _emoji) do + {:error, "Could not react"} + end + + defp local_custom_emoji_react(data, emoji) do + with %{file: path} = emojo <- Emoji.get(emoji) do + url = "#{Endpoint.url()}#{path}" + add_emoji_content(data, emojo.code, url) + else + _ -> {:error, "Emoji does not exist"} + end + end + + defp custom_emoji_react(object, data, emoji) do + if String.contains?(emoji, "@") do + remote_custom_emoji_react(object, data, emoji) + else + local_custom_emoji_react(data, emoji) + end + end + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} def emoji_react(actor, object, emoji) do with {:ok, data, meta} <- object_action(actor, object) do data = - data - |> Map.put("content", emoji) - |> Map.put("type", "EmojiReact") + if Emoji.unicode?(emoji) do + unicode_emoji_react(object, data, emoji) + else + custom_emoji_react(object, data, emoji) + end {:ok, data, meta} end @@ -142,6 +218,7 @@ def note(%ActivityDraft{} = draft) do "tag" => Keyword.values(draft.tags) |> Enum.uniq() } |> add_in_reply_to(draft.in_reply_to) + |> add_quote(draft.quote_post) |> Map.merge(draft.extra) {:ok, data, []} @@ -157,6 +234,16 @@ defp add_in_reply_to(object, in_reply_to) do end end + defp add_quote(object, nil), do: object + + defp add_quote(object, quote_post) do + with %Object{} = quote_object <- Object.normalize(quote_post, fetch: false) do + Map.put(object, "quoteUrl", quote_object.data["id"]) + else + _ -> object + end + end + def chat_message(actor, recipient, content, opts \\ []) do basic = %{ "id" => Utils.generate_object_id(), @@ -261,7 +348,7 @@ def announce(actor, object, options \\ []) do actor.ap_id == Relay.ap_id() -> [actor.follower_address] - public? and Visibility.is_local_public?(object) -> + public? and Visibility.local_public?(object) -> [actor.follower_address, object.data["actor"], Utils.as_local_public()] public? -> @@ -289,7 +376,7 @@ defp object_action(actor, object) do # Address the actor of the object, and our actor's follower collection if the post is public. to = - if Visibility.is_public?(object) do + if Visibility.public?(object) do [actor.follower_address, object.data["actor"]] else [object.data["actor"]] diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index ff9f84497..1071f8e6e 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors +# Copyright © 2017-2023 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF do @@ -54,6 +54,8 @@ defmodule Pleroma.Web.ActivityPub.MRF do @required_description_keys [:key, :related_policy] def filter_one(policy, message) do + Code.ensure_loaded(policy) + should_plug_history? = if function_exported?(policy, :history_awareness, 0) do policy.history_awareness() @@ -137,7 +139,16 @@ defp get_policies(_), do: [] @spec subdomains_regex([String.t()]) :: [Regex.t()] def subdomains_regex(domains) when is_list(domains) do - for domain <- domains, do: ~r(^#{String.replace(domain, "*.", "(.*\\.)*")}$)i + for domain <- domains do + try do + target = String.replace(domain, "*.", "(.*\\.)*") + ~r<^#{target}$>i + rescue + e -> + Logger.error("MRF: Invalid subdomain Regex: #{domain}") + reraise e, __STACKTRACE__ + end + end end @spec subdomain_match?([Regex.t()], String.t()) :: boolean() @@ -188,6 +199,8 @@ def config_descriptions do def config_descriptions(policies) do Enum.reduce(policies, @mrf_config_descriptions, fn policy, acc -> + Code.ensure_loaded(policy) + if function_exported?(policy, :config_description, 0) do description = @default_description @@ -199,7 +212,7 @@ def config_descriptions(policies) do if Enum.all?(@required_description_keys, &Map.has_key?(description, &1)) do [description | acc] else - Logger.warn( + Logger.warning( "#{policy} config description doesn't have one or all required keys #{inspect(@required_description_keys)}" ) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex index 97d75ecf2..df4ba819c 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -56,8 +56,6 @@ defp determine_if_followbot(%User{nickname: nickname, name: displayname, actor_t nick_score + name_score + actor_type_score end - defp determine_if_followbot(_), do: 0.0 - defp bot_allowed?(%{"object" => target}, bot_actor) do %User{} = user = normalize_by_ap_id(target) diff --git a/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex new file mode 100644 index 000000000..f884962b9 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/emoji_policy.ex @@ -0,0 +1,281 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.EmojiPolicy do + require Pleroma.Constants + + alias Pleroma.Object.Updater + alias Pleroma.Web.ActivityPub.MRF.Utils + + @moduledoc "Reject or force-unlisted emojis with certain URLs or names" + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp config_remove_url do + Pleroma.Config.get([:mrf_emoji, :remove_url], []) + end + + defp config_remove_shortcode do + Pleroma.Config.get([:mrf_emoji, :remove_shortcode], []) + end + + defp config_unlist_url do + Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_url], []) + end + + defp config_unlist_shortcode do + Pleroma.Config.get([:mrf_emoji, :federated_timeline_removal_shortcode], []) + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :manual + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => type, "object" => %{"type" => objtype} = object} = message) + when type in ["Create", "Update"] and objtype in Pleroma.Constants.status_object_types() do + with {:ok, object} <- + Updater.do_with_history(object, fn object -> + {:ok, process_remove(object, :url, config_remove_url())} + end), + {:ok, object} <- + Updater.do_with_history(object, fn object -> + {:ok, process_remove(object, :shortcode, config_remove_shortcode())} + end), + activity <- Map.put(message, "object", object), + activity <- maybe_delist(activity) do + {:ok, activity} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => type} = object) when type in Pleroma.Constants.actor_types() do + with object <- process_remove(object, :url, config_remove_url()), + object <- process_remove(object, :shortcode, config_remove_shortcode()) do + {:ok, object} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"type" => "EmojiReact"} = object) do + with {:ok, _} <- + matched_emoji_checker(config_remove_url(), config_remove_shortcode()).(object) do + {:ok, object} + else + _ -> + {:reject, "[EmojiPolicy] Rejected for having disallowed emoji"} + end + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(message) do + {:ok, message} + end + + defp match_string?(string, pattern) when is_binary(pattern) do + string == pattern + end + + defp match_string?(string, %Regex{} = pattern) do + String.match?(string, pattern) + end + + defp match_any?(string, patterns) do + Enum.any?(patterns, &match_string?(string, &1)) + end + + defp url_from_tag(%{"icon" => %{"url" => url}}), do: url + defp url_from_tag(_), do: nil + + defp url_from_emoji({_name, url}), do: url + + defp shortcode_from_tag(%{"name" => name}) when is_binary(name), do: String.trim(name, ":") + defp shortcode_from_tag(_), do: nil + + defp shortcode_from_emoji({name, _url}), do: name + + defp process_remove(object, :url, patterns) do + process_remove_impl(object, &url_from_tag/1, &url_from_emoji/1, patterns) + end + + defp process_remove(object, :shortcode, patterns) do + process_remove_impl(object, &shortcode_from_tag/1, &shortcode_from_emoji/1, patterns) + end + + defp process_remove_impl(object, extract_from_tag, extract_from_emoji, patterns) do + object = + if object["tag"] do + Map.put( + object, + "tag", + Enum.filter( + object["tag"], + fn + %{"type" => "Emoji"} = tag -> + str = extract_from_tag.(tag) + + if is_binary(str) do + not match_any?(str, patterns) + else + true + end + + _ -> + true + end + ) + ) + else + object + end + + object = + if object["emoji"] do + Map.put( + object, + "emoji", + object["emoji"] + |> Enum.reduce(%{}, fn {name, url} = emoji, acc -> + if not match_any?(extract_from_emoji.(emoji), patterns) do + Map.put(acc, name, url) + else + acc + end + end) + ) + else + object + end + + object + end + + defp matched_emoji_checker(urls, shortcodes) do + fn object -> + if any_emoji_match?(object, &url_from_tag/1, &url_from_emoji/1, urls) or + any_emoji_match?( + object, + &shortcode_from_tag/1, + &shortcode_from_emoji/1, + shortcodes + ) do + {:matched, nil} + else + {:ok, %{}} + end + end + end + + defp maybe_delist(%{"object" => object, "to" => to, "type" => "Create"} = activity) do + check = matched_emoji_checker(config_unlist_url(), config_unlist_shortcode()) + + should_delist? = fn object -> + with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check) do + false + else + _ -> true + end + end + + if Pleroma.Constants.as_public() in to and should_delist?.(object) do + to = List.delete(to, Pleroma.Constants.as_public()) + cc = [Pleroma.Constants.as_public() | activity["cc"] || []] + + activity + |> Map.put("to", to) + |> Map.put("cc", cc) + else + activity + end + end + + defp maybe_delist(activity), do: activity + + defp any_emoji_match?(object, extract_from_tag, extract_from_emoji, patterns) do + Kernel.||( + Enum.any?( + object["tag"] || [], + fn + %{"type" => "Emoji"} = tag -> + str = extract_from_tag.(tag) + + if is_binary(str) do + match_any?(str, patterns) + else + false + end + + _ -> + false + end + ), + (object["emoji"] || []) + |> Enum.any?(fn emoji -> match_any?(extract_from_emoji.(emoji), patterns) end) + ) + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def describe do + mrf_emoji = + Pleroma.Config.get(:mrf_emoji, []) + |> Enum.map(fn {key, value} -> + {key, Enum.map(value, &Utils.describe_regex_or_string/1)} + end) + |> Enum.into(%{}) + + {:ok, %{mrf_emoji: mrf_emoji}} + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def config_description do + %{ + key: :mrf_emoji, + related_policy: "Pleroma.Web.ActivityPub.MRF.EmojiPolicy", + label: "MRF Emoji", + description: + "Reject or force-unlisted emojis whose URLs or names match a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).", + children: [ + %{ + key: :remove_url, + type: {:list, :string}, + description: """ + A list of patterns which result in emoji whose URL matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu] + }, + %{ + key: :remove_shortcode, + type: {:list, :string}, + description: """ + A list of patterns which result in emoji whose shortcode matches being removed from the message. This will apply to statuses, emoji reactions, and user profiles. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + }, + %{ + key: :federated_timeline_removal_url, + type: {:list, :string}, + description: """ + A list of patterns which result in message with emojis whose URLs match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["https://example.org/foo.png", ~r/example.org\/foo/iu] + }, + %{ + key: :federated_timeline_removal_shortcode, + type: {:list, :string}, + description: """ + A list of patterns which result in message with emojis whose shortcodes match being removed from federated timelines (a.k.a unlisted). This will apply only to statuses. + + Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`. + """, + suggestions: ["foo", ~r/foo/iu] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 5b6adbb4b..5a4a97626 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -19,7 +19,7 @@ def filter(message) do try_follow(follower, message) else nil -> - Logger.warn( + Logger.warning( "#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname account does not exist, or the account is not correctly configured as a bot." ) diff --git a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex index 70224561c..5532093cb 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors +# Copyright © 2017-2023 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent do @@ -95,11 +95,13 @@ def filter( |> Enum.reject(&is_nil/1) |> sort_replied_user(replied_to_user) - explicitly_mentioned_uris = extract_mention_uris_from_content(content) + explicitly_mentioned_uris = + extract_mention_uris_from_content(content) + |> MapSet.new() added_mentions = - Enum.reduce(mention_users, "", fn %User{ap_id: uri} = user, acc -> - unless uri in explicitly_mentioned_uris do + Enum.reduce(mention_users, "", fn %User{ap_id: ap_id, uri: uri} = user, acc -> + if MapSet.disjoint?(MapSet.new([ap_id, uri]), explicitly_mentioned_uris) do acc <> Formatter.mention_from_user(user, %{mentions_format: :compact}) <> " " else acc diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex index b73fd974c..fdb9a9dba 100644 --- a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do alias Pleroma.Object @moduledoc """ - Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #) + Reject, TWKN-remove or Set-Sensitive messages with specific hashtags (without the leading #) Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists. """ @@ -84,7 +84,7 @@ def filter(%{"type" => type, "object" => object} = message) when type in ["Creat if hashtags != [] do with {:ok, message} <- check_reject(message, hashtags), {:ok, message} <- - (if "type" == "Create" do + (if type == "Create" do check_ftl_removal(message, hashtags) else {:ok, message} diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex new file mode 100644 index 000000000..b7a01c27c --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do + @moduledoc "Force a quote line into the message content." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp build_inline_quote(template, url) do + quote_line = String.replace(template, "{url}", "#{url}") + + "

#{quote_line}
" + end + + defp has_inline_quote?(content, quote_url) do + cond do + # Does the quote URL exist in the content? + content =~ quote_url -> true + # Does the content already have a .quote-inline span? + content =~ "" -> true + # No inline quote found + true -> false + end + end + + defp filter_object(%{"quoteUrl" => quote_url} = object) do + content = object["content"] || "" + + if has_inline_quote?(content, quote_url) do + object + else + template = Pleroma.Config.get([:mrf_inline_quote, :template]) + + content = + if String.ends_with?(content, "

"), + do: + String.trim_trailing(content, "

") <> + build_inline_quote(template, quote_url) <> "

", + else: content <> build_inline_quote(template, quote_url) + + Map.put(object, "content", content) + end + end + + @impl true + def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do + {:ok, Map.put(activity, "object", filter_object(object))} + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :auto + + @impl true + def config_description do + %{ + key: :mrf_inline_quote, + related_policy: "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy", + label: "MRF Inline Quote Policy", + description: "Force quote url to appear in post content.", + children: [ + %{ + key: :template, + type: :string, + description: + "The template to append to the post. `{url}` will be replaced with the actual link to the quoted post.", + suggestions: ["RT: {url}"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index 687ec6c2f..729da4e9c 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -5,18 +5,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do require Pleroma.Constants + alias Pleroma.Web.ActivityPub.MRF.Utils + @moduledoc "Reject or Word-Replace messages with a keyword or regex" @behaviour Pleroma.Web.ActivityPub.MRF.Policy - defp string_matches?(string, _) when not is_binary(string) do - false - end defp string_matches?(string, pattern) when is_binary(pattern) do String.contains?(string, pattern) end - defp string_matches?(string, pattern) do + defp string_matches?(string, %Regex{} = pattern) do String.match?(string, pattern) end @@ -128,7 +127,6 @@ def filter(message), do: {:ok, message} @impl true def describe do - # This horror is needed to convert regex sigils to strings mrf_keyword = Pleroma.Config.get(:mrf_keyword, []) |> Enum.map(fn {key, value} -> @@ -136,21 +134,12 @@ def describe do Enum.map(value, fn {pattern, replacement} -> %{ - "pattern" => - if not is_binary(pattern) do - inspect(pattern) - else - pattern - end, + "pattern" => Utils.describe_regex_or_string(pattern), "replacement" => replacement } pattern -> - if not is_binary(pattern) do - inspect(pattern) - else - pattern - end + Utils.describe_regex_or_string(pattern) end)} end) |> Enum.into(%{}) diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex index 855cda3b9..12bf4ddd2 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -10,9 +10,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do @impl true def filter(%{"actor" => actor} = object) do - with true <- is_local?(actor), - true <- is_eligible_type?(object), - true <- is_note?(object), + with true <- local?(actor), + true <- eligible_type?(object), + true <- note?(object), false <- has_attachment?(object), true <- only_mentions?(object) do {:reject, "[NoEmptyPolicy]"} @@ -24,7 +24,7 @@ def filter(%{"actor" => actor} = object) do def filter(object), do: {:ok, object} - defp is_local?(actor) do + defp local?(actor) do if actor |> String.starts_with?("#{Endpoint.url()}") do true else @@ -59,11 +59,11 @@ defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do defp only_mentions?(_), do: false - defp is_note?(%{"object" => %{"type" => "Note"}}), do: true - defp is_note?(_), do: false + defp note?(%{"object" => %{"type" => "Note"}}), do: true + defp note?(_), do: false - defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true - defp is_eligible_type?(_), do: false + defp eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true + defp eligible_type?(_), do: false @impl true def describe, do: {:ok, %{}} diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex index 0234de4d5..1f34883e7 100644 --- a/lib/pleroma/web/activity_pub/mrf/policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -3,8 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.Policy do - @callback filter(Map.t()) :: {:ok | :reject, Map.t()} - @callback describe() :: {:ok | :error, Map.t()} + @callback filter(map()) :: {:ok | :reject, map()} + @callback describe() :: {:ok | :error, map()} @callback config_description() :: %{ optional(:children) => [map()], key: atom(), diff --git a/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex new file mode 100644 index 000000000..ac353f03f --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/quote_to_link_tag_policy.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy do + @moduledoc "Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)" + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + + require Pleroma.Constants + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(%{"object" => %{"quoteUrl" => _} = object} = activity) do + {:ok, Map.put(activity, "object", filter_object(object))} + end + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def filter(object), do: {:ok, object} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def describe, do: {:ok, %{}} + + @impl Pleroma.Web.ActivityPub.MRF.Policy + def history_awareness, do: :auto + + defp filter_object(%{"quoteUrl" => quote_url} = object) do + tags = object["tag"] || [] + + if Enum.any?(tags, fn tag -> + CommonFixes.object_link_tag?(tag) and tag["href"] == quote_url + end) do + object + else + object + |> Map.put( + "tag", + tags ++ + [ + %{ + "type" => "Link", + "mediaType" => Pleroma.Constants.activity_json_canonical_mime_type(), + "href" => quote_url + } + ] + ) + end + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex index f66c379b5..237dfefa5 100644 --- a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -34,14 +34,16 @@ defp steal_emoji({shortcode, url}, emoji_dir_path) do |> Path.basename() |> Path.extname() - file_path = Path.join(emoji_dir_path, shortcode <> (extension || ".png")) + extension = if extension == "", do: ".png", else: extension + + file_path = Path.join(emoji_dir_path, shortcode <> extension) case File.write(file_path, response.body) do :ok -> shortcode e -> - Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") + Logger.warning("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") nil end else @@ -53,7 +55,7 @@ defp steal_emoji({shortcode, url}, emoji_dir_path) do end else e -> - Logger.warn("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}") + Logger.warning("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}") nil end end diff --git a/lib/pleroma/web/activity_pub/mrf/utils.ex b/lib/pleroma/web/activity_pub/mrf/utils.ex new file mode 100644 index 000000000..f2dc9eea9 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/utils.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.Utils do + @spec describe_regex_or_string(String.t() | Regex.t()) :: String.t() + def describe_regex_or_string(pattern) do + # This horror is needed to convert regex sigils to strings + if not is_binary(pattern) do + inspect(pattern) + else + pattern + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 5bcd6da46..b3043b93a 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AudioImageVideoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator @@ -102,7 +102,7 @@ def validate( %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta ) - when objtype in ~w[Question Answer Audio Video Event Article Note Page] do + when objtype in ~w[Question Answer Audio Video Image Event Article Note Page] do with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object), meta = Keyword.put(meta, :object_data, object_data), {:ok, create_activity} <- @@ -115,13 +115,14 @@ def validate( end def validate(%{"type" => type} = object, meta) - when type in ~w[Event Question Audio Video Article Note Page] do + when type in ~w[Event Question Audio Video Image Article Note Page] do validator = case type do "Event" -> EventValidator "Question" -> QuestionValidator - "Audio" -> AudioVideoValidator - "Video" -> AudioVideoValidator + "Audio" -> AudioImageVideoValidator + "Video" -> AudioImageVideoValidator + "Image" -> AudioImageVideoValidator "Article" -> ArticleNotePageValidator "Note" -> ArticleNotePageValidator "Page" -> ArticleNotePageValidator @@ -172,6 +173,9 @@ def validate( {:object_validation, e} -> e + + {:error, %Ecto.Changeset{} = e} -> + {:error, e} end end @@ -233,8 +237,8 @@ def cast_and_apply(%{"type" => "Answer"} = object) do AnswerValidator.cast_and_apply(object) end - def cast_and_apply(%{"type" => type} = object) when type in ~w[Audio Video] do - AudioVideoValidator.cast_and_apply(object) + def cast_and_apply(%{"type" => type} = object) when type in ~w[Audio Image Video] do + AudioImageVideoValidator.cast_and_apply(object) end def cast_and_apply(%{"type" => "Event"} = object) do diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex index 5202db7f1..db3259550 100644 --- a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -73,6 +73,7 @@ defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary( end defp maybe_refetch_user(%User{ap_id: ap_id}) do - Pleroma.Web.ActivityPub.Transmogrifier.upgrade_user_from_ap_id(ap_id) + # Maybe it could use User.get_or_fetch_by_ap_id to avoid refreshing too often + User.fetch_by_ap_id(ap_id) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index c2c7ba1a8..d0218583e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -82,7 +82,7 @@ defp validate_announcable(cng) do object when is_binary(object) <- get_field(cng, :object), %User{} = actor <- User.get_cached_by_ap_id(actor), %Object{} = object <- Object.get_cached_by_ap_id(object), - false <- Visibility.is_public?(object) do + false <- Visibility.public?(object) do same_actor = object.data["actor"] == actor.ap_id recipients = get_field(cng, :to) ++ get_field(cng, :cc) local_public = Utils.as_local_public() 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 2670e3f17..1b5b2e8fb 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 @@ -84,6 +84,7 @@ defp fix(data) do |> fix_tag() |> fix_replies() |> fix_attachments() + |> CommonFixes.fix_quote_url() |> Transmogrifier.fix_emoji() |> Transmogrifier.fix_content_map() end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex similarity index 86% rename from lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex rename to lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex index 671a7ef0c..65ac6bb93 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2022 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator do +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioImageVideoValidator do use Ecto.Schema alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes @@ -55,9 +55,14 @@ defp find_attachment(url) do url |> Enum.concat(mpeg_url["tag"] || []) |> Enum.find(fn - %{"mediaType" => mime_type} -> String.starts_with?(mime_type, ["video/", "audio/"]) - %{"mimeType" => mime_type} -> String.starts_with?(mime_type, ["video/", "audio/"]) - _ -> false + %{"mediaType" => mime_type} -> + String.starts_with?(mime_type, ["video/", "audio/", "image/"]) + + %{"mimeType" => mime_type} -> + String.starts_with?(mime_type, ["video/", "audio/", "image/"]) + + _ -> + false end) end @@ -94,6 +99,7 @@ defp fix(data) do data |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() + |> CommonFixes.fix_quote_url() |> Transmogrifier.fix_emoji() |> fix_url() |> fix_content() @@ -110,7 +116,7 @@ def changeset(struct, data) do defp validate_data(data_cng) do data_cng - |> validate_inclusion(:type, ["Audio", "Video"]) + |> validate_inclusion(:type, ~w[Audio Image Video]) |> validate_required([:id, :actor, :attributedTo, :type, :context]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index efae48cae..09e25be89 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -57,6 +57,11 @@ def fix_attachment(%{"attachment" => [attachment | _]} = data) do |> Map.put("attachment", attachment) end + def fix_attachment(%{"attachment" => attachment} = data) when attachment == [] do + data + |> Map.drop(["attachment"]) + end + def fix_attachment(data), do: data def changeset(struct, data) do 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 7b60c139a..1a5d02601 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -27,7 +27,7 @@ defmacro activity_fields do end end - # All objects except Answer and CHatMessage + # All objects except Answer and ChatMessage defmacro object_fields do quote bind_quoted: binding() do field(:content, :string) @@ -57,8 +57,10 @@ defmacro status_object_fields do field(:replies_count, :integer, default: 0) field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) + field(:quotes_count, :integer, default: 0) field(:inReplyTo, ObjectValidators.ObjectID) - field(:url, ObjectValidators.Uri) + field(:quoteUrl, ObjectValidators.ObjectID) + field(:url, ObjectValidators.BareUri) field(:likes, {:array, ObjectValidators.ObjectID}, default: []) field(:announcements, {:array, ObjectValidators.ObjectID}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index add46d561..ab56ba468 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils + require Pleroma.Constants + def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do {:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback) @@ -76,4 +78,48 @@ def fix_object_action_recipients(data, %Object{data: %{"actor" => actor}}) do Map.put(data, "to", to) end + + def fix_quote_url(%{"quoteUrl" => _quote_url} = data), do: data + + # Fedibird + # https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac + def fix_quote_url(%{"quoteUri" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Old Fedibird (bug) + # https://github.com/fedibird/mastodon/issues/9 + def fix_quote_url(%{"quoteURL" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + # Misskey fallback + def fix_quote_url(%{"_misskey_quote" => quote_url} = data) do + Map.put(data, "quoteUrl", quote_url) + end + + def fix_quote_url(%{"tag" => [_ | _] = tags} = data) do + tag = Enum.find(tags, &object_link_tag?/1) + + if not is_nil(tag) do + data + |> Map.put("quoteUrl", tag["href"]) + else + data + end + end + + def fix_quote_url(data), do: data + + # https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md + def object_link_tag?(%{ + "type" => "Link", + "mediaType" => media_type, + "href" => href + }) + when media_type in Pleroma.Constants.activity_json_mime_types() and is_binary(href) do + true + end + + def object_link_tag?(_), do: false end diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index 0858281e5..65ba047e6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do use Ecto.Schema + alias Pleroma.Emoji alias Pleroma.Object alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -19,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields message_fields() activity_fields() + embeds_many(:tag, TagValidator) end end @@ -43,7 +46,8 @@ def cast_data(data) do def changeset(struct, data) do struct - |> cast(data, __schema__(:fields)) + |> cast(data, __schema__(:fields) -- [:tag]) + |> cast_embed(:tag) end defp fix(data) do @@ -53,12 +57,16 @@ defp fix(data) do |> CommonFixes.fix_actor() |> CommonFixes.fix_activity_addressing() - with %Object{} = object <- Object.normalize(data["object"]) do - data - |> CommonFixes.fix_activity_context(object) - |> CommonFixes.fix_object_action_recipients(object) - else - _ -> data + data = Map.put_new(data, "tag", []) + + case Object.normalize(data["object"]) do + %Object{} = object -> + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) + + _ -> + data end end @@ -66,10 +74,10 @@ defp fix_emoji_qualification(%{"content" => emoji} = data) do new_emoji = Pleroma.Emoji.fully_qualify_emoji(emoji) cond do - Pleroma.Emoji.is_unicode_emoji?(emoji) -> + Pleroma.Emoji.unicode?(emoji) -> data - Pleroma.Emoji.is_unicode_emoji?(new_emoji) -> + Pleroma.Emoji.unicode?(new_emoji) -> data |> Map.put("content", new_emoji) true -> @@ -82,11 +90,31 @@ defp fix_emoji_qualification(data), do: data defp validate_emoji(cng) do content = get_field(cng, :content) - if Pleroma.Emoji.is_unicode_emoji?(content) do + if Emoji.unicode?(content) || Emoji.custom?(content) do cng else cng - |> add_error(:content, "must be a single character emoji") + |> add_error(:content, "is not a valid emoji") + end + end + + defp maybe_validate_tag_presence(cng) do + content = get_field(cng, :content) + + if Emoji.unicode?(content) do + cng + else + tag = get_field(cng, :tag) + emoji_name = Emoji.maybe_strip_name(content) + + case tag do + [%{name: ^emoji_name, type: "Emoji", icon: %{url: _}}] -> + cng + + _ -> + cng + |> add_error(:tag, "does not contain an Emoji tag") + end end end @@ -97,5 +125,6 @@ defp validate_data(data_cng) do |> validate_actor_presence() |> validate_object_presence() |> validate_emoji() + |> maybe_validate_tag_presence() end end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index ce3305142..621085e6c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -62,6 +62,7 @@ defp fix(data) do data |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() + |> CommonFixes.fix_quote_url() |> Transmogrifier.fix_emoji() |> fix_closed() end diff --git a/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex index 9f15f1981..47cf7b415 100644 --- a/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex @@ -9,15 +9,20 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do import Ecto.Changeset + require Pleroma.Constants + @primary_key false embedded_schema do # Common field(:type, :string) field(:name, :string) - # Mention, Hashtag + # Mention, Hashtag, Link field(:href, ObjectValidators.Uri) + # Link + field(:mediaType, :string) + # Emoji embeds_one :icon, IconObjectValidator, primary_key: false do field(:type, :string) @@ -68,6 +73,19 @@ def changeset(struct, %{"type" => "Emoji"} = data) do |> validate_required([:type, :name, :icon]) end + def changeset(struct, %{"type" => "Link"} = data) do + struct + |> cast(data, [:type, :name, :mediaType, :href]) + |> validate_inclusion(:mediaType, Pleroma.Constants.activity_json_mime_types()) + |> validate_required([:type, :href, :mediaType]) + end + + def changeset(struct, %{"type" => _} = data) do + struct + |> cast(data, []) + |> Map.put(:action, :ignore) + end + def icon_changeset(struct, data) do struct |> cast(data, [:type, :url]) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index ca8653ab1..40184bd97 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -62,7 +62,7 @@ defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do do_not_federate = meta[:do_not_federate] || !config().get([:instance, :federating]) - if !do_not_federate and local and not Visibility.is_local_public?(activity) do + if !do_not_federate and local and not Visibility.local_public?(activity) do activity = if object = Keyword.get(meta, :object_data) do %{activity | data: Map.put(activity.data, "object", object)} diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 6c1ba76a3..c27612697 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -13,23 +13,60 @@ defmodule Pleroma.Web.ActivityPub.Publisher do alias Pleroma.User alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Workers.PublisherWorker require Pleroma.Constants import Pleroma.Web.ActivityPub.Visibility - @behaviour Pleroma.Web.Federator.Publisher - require Logger @moduledoc """ ActivityPub outgoing federation module. """ + @doc """ + Enqueue publishing a single activity. + """ + @spec enqueue_one(map(), Keyword.t()) :: {:ok, %Oban.Job{}} + def enqueue_one(%{} = params, worker_args \\ []) do + PublisherWorker.enqueue( + "publish_one", + %{"params" => params}, + worker_args + ) + end + + @doc """ + Gathers a set of remote users given an IR envelope. + """ + def remote_users(%User{id: user_id}, %{data: %{"to" => to} = data}) do + cc = Map.get(data, "cc", []) + + bcc = + data + |> Map.get("bcc", []) + |> Enum.reduce([], fn ap_id, bcc -> + case Pleroma.List.get_by_ap_id(ap_id) do + %Pleroma.List{user_id: ^user_id} = list -> + {:ok, following} = Pleroma.List.get_following(list) + bcc ++ Enum.map(following, & &1.ap_id) + + _ -> + bcc + end + end) + + [to, cc, bcc] + |> Enum.concat() + |> Enum.map(&User.get_cached_by_ap_id/1) + |> Enum.filter(fn user -> user && !user.local end) + end + @doc """ Determine if an activity can be represented by running it through Transmogrifier. """ - def is_representable?(%Activity{} = activity) do + def representable?(%Activity{} = activity) do with {:ok, _data} <- Transmogrifier.prepare_outgoing(activity.data) do true else @@ -80,9 +117,23 @@ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = pa result else - {_post_result, response} -> + {_post_result, %{status: code} = response} = e -> unless params[:unreachable_since], do: Instances.set_unreachable(inbox) - {:error, response} + Logger.metadata(activity: id, inbox: inbox, status: code) + Logger.error("Publisher failed to inbox #{inbox} with status #{code}") + + case response do + %{status: 403} -> {:discard, :forbidden} + %{status: 404} -> {:discard, :not_found} + %{status: 410} -> {:discard, :not_found} + _ -> {:error, e} + end + + e -> + unless params[:unreachable_since], do: Instances.set_unreachable(inbox) + Logger.metadata(activity: id, inbox: inbox) + Logger.error("Publisher failed to inbox #{inbox} #{inspect(e)}") + {:error, e} end end @@ -118,7 +169,7 @@ defp should_federate?(inbox, public) do end end - @spec recipients(User.t(), Activity.t()) :: list(User.t()) | [] + @spec recipients(User.t(), Activity.t()) :: [[User.t()]] defp recipients(actor, activity) do followers = if actor.follower_address in activity.recipients do @@ -138,7 +189,10 @@ defp recipients(actor, activity) do [] end - Pleroma.Web.Federator.Publisher.remote_users(actor, activity) ++ followers ++ fetchers + mentioned = remote_users(actor, activity) + non_mentioned = (followers ++ fetchers) -- mentioned + + [mentioned, non_mentioned] end defp get_cc_ap_ids(ap_id, recipients) do @@ -192,45 +246,52 @@ def determine_inbox( def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) when is_list(bcc) and bcc != [] do - public = is_public?(activity) + public = public?(activity) {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) - recipients = recipients(actor, activity) + [priority_recipients, recipients] = recipients(actor, activity) inboxes = - recipients - |> Enum.filter(&User.ap_enabled?/1) - |> Enum.map(fn actor -> actor.inbox end) - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) - |> Instances.filter_reachable() + [priority_recipients, recipients] + |> Enum.map(fn recipients -> + recipients + |> Enum.map(fn %User{} = user -> + determine_inbox(activity, user) + end) + |> Enum.uniq() + |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + |> Instances.filter_reachable() + end) Repo.checkout(fn -> - Enum.each(inboxes, fn {inbox, unreachable_since} -> - %User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end) + Enum.each(inboxes, fn inboxes -> + Enum.each(inboxes, fn {inbox, unreachable_since} -> + %User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end) - # Get all the recipients on the same host and add them to cc. Otherwise, a remote - # instance would only accept a first message for the first recipient and ignore the rest. - cc = get_cc_ap_ids(ap_id, recipients) + # Get all the recipients on the same host and add them to cc. Otherwise, a remote + # instance would only accept a first message for the first recipient and ignore the rest. + cc = get_cc_ap_ids(ap_id, recipients) - json = - data - |> Map.put("cc", cc) - |> Jason.encode!() + json = + data + |> Map.put("cc", cc) + |> Jason.encode!() - Pleroma.Web.Federator.Publisher.enqueue_one(__MODULE__, %{ - inbox: inbox, - json: json, - actor_id: actor.id, - id: activity.data["id"], - unreachable_since: unreachable_since - }) + __MODULE__.enqueue_one(%{ + inbox: inbox, + json: json, + actor_id: actor.id, + id: activity.data["id"], + unreachable_since: unreachable_since + }) + end) end) end) end # Publishes an activity to all relevant peers. def publish(%User{} = actor, %Activity{} = activity) do - public = is_public?(activity) + public = public?(activity) if public && Config.get([:instance, :allow_relay]) do Logger.debug(fn -> "Relaying #{activity.data["id"]} out" end) @@ -240,26 +301,38 @@ def publish(%User{} = actor, %Activity{} = activity) do {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) json = Jason.encode!(data) - recipients(actor, activity) - |> Enum.filter(fn user -> User.ap_enabled?(user) end) - |> Enum.map(fn %User{} = user -> - determine_inbox(activity, user) - end) - |> Enum.uniq() - |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) - |> Instances.filter_reachable() - |> Enum.each(fn {inbox, unreachable_since} -> - Pleroma.Web.Federator.Publisher.enqueue_one( - __MODULE__, - %{ - inbox: inbox, - json: json, - actor_id: actor.id, - id: activity.data["id"], - unreachable_since: unreachable_since - } - ) + [priority_inboxes, inboxes] = + recipients(actor, activity) + |> Enum.map(fn recipients -> + recipients + |> Enum.map(fn %User{} = user -> + determine_inbox(activity, user) + end) + |> Enum.uniq() + |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) + end) + + inboxes = inboxes -- priority_inboxes + + [{priority_inboxes, 0}, {inboxes, 1}] + |> Enum.each(fn {inboxes, priority} -> + inboxes + |> Instances.filter_reachable() + |> Enum.each(fn {inbox, unreachable_since} -> + __MODULE__.enqueue_one( + %{ + inbox: inbox, + json: json, + actor_id: actor.id, + id: activity.data["id"], + unreachable_since: unreachable_since + }, + priority: priority + ) + end) end) + + :ok end def gather_webfinger_links(%User{} = user) do diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index 2010351d1..91a647f29 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -58,7 +58,7 @@ defp fetch_target_user(ap_id, opts) do @spec publish(any()) :: {:ok, Activity.t()} | {:error, any()} def publish(%Activity{data: %{"type" => "Create"}} = activity) do with %User{} = user <- get_actor(), - true <- Visibility.is_public?(activity) do + true <- Visibility.public?(activity) do CommonAPI.repeat(activity.id, user) else error -> format_error(error) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a2152b945..5cb8a9700 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -197,6 +197,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Increase replies count # - Set up ActivityExpiration # - Set up notifications + # - Index incoming posts for search (if needed) @impl true def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, object, meta} <- handle_object_creation(meta[:object_data], activity, meta), @@ -209,6 +210,10 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) do Object.increase_replies_count(in_reply_to) end + if quote_url = object.data["quoteUrl"] do + Object.increase_quotes_count(quote_url) + end + reply_depth = (meta[:depth] || 0) + 1 # FIXME: Force inReplyTo to replies @@ -226,6 +231,10 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) do Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) end) + Pleroma.Search.add_to_index(Map.put(activity, :object, object)) + + Utils.maybe_handle_group_posts(activity) + meta = meta |> add_notifications(notifications) @@ -249,7 +258,7 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) do Utils.add_announce_to_object(object, announced_object) - if !User.is_internal_user?(user) do + if !User.internal?(user) do Notification.create_notifications(object) ap_streamer().stream_out(object) @@ -285,6 +294,7 @@ def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do # - Reduce the user note count # - Reduce the reply count # - Stream out the activity + # - Removes posts from search index (if needed) @impl true def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = @@ -294,9 +304,9 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, result = case deleted_object do %Object{} -> - with {:ok, deleted_object, _activity} <- Object.delete(deleted_object), + with {_, {:ok, deleted_object, _activity}} <- {:object, Object.delete(deleted_object)}, {_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]}, - %User{} = user <- User.get_cached_by_ap_id(actor) do + {_, %User{} = user} <- {:user, User.get_cached_by_ap_id(actor)} do User.remove_pinned_object_id(user, deleted_object.data["id"]) {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) @@ -305,6 +315,10 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, Object.decrease_replies_count(in_reply_to) end + if quote_url = deleted_object.data["quoteUrl"] do + Object.decrease_quotes_count(quote_url) + end + MessageReference.delete_for_object(deleted_object) ap_streamer().stream_out(object) @@ -314,6 +328,17 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, {:actor, _} -> @logger.error("The object doesn't have an actor: #{inspect(deleted_object)}") :no_object_actor + + {:user, _} -> + @logger.error( + "The object's actor could not be resolved to a user: #{inspect(deleted_object)}" + ) + + :no_object_user + + {:object, _} -> + @logger.error("The object could not be deleted: #{inspect(deleted_object)}") + {:error, object} end %User{} -> @@ -323,6 +348,11 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, end if result == :ok do + # Only remove from index when deleting actual objects, not users or anything else + with %Pleroma.Object{} <- deleted_object do + Pleroma.Search.remove_from_index(deleted_object) + end + {:ok, object, meta} else {:error, result} @@ -428,37 +458,13 @@ defp handle_update_object( end if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do - %{ - updated_data: updated_object_data, - updated: updated, - used_history_in_new_object?: used_history_in_new_object? - } = Object.Updater.make_new_object_data_from_update_object(orig_object_data, updated_object) + {:ok, _, updated} = + Object.Updater.do_update_and_invalidate_cache(orig_object, updated_object) - changeset = - orig_object - |> Repo.preload(:hashtags) - |> Object.change(%{data: updated_object_data}) - - with {:ok, new_object} <- Repo.update(changeset), - {:ok, _} <- Object.invalid_object_cache(new_object), - {:ok, _} <- Object.set_cache(new_object), - # The metadata/utils.ex uses the object id for the cache. - {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do - if used_history_in_new_object? do - with create_activity when not is_nil(create_activity) <- - Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id), - {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do - nil - else - _ -> nil - end - end - - if updated do - object - |> Activity.normalize() - |> ActivityPub.notify_and_stream() - end + if updated do + object + |> Activity.normalize() + |> ActivityPub.notify_and_stream() end end @@ -520,7 +526,7 @@ def handle_object_creation(%{"type" => "Answer"} = object_map, _activity, meta) end def handle_object_creation(%{"type" => objtype} = object, _activity, meta) - when objtype in ~w[Audio Video Event Article Note Page] do + when objtype in ~w[Audio Video Image Event Article Note Page] do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} end @@ -574,7 +580,7 @@ def handle_undoing( def handle_undoing(object), do: {:error, ["don't know how to handle", object]} - @spec delete_object(Object.t()) :: :ok | {:error, Ecto.Changeset.t()} + @spec delete_object(Activity.t()) :: :ok | {:error, Ecto.Changeset.t()} defp delete_object(object) do with {:ok, _} <- Repo.delete(object), do: :ok end diff --git a/lib/pleroma/web/activity_pub/side_effects/handling.ex b/lib/pleroma/web/activity_pub/side_effects/handling.ex index eb012f576..4751bb4ce 100644 --- a/lib/pleroma/web/activity_pub/side_effects/handling.ex +++ b/lib/pleroma/web/activity_pub/side_effects/handling.ex @@ -4,5 +4,5 @@ defmodule Pleroma.Web.ActivityPub.SideEffects.Handling do @callback handle(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} - @callback handle_after_transaction(map()) :: map() + @callback handle_after_transaction(keyword()) :: keyword() end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index e4c04da0d..69854b084 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -20,11 +20,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator - alias Pleroma.Workers.TransmogrifierWorker import Ecto.Query - require Logger require Pleroma.Constants @doc """ @@ -156,8 +154,7 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) |> Map.put("context", replied_object.data["context"] || object["conversation"]) |> Map.drop(["conversation", "inReplyToAtomUri"]) else - e -> - Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") + _ -> object end else @@ -167,6 +164,26 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) def fix_in_reply_to(object, _options), do: object + def fix_quote_url_and_maybe_fetch(object, options \\ []) do + quote_url = + case Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes.fix_quote_url(object) do + %{"quoteUrl" => quote_url} -> quote_url + _ -> nil + end + + with {:quoting?, true} <- {:quoting?, not is_nil(quote_url)}, + {:ok, quoted_object} <- get_obj_helper(quote_url, options), + %Activity{} <- Activity.get_create_by_object_ap_id(quoted_object.data["id"]) do + Map.put(object, "quoteUrl", quoted_object.data["id"]) + else + {:quoting?, _} -> + object + + _ -> + object + end + end + defp prepare_in_reply_to(in_reply_to) do cond do is_bitstring(in_reply_to) -> @@ -319,6 +336,10 @@ def fix_tag(%{"tag" => %{} = tag} = object) do def fix_tag(object), do: object + def fix_content_map(%{"contentMap" => nil} = object) do + Map.drop(object, ["contentMap"]) + end + # content map usually only has one language so this will do for now. def fix_content_map(%{"contentMap" => content_map} = object) do content_groups = Map.to_list(content_map) @@ -447,7 +468,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, options ) - when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page} do + when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page Image} do fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) object = @@ -455,6 +476,7 @@ def handle_incoming( |> strip_internal_fields() |> fix_type(fetch_options) |> fix_in_reply_to(fetch_options) + |> fix_quote_url_and_maybe_fetch(fetch_options) data = Map.put(data, "object", object) options = Keyword.put(options, :local, false) @@ -629,6 +651,16 @@ def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_r def set_reply_to_uri(obj), do: obj + @doc """ + Fedibird compatibility + https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac + """ + def set_quote_url(%{"quoteUrl" => quote_url} = object) when is_binary(quote_url) do + Map.put(object, "quoteUri", quote_url) + end + + def set_quote_url(obj), do: obj + @doc """ Serialized Mastodon-compatible `replies` collection containing _self-replies_. Based on Mastodon's ActivityPub::NoteSerializer#replies. @@ -683,6 +715,7 @@ def prepare_object(object) do |> prepare_attachments |> set_conversation |> set_reply_to_uri + |> set_quote_url |> set_replies |> strip_internal_fields |> strip_internal_tags @@ -750,7 +783,7 @@ def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => objec |> Object.normalize(fetch: false) data = - if Visibility.is_private?(object) && object.data["actor"] == ap_id do + if Visibility.private?(object) && object.data["actor"] == ap_id do data |> Map.put("object", object |> Map.get(:data) |> prepare_object) else data |> maybe_fix_object_url @@ -820,8 +853,7 @@ def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do relative_object do Map.put(data, "object", external_url) else - {:fetch, e} -> - Logger.error("Couldn't fetch #{object} #{inspect(e)}") + {:fetch, _} -> data _ -> @@ -946,47 +978,6 @@ defp strip_internal_tags(%{"tag" => tags} = object) do defp strip_internal_tags(object), do: object - def perform(:user_upgrade, user) do - # we pass a fake user so that the followers collection is stripped away - old_follower_address = User.ap_followers(%User{nickname: user.nickname}) - - from( - a in Activity, - where: ^old_follower_address in a.recipients, - update: [ - set: [ - recipients: - fragment( - "array_replace(?,?,?)", - a.recipients, - ^old_follower_address, - ^user.follower_address - ) - ] - ] - ) - |> Repo.update_all([]) - end - - def upgrade_user_from_ap_id(ap_id) do - with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), - {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), - {:ok, user} <- update_user(user, data) do - {:ok, _pid} = Task.start(fn -> ActivityPub.pinned_fetch_task(user) end) - TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) - {:ok, user} - else - %User{} = user -> {:ok, user} - e -> e - end - end - - defp update_user(user, data) do - user - |> User.remote_user_changeset(data) - |> User.update_and_set_cache() - end - def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do Map.put(data, "url", url["href"]) end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index b898d6fe8..52cb64fc5 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.UUID alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object @@ -31,7 +32,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do "Page", "Question", "Answer", - "Audio" + "Audio", + "Image" ] @strip_status_report_states ~w(closed resolved) @supported_report_states ~w(open closed resolved) @@ -165,7 +167,7 @@ def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) d with true <- Config.get!([:instance, :federating]), true <- type != "Block" || outgoing_blocks, - false <- Visibility.is_local_public?(activity) do + false <- Visibility.local_public?(activity) do Pleroma.Web.Federator.publish(activity) end @@ -275,7 +277,7 @@ def make_like_data( object_actor = User.get_cached_by_ap_id(object_actor_id) to = - if Visibility.is_public?(object) do + if Visibility.public?(object) do [actor.follower_address, object.data["actor"]] else [object.data["actor"]] @@ -325,21 +327,29 @@ def update_element_in_object(property, element, object, count \\ nil) do {:ok, Object.t()} | {:error, Ecto.Changeset.t()} def add_emoji_reaction_to_object( - %Activity{data: %{"content" => emoji, "actor" => actor}}, + %Activity{data: %{"content" => emoji, "actor" => actor}} = activity, object ) do reactions = get_cached_emoji_reactions(object) + emoji = Pleroma.Emoji.maybe_strip_name(emoji) + url = maybe_emoji_url(emoji, activity) new_reactions = - case Enum.find_index(reactions, fn [candidate, _] -> emoji == candidate end) do + case Enum.find_index(reactions, fn [candidate, _, candidate_url] -> + if is_nil(candidate_url) do + emoji == candidate + else + url == candidate_url + end + end) do nil -> - reactions ++ [[emoji, [actor]]] + reactions ++ [[emoji, [actor], url]] index -> List.update_at( reactions, index, - fn [emoji, users] -> [emoji, Enum.uniq([actor | users])] end + fn [emoji, users, url] -> [emoji, Enum.uniq([actor | users]), url] end ) end @@ -348,18 +358,40 @@ def add_emoji_reaction_to_object( update_element_in_object("reaction", new_reactions, object, count) end + defp maybe_emoji_url( + name, + %Activity{ + data: %{ + "tag" => [ + %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}} + ] + } + } + ), + do: url + + defp maybe_emoji_url(_, _), do: nil + def emoji_count(reactions_list) do - Enum.reduce(reactions_list, 0, fn [_, users], acc -> acc + length(users) end) + Enum.reduce(reactions_list, 0, fn [_, users, _], acc -> acc + length(users) end) end def remove_emoji_reaction_from_object( - %Activity{data: %{"content" => emoji, "actor" => actor}}, + %Activity{data: %{"content" => emoji, "actor" => actor}} = activity, object ) do + emoji = Pleroma.Emoji.maybe_strip_name(emoji) reactions = get_cached_emoji_reactions(object) + url = maybe_emoji_url(emoji, activity) new_reactions = - case Enum.find_index(reactions, fn [candidate, _] -> emoji == candidate end) do + case Enum.find_index(reactions, fn [candidate, _, candidate_url] -> + if is_nil(candidate_url) do + emoji == candidate + else + url == candidate_url + end + end) do nil -> reactions @@ -367,9 +399,9 @@ def remove_emoji_reaction_from_object( List.update_at( reactions, index, - fn [emoji, users] -> [emoji, List.delete(users, actor)] end + fn [emoji, users, url] -> [emoji, List.delete(users, actor), url] end ) - |> Enum.reject(fn [_, users] -> Enum.empty?(users) end) + |> Enum.reject(fn [_, users, _] -> Enum.empty?(users) end) end count = emoji_count(new_reactions) @@ -377,11 +409,7 @@ def remove_emoji_reaction_from_object( end def get_cached_emoji_reactions(object) do - if is_list(object.data["reactions"]) do - object.data["reactions"] - else - [] - end + Object.get_emoji_reactions(object) end @spec add_like_to_object(Activity.t(), Object.t()) :: @@ -489,17 +517,37 @@ def fetch_latest_undo(%User{ap_id: ap_id}) do def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do %{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id) + emoji = Pleroma.Emoji.maybe_quote(emoji) "EmojiReact" |> Activity.Queries.by_type() |> where(actor: ^ap_id) - |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji)) + |> custom_emoji_discriminator(emoji) |> Activity.Queries.by_object_id(object_ap_id) |> order_by([activity], fragment("? desc nulls last", activity.id)) |> limit(1) |> Repo.one() end + defp custom_emoji_discriminator(query, emoji) do + if String.contains?(emoji, "@") do + stripped = Pleroma.Emoji.maybe_strip_name(emoji) + [name, domain] = String.split(stripped, "@") + domain_pattern = "%/" <> domain <> "/%" + emoji_pattern = Pleroma.Emoji.maybe_quote(name) + + query + |> where([activity], fragment("?->>'content' = ? + AND EXISTS ( + SELECT FROM jsonb_array_elements(?->'tag') elem + WHERE elem->>'id' ILIKE ? + )", activity.data, ^emoji_pattern, activity.data, ^domain_pattern)) + else + query + |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji)) + end + end + #### Announce-related helpers @doc """ @@ -728,10 +776,9 @@ defp build_flag_object(act) when is_map(act) or is_binary(act) do build_flag_object(object) nil -> - if %Object{} = object = Object.get_by_ap_id(id) do - build_flag_object(object) - else - %{"id" => id, "deleted" => true} + case Object.get_by_ap_id(id) do + %Object{} = object -> build_flag_object(object) + _ -> %{"id" => id, "deleted" => true} end end end @@ -805,9 +852,11 @@ def strip_report_status_data(activity) do [actor | reported_activities] = activity.data["object"] stripped_activities = - Enum.map(reported_activities, fn - act when is_map(act) -> act["id"] - act when is_binary(act) -> act + Enum.reduce(reported_activities, [], fn act, acc -> + case ObjectID.cast(act) do + {:ok, act} -> [act | acc] + _ -> acc + end end) new_data = put_in(activity.data, ["object"], [actor | stripped_activities]) @@ -885,4 +934,27 @@ def get_existing_votes(actor, %{data: %{"id" => id}}) do |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data)) |> Repo.all() end + + def maybe_handle_group_posts(activity) do + poster = User.get_cached_by_ap_id(activity.actor) + + mentions = + activity.data["to"] + |> Enum.filter(&(&1 != activity.actor)) + + mentioned_local_groups = + User.get_all_by_ap_id(mentions) + |> Enum.filter(fn user -> + user.actor_type == "Group" and + user.local and + not User.blocks?(user, poster) + end) + + mentioned_local_groups + |> Enum.each(fn group -> + Pleroma.Web.CommonAPI.repeat(activity.id, group) + end) + + :ok + end end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index f69fca075..24ee683ae 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -46,6 +46,7 @@ def render("service.json", %{user: user}) do "following" => "#{user.ap_id}/following", "followers" => "#{user.ap_id}/followers", "inbox" => "#{user.ap_id}/inbox", + "outbox" => "#{user.ap_id}/outbox", "name" => "Pleroma", "summary" => "An internal service actor for this Pleroma instance. No user-serviceable parts inside.", diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 7c57f88f9..97fc7fa1b 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -11,28 +11,28 @@ defmodule Pleroma.Web.ActivityPub.Visibility do require Pleroma.Constants - @spec is_public?(Object.t() | Activity.t() | map()) :: boolean() - def is_public?(%Object{data: %{"type" => "Tombstone"}}), do: false - def is_public?(%Object{data: data}), do: is_public?(data) - def is_public?(%Activity{data: %{"type" => "Move"}}), do: true - def is_public?(%Activity{data: data}), do: is_public?(data) - def is_public?(%{"directMessage" => true}), do: false + @spec public?(Object.t() | Activity.t() | map()) :: boolean() + def public?(%Object{data: %{"type" => "Tombstone"}}), do: false + def public?(%Object{data: data}), do: public?(data) + def public?(%Activity{data: %{"type" => "Move"}}), do: true + def public?(%Activity{data: data}), do: public?(data) + def public?(%{"directMessage" => true}), do: false - def is_public?(data) do + def public?(data) do Utils.label_in_message?(Pleroma.Constants.as_public(), data) or Utils.label_in_message?(Utils.as_local_public(), data) end - def is_local_public?(%Object{data: data}), do: is_local_public?(data) - def is_local_public?(%Activity{data: data}), do: is_local_public?(data) + def local_public?(%Object{data: data}), do: local_public?(data) + def local_public?(%Activity{data: data}), do: local_public?(data) - def is_local_public?(data) do + def local_public?(data) do Utils.label_in_message?(Utils.as_local_public(), data) and not Utils.label_in_message?(Pleroma.Constants.as_public(), data) end - def is_private?(activity) do - with false <- is_public?(activity), + def private?(activity) do + with false <- public?(activity), %User{follower_address: follower_address} <- User.get_cached_by_ap_id(activity.data["actor"]) do follower_address in activity.data["to"] @@ -41,20 +41,20 @@ def is_private?(activity) do end end - def is_announceable?(activity, user, public \\ true) do - is_public?(activity) || - (!public && is_private?(activity) && activity.data["actor"] == user.ap_id) + def announceable?(activity, user, public \\ true) do + public?(activity) || + (!public && private?(activity) && activity.data["actor"] == user.ap_id) end - def is_direct?(%Activity{data: %{"directMessage" => true}}), do: true - def is_direct?(%Object{data: %{"directMessage" => true}}), do: true + def direct?(%Activity{data: %{"directMessage" => true}}), do: true + def direct?(%Object{data: %{"directMessage" => true}}), do: true - def is_direct?(activity) do - !is_public?(activity) && !is_private?(activity) + def direct?(activity) do + !public?(activity) && !private?(activity) end - def is_list?(%{data: %{"listMessage" => _}}), do: true - def is_list?(_), do: false + def list?(%{data: %{"listMessage" => _}}), do: true + def list?(_), do: false @spec visible_for_user?(Object.t() | Activity.t() | nil, User.t() | nil) :: boolean() def visible_for_user?(%Object{data: %{"type" => "Tombstone"}}, _), do: false @@ -77,7 +77,7 @@ def visible_for_user?(%{__struct__: module} = message, nil) when module in [Activity, Object] do if restrict_unauthenticated_access?(message), do: false, - else: is_public?(message) and not is_local_public?(message) + else: public?(message) and not local_public?(message) end def visible_for_user?(%{__struct__: module} = message, user) @@ -86,8 +86,8 @@ def visible_for_user?(%{__struct__: module} = message, user) y = [message.data["actor"]] ++ message.data["to"] ++ (message.data["cc"] || []) user_is_local = user.local - federatable = not is_local_public?(message) - (is_public?(message) || Enum.any?(x, &(&1 in y))) and (user_is_local || federatable) + federatable = not local_public?(message) + (public?(message) || Enum.any?(x, &(&1 in y))) and (user_is_local || federatable) end def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) do diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index a03318c0e..2c9c27294 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do alias Pleroma.ConfigDB alias Pleroma.Web.Plugs.OAuthScopesPlug - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action == :update) plug( @@ -76,7 +76,7 @@ def descriptions(conn, _params) do json(conn, translate_descriptions(descriptions)) end - def show(conn, %{only_db: true}) do + def show(%{private: %{open_api_spex: %{params: %{only_db: true}}}} = conn, _) do with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) @@ -128,7 +128,7 @@ def show(conn, _params) do end end - def update(%{body_params: %{configs: configs}} = conn, _) do + def update(%{private: %{open_api_spex: %{body_params: %{configs: configs}}}} = conn, _) do with :ok <- configurable_from_database() do results = configs diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex index b4dbb82fe..9e2ed4aac 100644 --- a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex @@ -18,13 +18,24 @@ defmodule Pleroma.Web.AdminAPI.FrontendController do def index(conn, _params) do installed = installed() + # FIrst get frontends from config, + # then add frontends that are installed but not in the config frontends = - [:frontends, :available] - |> Config.get([]) + Config.get([:frontends, :available], []) |> Enum.map(fn {name, desc} -> - Map.put(desc, "installed", name in installed) + desc + |> Map.put("installed", name in installed) + |> Map.put("installed_refs", installed_refs(name)) end) + frontends = + frontends ++ + (installed + |> Enum.filter(fn n -> not Enum.any?(frontends, fn f -> f["name"] == n end) end) + |> Enum.map(fn name -> + %{"name" => name, "installed" => true, "installed_refs" => installed_refs(name)} + end)) + render(conn, "index.json", frontends: frontends) end @@ -43,4 +54,12 @@ defp installed do [] end end + + def installed_refs(name) do + if name in installed() do + File.ls!(Path.join(Pleroma.Frontend.dir(), name)) + else + [] + end + end end diff --git a/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex b/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex index 990a94313..d76a95960 100644 --- a/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/instance_document_controller.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.AdminAPI.InstanceDocumentController do alias Pleroma.Web.Plugs.InstanceStatic alias Pleroma.Web.Plugs.OAuthScopesPlug - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) action_fallback(Pleroma.Web.AdminAPI.FallbackController) @@ -18,7 +18,7 @@ defmodule Pleroma.Web.AdminAPI.InstanceDocumentController do plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["admin:write"]} when action in [:update, :delete]) - def show(conn, %{name: document_name}) do + def show(%{private: %{open_api_spex: %{params: %{name: document_name}}}} = conn, _) do with {:ok, url} <- InstanceDocument.get(document_name), {:ok, content} <- File.read(InstanceStatic.file_path(url)) do conn @@ -27,13 +27,18 @@ def show(conn, %{name: document_name}) do end end - def update(%{body_params: %{file: file}} = conn, %{name: document_name}) do + def update( + %{ + private: %{open_api_spex: %{body_params: %{file: file}, params: %{name: document_name}}} + } = conn, + _ + ) do with {:ok, url} <- InstanceDocument.put(document_name, file.path) do json(conn, %{"url" => url}) end end - def delete(conn, %{name: document_name}) do + def delete(%{private: %{open_api_spex: %{params: %{name: document_name}}}} = conn, _) do with :ok <- InstanceDocument.delete(document_name) do json(conn, %{}) end diff --git a/lib/pleroma/web/admin_api/controllers/invite_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_controller.ex index c5d759bb5..7e3020f28 100644 --- a/lib/pleroma/web/admin_api/controllers/invite_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/invite_controller.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.AdminAPI.InviteController do require Logger - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(OAuthScopesPlug, %{scopes: ["admin:read:invites"]} when action == :index) plug( @@ -33,14 +33,14 @@ def index(conn, _params) do end @doc "Create an account registration invite token" - def create(%{body_params: params} = conn, _) do + def create(%{private: %{open_api_spex: %{body_params: params}}} = conn, _) do {:ok, invite} = UserInviteToken.create_invite(params) render(conn, "show.json", invite: invite) end @doc "Revokes invite by token" - def revoke(%{body_params: %{token: token}} = conn, _) do + def revoke(%{private: %{open_api_spex: %{body_params: %{token: token}}}} = conn, _) do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do render(conn, "show.json", invite: updated_invite) @@ -51,7 +51,13 @@ def revoke(%{body_params: %{token: token}} = conn, _) do end @doc "Sends registration invite via email" - def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do + def email( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: %{email: email} = params}} + } = conn, + _ + ) do with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index 4d53f5451..8b43ea90f 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do @cachex Pleroma.Config.get([:cachex, :provider], Cachex) - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug( OAuthScopesPlug, @@ -27,7 +27,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation - def index(%{assigns: %{user: _}} = conn, params) do + def index(%{assigns: %{user: _}, private: %{open_api_spex: %{params: params}}} = conn, _) do entries = fetch_entries(params) urls = paginate_entries(entries, params.page, params.page_size) @@ -59,12 +59,19 @@ defp paginate_entries(entries, page, page_size) do Enum.slice(entries, offset, page_size) end - def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + def delete( + %{assigns: %{user: _}, private: %{open_api_spex: %{body_params: %{urls: urls}}}} = conn, + _ + ) do MediaProxy.remove_from_banned_urls(urls) json(conn, %{}) end - def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _) do + def purge( + %{assigns: %{user: _}, private: %{open_api_spex: %{body_params: %{urls: urls, ban: ban}}}} = + conn, + _ + ) do MediaProxy.Invalidation.purge(urls) if ban do diff --git a/lib/pleroma/web/admin_api/controllers/relay_controller.ex b/lib/pleroma/web/admin_api/controllers/relay_controller.ex index 2e83fe139..1f36d3be5 100644 --- a/lib/pleroma/web/admin_api/controllers/relay_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/relay_controller.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.AdminAPI.RelayController do require Logger - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug( OAuthScopesPlug, @@ -31,7 +31,13 @@ def index(conn, _params) do end end - def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + def follow( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{relay_url: target}}} + } = conn, + _ + ) do with {:ok, _message} <- Relay.follow(target) do ModerationLog.insert_log(%{action: "relay_follow", actor: admin, target: target}) @@ -44,7 +50,13 @@ def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, end end - def unfollow(%{assigns: %{user: admin}, body_params: %{relay_url: target} = params} = conn, _) do + def unfollow( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{relay_url: target} = params}} + } = conn, + _ + ) do with {:ok, _message} <- Relay.unfollow(target, %{force: params[:force]}) do ModerationLog.insert_log(%{action: "relay_unfollow", actor: admin, target: target}) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 15cbbcc3e..89d8cc820 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do require Logger - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show]) plug( @@ -31,13 +31,13 @@ defmodule Pleroma.Web.AdminAPI.ReportController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ReportOperation - def index(conn, params) do + def index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do reports = Utils.get_reports(params, params.page, params.page_size) render(conn, "index.json", reports: reports) end - def show(conn, %{id: id}) do + def show(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Activity{} = report <- Activity.get_report(id) do render(conn, "show.json", Report.extract_report_info(report)) else @@ -45,7 +45,13 @@ def show(conn, %{id: id}) do end end - def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, _) do + def update( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{reports: reports}}} + } = conn, + _ + ) do result = Enum.map(reports, fn report -> case CommonAPI.update_report_state(report.id, report.state) do @@ -73,9 +79,13 @@ def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, end end - def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = conn, %{ - id: report_id - }) do + def notes_create( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: %{content: content}, params: %{id: report_id}}} + } = conn, + _ + ) do with {:ok, _} <- ReportNote.create(user.id, report_id, content), report <- Activity.get_by_id_with_user_actor(report_id) do ModerationLog.insert_log(%{ @@ -92,10 +102,20 @@ def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = c end end - def notes_delete(%{assigns: %{user: user}} = conn, %{ - id: note_id, - report_id: report_id - }) do + def notes_delete( + %{ + assigns: %{user: user}, + private: %{ + open_api_spex: %{ + params: %{ + id: note_id, + report_id: report_id + } + } + } + } = conn, + _ + ) do with {:ok, note} <- ReportNote.destroy(note_id), report <- Activity.get_by_id_with_user_actor(report_id) do ModerationLog.insert_log(%{ diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 7b4ee46a4..9ac275396 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do @users_page_size 50 - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug( OAuthScopesPlug, @@ -51,13 +51,22 @@ defmodule Pleroma.Web.AdminAPI.UserController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.UserOperation - def delete(conn, %{nickname: nickname}) do + def delete(%{private: %{open_api_spex: %{params: %{nickname: nickname}}}} = conn, _) do conn - |> Map.put(:body_params, %{nicknames: [nickname]}) - |> delete(%{}) + |> do_deletes([nickname]) end - def delete(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def delete( + %{ + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do + conn + |> do_deletes(nicknames) + end + + defp do_deletes(%{assigns: %{user: admin}} = conn, nicknames) when is_list(nicknames) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) Enum.each(users, fn user -> @@ -77,9 +86,13 @@ def delete(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = co def follow( %{ assigns: %{user: admin}, - body_params: %{ - follower: follower_nick, - followed: followed_nick + private: %{ + open_api_spex: %{ + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } } } = conn, _ @@ -102,9 +115,13 @@ def follow( def unfollow( %{ assigns: %{user: admin}, - body_params: %{ - follower: follower_nick, - followed: followed_nick + private: %{ + open_api_spex: %{ + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } } } = conn, _ @@ -124,7 +141,13 @@ def unfollow( json(conn, "ok") end - def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) do + def create( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{users: users}}} + } = conn, + _ + ) do changesets = users |> Enum.map(fn %{nickname: nickname, email: email, password: password} -> @@ -178,7 +201,13 @@ def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) d end end - def show(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do + def show( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{params: %{nickname: nickname}}} + } = conn, + _ + ) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do render(conn, "show.json", %{user: user}) else @@ -186,7 +215,11 @@ def show(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do end end - def toggle_activation(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do + def toggle_activation( + %{assigns: %{user: admin}, private: %{open_api_spex: %{params: %{nickname: nickname}}}} = + conn, + _ + ) do user = User.get_cached_by_nickname(nickname) {:ok, updated_user} = User.set_activation(user, !user.is_active) @@ -202,7 +235,13 @@ def toggle_activation(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) render(conn, "show.json", user: updated_user) end - def activate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def activate( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_activation(users, true) @@ -212,10 +251,16 @@ def activate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = action: "activate" }) - render(conn, "index.json", users: Keyword.values(updated_users)) + render(conn, "index.json", users: updated_users) end - def deactivate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def deactivate( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_activation(users, false) @@ -225,10 +270,16 @@ def deactivate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} action: "deactivate" }) - render(conn, "index.json", users: Keyword.values(updated_users)) + render(conn, "index.json", users: updated_users) end - def approve(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def approve( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.approve(users) @@ -241,7 +292,13 @@ def approve(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = c render(conn, "index.json", users: updated_users) end - def suggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def suggest( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_suggestion(users, true) @@ -254,7 +311,13 @@ def suggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = c render(conn, "index.json", users: updated_users) end - def unsuggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do + def unsuggest( + %{ + assigns: %{user: admin}, + private: %{open_api_spex: %{body_params: %{nicknames: nicknames}}} + } = conn, + _ + ) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_suggestion(users, false) @@ -267,7 +330,7 @@ def unsuggest(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = render(conn, "index.json", users: updated_users) end - def index(conn, params) do + def index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do {page, page_size} = page_params(params) filters = maybe_parse_filters(params[:filters]) diff --git a/lib/pleroma/web/admin_api/views/frontend_view.ex b/lib/pleroma/web/admin_api/views/frontend_view.ex index 0ca3d67cb..ae4016581 100644 --- a/lib/pleroma/web/admin_api/views/frontend_view.ex +++ b/lib/pleroma/web/admin_api/views/frontend_view.ex @@ -15,7 +15,8 @@ def render("show.json", %{frontend: frontend}) do git: frontend["git"], build_url: frontend["build_url"], ref: frontend["ref"], - installed: frontend["installed"] + installed: frontend["installed"], + installed_refs: frontend["installed_refs"] } end end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 2d56dc643..3588608f2 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -10,6 +10,14 @@ defmodule Pleroma.Web.ApiSpec do @behaviour OpenApi + defp streaming_paths do + %{ + "/api/v1/streaming" => %OpenApiSpex.PathItem{ + get: Pleroma.Web.ApiSpec.StreamingOperation.streaming_operation() + } + } + end + @impl OpenApi def spec(opts \\ []) do %OpenApi{ @@ -35,7 +43,7 @@ def spec(opts \\ []) do - [Mastodon API documentation](https://docs.joinmastodon.org/client/intro/) - [Differences in Mastodon API responses from vanilla Mastodon](https://docs-develop.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) - Please report such occurences on our [issue tracker](https://git.pleroma.social/pleroma/pleroma/-/issues). Feel free to submit API questions or proposals there too! + Please report such occurrences on our [issue tracker](https://git.pleroma.social/pleroma/pleroma/-/issues). Feel free to submit API questions or proposals there too! """, # Strip environment from the version version: Application.spec(:pleroma, :vsn) |> to_string() |> String.replace(~r/\+.*$/, ""), @@ -45,7 +53,7 @@ def spec(opts \\ []) do } }, # populate the paths from a phoenix router - paths: OpenApiSpex.Paths.from_router(Router), + paths: Map.merge(streaming_paths(), OpenApiSpex.Paths.from_router(Router)), components: %OpenApiSpex.Components{ parameters: %{ "accountIdOrNickname" => @@ -86,14 +94,14 @@ def spec(opts \\ []) do "tags" => [ "Chat administration", "Emoji pack administration", - "Frontend managment", + "Frontend management", "Instance configuration", "Instance documents", "Invites", "MediaProxy cache", - "OAuth application managment", + "OAuth application management", "Relays", - "Report managment", + "Report management", "Status administration", "User administration", "Announcement management" diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index add59eb88..f3e8e093e 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -27,10 +27,12 @@ def init(opts) do @impl Plug - def call(conn, %{operation_id: operation_id, render_error: render_error}) do + def call(conn, %{operation_id: operation_id, render_error: render_error} = opts) do {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) operation = operation_lookup[operation_id] + cast_opts = opts |> Map.take([:replace_params]) |> Map.to_list() + content_type = case Conn.get_req_header(conn, "content-type") do [header_value | _] -> @@ -44,7 +46,7 @@ def call(conn, %{operation_id: operation_id, render_error: render_error}) do conn = Conn.put_private(conn, :operation_id, operation_id) - case cast_and_validate(spec, operation, conn, content_type, strict?()) do + case cast_and_validate(spec, operation, conn, content_type, strict?(), cast_opts) do {:ok, conn} -> conn @@ -94,11 +96,11 @@ def call( def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts) - defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do - OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + defp cast_and_validate(spec, operation, conn, content_type, true = _strict, cast_opts) do + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) end - defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do + defp cast_and_validate(spec, operation, conn, content_type, false = _strict, cast_opts) do case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do {:ok, conn} -> {:ok, conn} @@ -123,7 +125,7 @@ defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do end) conn = %Conn{conn | query_params: query_params} - OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) end end diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index f20a9163d..7257253ba 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -62,7 +62,7 @@ def with_relationships_param do Operation.parameter( :with_relationships, :query, - BooleanLike, + BooleanLike.schema(), "Embed relationships into accounts. **If this parameter is not set account's `pleroma.relationship` is going to be `null`.**" ) end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 294590186..36025e47a 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -122,22 +122,27 @@ def statuses_operation do parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), + Operation.parameter( + :pinned, + :query, + BooleanLike.schema(), + "Include only pinned statuses" + ), Operation.parameter(:tagged, :query, :string, "With tag"), Operation.parameter( :only_media, :query, - BooleanLike, + BooleanLike.schema(), "Include only statuses with media attached" ), Operation.parameter( :with_muted, :query, - BooleanLike, + BooleanLike.schema(), "Include statuses from muted accounts." ), - Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), - Operation.parameter(:exclude_replies, :query, BooleanLike, "Exclude replies"), + Operation.parameter(:exclude_reblogs, :query, BooleanLike.schema(), "Exclude reblogs"), + Operation.parameter(:exclude_replies, :query, BooleanLike.schema(), "Exclude replies"), Operation.parameter( :exclude_visibilities, :query, @@ -147,7 +152,7 @@ def statuses_operation do Operation.parameter( :with_muted, :query, - BooleanLike, + BooleanLike.schema(), "Include reactions from muted accounts." ) ] ++ pagination_params(), @@ -347,7 +352,7 @@ def endorse_operation do summary: "Endorse", operationId: "AccountController.endorse", security: [%{"oAuth" => ["follow", "write:accounts"]}], - description: "Addds the given account to endorsed accounts list.", + description: "Adds the given account to endorsed accounts list.", parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ 200 => Operation.response("Relationship", "application/json", AccountRelationship), @@ -452,7 +457,7 @@ def blocks_operation do operationId: "AccountController.blocks", description: "View your blocks. See also accounts/:id/{block,unblock}", security: [%{"oAuth" => ["read:blocks"]}], - parameters: pagination_params(), + parameters: [with_relationships_param() | pagination_params()], responses: %{ 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex index 4bfe5ac5a..e17881b49 100644 --- a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -16,7 +16,7 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["Frontend managment"], + tags: ["Frontend management"], summary: "Retrieve a list of available frontends", operationId: "AdminAPI.FrontendController.index", security: [%{"oAuth" => ["admin:read"]}], @@ -29,7 +29,7 @@ def index_operation do def install_operation do %Operation{ - tags: ["Frontend managment"], + tags: ["Frontend management"], summary: "Install a frontend", operationId: "AdminAPI.FrontendController.install", security: [%{"oAuth" => ["admin:read"]}], @@ -51,8 +51,9 @@ defp list_of_frontends do name: %Schema{type: :string}, git: %Schema{type: :string, format: :uri, nullable: true}, build_url: %Schema{type: :string, format: :uri, nullable: true}, - ref: %Schema{type: :string}, - installed: %Schema{type: :boolean} + ref: %Schema{type: :string, nullable: true}, + installed: %Schema{type: :boolean}, + installed_refs: %Schema{type: :array, items: %Schema{type: :string}} } } } diff --git a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex index 1a05aff6a..2b2496c26 100644 --- a/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/o_auth_app_operation.ex @@ -17,7 +17,7 @@ def open_api_operation(action) do def index_operation do %Operation{ summary: "Retrieve a list of OAuth applications", - tags: ["OAuth application managment"], + tags: ["OAuth application management"], operationId: "AdminAPI.OAuthAppController.index", security: [%{"oAuth" => ["admin:write"]}], parameters: [ @@ -69,7 +69,7 @@ def index_operation do def create_operation do %Operation{ - tags: ["OAuth application managment"], + tags: ["OAuth application management"], summary: "Create an OAuth application", operationId: "AdminAPI.OAuthAppController.create", requestBody: request_body("Parameters", create_request()), @@ -84,7 +84,7 @@ def create_operation do def update_operation do %Operation{ - tags: ["OAuth application managment"], + tags: ["OAuth application management"], summary: "Update OAuth application", operationId: "AdminAPI.OAuthAppController.update", parameters: [id_param() | admin_api_params()], @@ -102,7 +102,7 @@ def update_operation do def delete_operation do %Operation{ - tags: ["OAuth application managment"], + tags: ["OAuth application management"], summary: "Delete OAuth application", operationId: "AdminAPI.OAuthAppController.delete", parameters: [id_param() | admin_api_params()], diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex index 312e091a5..fbb6896a9 100644 --- a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -19,7 +19,7 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["Report managment"], + tags: ["Report management"], summary: "Retrieve a list of reports", operationId: "AdminAPI.ReportController.index", security: [%{"oAuth" => ["admin:read:reports"]}], @@ -69,7 +69,7 @@ def index_operation do def show_operation do %Operation{ - tags: ["Report managment"], + tags: ["Report management"], summary: "Retrieve a report", operationId: "AdminAPI.ReportController.show", parameters: [id_param() | admin_api_params()], @@ -83,7 +83,7 @@ def show_operation do def update_operation do %Operation{ - tags: ["Report managment"], + tags: ["Report management"], summary: "Change state of specified reports", operationId: "AdminAPI.ReportController.update", security: [%{"oAuth" => ["admin:write:reports"]}], @@ -99,7 +99,7 @@ def update_operation do def notes_create_operation do %Operation{ - tags: ["Report managment"], + tags: ["Report management"], summary: "Add a note to the report", operationId: "AdminAPI.ReportController.notes_create", parameters: [id_param() | admin_api_params()], @@ -120,7 +120,7 @@ def notes_create_operation do def notes_delete_operation do %Operation{ - tags: ["Report managment"], + tags: ["Report management"], summary: "Delete note attached to the report", operationId: "AdminAPI.ReportController.notes_delete", parameters: [ @@ -141,7 +141,7 @@ def report_state do end def id_param do - Operation.parameter(:id, :path, FlakeID, "Report ID", + Operation.parameter(:id, :path, FlakeID.schema(), "Report ID", example: "9umDrYheeY451cQnEe", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index cf6a055fc..f56e57a41 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -137,7 +137,12 @@ def index_operation do "Deprecated due to no support for pagination. Using [/api/v2/pleroma/chats](#operation/ChatController.index2) instead is recommended.", operationId: "ChatController.index", parameters: [ - Operation.parameter(:with_muted, :query, BooleanLike, "Include chats from muted users") + Operation.parameter( + :with_muted, + :query, + BooleanLike.schema(), + "Include chats from muted users" + ) ], responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) @@ -156,7 +161,12 @@ def index2_operation do summary: "Retrieve list of chats", operationId: "ChatController.index2", parameters: [ - Operation.parameter(:with_muted, :query, BooleanLike, "Include chats from muted users") + Operation.parameter( + :with_muted, + :query, + BooleanLike.schema(), + "Include chats from muted users" + ) | pagination_params() ], responses: %{ diff --git a/lib/pleroma/web/api_spec/operations/directory_operation.ex b/lib/pleroma/web/api_spec/operations/directory_operation.ex index 23fa84dff..2eca17664 100644 --- a/lib/pleroma/web/api_spec/operations/directory_operation.ex +++ b/lib/pleroma/web/api_spec/operations/directory_operation.ex @@ -29,7 +29,7 @@ def index_operation do "Order by recent activity or account creation", required: nil ), - Operation.parameter(:local, :query, BooleanLike, "Include local users only") + Operation.parameter(:local, :query, BooleanLike.schema(), "Include local users only") ] ++ pagination_params(), responses: %{ 200 => diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex index 74341d64f..8d6be89a7 100644 --- a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -21,7 +21,7 @@ def index_operation do summary: "Get an object of emoji to account mappings with accounts that reacted to the post", parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", required: true), Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", required: nil ), @@ -45,7 +45,7 @@ def create_operation do tags: ["Emoji reactions"], summary: "React to a post with a unicode emoji", parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", required: true), Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", required: true ) @@ -64,7 +64,7 @@ def delete_operation do tags: ["Emoji reactions"], summary: "Remove a reaction to a post with a unicode emoji", parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", required: true), Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index a07be7e40..708b74b12 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -23,6 +23,18 @@ def show_operation do } end + def show2_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve instance information", + description: "Information about the server", + operationId: "InstanceController.show2", + responses: %{ + 200 => Operation.response("Instance", "application/json", instance2()) + } + } + end + def peers_operation do %Operation{ tags: ["Instance misc"], @@ -89,7 +101,7 @@ defp instance do languages: %Schema{ type: :array, items: %Schema{type: :string}, - description: "Primary langauges of the website and its staff" + description: "Primary languages of the website and its staff" }, registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"}, # Extra (not present in Mastodon): @@ -165,6 +177,166 @@ defp instance do } end + defp instance2 do + %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string, description: "The domain name of the instance"}, + title: %Schema{type: :string, description: "The title of the website"}, + version: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + source_url: %Schema{ + type: :string, + description: "The version of Pleroma installed on the instance" + }, + description: %Schema{ + type: :string, + description: "Admin-defined description of the Pleroma site" + }, + usage: %Schema{ + type: :object, + description: "Instance usage statistics", + properties: %{ + users: %Schema{ + type: :object, + description: "User count statistics", + properties: %{ + active_month: %Schema{ + type: :integer, + description: "Monthly active users" + } + } + } + } + }, + email: %Schema{ + type: :string, + description: "An email that may be contacted for any inquiries", + format: :email + }, + urls: %Schema{ + type: :object, + description: "URLs of interest for clients apps", + properties: %{} + }, + stats: %Schema{ + type: :object, + description: "Statistics about how much information the instance contains", + properties: %{ + user_count: %Schema{ + type: :integer, + description: "Users registered on this instance" + }, + status_count: %Schema{ + type: :integer, + description: "Statuses authored by users on instance" + }, + domain_count: %Schema{ + type: :integer, + description: "Domains federated with this instance" + } + } + }, + thumbnail: %Schema{ + type: :object, + properties: %{ + url: %Schema{ + type: :string, + description: "Banner image for the website", + nullable: true + } + } + }, + languages: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Primary languages of the website and its staff" + }, + registrations: %Schema{ + type: :object, + description: "Registrations-related configuration", + properties: %{ + enabled: %Schema{ + type: :boolean, + description: "Whether registrations are enabled" + }, + approval_required: %Schema{ + type: :boolean, + description: "Whether users need to be manually approved by admin" + } + } + }, + configuration: %Schema{ + type: :object, + description: "Instance configuration", + properties: %{ + urls: %Schema{ + type: :object, + properties: %{ + streaming: %Schema{ + type: :string, + description: "Websockets address for push streaming" + } + } + }, + statuses: %Schema{ + type: :object, + description: "A map with poll limits for local statuses", + properties: %{ + max_characters: %Schema{ + type: :integer, + description: "Posts character limit (CW/Subject included in the counter)" + }, + max_media_attachments: %Schema{ + type: :integer, + description: "Media attachment limit" + } + } + }, + media_attachments: %Schema{ + type: :object, + description: "A map with poll limits for media attachments", + properties: %{ + image_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded images" + }, + video_size_limit: %Schema{ + type: :integer, + description: "File size limit of uploaded videos" + } + } + }, + polls: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_characters_per_option: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + } + } + } + } + } + end + defp array_of_domains do %Schema{ type: :array, diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index d7e7ae24e..a79eb8f74 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -62,7 +62,7 @@ def index_operation do Operation.parameter( :with_muted, :query, - BooleanLike, + BooleanLike.schema(), "Include the notifications from muted users" ) ] ++ pagination_params(), diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index 5375c5b15..7340653fb 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -142,7 +142,7 @@ def birthdays_operation do end defp id_param do - Operation.parameter(:id, :path, FlakeID, "Account ID", + Operation.parameter(:id, :path, FlakeID.schema(), "Account ID", example: "9umDrYheeY451cQnEe", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex index 45fa2b058..400f3825d 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex @@ -64,7 +64,13 @@ defp backup do content_type: %Schema{type: :string}, file_name: %Schema{type: :string}, file_size: %Schema{type: :integer}, - processed: %Schema{type: :boolean} + processed: %Schema{type: :boolean, description: "whether this backup has succeeded"}, + state: %Schema{ + type: :string, + description: "the state of the backup", + enum: ["pending", "running", "complete", "failed"] + }, + processed_number: %Schema{type: :integer, description: "the number of records processed"} }, example: %{ "content_type" => "application/zip", @@ -72,7 +78,9 @@ defp backup do "https://cofe.fe:4000/media/backups/archive-foobar-20200908T164207-Yr7vuT5Wycv-sN3kSN2iJ0k-9pMo60j9qmvRCdDqIew.zip", "file_size" => 4105, "inserted_at" => "2020-09-08T16:42:07.000Z", - "processed" => true + "processed" => true, + "state" => "complete", + "processed_number" => 20 } } end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex index b6273bfcf..f595583b6 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -22,7 +22,8 @@ def create_operation do summary: "Creates a new Listen activity for an account", security: [%{"oAuth" => ["write"]}], operationId: "PleromaAPI.ScrobbleController.create", - requestBody: request_body("Parameters", create_request(), requried: true), + deprecated: true, + requestBody: request_body("Parameters", create_request(), required: true), responses: %{ 200 => Operation.response("Scrobble", "application/json", scrobble()) } @@ -34,6 +35,7 @@ def index_operation do tags: ["Scrobbles"], summary: "Requests a list of current and recent Listen activities for an account", operationId: "PleromaAPI.ScrobbleController.index", + deprecated: true, parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() ], @@ -57,6 +59,7 @@ defp create_request do album: %Schema{type: :string, description: "The album of the media playing"}, artist: %Schema{type: :string, description: "The artist of the media playing"}, length: %Schema{type: :integer, description: "The length of the media playing"}, + externalLink: %Schema{type: :string, description: "A URL referencing the media playing"}, visibility: %Schema{ allOf: [VisibilityScope], default: "public", @@ -67,7 +70,8 @@ defp create_request do "title" => "Some Title", "artist" => "Some Artist", "album" => "Some Album", - "length" => 180_000 + "length" => 180_000, + "externalLink" => "https://www.last.fm/music/Some+Artist/_/Some+Title" } } end @@ -81,6 +85,7 @@ defp scrobble do title: %Schema{type: :string, description: "The title of the media playing"}, album: %Schema{type: :string, description: "The album of the media playing"}, artist: %Schema{type: :string, description: "The artist of the media playing"}, + externalLink: %Schema{type: :string, description: "A URL referencing the media playing"}, length: %Schema{ type: :integer, description: "The length of the media playing", @@ -95,6 +100,7 @@ defp scrobble do "artist" => "Some Artist", "album" => "Some Album", "length" => 180_000, + "externalLink" => "https://www.last.fm/music/Some+Artist/_/Some+Title", "created_at" => "2019-09-28T12:40:45.000Z" } } diff --git a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex new file mode 100644 index 000000000..77c604952 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaStatusOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def quotes_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Quoted by", + description: "View quotes for a given status", + operationId: "PleromaAPI.StatusController.quotes", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response( + "Array of Status", + "application/json", + StatusOperation.array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def id_param do + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex index efd784f03..6dd251743 100644 --- a/lib/pleroma/web/api_spec/operations/poll_operation.ex +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -47,7 +47,7 @@ def vote_operation do end defp id_param do - Operation.parameter(:id, :path, FlakeID, "Poll ID", + Operation.parameter(:id, :path, FlakeID.schema(), "Poll ID", example: "123", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex index 802d3b6dd..c7ed02ff3 100644 --- a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex +++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex @@ -88,7 +88,7 @@ def delete_operation do end defp id_param do - Operation.parameter(:id, :path, FlakeID, "Poll ID", + Operation.parameter(:id, :path, FlakeID.schema(), "Poll ID", example: "123", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index 1a7e49be4..539743ba3 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -70,7 +70,7 @@ def search_operation do Operation.parameter( :account_id, :query, - FlakeID, + FlakeID.schema(), "If provided, statuses returned will be authored only by this account" ), Operation.parameter( @@ -116,7 +116,7 @@ def search2_operation do Operation.parameter( :account_id, :query, - FlakeID, + FlakeID.schema(), "If provided, statuses returned will be authored only by this account" ), Operation.parameter( diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 5d6e82f3c..ef4e34044 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -39,7 +39,7 @@ def index_operation do Operation.parameter( :with_muted, :query, - BooleanLike, + BooleanLike.schema(), "Include reactions from muted acccounts." ) ], @@ -82,7 +82,7 @@ def show_operation do Operation.parameter( :with_muted, :query, - BooleanLike, + BooleanLike.schema(), "Include reactions from muted acccounts." ) ], @@ -534,7 +534,7 @@ defp create_request do format: :"date-time", nullable: true, description: - "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." + "ISO 8601 Datetime at which to schedule a status. Providing this parameter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." }, language: %Schema{ type: :string, @@ -546,7 +546,7 @@ defp create_request do allOf: [BooleanLike], nullable: true, description: - "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" + "If set to `true` the post won't be actually posted, but the status entity would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" }, content_type: %Schema{ type: :string, @@ -581,6 +581,11 @@ defp create_request do type: :string, description: "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + }, + quote_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being quoted, if any" } }, example: %{ @@ -680,7 +685,7 @@ def poll_params do end def id_param do - Operation.parameter(:id, :path, FlakeID, "Status ID", + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", example: "9umDrYheeY451cQnEe", required: true ) diff --git a/lib/pleroma/web/api_spec/operations/streaming_operation.ex b/lib/pleroma/web/api_spec/operations/streaming_operation.ex new file mode 100644 index 000000000..b580bc2f0 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/streaming_operation.ex @@ -0,0 +1,464 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.StreamingOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Response + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.NotificationOperation + alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + require Pleroma.Constants + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + @spec streaming_operation() :: Operation.t() + def streaming_operation do + %Operation{ + tags: ["Timelines"], + summary: "Establish streaming connection", + description: """ + Receive statuses in real-time via WebSocket. + + You can specify the access token on the query string or through the `sec-websocket-protocol` header. Using + the query string to authenticate is considered unsafe and should not be used unless you have to (e.g. to maintain + your client's compatibility with Mastodon). + + You may specify a stream on the query string. If you do so and you are connecting to a stream that requires logged-in users, + you must specify the access token at the time of the connection (i.e. via query string or header). + + Otherwise, you have the option to authenticate after you have established the connection through client-sent events. + + The "Request body" section below describes what events clients can send through WebSocket, and the "Responses" section + describes what events server will send through WebSocket. + """, + security: [%{"oAuth" => ["read:statuses", "read:notifications"]}], + operationId: "WebsocketHandler.streaming", + parameters: + [ + Operation.parameter(:connection, :header, %Schema{type: :string}, "connection header", + required: true + ), + Operation.parameter(:upgrade, :header, %Schema{type: :string}, "upgrade header", + required: true + ), + Operation.parameter( + :"sec-websocket-key", + :header, + %Schema{type: :string}, + "sec-websocket-key header", + required: true + ), + Operation.parameter( + :"sec-websocket-version", + :header, + %Schema{type: :string}, + "sec-websocket-version header", + required: true + ) + ] ++ stream_params() ++ access_token_params(), + requestBody: request_body("Client-sent events", client_sent_events()), + responses: %{ + 101 => switching_protocols_response(), + 200 => + Operation.response( + "Server-sent events", + "application/json", + server_sent_events() + ) + } + } + end + + defp stream_params do + stream_specifier() + |> Enum.map(fn {name, schema} -> + Operation.parameter(name, :query, schema, get_schema(schema).description) + end) + end + + defp access_token_params do + [ + Operation.parameter(:access_token, :query, token(), token().description), + Operation.parameter(:"sec-websocket-protocol", :header, token(), token().description) + ] + end + + defp switching_protocols_response do + %Response{ + description: "Switching protocols", + headers: %{ + "connection" => %OpenApiSpex.Header{required: true}, + "upgrade" => %OpenApiSpex.Header{required: true}, + "sec-websocket-accept" => %OpenApiSpex.Header{required: true} + } + } + end + + defp server_sent_events do + %Schema{ + oneOf: [ + update_event(), + status_update_event(), + notification_event(), + chat_update_event(), + follow_relationships_update_event(), + conversation_event(), + delete_event(), + pleroma_respond_event() + ] + } + end + + defp stream do + %Schema{ + type: :array, + title: "Stream", + description: """ + The stream identifier. + The first item is the name of the stream. If the stream needs a differentiator, the second item will be the corresponding identifier. + Currently, for the following stream types, there is a second element in the array: + + - `list`: The second element is the id of the list, as a string. + - `hashtag`: The second element is the name of the hashtag. + - `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance. + """, + maxItems: 2, + minItems: 1, + items: %Schema{type: :string}, + example: ["hashtag", "mew"] + } + end + + defp get_schema(%Schema{} = schema), do: schema + defp get_schema(schema), do: schema.schema + + defp server_sent_event_helper(name, description, type, payload, opts \\ []) do + payload_type = Keyword.get(opts, :payload_type, :json) + has_stream = Keyword.get(opts, :has_stream, true) + + stream_properties = + if has_stream do + %{stream: stream()} + else + %{} + end + + stream_example = if has_stream, do: %{"stream" => get_schema(stream()).example}, else: %{} + + stream_required = if has_stream, do: [:stream], else: [] + + payload_schema = + if payload_type == :json do + %Schema{ + title: "Event payload", + description: "JSON-encoded string of #{get_schema(payload).title}", + allOf: [payload] + } + else + payload + end + + payload_example = + if payload_type == :json do + get_schema(payload).example |> Jason.encode!() + else + get_schema(payload).example + end + + %Schema{ + type: :object, + title: name, + description: description, + required: [:event, :payload] ++ stream_required, + properties: + %{ + event: %Schema{ + title: "Event type", + description: "Type of the event.", + type: :string, + required: true, + enum: [type] + }, + payload: payload_schema + } + |> Map.merge(stream_properties), + example: + %{ + "event" => type, + "payload" => payload_example + } + |> Map.merge(stream_example) + } + end + + defp update_event do + server_sent_event_helper("New status", "A newly-posted status.", "update", Status) + end + + defp status_update_event do + server_sent_event_helper("Edit", "A status that was just edited", "status.update", Status) + end + + defp notification_event do + server_sent_event_helper( + "Notification", + "A new notification.", + "notification", + NotificationOperation.notification() + ) + end + + defp follow_relationships_update_event do + server_sent_event_helper( + "Follow relationships update", + "An update to follow relationships.", + "pleroma:follow_relationships_update", + %Schema{ + type: :object, + title: "Follow relationships update", + required: [:state, :follower, :following], + properties: %{ + state: %Schema{ + type: :string, + description: "Follow state of the relationship.", + enum: ["follow_pending", "follow_accept", "follow_reject", "unfollow"] + }, + follower: %Schema{ + type: :object, + description: "Information about the follower.", + required: [:id, :follower_count, :following_count], + properties: %{ + id: FlakeID, + follower_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer} + } + }, + following: %Schema{ + type: :object, + description: "Information about the following person.", + required: [:id, :follower_count, :following_count], + properties: %{ + id: FlakeID, + follower_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer} + } + } + }, + example: %{ + "state" => "follow_pending", + "follower" => %{ + "id" => "someUser1", + "follower_count" => 1, + "following_count" => 1 + }, + "following" => %{ + "id" => "someUser2", + "follower_count" => 1, + "following_count" => 1 + } + } + } + ) + end + + defp chat_update_event do + server_sent_event_helper( + "Chat update", + "A new chat message.", + "pleroma:chat_update", + Chat + ) + end + + defp conversation_event do + server_sent_event_helper( + "Conversation update", + "An update about a conversation", + "conversation", + Conversation + ) + end + + defp delete_event do + server_sent_event_helper( + "Delete", + "A status that was just deleted.", + "delete", + %Schema{ + type: :string, + title: "Status id", + description: "Id of the deleted status", + allOf: [FlakeID], + example: "some-opaque-id" + }, + payload_type: :string, + has_stream: false + ) + end + + defp pleroma_respond_event do + server_sent_event_helper( + "Server response", + "A response to a client-sent event.", + "pleroma:respond", + %Schema{ + type: :object, + title: "Results", + required: [:result, :type], + properties: %{ + result: %Schema{ + type: :string, + title: "Result of the request", + enum: ["success", "error", "ignored"] + }, + error: %Schema{ + type: :string, + title: "Error code", + description: "An error identifier. Only appears if `result` is `error`." + }, + type: %Schema{ + type: :string, + description: "Type of the request." + } + }, + example: %{"result" => "success", "type" => "pleroma:authenticate"} + }, + has_stream: false + ) + end + + defp client_sent_events do + %Schema{ + oneOf: [ + subscribe_event(), + unsubscribe_event(), + authenticate_event() + ] + } + end + + defp request_body(description, schema, opts \\ []) do + %OpenApiSpex.RequestBody{ + description: description, + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: schema, + example: opts[:example], + examples: opts[:examples] + } + } + } + end + + defp client_sent_event_helper(name, description, type, properties, opts) do + required = opts[:required] || [] + + %Schema{ + type: :object, + title: name, + required: [:type] ++ required, + description: description, + properties: + %{ + type: %Schema{type: :string, enum: [type], description: "Type of the event."} + } + |> Map.merge(properties), + example: opts[:example] + } + end + + defp subscribe_event do + client_sent_event_helper( + "Subscribe", + "Subscribe to a stream.", + "subscribe", + stream_specifier(), + required: [:stream], + example: %{"type" => "subscribe", "stream" => "list", "list" => "1"} + ) + end + + defp unsubscribe_event do + client_sent_event_helper( + "Unsubscribe", + "Unsubscribe from a stream.", + "unsubscribe", + stream_specifier(), + required: [:stream], + example: %{ + "type" => "unsubscribe", + "stream" => "public:remote:media", + "instance" => "example.org" + } + ) + end + + defp authenticate_event do + client_sent_event_helper( + "Authenticate", + "Authenticate via an access token.", + "pleroma:authenticate", + %{ + token: token() + }, + required: [:token] + ) + end + + defp token do + %Schema{ + type: :string, + description: "An OAuth access token with corresponding permissions.", + example: "some token" + } + end + + defp stream_specifier do + %{ + stream: %Schema{ + type: :string, + description: "The name of the stream.", + enum: + Pleroma.Constants.public_streams() ++ + [ + "public:remote", + "public:remote:media", + "user", + "user:pleroma_chat", + "user:notification", + "direct", + "list", + "hashtag" + ] + }, + list: %Schema{ + type: :string, + title: "List id", + description: "The id of the list. Required when `stream` is `list`.", + example: "some-id" + }, + tag: %Schema{ + type: :string, + title: "Hashtag name", + description: "The name of the hashtag. Required when `stream` is `hashtag`.", + example: "mew" + }, + instance: %Schema{ + type: :string, + title: "Domain name", + description: + "Domain name of the instance. Required when `stream` is `public:remote` or `public:remote:media`.", + example: "example.org" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index fbe3f763a..f55e59805 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -176,7 +176,12 @@ defp instance_param do end defp with_muted_param do - Operation.parameter(:with_muted, :query, BooleanLike, "Include activities by muted users") + Operation.parameter( + :with_muted, + :query, + BooleanLike.schema(), + "Include activities by muted users" + ) end defp exclude_visibilities_param do diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index 084329ad7..724d873c0 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -87,7 +87,7 @@ def change_password_operation do defp change_password_request do %Schema{ title: "ChangePasswordRequest", - description: "POST body for changing the account's passowrd", + description: "POST body for changing the account's password", type: :object, required: [:password, :new_password, :new_password_confirmation], properties: %{ @@ -136,23 +136,23 @@ defp change_email_request do } end - def update_notificaton_settings_operation do + def update_notification_settings_operation do %Operation{ tags: ["Settings"], summary: "Update Notification Settings", security: [%{"oAuth" => ["write:accounts"]}], - operationId: "UtilController.update_notificaton_settings", + operationId: "UtilController.update_notification_settings", parameters: [ Operation.parameter( :block_from_strangers, :query, - BooleanLike, + BooleanLike.schema(), "blocks notifications from accounts you do not follow" ), Operation.parameter( :hide_notification_contents, :query, - BooleanLike, + BooleanLike.schema(), "removes the contents of a message from the push notification" ) ], diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex index 48634a14f..2871b5f99 100644 --- a/lib/pleroma/web/api_spec/schemas/attachment.ex +++ b/lib/pleroma/web/api_spec/schemas/attachment.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do title: "Attachment", description: "Represents a file or media attachment that can be added to a status.", type: :object, - requried: [:id, :url, :preview_url], + required: [:id, :url, :preview_url], properties: %{ id: %Schema{type: :string, description: "The ID of the attachment in the database."}, url: %Schema{ diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index bc29cf4a6..a4052803b 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -193,6 +193,30 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do nullable: true, description: "The `acct` property of User entity for replied user (if any)" }, + quote: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "Quoted status (if any)" + }, + quote_id: %Schema{ + nullable: true, + allOf: [FlakeID], + description: "ID of the status being quoted, if any" + }, + quote_url: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "URL of the quoted status" + }, + quote_visible: %Schema{ + type: :boolean, + description: "`true` if the quoted post is visible to the user" + }, + quotes_count: %Schema{ + type: :integer, + description: "How many statuses quoted this status" + }, local: %Schema{ type: :boolean, description: "`true` if the post was made on the local instance" @@ -347,7 +371,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do "in_reply_to_account_acct" => nil, "local" => true, "spoiler_text" => %{"text/plain" => ""}, - "thread_muted" => false + "thread_muted" => false, + "quotes_count" => 0 }, "poll" => nil, "reblog" => nil, diff --git a/lib/pleroma/web/api_spec/scopes/compiler.ex b/lib/pleroma/web/api_spec/scopes/compiler.ex new file mode 100644 index 000000000..162edc9a3 --- /dev/null +++ b/lib/pleroma/web/api_spec/scopes/compiler.ex @@ -0,0 +1,82 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Scopes.Compiler do + defmacro __before_compile__(_env) do + strings = __MODULE__.extract_all_scopes() + + quote do + def placeholder do + unquote do + Enum.map( + strings, + fn string -> + quote do + Pleroma.Web.Gettext.dgettext_noop( + "oauth_scopes", + unquote(string) + ) + end + end + ) + end + end + end + end + + def extract_all_scopes do + extract_all_scopes_from(Pleroma.Web.ApiSpec.spec()) + end + + def extract_all_scopes_from(specs) do + specs.paths + |> Enum.reduce([], fn + {_path, %{} = path_item}, acc -> + extract_routes(path_item) + |> Enum.flat_map(fn operation -> process_operation(operation) end) + |> Kernel.++(acc) + + {_, _}, acc -> + acc + end) + |> Enum.uniq() + end + + defp extract_routes(path_item) do + path_item + |> Map.from_struct() + |> Enum.map(fn {_method, path_item} -> path_item end) + |> Enum.filter(fn + %OpenApiSpex.Operation{} = _operation -> true + _ -> false + end) + end + + defp process_operation(operation) do + operation.security + |> Kernel.||([]) + |> Enum.flat_map(fn + %{"oAuth" => scopes} -> process_scopes(scopes) + _ -> [] + end) + end + + defp process_scopes(scopes) do + scopes + |> Enum.flat_map(fn scope -> + process_scope(scope) + end) + end + + def process_scope(scope) do + hierarchy = String.split(scope, ":") + + {_, list} = + Enum.reduce(hierarchy, {"", []}, fn comp, {cur, list} -> + {cur <> comp <> ":", [cur <> comp | list]} + end) + + list + end +end diff --git a/lib/pleroma/web/api_spec/scopes/translator.ex b/lib/pleroma/web/api_spec/scopes/translator.ex new file mode 100644 index 000000000..54eea3593 --- /dev/null +++ b/lib/pleroma/web/api_spec/scopes/translator.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Scopes.Translator do + require Pleroma.Web.ApiSpec.Scopes.Compiler + require Pleroma.Web.Gettext + + @before_compile Pleroma.Web.ApiSpec.Scopes.Compiler +end diff --git a/lib/pleroma/web/auth/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex index a0bd154db..01bf1575c 100644 --- a/lib/pleroma/web/auth/authenticator.ex +++ b/lib/pleroma/web/auth/authenticator.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.Auth.Authenticator do @callback get_user(Plug.Conn.t()) :: {:ok, user :: struct()} | {:error, any()} @callback create_from_registration(Plug.Conn.t(), registration :: struct()) :: - {:ok, User.t()} | {:error, any()} + {:ok, Pleroma.User.t()} | {:error, any()} @callback get_registration(Plug.Conn.t()) :: {:ok, registration :: struct()} | {:error, any()} @callback handle_error(Plug.Conn.t(), any()) :: any() @callback auth_template() :: String.t() | nil diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 89cc0d6fe..27e82ecc8 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -33,6 +33,7 @@ def block(blocker, blocked) do def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + :ok <- validate_chat_attachment_attribution(maybe_attachment, user), :ok <- validate_chat_content_length(content, !!maybe_attachment), {_, {:ok, chat_message_data, _meta}} <- {:build_object, @@ -71,6 +72,17 @@ defp format_chat_content(content) do text end + defp validate_chat_attachment_attribution(nil, _), do: :ok + + defp validate_chat_attachment_attribution(attachment, user) do + with :ok <- Object.authorize_access(attachment, user) do + :ok + else + e -> + e + end + end + defp validate_chat_content_length(_, true), do: :ok defp validate_chat_content_length(nil, false), do: {:error, :no_content} @@ -142,7 +154,7 @@ def reject_follow_request(follower, followed) do def delete(activity_id, user) do with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <- - {:find_activity, Activity.get_by_id(activity_id)}, + {:find_activity, Activity.get_by_id(activity_id, filter: [])}, {_, %Object{} = object, _} <- {:find_object, Object.normalize(activity, fetch: false), activity}, true <- User.privileged?(user, :messages_delete) || user.ap_id == object.data["actor"], @@ -360,7 +372,7 @@ def public_announce?(_, %{visibility: visibility}) do: visibility in ~w(public unlisted) def public_announce?(object, _) do - Visibility.is_public?(object) + Visibility.public?(object) end def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} @@ -488,12 +500,12 @@ defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do end defp activity_is_public(activity) do - with false <- Visibility.is_public?(activity) do + with false <- Visibility.public?(activity) do {:error, :visibility_error} end end - @spec unpin(String.t(), User.t()) :: {:ok, User.t()} | {:error, term()} + @spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} def unpin(id, user) do with %Activity{} = activity <- create_activity_by_id(id), {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), @@ -538,7 +550,7 @@ def remove_mute(user_id, activity_id) do remove_mute(user, activity) else {what, result} = error -> - Logger.warn( + Logger.warning( "CommonAPI.remove_mute/2 failed. #{what}: #{result}, user_id: #{user_id}, activity_id: #{activity_id}" ) @@ -583,7 +595,7 @@ def update_report_state(activity_ids, state) when is_list(activity_ids) do end def update_report_state(activity_id, state) do - with %Activity{} = activity <- Activity.get_by_id(activity_id) do + with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do Utils.update_report_state(activity, state) else nil -> {:error, :not_found} diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 9af635da8..bc46a8a36 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -7,10 +7,14 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do alias Pleroma.Conversation.Participation alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils import Pleroma.Web.Gettext + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + @type t :: %__MODULE__{} defstruct valid?: true, errors: [], @@ -22,6 +26,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do attachments: [], in_reply_to: nil, in_reply_to_conversation: nil, + quote_post: nil, visibility: nil, expires_at: nil, extra: nil, @@ -53,7 +58,9 @@ def create(user, params) do |> poll() |> with_valid(&in_reply_to/1) |> with_valid(&in_reply_to_conversation/1) + |> with_valid("e_post/1) |> with_valid(&visibility/1) + |> with_valid("ing_visibility/1) |> content() |> with_valid(&to_and_cc/1) |> with_valid(&context/1) @@ -78,7 +85,7 @@ def listen(user, params) do defp listen_object(draft) do object = draft.params - |> Map.take([:album, :artist, :title, :length]) + |> Map.take([:album, :artist, :title, :length, :externalLink]) |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Audio") |> Map.put("to", draft.to) @@ -111,7 +118,7 @@ defp full_payload(%{status: status, summary: summary} = draft) do end defp attachments(%{params: params} = draft) do - attachments = Utils.attachments_from_ids(params) + attachments = Utils.attachments_from_ids(params, draft.user) draft = %__MODULE__{draft | attachments: attachments} case Utils.validate_attachments_count(attachments) do @@ -132,6 +139,18 @@ defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} defp in_reply_to(draft), do: draft + defp quote_post(%{params: %{quote_id: id}} = draft) when not_empty_string(id) do + case Activity.get_by_id_with_object(id) do + %Activity{} = activity -> + %__MODULE__{draft | quote_post: activity} + + _ -> + draft + end + end + + defp quote_post(draft), do: draft + defp in_reply_to_conversation(draft) do in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} @@ -147,6 +166,29 @@ defp visibility(%{params: params} = draft) do end end + defp can_quote?(_draft, _object, visibility) when visibility in ~w(public unlisted local) do + true + end + + defp can_quote?(draft, object, "private") do + draft.user.ap_id == object.data["actor"] + end + + defp can_quote?(_, _, _) do + false + end + + defp quoting_visibility(%{quote_post: %Activity{}} = draft) do + with %Object{} = object <- Object.normalize(draft.quote_post, fetch: false), + true <- can_quote?(draft, object, Visibility.get_visibility(object)) do + draft + else + _ -> add_error(draft, dgettext("errors", "Cannot quote private message")) + end + end + + defp quoting_visibility(draft), do: draft + defp expires_at(draft) do case CommonAPI.check_expiry_date(draft.params[:expires_in]) do {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} @@ -164,12 +206,15 @@ defp poll(draft) do end end - defp content(draft) do + defp content(%{mentions: mentions} = draft) do {content_html, mentioned_users, tags} = Utils.make_content_html(draft) + mentioned_ap_ids = + Enum.map(mentioned_users, fn {_, mentioned_user} -> mentioned_user.ap_id end) + mentions = - mentioned_users - |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) + mentions + |> Kernel.++(mentioned_ap_ids) |> Utils.get_addressed_users(draft.params[:to]) %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index ff0814329..52c08f00f 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -23,21 +23,21 @@ defmodule Pleroma.Web.CommonAPI.Utils do require Logger require Pleroma.Constants - def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do - attachments_from_ids_descs(ids, desc) + def attachments_from_ids(%{media_ids: ids, descriptions: desc}, user) do + attachments_from_ids_descs(ids, desc, user) end - def attachments_from_ids(%{media_ids: ids}) do - attachments_from_ids_no_descs(ids) + def attachments_from_ids(%{media_ids: ids}, user) do + attachments_from_ids_no_descs(ids, user) end - def attachments_from_ids(_), do: [] + def attachments_from_ids(_, _), do: [] - def attachments_from_ids_no_descs([]), do: [] + def attachments_from_ids_no_descs([], _), do: [] - def attachments_from_ids_no_descs(ids) do + def attachments_from_ids_no_descs(ids, user) do Enum.map(ids, fn media_id -> - case get_attachment(media_id) do + case get_attachment(media_id, user) do %Object{data: data} -> data _ -> nil end @@ -45,21 +45,27 @@ def attachments_from_ids_no_descs(ids) do |> Enum.reject(&is_nil/1) end - def attachments_from_ids_descs([], _), do: [] + def attachments_from_ids_descs([], _, _), do: [] - def attachments_from_ids_descs(ids, descs_str) do + def attachments_from_ids_descs(ids, descs_str, user) do {_, descs} = Jason.decode(descs_str) Enum.map(ids, fn media_id -> - with %Object{data: data} <- get_attachment(media_id) do + with %Object{data: data} <- get_attachment(media_id, user) do Map.put(data, "name", descs[media_id]) end end) |> Enum.reject(&is_nil/1) end - defp get_attachment(media_id) do - Repo.get(Object, media_id) + defp get_attachment(media_id, user) do + with %Object{data: data} = object <- Repo.get(Object, media_id), + %{"type" => type} when type in Pleroma.Constants.upload_object_types() <- data, + :ok <- Object.authorize_access(object, user) do + object + else + _ -> nil + end end @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())} @@ -103,7 +109,7 @@ def get_to_and_cc(%{visibility: "private"} = draft) do def get_to_and_cc(%{visibility: "direct"} = draft) do # If the OP is a DM already, add the implicit actor. - if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do + if draft.in_reply_to && Visibility.direct?(draft.in_reply_to) do {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []} else {draft.mentions, []} @@ -145,6 +151,8 @@ def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) when is_list(options) do limits = Config.get([:instance, :poll_limits]) + options = options |> Enum.uniq() + with :ok <- validate_poll_expiration(expires_in, limits), :ok <- validate_poll_options_amount(options, limits), :ok <- validate_poll_options_length(options, limits) do @@ -180,10 +188,15 @@ def make_poll_data(_data) do end defp validate_poll_options_amount(options, %{max_options: max_options}) do - if Enum.count(options) > max_options do - {:error, "Poll can't contain more than #{max_options} options"} - else - :ok + cond do + Enum.count(options) < 2 -> + {:error, "Poll must contain at least 2 options"} + + Enum.count(options) > max_options -> + {:error, "Poll can't contain more than #{max_options} options"} + + true -> + :ok end end @@ -308,13 +321,13 @@ def date_to_asctime(date) when is_binary(date) do format_asctime(date) else _e -> - Logger.warn("Date #{date} in wrong format, must be ISO 8601") + Logger.warning("Date #{date} in wrong format, must be ISO 8601") "" end end def date_to_asctime(date) do - Logger.warn("Date #{date} in wrong format, must be ISO 8601") + Logger.warning("Date #{date} in wrong format, must be ISO 8601") "" end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 0c7fc17f4..d3cf372dc 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -53,10 +53,15 @@ def add_link_headers(conn, entries, extra_params) do end end + # TODO: Only fetch the params from open_api_spex when everything is converted @id_keys Pagination.page_keys() -- ["limit", "order"] defp build_pagination_fields(conn, min_id, max_id, extra_params) do params = - conn.params + if Map.has_key?(conn.private, :open_api_spex) do + get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)]) + else + conn.params + end |> Map.drop(Map.keys(conn.path_params) |> Enum.map(&String.to_existing_atom/1)) |> Map.merge(extra_params) |> Map.drop(@id_keys) @@ -85,18 +90,15 @@ def get_pagination_fields(conn, entries, extra_params \\ %{}) do end end - def assign_account_by_id(conn, _) do - case Pleroma.User.get_cached_by_id(conn.params.id) do + def assign_account_by_id(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + case Pleroma.User.get_cached_by_id(id) do %Pleroma.User{} = account -> assign(conn, :account, account) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end end def try_render(conn, target, params) when is_binary(target) do - case render(conn, target, params) do - nil -> render_error(conn, :not_implemented, "Can't display this activity") - res -> res - end + render(conn, target, params) end def try_render(conn, _, _) do diff --git a/lib/pleroma/web/embed_controller.ex b/lib/pleroma/web/embed_controller.ex index 8b9f0a051..ab0df9c5a 100644 --- a/lib/pleroma/web/embed_controller.ex +++ b/lib/pleroma/web/embed_controller.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.EmbedController do def show(conn, %{"id" => id}) do with %Activity{local: true} = activity <- Activity.get_by_id_with_object(id), - true <- Visibility.is_public?(activity.object) do + true <- Visibility.public?(activity.object) do {:ok, author} = User.get_or_fetch(activity.object.data["actor"]) conn diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index d8d40cceb..307fa069e 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -9,7 +9,20 @@ defmodule Pleroma.Web.Endpoint do alias Pleroma.Config - socket("/socket", Pleroma.Web.UserSocket) + socket("/socket", Pleroma.Web.UserSocket, + websocket: [ + path: "/websocket", + serializer: [ + {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, + {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} + ], + timeout: 60_000, + transport_log: false, + compress: false + ], + longpoll: false + ) + socket("/live", Phoenix.LiveView.Socket) plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) @@ -101,13 +114,10 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.Logger, log: :debug) plug(Plug.Parsers, - parsers: [ - :urlencoded, - {:multipart, length: {Config, :get, [[:instance, :upload_limit]]}}, - :json - ], + parsers: [:urlencoded, Pleroma.Web.Multipart, :json], pass: ["*/*"], json_decoder: Jason, + # Note: this is compile-time only, won't work for database-config length: Config.get([:instance, :upload_limit]), body_reader: {Pleroma.Web.Plugs.DigestPlug, :read_body, []} ) @@ -141,47 +151,6 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Web.Plugs.RemoteIp) - defmodule Instrumenter do - use Prometheus.PhoenixInstrumenter - end - - defmodule PipelineInstrumenter do - use Prometheus.PlugPipelineInstrumenter - end - - defmodule MetricsExporter do - use Prometheus.PlugExporter - end - - defmodule MetricsExporterCaller do - @behaviour Plug - - def init(opts), do: opts - - def call(conn, opts) do - prometheus_config = Application.get_env(:prometheus, MetricsExporter, []) - ip_whitelist = List.wrap(prometheus_config[:ip_whitelist]) - - cond do - !prometheus_config[:enabled] -> - conn - - ip_whitelist != [] and - !Enum.find(ip_whitelist, fn ip -> - Pleroma.Helpers.InetHelper.parse_address(ip) == {:ok, conn.remote_ip} - end) -> - conn - - true -> - MetricsExporter.call(conn, opts) - end - end - end - - plug(PipelineInstrumenter) - - plug(MetricsExporterCaller) - plug(Pleroma.Web.Router) @doc """ diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 1a86f7a53..4a0885fab 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -17,10 +17,28 @@ def api_not_implemented(conn, _params) do |> json(%{error: "Not implemented"}) end + def add_generated_metadata(page_content, extra \\ "") do + title = "#{Pleroma.Config.get([:instance, :name])}" + favicon = "" + manifest = "" + + page_content + |> String.replace( + "", + title <> favicon <> manifest <> extra + ) + end + def redirector(conn, _params, code \\ 200) do + {:ok, index_content} = File.read(index_file_path()) + + response = + index_content + |> add_generated_metadata() + conn |> put_resp_content_type("text/html") - |> send_file(code, index_file_path()) + |> send_resp(code, response) end def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} = params) do @@ -34,14 +52,12 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} def redirector_with_meta(conn, params) do {:ok, index_content} = File.read(index_file_path()) - tags = build_tags(conn, params) preloads = preload_data(conn, params) - title = "#{Pleroma.Config.get([:instance, :name])}" response = index_content - |> String.replace("", tags <> preloads <> title) + |> add_generated_metadata(tags <> preloads) conn |> put_resp_content_type("text/html") @@ -55,11 +71,10 @@ def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do def redirector_with_preload(conn, params) do {:ok, index_content} = File.read(index_file_path()) preloads = preload_data(conn, params) - title = "#{Pleroma.Config.get([:instance, :name])}" response = index_content - |> String.replace("", preloads <> title) + |> add_generated_metadata(preloads) conn |> put_resp_content_type("text/html") diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 318b6cb11..1f2c3835a 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -6,10 +6,9 @@ defmodule Pleroma.Web.Federator do alias Pleroma.Activity alias Pleroma.Object.Containment alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Publisher alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.Federator.Publisher alias Pleroma.Workers.PublisherWorker alias Pleroma.Workers.ReceiverWorker @@ -36,6 +35,17 @@ def allowed_thread_distance?(distance) do end # Client API + def incoming_ap_doc(%{params: params, req_headers: req_headers}) do + ReceiverWorker.enqueue( + "incoming_ap_doc", + %{"req_headers" => req_headers, "params" => params, "timeout" => :timer.seconds(20)}, + priority: 2 + ) + end + + def incoming_ap_doc(%{"type" => "Delete"} = params) do + ReceiverWorker.enqueue("incoming_ap_doc", %{"params" => params}, priority: 3) + end def incoming_ap_doc(params) do ReceiverWorker.enqueue("incoming_ap_doc", %{"params" => params}) @@ -58,10 +68,8 @@ defp publish_priority(_), do: 0 # Job Worker Callbacks - @spec perform(atom(), module(), any()) :: {:ok, any()} | {:error, any()} - def perform(:publish_one, module, params) do - apply(module, :publish_one, [params]) - end + @spec perform(atom(), any()) :: {:ok, any()} | {:error, any()} + def perform(:publish_one, params), do: Publisher.publish_one(params) def perform(:publish, activity) do Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end) @@ -80,7 +88,7 @@ def perform(:incoming_ap_doc, params) do # NOTE: we use the actor ID to do the containment, this is fine because an # actor shouldn't be acting on objects outside their own AP server. - with {_, {:ok, _user}} <- {:actor, ap_enabled_actor(actor)}, + with {_, {:ok, _user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)}, nil <- Activity.normalize(params["id"]), {_, :ok} <- {:correct_origin?, Containment.contain_origin_from_id(actor, params)}, @@ -110,14 +118,4 @@ def perform(:incoming_ap_doc, params) do {:error, e} end end - - def ap_enabled_actor(id) do - user = User.get_cached_by_ap_id(id) - - if User.ap_enabled?(user) do - {:ok, user} - else - ActivityPub.make_user_from_ap_id(id) - end - end end diff --git a/lib/pleroma/web/federator/publisher.ex b/lib/pleroma/web/federator/publisher.ex deleted file mode 100644 index a45796e9d..000000000 --- a/lib/pleroma/web/federator/publisher.ex +++ /dev/null @@ -1,109 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Federator.Publisher do - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.User - alias Pleroma.Workers.PublisherWorker - - require Logger - - @moduledoc """ - Defines the contract used by federation implementations to publish messages to - their peers. - """ - - @doc """ - Determine whether an activity can be relayed using the federation module. - """ - @callback is_representable?(Pleroma.Activity.t()) :: boolean() - - @doc """ - Relays an activity to a specified peer, determined by the parameters. The - parameters used are controlled by the federation module. - """ - @callback publish_one(Map.t()) :: {:ok, Map.t()} | {:error, any()} - - @doc """ - Enqueue publishing a single activity. - """ - @spec enqueue_one(module(), Map.t()) :: :ok - def enqueue_one(module, %{} = params) do - PublisherWorker.enqueue( - "publish_one", - %{"module" => to_string(module), "params" => params} - ) - end - - @doc """ - Relays an activity to all specified peers. - """ - @callback publish(User.t(), Activity.t()) :: :ok | {:error, any()} - - @spec publish(User.t(), Activity.t()) :: :ok - def publish(%User{} = user, %Activity{} = activity) do - Config.get([:instance, :federation_publisher_modules]) - |> Enum.each(fn module -> - if module.is_representable?(activity) do - Logger.debug("Publishing #{activity.data["id"]} using #{inspect(module)}") - module.publish(user, activity) - end - end) - - :ok - end - - @doc """ - Gathers links used by an outgoing federation module for WebFinger output. - """ - @callback gather_webfinger_links(User.t()) :: list() - - @spec gather_webfinger_links(User.t()) :: list() - def gather_webfinger_links(%User{} = user) do - Config.get([:instance, :federation_publisher_modules]) - |> Enum.reduce([], fn module, links -> - links ++ module.gather_webfinger_links(user) - end) - end - - @doc """ - Gathers nodeinfo protocol names supported by the federation module. - """ - @callback gather_nodeinfo_protocol_names() :: list() - - @spec gather_nodeinfo_protocol_names() :: list() - def gather_nodeinfo_protocol_names do - Config.get([:instance, :federation_publisher_modules]) - |> Enum.reduce([], fn module, links -> - links ++ module.gather_nodeinfo_protocol_names() - end) - end - - @doc """ - Gathers a set of remote users given an IR envelope. - """ - def remote_users(%User{id: user_id}, %{data: %{"to" => to} = data}) do - cc = Map.get(data, "cc", []) - - bcc = - data - |> Map.get("bcc", []) - |> Enum.reduce([], fn ap_id, bcc -> - case Pleroma.List.get_by_ap_id(ap_id) do - %Pleroma.List{user_id: ^user_id} = list -> - {:ok, following} = Pleroma.List.get_following(list) - bcc ++ Enum.map(following, & &1.ap_id) - - _ -> - bcc - end - end) - - [to, cc, bcc] - |> Enum.concat() - |> Enum.map(&User.get_cached_by_ap_id/1) - |> Enum.filter(fn user -> user && !user.local end) - end -end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 449659f4b..e1ee33d62 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.Feed.FeedView do use Phoenix.HTML use Pleroma.Web, :view - alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.Gettext @@ -72,7 +71,9 @@ def logo(user) do def last_activity(activities), do: List.last(activities) - def activity_title(%{"content" => content, "summary" => summary} = data, opts \\ %{}) do + def activity_title(%{"content" => content} = data, opts \\ %{}) do + summary = Map.get(data, "summary", "") + title = cond do summary != "" -> summary @@ -81,9 +82,8 @@ def activity_title(%{"content" => content, "summary" => summary} = data, opts \\ end title - |> Pleroma.Web.Metadata.Utils.scrub_html() - |> Pleroma.Emoji.Formatter.demojify() - |> Formatter.truncate(opts[:max_length], opts[:omission]) + |> Pleroma.Web.Metadata.Utils.scrub_html_and_truncate(opts[:max_length], opts[:omission]) + |> HtmlEntities.encode() end def activity_description(data) do @@ -132,7 +132,7 @@ def escape(html) do |> safe_to_string() end - @spec to_rfc3339(String.t() | NativeDateTime.t()) :: String.t() + @spec to_rfc3339(String.t() | NaiveDateTime.t()) :: String.t() def to_rfc3339(date) when is_binary(date) do date |> Timex.parse!("{ISO:Extended}") @@ -145,7 +145,7 @@ def to_rfc3339(nd) do |> Timex.format!("{RFC3339}") end - @spec to_rfc2822(String.t() | DateTime.t() | NativeDateTime.t()) :: String.t() + @spec to_rfc2822(String.t() | DateTime.t() | NaiveDateTime.t()) :: String.t() def to_rfc2822(datestr) when is_binary(datestr) do datestr |> Timex.parse!("{ISO:Extended}") diff --git a/lib/pleroma/web/gettext.ex b/lib/pleroma/web/gettext.ex index 5ef49d841..1fa3f9768 100644 --- a/lib/pleroma/web/gettext.ex +++ b/lib/pleroma/web/gettext.ex @@ -85,12 +85,12 @@ def get_locales do Process.get({Pleroma.Web.Gettext, :locales}, []) end - def is_locale_list(locales) do + def locale_list?(locales) do Enum.all?(locales, &is_binary/1) end def put_locales(locales) do - if is_locale_list(locales) do + if locale_list?(locales) do Process.put({Pleroma.Web.Gettext, :locales}, Enum.uniq(locales)) Gettext.put_locale(Enum.at(locales, 0, Gettext.get_locale())) :ok diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 02dfc552f..9226a2deb 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.Utils.Params - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(:skip_auth when action in [:create, :lookup]) @@ -92,7 +92,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( RateLimiter, - [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions + [name: :relation_id_action, params: ["id", "uri"]] when action in @relationship_actions ) plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions) @@ -104,7 +104,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation @doc "POST /api/v1/accounts" - def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do + def create( + %{assigns: %{app: app}, private: %{open_api_spex: %{body_params: params}}} = conn, + _params + ) do with :ok <- validate_email_param(params), :ok <- TwitterAPI.validate_captcha(app, params), {:ok, user} <- TwitterAPI.register_user(params), @@ -168,7 +171,10 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do + def update_credentials( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn, + _params + ) do params = params |> Enum.filter(fn {_, value} -> not is_nil(value) end) @@ -235,7 +241,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p # So we first build the normal local changeset, then apply it to the # user data, but don't persist it. With this, we generate the object # data for our update activity. We feed this and the changeset as meta - # inforation into the pipeline, where they will be properly updated and + # information into the pipeline, where they will be properly updated and # federated. with changeset <- User.update_changeset(user, user_params), {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update), @@ -263,6 +269,18 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p {:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} -> render_error(conn, :request_entity_too_large, "File is too large") + {:error, %Ecto.Changeset{errors: [{:bio, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Bio is too long") + + {:error, %Ecto.Changeset{errors: [{:name, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Name is too long") + + {:error, %Ecto.Changeset{errors: [{:fields, {"invalid", _}} | _]}} -> + render_error(conn, :request_entity_too_large, "One or more field entries are too long") + + {:error, %Ecto.Changeset{errors: [{:fields, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Too many field entries") + _e -> render_error(conn, :forbidden, "Invalid request") end @@ -277,7 +295,10 @@ defp normalize_fields_attributes(fields) do end @doc "GET /api/v1/accounts/relationships" - def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do + def relationships( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do targets = User.get_all_by_ids(List.wrap(id)) render(conn, "relationships.json", user: user, targets: targets) @@ -287,7 +308,13 @@ def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) @doc "GET /api/v1/accounts/:id" - def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id} = params) do + def show( + %{ + assigns: %{user: for_user}, + private: %{open_api_spex: %{params: %{id: nickname_or_id} = params}} + } = conn, + _params + ) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), :visible <- User.visible_for(user, for_user) do render(conn, "show.json", @@ -301,7 +328,10 @@ def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id} = params) d end @doc "GET /api/v1/accounts/:id/statuses" - def statuses(%{assigns: %{user: reading_user}} = conn, params) do + def statuses( + %{assigns: %{user: reading_user}, private: %{open_api_spex: %{params: params}}} = conn, + _params + ) do with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), :visible <- User.visible_for(user, reading_user) do params = @@ -336,7 +366,11 @@ defp user_visibility_error(conn, error) do end @doc "GET /api/v1/accounts/:id/followers" - def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + def followers( + %{assigns: %{user: for_user, account: user}, private: %{open_api_spex: %{params: params}}} = + conn, + _params + ) do params = params |> Enum.map(fn {key, value} -> {to_string(key), value} end) @@ -361,7 +395,11 @@ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do end @doc "GET /api/v1/accounts/:id/following" - def following(%{assigns: %{user: for_user, account: user}} = conn, params) do + def following( + %{assigns: %{user: for_user, account: user}, private: %{open_api_spex: %{params: params}}} = + conn, + _params + ) do params = params |> Enum.map(fn {key, value} -> {to_string(key), value} end) @@ -399,7 +437,13 @@ def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, "Can not follow yourself"} end - def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do + def follow( + %{ + assigns: %{user: follower, account: followed}, + private: %{open_api_spex: %{body_params: params}} + } = conn, + _ + ) do with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do render(conn, "relationship.json", user: follower, target: followed) else @@ -419,7 +463,13 @@ def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) d end @doc "POST /api/v1/accounts/:id/mute" - def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do + def mute( + %{ + assigns: %{user: muter, account: muted}, + private: %{open_api_spex: %{body_params: params}} + } = conn, + _params + ) do params = params |> Map.put_new(:duration, Map.get(params, :expires_in, 0)) @@ -460,7 +510,10 @@ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do @doc "POST /api/v1/accounts/:id/note" def note( - %{assigns: %{user: noter, account: target}, body_params: %{comment: comment}} = conn, + %{ + assigns: %{user: noter, account: target}, + private: %{open_api_spex: %{body_params: %{comment: comment}}} + } = conn, _params ) do with {:ok, _user_note} <- UserNote.create(noter, target, comment) do @@ -501,7 +554,7 @@ def remove_from_followers(%{assigns: %{user: followed, account: follower}} = con end @doc "POST /api/v1/follows" - def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do + def follow_by_uri(%{private: %{open_api_spex: %{body_params: %{uri: uri}}}} = conn, _) do case User.get_cached_by_nickname(uri) do %User{} = user -> conn @@ -540,11 +593,16 @@ def blocks(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(users) - |> render("index.json", users: users, for: user, as: :user) + |> render("index.json", + users: users, + for: user, + as: :user, + embed_relationships: embed_relationships?(params) + ) end @doc "GET /api/v1/accounts/lookup" - def lookup(conn, %{acct: nickname} = _params) do + def lookup(%{private: %{open_api_spex: %{params: %{acct: nickname}}}} = conn, _params) do with %User{} = user <- User.get_by_nickname(nickname) do render(conn, "show.json", user: user, diff --git a/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex index 253f06cfb..f89425966 100644 --- a/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/directory_controller.ex @@ -15,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.DirectoryController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_auth when action == "index") + plug(:skip_auth when action == :index) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DirectoryOperation diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index b2e347ed9..4615794a1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do alias Pleroma.User alias Pleroma.Web.Plugs.OAuthScopesPlug - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation plug( @@ -27,23 +27,31 @@ def index(%{assigns: %{user: user}} = conn, _) do end @doc "POST /api/v1/domain_blocks" - def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do + def create( + %{assigns: %{user: blocker}, private: %{open_api_spex: %{body_params: %{domain: domain}}}} = + conn, + _params + ) do User.block_domain(blocker, domain) json(conn, %{}) end - def create(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + def create(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do User.block_domain(blocker, domain) json(conn, %{}) end @doc "DELETE /api/v1/domain_blocks" - def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do + def delete( + %{assigns: %{user: blocker}, private: %{open_api_spex: %{body_params: %{domain: domain}}}} = + conn, + _params + ) do User.unblock_domain(blocker, domain) json(conn, %{}) end - def delete(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + def delete(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do User.unblock_domain(blocker, domain) json(conn, %{}) end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index ba6d074cc..6eee55d1b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(:assign_follower when action != :index) action_fallback(:errors) @@ -44,7 +44,7 @@ def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do end end - defp assign_follower(%{params: %{id: id}} = conn, _) do + defp assign_follower(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do case User.get_cached_by_id(id) do %User{} = follower -> assign(conn, :follower, follower) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 6410e872c..3e664903a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_auth when action in [:show, :peers]) + plug(:skip_auth when action in [:show, :show2, :peers]) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation @@ -16,6 +16,11 @@ def show(conn, _params) do render(conn, "show.json") end + @doc "GET /api/v2/instance" + def show2(conn, _params) do + render(conn, "show2.json") + end + @doc "GET /api/v1/instance/peers" def peers(conn, _params) do json(conn, Pleroma.Stats.get_peers()) diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index 2117aae3a..3bfc365a5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.ListController do @oauth_read_actions [:index, :show, :list_accounts] - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(:list_by_id_and_user when action not in [:index, :create]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions) plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions) @@ -21,25 +21,33 @@ defmodule Pleroma.Web.MastodonAPI.ListController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation # GET /api/v1/lists - def index(%{assigns: %{user: user}} = conn, opts) do - lists = Pleroma.List.for_user(user, opts) + def index(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do + lists = Pleroma.List.for_user(user, params) render(conn, "index.json", lists: lists) end # POST /api/v1/lists - def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do + def create( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: %{title: title}}}} = + conn, + _ + ) do with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do render(conn, "show.json", list: list) end end - # GET /api/v1/lists/:id + # GET /api/v1/lists/:idOB def show(%{assigns: %{list: list}} = conn, _) do render(conn, "show.json", list: list) end # PUT /api/v1/lists/:id - def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do + def update( + %{assigns: %{list: list}, private: %{open_api_spex: %{body_params: %{title: title}}}} = + conn, + _ + ) do with {:ok, list} <- Pleroma.List.rename(list, title) do render(conn, "show.json", list: list) end @@ -62,7 +70,13 @@ def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) do end # POST /api/v1/lists/:id/accounts - def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do + def add_to_list( + %{ + assigns: %{list: list}, + private: %{open_api_spex: %{body_params: %{account_ids: account_ids}}} + } = conn, + _ + ) do Enum.each(account_ids, fn account_id -> with %User{} = followed <- User.get_cached_by_id(account_id) do Pleroma.List.follow(list, followed) @@ -74,9 +88,22 @@ def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_id # DELETE /api/v1/lists/:id/accounts def remove_from_list( - %{assigns: %{list: list}, params: %{account_ids: account_ids}} = conn, + %{ + private: %{open_api_spex: %{params: %{account_ids: account_ids}}} + } = conn, _ ) do + do_remove_from_list(conn, account_ids) + end + + def remove_from_list( + %{private: %{open_api_spex: %{body_params: %{account_ids: account_ids}}}} = conn, + _ + ) do + do_remove_from_list(conn, account_ids) + end + + defp do_remove_from_list(%{assigns: %{list: list}} = conn, account_ids) do Enum.each(account_ids, fn account_id -> with %User{} = followed <- User.get_cached_by_id(account_id) do Pleroma.List.unfollow(list, followed) @@ -86,11 +113,10 @@ def remove_from_list( json(conn, %{}) end - def remove_from_list(%{body_params: params} = conn, _) do - remove_from_list(%{conn | params: params}, %{}) - end - - defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do + defp list_by_id_and_user( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do case Pleroma.List.get(id, user) do %Pleroma.List{} = list -> assign(conn, :list, list) nil -> conn |> render_error(:not_found, "List not found") |> halt() diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 7d9a63cf4..056bad844 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:create, :create2]) - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show) @@ -20,7 +20,11 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation @doc "POST /api/v1/media" - def create(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do + def create( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: %{file: file} = data}}} = + conn, + _ + ) do with {:ok, object} <- ActivityPub.upload( file, @@ -36,7 +40,11 @@ def create(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, def create(_conn, _data), do: {:error, :bad_request} @doc "POST /api/v2/media" - def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do + def create2( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: %{file: file} = data}}} = + conn, + _ + ) do with {:ok, object} <- ActivityPub.upload( file, @@ -54,7 +62,15 @@ def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, def create2(_conn, _data), do: {:error, :bad_request} @doc "PUT /api/v1/media/:id" - def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do + def update( + %{ + assigns: %{user: user}, + private: %{ + open_api_spex: %{body_params: %{description: description}, params: %{id: id}} + } + } = conn, + _ + ) do with %Object{} = object <- Object.get_by_id(id), :ok <- Object.authorize_access(object, user), {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do @@ -67,7 +83,7 @@ def update(%{assigns: %{user: user}, body_params: %{description: description}} = def update(conn, data), do: show(conn, data) @doc "GET /api/v1/media/:id" - def show(%{assigns: %{user: user}} = conn, %{id: id}) do + def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Object{data: data, id: object_id} = object <- Object.get_by_id(id), :ok <- Object.authorize_access(object, user) do attachment_data = Map.put(data, "id", object_id) diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 7bb10a9b2..afd83b785 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do @oauth_read_actions [:show, :index] - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug( OAuthScopesPlug, @@ -24,8 +24,21 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation + @default_notification_types ~w{ + mention + follow + follow_request + reblog + favourite + move + pleroma:emoji_reaction + poll + update + status + } + # GET /api/v1/notifications - def index(conn, %{account_id: account_id} = params) do + def index(%{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, _) do case Pleroma.User.get_cached_by_id(account_id) do %{ap_id: account_ap_id} -> params = @@ -33,7 +46,7 @@ def index(conn, %{account_id: account_id} = params) do |> Map.delete(:account_id) |> Map.put(:account_ap_id, account_ap_id) - index(conn, params) + do_get_notifications(conn, params) _ -> conn @@ -42,19 +55,11 @@ def index(conn, %{account_id: account_id} = params) do end end - @default_notification_types ~w{ - mention - follow - follow_request - reblog - favourite - move - pleroma:emoji_reaction - poll - update - status - } - def index(%{assigns: %{user: user}} = conn, params) do + def index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do + do_get_notifications(conn, params) + end + + defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do params = Map.new(params, fn {k, v} -> {to_string(k), v} end) |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) @@ -70,7 +75,7 @@ def index(%{assigns: %{user: user}} = conn, params) do end # GET /api/v1/notifications/:id - def show(%{assigns: %{user: user}} = conn, %{id: id}) do + def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with {:ok, notification} <- Notification.get(user, id) do render(conn, "show.json", notification: notification, for: user) else @@ -89,8 +94,20 @@ def clear(%{assigns: %{user: user}} = conn, _params) do # POST /api/v1/notifications/:id/dismiss - def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do - with {:ok, _notif} <- Notification.dismiss(user, id) do + def dismiss(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + do_dismiss(conn, id) + end + + # POST /api/v1/notifications/dismiss (deprecated) + def dismiss_via_body( + %{private: %{open_api_spex: %{body_params: %{id: id}}}} = conn, + _ + ) do + do_dismiss(conn, id) + end + + defp do_dismiss(%{assigns: %{user: user}} = conn, notification_id) do + with {:ok, _notif} <- Notification.dismiss(user, notification_id) do json(conn, %{}) else {:error, reason} -> @@ -100,13 +117,11 @@ def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do end end - # POST /api/v1/notifications/dismiss (deprecated) - def dismiss_via_body(%{body_params: params} = conn, _) do - dismiss(conn, params) - end - # DELETE /api/v1/notifications/destroy_multiple - def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do + def destroy_multiple( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{ids: ids}}}} = conn, + _ + ) do Notification.destroy_multiple(user, ids) json(conn, %{}) end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 002c210d2..b074ee405 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -15,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug( OAuthScopesPlug, @@ -29,7 +29,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @doc "GET /api/v1/polls/:id" - def show(%{assigns: %{user: user}} = conn, %{id: id}) do + def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user) do @@ -41,7 +41,13 @@ def show(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "POST /api/v1/polls/:id/votes" - def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do + def vote( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: %{choices: choices}, params: %{id: id}}} + } = conn, + _ + ) do with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user), diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex index 0392fcef1..1b7095ec5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do @oauth_read_actions [:show, :index] - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) plug(:assign_scheduled_activity when action != :index) @@ -23,7 +23,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation @doc "GET /api/v1/scheduled_statuses" - def index(%{assigns: %{user: user}} = conn, params) do + def index(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do params = Map.new(params, fn {key, value} -> {to_string(key), value} end) with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do @@ -39,7 +39,13 @@ def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) end @doc "PUT /api/v1/scheduled_statuses/:id" - def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do + def update( + %{ + assigns: %{scheduled_activity: scheduled_activity}, + private: %{open_api_spex: %{body_params: params}} + } = conn, + _ + ) do with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do render(conn, "show.json", scheduled_activity: scheduled_activity) end @@ -52,7 +58,10 @@ def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params end end - defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do + defp assign_scheduled_activity( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do case ScheduledActivity.get(user, id) do %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 5e6e04734..7fdf684d2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do use Pleroma.Web, :controller - alias Pleroma.Activity alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ControllerHelper @@ -19,7 +18,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do @search_limit 40 - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) @@ -30,7 +29,11 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation - def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do + def account_search( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{q: query} = params}}} = + conn, + _ + ) do accounts = User.search(query, search_options(params, user)) conn @@ -45,7 +48,12 @@ def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do def search2(conn, params), do: do_search(:v2, conn, params) def search(conn, params), do: do_search(:v1, conn, params) - defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do + defp do_search( + version, + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{q: query} = params}}} = + conn, + _ + ) do query = String.trim(query) options = search_options(params, user) timeout = Keyword.get(Repo.config(), :timeout, 15_000) @@ -100,7 +108,7 @@ defp resource_search(_, "accounts", query, options) do end defp resource_search(_, "statuses", query, options) do - statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end) + statuses = with_fallback(fn -> Pleroma.Search.search(query, options) end) StatusView.render("index.json", activities: statuses, diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index e594ea491..5aa7bddf0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter - plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(:skip_public_check when action in [:index, :show]) @@ -110,7 +110,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do + def index( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{ids: ids} = params}}} = + conn, + _ + ) do limit = 100 activities = @@ -134,7 +138,9 @@ def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do def create( %{ assigns: %{user: user}, - body_params: %{status: _, scheduled_at: scheduled_at} = params + private: %{ + open_api_spex: %{body_params: %{status: _, scheduled_at: scheduled_at} = params} + } } = conn, _ ) @@ -156,7 +162,13 @@ def create( else {:far_enough, _} -> params = Map.drop(params, [:scheduled_at]) - create(%Plug.Conn{conn | body_params: params}, %{}) + + put_in( + conn, + [Access.key(:private), Access.key(:open_api_spex), Access.key(:body_params)], + params + ) + |> do_create error -> error @@ -164,7 +176,35 @@ def create( end # Creates a regular status - def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do + def create( + %{ + private: %{open_api_spex: %{body_params: %{status: _}}} + } = conn, + _ + ) do + do_create(conn) + end + + def create( + %{ + assigns: %{user: _user}, + private: %{open_api_spex: %{body_params: %{media_ids: _} = params}} + } = conn, + _ + ) do + params = Map.put(params, :status, "") + + put_in( + conn, + [Access.key(:private), Access.key(:open_api_spex), Access.key(:body_params)], + params + ) + |> do_create + end + + defp do_create( + %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn + ) do params = Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) |> put_application(conn) @@ -189,13 +229,11 @@ def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, end end - def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = conn, _) do - params = Map.put(params, :status, "") - create(%Plug.Conn{conn | body_params: params}, %{}) - end - @doc "GET /api/v1/statuses/:id/history" - def show_history(%{assigns: assigns} = conn, %{id: id} = params) do + def show_history( + %{assigns: assigns, private: %{open_api_spex: %{params: %{id: id} = params}}} = conn, + _ + ) do with user = assigns[:user], %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do @@ -211,7 +249,7 @@ def show_history(%{assigns: assigns} = conn, %{id: id} = params) do end @doc "GET /api/v1/statuses/:id/source" - def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do + def show_source(%{assigns: assigns, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with user = assigns[:user], %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do @@ -225,7 +263,13 @@ def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do end @doc "PUT /api/v1/statuses/:id" - def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do + def update( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: body_params, params: %{id: id} = params}} + } = conn, + _ + ) do with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)}, {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, {_, true} <- {:is_create, activity.data["type"] == "Create"}, @@ -248,7 +292,11 @@ def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} end @doc "GET /api/v1/statuses/:id" - def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do + def show( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} = + conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do try_render(conn, "show.json", @@ -263,7 +311,7 @@ def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do end @doc "DELETE /api/v1/statuses/:id" - def delete(%{assigns: %{user: user}} = conn, %{id: id}) do + def delete(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:ok, %Activity{}} <- CommonAPI.delete(id, user) do try_render(conn, "show.json", @@ -278,7 +326,13 @@ def delete(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "POST /api/v1/statuses/:id/reblog" - def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do + def reblog( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: params, params: %{id: ap_id_or_id}}} + } = conn, + _ + ) do with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params), %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) @@ -286,7 +340,11 @@ def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or end @doc "POST /api/v1/statuses/:id/unreblog" - def unreblog(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + def unreblog( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} = + conn, + _ + ) do with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user), %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) @@ -294,7 +352,11 @@ def unreblog(%{assigns: %{user: user}} = conn, %{id: activity_id}) do end @doc "POST /api/v1/statuses/:id/favourite" - def favourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + def favourite( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} = + conn, + _ + ) do with {:ok, _fav} <- CommonAPI.favorite(user, activity_id), %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -302,7 +364,11 @@ def favourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do end @doc "POST /api/v1/statuses/:id/unfavourite" - def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do + def unfavourite( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: activity_id}}}} = + conn, + _ + ) do with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user), %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -310,7 +376,11 @@ def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do end @doc "POST /api/v1/statuses/:id/pin" - def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do + def pin( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: ap_id_or_id}}}} = + conn, + _ + ) do with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) else @@ -329,14 +399,21 @@ def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do end @doc "POST /api/v1/statuses/:id/unpin" - def unpin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do + def unpin( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: ap_id_or_id}}}} = + conn, + _ + ) do with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end @doc "POST /api/v1/statuses/:id/bookmark" - def bookmark(%{assigns: %{user: user}} = conn, %{id: id}) do + def bookmark( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), @@ -346,7 +423,10 @@ def bookmark(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "POST /api/v1/statuses/:id/unbookmark" - def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do + def unbookmark( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), @@ -356,7 +436,13 @@ def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "POST /api/v1/statuses/:id/mute" - def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{id: id}) do + def mute_conversation( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{body_params: params, params: %{id: id}}} + } = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id(id), {:ok, activity} <- CommonAPI.add_mute(user, activity, params) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -364,7 +450,13 @@ def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{i end @doc "POST /api/v1/statuses/:id/unmute" - def unmute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do + def unmute_conversation( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{params: %{id: id}}} + } = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id(id), {:ok, activity} <- CommonAPI.remove_mute(user, activity) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -373,7 +465,10 @@ def unmute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "GET /api/v1/statuses/:id/card" @deprecated "https://github.com/tootsuite/mastodon/pull/11213" - def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do + def card( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: status_id}}}} = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id(status_id), true <- Visibility.visible_for_user?(activity, user) do data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) @@ -384,7 +479,10 @@ def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do end @doc "GET /api/v1/statuses/:id/favourited_by" - def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do + def favourited_by( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do with true <- Pleroma.Config.get([:instance, :show_reactions]), %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, @@ -405,7 +503,10 @@ def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "GET /api/v1/statuses/:id/reblogged_by" - def reblogged_by(%{assigns: %{user: user}} = conn, %{id: id}) do + def reblogged_by( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"announcements" => announces, "id" => ap_id}} <- @@ -437,7 +538,10 @@ def reblogged_by(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "GET /api/v1/statuses/:id/context" - def context(%{assigns: %{user: user}} = conn, %{id: id}) do + def context( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, + _ + ) do with %Activity{} = activity <- Activity.get_by_id(id) do activities = ActivityPub.fetch_activities_for_context(activity.data["context"], %{ @@ -451,7 +555,10 @@ def context(%{assigns: %{user: user}} = conn, %{id: id}) do end @doc "GET /api/v1/favourites" - def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do + def favourites( + %{assigns: %{user: %User{} = user}, private: %{open_api_spex: %{params: params}}} = conn, + _ + ) do activities = ActivityPub.fetch_favourites(user, params) conn @@ -464,7 +571,7 @@ def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do end @doc "GET /api/v1/bookmarks" - def bookmarks(%{assigns: %{user: user}} = conn, params) do + def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do user = User.get_cached_by_id(user.id) bookmarks = diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index cc3e3582f..267c3e3ed 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -1,5 +1,5 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors +# Copyright © 2017-2023 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.AccountView do @@ -194,6 +194,8 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do end defp do_render("show.json", %{user: user} = opts) do + self = opts[:for] == user + user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname @@ -203,16 +205,16 @@ defp do_render("show.json", %{user: user} = opts) do header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true) following_count = - if !user.hide_follows_count or !user.hide_follows or opts[:for] == user, + if !user.hide_follows_count or !user.hide_follows or self, do: user.following_count, else: 0 followers_count = - if !user.hide_followers_count or !user.hide_followers or opts[:for] == user, + if !user.hide_followers_count or !user.hide_followers or self, do: user.follower_count, else: 0 - bot = user.actor_type == "Service" + bot = bot?(user) emojis = Enum.map(user.emoji, fn {shortcode, raw_url} -> @@ -249,6 +251,10 @@ defp do_render("show.json", %{user: user} = opts) do nil end + last_status_at = + user.last_status_at && + user.last_status_at |> NaiveDateTime.to_date() |> Date.to_iso8601() + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -277,7 +283,7 @@ defp do_render("show.json", %{user: user} = opts) do actor_type: user.actor_type } }, - last_status_at: user.last_status_at, + last_status_at: last_status_at, # Pleroma extensions # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub @@ -464,4 +470,12 @@ defp maybe_show_birthday(data, _, _) do defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil + + defp bot?(user) do + # Because older and/or Mastodon clients may not recognize a Group actor properly, + # and currently the group actor can only boost things, we should let these clients + # think groups are bots. + # See https://git.pleroma.social/pleroma/pleroma-meta/-/issues/14 + user.actor_type == "Service" || user.actor_type == "Group" + end end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index abf7f29ab..7f73917d6 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -13,12 +13,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do def render("show.json", _) do instance = Config.get(:instance) - %{ - uri: Pleroma.Web.Endpoint.url(), - title: Keyword.get(instance, :name), + common_information(instance) + |> Map.merge(%{ + uri: Pleroma.Web.WebFinger.host(), description: Keyword.get(instance, :description), short_description: Keyword.get(instance, :short_description), - version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", email: Keyword.get(instance, :email), urls: %{ streaming_api: Pleroma.Web.Endpoint.websocket_url() @@ -27,9 +26,9 @@ def render("show.json", _) do thumbnail: URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail)) |> to_string, - languages: Keyword.get(instance, :languages, ["en"]), registrations: Keyword.get(instance, :registrations_open), approval_required: Keyword.get(instance, :account_approval_required), + configuration: configuration(), # Extra (not present in Mastodon): max_toot_chars: Keyword.get(instance, :limit), max_media_attachments: Keyword.get(instance, :max_media_attachments), @@ -41,19 +40,45 @@ def render("show.json", _) do background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), shout_limit: Config.get([:shout, :limit]), description_limit: Keyword.get(instance, :description_limit), - pleroma: %{ - metadata: %{ - account_activation_required: Keyword.get(instance, :account_activation_required), - features: features(), - federation: federation(), - fields_limits: fields_limits(), - post_formats: Config.get([:instance, :allowed_post_formats]), - birthday_required: Config.get([:instance, :birthday_required]), - birthday_min_age: Config.get([:instance, :birthday_min_age]) - }, - stats: %{mau: Pleroma.User.active_user_count()}, - vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) - } + chat_limit: Keyword.get(instance, :chat_limit), + pleroma: pleroma_configuration(instance) + }) + end + + def render("show2.json", _) do + instance = Config.get(:instance) + + common_information(instance) + |> Map.merge(%{ + domain: Pleroma.Web.WebFinger.host(), + source_url: Pleroma.Application.repository(), + description: Keyword.get(instance, :short_description), + usage: %{users: %{active_month: Pleroma.User.active_user_count()}}, + thumbnail: %{ + url: + URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail)) + |> to_string + }, + configuration: configuration2(), + registrations: %{ + enabled: Keyword.get(instance, :registrations_open), + approval_required: Keyword.get(instance, :account_approval_required), + message: nil + }, + contact: %{ + email: Keyword.get(instance, :email), + account: nil + }, + # Extra (not present in Mastodon): + pleroma: pleroma_configuration2(instance) + }) + end + + defp common_information(instance) do + %{ + title: Keyword.get(instance, :name), + version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", + languages: Keyword.get(instance, :languages, ["en"]) } end @@ -69,6 +94,7 @@ def features do "multifetch", "pleroma:api/v1/notifications:include_types_filter", "editing", + "quote_posting", if Config.get([:activitypub, :blockers_visible]) do "blockers_visible" end, @@ -92,6 +118,7 @@ def features do "safe_dm_mentions" end, "pleroma_emoji_reactions", + "pleroma_custom_emoji_reactions", "pleroma_chat_messages", if Config.get([:instance, :show_reactions]) do "exposable_reactions" @@ -99,7 +126,8 @@ def features do if Config.get([:instance, :profile_directory]) do "profile_directory" end, - "pleroma:get:main/ostatus" + "pleroma:get:main/ostatus", + "pleroma:group_actors" ] |> Enum.filter(& &1) end @@ -131,7 +159,7 @@ def federation do |> Map.put(:enabled, Config.get([:instance, :federating])) end - def fields_limits do + defp fields_limits do %{ max_fields: Config.get([:instance, :max_account_fields]), max_remote_fields: Config.get([:instance, :max_remote_account_fields]), @@ -139,4 +167,66 @@ def fields_limits do value_length: Config.get([:instance, :account_field_value_length]) } end + + defp configuration do + %{ + statuses: %{ + max_characters: Config.get([:instance, :limit]), + max_media_attachments: Config.get([:instance, :max_media_attachments]) + }, + media_attachments: %{ + image_size_limit: Config.get([:instance, :upload_limit]), + video_size_limit: Config.get([:instance, :upload_limit]) + }, + polls: %{ + max_options: Config.get([:instance, :poll_limits, :max_options]), + max_characters_per_option: Config.get([:instance, :poll_limits, :max_option_chars]), + min_expiration: Config.get([:instance, :poll_limits, :min_expiration]), + max_expiration: Config.get([:instance, :poll_limits, :max_expiration]) + } + } + end + + defp configuration2 do + configuration() + |> Map.merge(%{ + urls: %{streaming: Pleroma.Web.Endpoint.websocket_url()} + }) + end + + defp pleroma_configuration(instance) do + %{ + metadata: %{ + account_activation_required: Keyword.get(instance, :account_activation_required), + features: features(), + federation: federation(), + fields_limits: fields_limits(), + post_formats: Config.get([:instance, :allowed_post_formats]), + birthday_required: Config.get([:instance, :birthday_required]), + birthday_min_age: Config.get([:instance, :birthday_min_age]) + }, + stats: %{mau: Pleroma.User.active_user_count()}, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } + end + + defp pleroma_configuration2(instance) do + configuration = pleroma_configuration(instance) + + configuration + |> Map.merge(%{ + metadata: + configuration.metadata + |> Map.merge(%{ + avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), + background_upload_limit: Keyword.get(instance, :background_upload_limit), + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + background_image: + Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), + chat_limit: Keyword.get(instance, :chat_limit), + description_limit: Keyword.get(instance, :description_limit), + shout_limit: Config.get([:shout, :limit]) + }) + }) + end end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index ff795393b..3f2478719 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MediaProxy alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id @@ -148,7 +149,9 @@ defp put_report(response, activity) do end defp put_emoji(response, activity) do - Map.put(response, :emoji, activity.data["content"]) + response + |> Map.put(:emoji, activity.data["content"]) + |> Map.put(:emoji_url, MediaProxy.url(Pleroma.Emoji.emoji_url(activity.data))) end defp put_chat_message(response, activity, reading_user, opts) do diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 0a8c98b44..6303e72ce 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -57,6 +57,27 @@ defp get_replied_to_activities(activities) do end) end + defp get_quoted_activities([]), do: %{} + + defp get_quoted_activities(activities) do + activities + |> Enum.map(fn + %{data: %{"type" => "Create"}} = activity -> + object = Object.normalize(activity, fetch: false) + object && object.data["quoteUrl"] != "" && object.data["quoteUrl"] + + _ -> + nil + end) + |> Enum.filter(& &1) + |> Activity.create_by_object_ap_id_with_object() + |> Repo.all() + |> Enum.reduce(%{}, fn activity, acc -> + object = Object.normalize(activity, fetch: false) + if object, do: Map.put(acc, object.data["id"], activity), else: acc + end) + end + # DEPRECATED This field seems to be a left-over from the StatusNet era. # If your application uses `pleroma.conversation_id`: this field is deprecated. # It is currently stubbed instead by doing a CRC32 of the context, and @@ -97,6 +118,7 @@ def render("index.json", opts) do # length(activities_with_links) * timeout fetch_rich_media_for_activities(activities) replied_to_activities = get_replied_to_activities(activities) + quoted_activities = get_quoted_activities(activities) parent_activities = activities @@ -129,6 +151,7 @@ def render("index.json", opts) do opts = opts |> Map.put(:replied_to_activities, replied_to_activities) + |> Map.put(:quoted_activities, quoted_activities) |> Map.put(:parent_activities, parent_activities) |> Map.put(:relationships, relationships_opt) @@ -277,7 +300,6 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} end reply_to = get_reply_to(activity, opts) - reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) history_len = @@ -290,6 +312,22 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} # Here the implicit index of the current content is 0 chrono_order = history_len - 1 + quote_activity = get_quote(activity, opts) + + quote_id = + case quote_activity do + %Activity{id: id} -> id + _ -> nil + end + + quote_post = + if visible_for_user?(quote_activity, opts[:for]) and opts[:show_quote] != false do + quote_rendering_opts = Map.merge(opts, %{activity: quote_activity, show_quote: false}) + render("show.json", quote_rendering_opts) + else + nil + end + content = object |> render_content() @@ -334,14 +372,14 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} end emoji_reactions = - object.data - |> Map.get("reactions", []) + object + |> Object.get_emoji_reactions() |> EmojiReactionController.filter_allowed_users( opts[:for], Map.get(opts, :with_muted, false) ) - |> Stream.map(fn {emoji, users} -> - build_emoji_map(emoji, users, opts[:for]) + |> Stream.map(fn {emoji, users, url} -> + build_emoji_map(emoji, users, url, opts[:for]) end) |> Enum.to_list() @@ -398,6 +436,10 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} conversation_id: get_context_id(activity), context: object.data["context"], in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, + quote: quote_post, + quote_id: quote_id, + quote_url: object.data["quoteUrl"], + quote_visible: visible_for_user?(quote_activity, opts[:for]), content: %{"text/plain" => content_plaintext}, spoiler_text: %{"text/plain" => summary}, expires_at: expires_at, @@ -405,7 +447,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} thread_muted: thread_muted?, emoji_reactions: emoji_reactions, parent_visible: visible_for_user?(reply_to, opts[:for]), - pinned_at: pinned_at + pinned_at: pinned_at, + quotes_count: object.data["quotesCount"] || 0 } } end @@ -520,25 +563,24 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url = page_url_data |> to_string - image_url_data = - if is_binary(rich_media["image"]) do - URI.parse(rich_media["image"]) - else - nil - end - - image_url = build_image_url(image_url_data, page_url_data) + image_url = proxied_url(rich_media["image"], page_url_data) + audio_url = proxied_url(rich_media["audio"], page_url_data) + video_url = proxied_url(rich_media["video"], page_url_data) %{ type: "link", provider_name: page_url_data.host, provider_url: page_url_data.scheme <> "://" <> page_url_data.host, url: page_url, - image: image_url |> MediaProxy.url(), + image: image_url, title: rich_media["title"] || "", description: rich_media["description"] || "", pleroma: %{ - opengraph: rich_media + opengraph: + rich_media + |> Maps.put_if_present("image", image_url) + |> Maps.put_if_present("audio", audio_url) + |> Maps.put_if_present("video", video_url) } } end @@ -633,6 +675,25 @@ def get_reply_to(%{data: %{"object" => _object}} = activity, _) do end end + def get_quote(activity, %{quoted_activities: quoted_activities}) do + object = Object.normalize(activity, fetch: false) + + with nil <- quoted_activities[object.data["quoteUrl"]] do + # For when a quote post is inside an Announce + Activity.get_create_by_object_ap_id_with_object(object.data["quoteUrl"]) + end + end + + def get_quote(%{data: %{"object" => _object}} = activity, _) do + object = Object.normalize(activity, fetch: false) + + if object.data["quoteUrl"] && object.data["quoteUrl"] != "" do + Activity.get_create_by_object_ap_id(object.data["quoteUrl"]) + else + nil + end + end + def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do url = object.data["url"] || object.data["id"] @@ -702,11 +763,13 @@ defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_ end end - defp build_emoji_map(emoji, users, current_user) do + defp build_emoji_map(emoji, users, url, current_user) do %{ - name: emoji, + name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url), count: length(users), - me: !!(current_user && current_user.ap_id in users) + url: MediaProxy.url(url), + me: !!(current_user && current_user.ap_id in users), + account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end) } end @@ -733,8 +796,6 @@ defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do URI.merge(page_url_data, image_url_data) |> to_string end - defp build_image_url(_, _), do: nil - defp get_source_text(%{"content" => content} = _source) do content end @@ -754,4 +815,12 @@ defp get_source_content_type(%{"mediaType" => type} = _source) do defp get_source_content_type(_source) do Utils.get_content_type(nil) end + + defp proxied_url(url, page_url_data) do + if is_binary(url) do + build_image_url(URI.parse(url), page_url_data) |> MediaProxy.url() + else + nil + end + end end diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 88444106d..07c2b62e3 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do alias Pleroma.User alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Streamer + alias Pleroma.Web.StreamerView @behaviour :cowboy_websocket @@ -32,8 +33,15 @@ def init(%{qs: qs} = req, state) do req end + topics = + if topic do + [topic] + else + [] + end + {:cowboy_websocket, req, - %{user: user, topic: topic, oauth_token: oauth_token, count: 0, timer: nil}, + %{user: user, topics: topics, oauth_token: oauth_token, count: 0, timer: nil}, %{idle_timeout: @timeout}} else {:error, :bad_topic} -> @@ -50,10 +58,10 @@ def init(%{qs: qs} = req, state) do def websocket_init(state) do Logger.debug( - "#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic}" + "#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topics #{state.topics}" ) - Streamer.add_socket(state.topic, state.oauth_token) + Enum.each(state.topics, fn topic -> Streamer.add_socket(topic, state.oauth_token) end) {:ok, %{state | timer: timer()}} end @@ -66,16 +74,26 @@ def websocket_handle(:pong, state) do # We only receive pings for now def websocket_handle(:ping, state), do: {:ok, state} + def websocket_handle({:text, text}, state) do + with {:ok, %{} = event} <- Jason.decode(text) do + handle_client_event(event, state) + else + _ -> + Logger.error("#{__MODULE__} received non-JSON event: #{inspect(text)}") + {:ok, state} + end + end + def websocket_handle(frame, state) do Logger.error("#{__MODULE__} received frame: #{inspect(frame)}") {:ok, state} end - def websocket_info({:render_with_user, view, template, item}, state) do + def websocket_info({:render_with_user, view, template, item, topic}, state) do user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) unless Streamer.filtered_by_user?(user, item) do - websocket_info({:text, view.render(template, item, user)}, %{state | user: user}) + websocket_info({:text, view.render(template, item, user, topic)}, %{state | user: user}) else {:ok, state} end @@ -109,10 +127,10 @@ def terminate(_reason, _req, []), do: :ok def terminate(reason, _req, state) do Logger.debug( - "#{__MODULE__} terminating websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic || "?"}: #{inspect(reason)}" + "#{__MODULE__} terminating websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topics #{state.topics || "?"}: #{inspect(reason)}" ) - Streamer.remove_socket(state.topic) + Enum.each(state.topics, fn topic -> Streamer.remove_socket(topic) end) :ok end @@ -137,4 +155,103 @@ defp authenticate_request(access_token, sec_websocket) do defp timer do Process.send_after(self(), :tick, @tick) end + + defp handle_client_event(%{"type" => "subscribe", "stream" => _topic} = params, state) do + with {_, {:ok, topic}} <- + {:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)}, + {_, false} <- {:subscribed, topic in state.topics} do + Streamer.add_socket(topic, state.oauth_token) + + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "success"})} + ], %{state | topics: [topic | state.topics]}} + else + {:subscribed, true} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "subscribe", result: "ignored"})} + ], state} + + {:topic, {:error, error}} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "subscribe", + result: "error", + error: error + })} + ], state} + end + end + + defp handle_client_event(%{"type" => "unsubscribe", "stream" => _topic} = params, state) do + with {_, {:ok, topic}} <- + {:topic, Streamer.get_topic(params["stream"], state.user, state.oauth_token, params)}, + {_, true} <- {:subscribed, topic in state.topics} do + Streamer.remove_socket(topic) + + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "success"})} + ], %{state | topics: List.delete(state.topics, topic)}} + else + {:subscribed, false} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{type: "unsubscribe", result: "ignored"})} + ], state} + + {:topic, {:error, error}} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "unsubscribe", + result: "error", + error: error + })} + ], state} + end + end + + defp handle_client_event( + %{"type" => "pleroma:authenticate", "token" => access_token} = _params, + state + ) do + with {:auth, nil, nil} <- {:auth, state.user, state.oauth_token}, + {:ok, user, oauth_token} <- authenticate_request(access_token, nil) do + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "success" + })} + ], %{state | user: user, oauth_token: oauth_token}} + else + {:auth, _, _} -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "error", + error: :already_authenticated + })} + ], state} + + _ -> + {[ + {:text, + StreamerView.render("pleroma_respond.json", %{ + type: "pleroma:authenticate", + result: "error", + error: :unauthorized + })} + ], state} + end + end + + defp handle_client_event(params, state) do + Logger.error("#{__MODULE__} received unknown event: #{inspect(params)}") + {[], state} + end end diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index d2ad62c13..c11484ecb 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -12,6 +12,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Web.MediaProxy alias Plug.Conn + plug(:sandbox) + def remote(conn, %{"sig" => sig64, "url" => url64}) do with {_, true} <- {:enabled, MediaProxy.enabled?()}, {:ok, url} <- MediaProxy.decode_url(sig64, url64), @@ -54,7 +56,7 @@ defp handle_preview(conn, url) do media_proxy_url = MediaProxy.url(url) with {:ok, %{status: status} = head_response} when status in 200..299 <- - Pleroma.HTTP.request("HEAD", media_proxy_url, [], [], pool: :media) do + Pleroma.HTTP.request(:head, media_proxy_url, "", [], pool: :media) do content_type = Tesla.get_header(head_response, "content-type") content_length = Tesla.get_header(head_response, "content-length") content_length = content_length && String.to_integer(content_length) @@ -202,4 +204,9 @@ defp media_preview_proxy_config do defp media_proxy_opts do Config.get([:media_proxy, :proxy_opts], []) end + + defp sandbox(conn, _params) do + conn + |> merge_resp_headers([{"content-security-policy", "sandbox;"}]) + end end diff --git a/lib/pleroma/web/metadata/providers/rel_me.ex b/lib/pleroma/web/metadata/providers/rel_me.ex index f0bee85c8..eabd8cb00 100644 --- a/lib/pleroma/web/metadata/providers/rel_me.ex +++ b/lib/pleroma/web/metadata/providers/rel_me.ex @@ -8,12 +8,20 @@ defmodule Pleroma.Web.Metadata.Providers.RelMe do @impl Provider def build_tags(%{user: user}) do - bio_tree = Floki.parse_fragment!(user.bio) + profile_tree = + user.bio + |> append_fields_tag(user.fields) + |> Floki.parse_fragment!() - (Floki.attribute(bio_tree, "link[rel~=me]", "href") ++ - Floki.attribute(bio_tree, "a[rel~=me]", "href")) + (Floki.attribute(profile_tree, "link[rel~=me]", "href") ++ + Floki.attribute(profile_tree, "a[rel~=me]", "href")) |> Enum.map(fn link -> {:link, [rel: "me", href: link], []} end) end + + defp append_fields_tag(bio, fields) do + fields + |> Enum.reduce(bio, fn %{"value" => v}, res -> res <> v end) + end end diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 2dac22ee2..426022c65 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -76,9 +76,10 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do {:meta, [name: "twitter:card", content: "summary_large_image"], []}, {:meta, [ - name: "twitter:player", + name: "twitter:image", content: MediaProxy.url(url["href"]) - ], []} + ], []}, + {:meta, [name: "twitter:image:alt", content: truncate(attachment["name"])], []} | acc ] |> maybe_add_dimensions(url) @@ -130,4 +131,12 @@ defp maybe_add_dimensions(metadata, url) do metadata end end + + defp truncate(nil), do: "" + + defp truncate(text) do + # truncate to 420 characters + # see https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup + Pleroma.Formatter.truncate(text, 420) + end end diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 15414a988..80a8be9a2 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -30,12 +30,13 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do |> scrub_html_and_truncate_object_field(object) end - def scrub_html_and_truncate(content, max_length \\ 200) when is_binary(content) do + def scrub_html_and_truncate(content, max_length \\ 200, omission \\ "...") + when is_binary(content) do content |> scrub_html |> Emoji.Formatter.demojify() |> HtmlEntities.decode() - |> Formatter.truncate(max_length) + |> Formatter.truncate(max_length, omission) end def scrub_html(content) when is_binary(content) do diff --git a/lib/pleroma/web/multipart.ex b/lib/pleroma/web/multipart.ex new file mode 100644 index 000000000..e24bb14c2 --- /dev/null +++ b/lib/pleroma/web/multipart.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# +defmodule Pleroma.Web.Multipart do + @multipart Plug.Parsers.MULTIPART + + def init(opts) do + opts + end + + def parse(conn, "multipart", subtype, headers, opts) do + length = Pleroma.Config.get([:instance, :upload_limit]) + opts = @multipart.init([length: length] ++ opts) + @multipart.parse(conn, "multipart", subtype, headers, opts) + end + + def parse(conn, _type, _subtype, _headers, _opts) do + {:next, conn} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index 9e27ac26c..4d5a9a57f 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.Nodeinfo.Nodeinfo do alias Pleroma.Config alias Pleroma.Stats alias Pleroma.User - alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.ActivityPub.Publisher alias Pleroma.Web.MastodonAPI.InstanceView # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field diff --git a/lib/pleroma/web/o_auth/authorization.ex b/lib/pleroma/web/o_auth/authorization.ex index 593d2d66f..22e5bfc53 100644 --- a/lib/pleroma/web/o_auth/authorization.ex +++ b/lib/pleroma/web/o_auth/authorization.ex @@ -28,7 +28,7 @@ defmodule Pleroma.Web.OAuth.Authorization do end @spec create_authorization(App.t(), User.t() | %{}, [String.t()] | nil) :: - {:ok, Authorization.t()} | {:error, Changeset.t()} + {:ok, Authorization.t()} | {:error, Ecto.Changeset.t()} def create_authorization(%App{} = app, %User{} = user, scopes \\ nil) do %{ scopes: scopes || app.scopes, @@ -39,7 +39,7 @@ def create_authorization(%App{} = app, %User{} = user, scopes \\ nil) do |> Repo.insert() end - @spec create_changeset(map()) :: Changeset.t() + @spec create_changeset(map()) :: Ecto.Changeset.t() def create_changeset(attrs \\ %{}) do %Authorization{} |> cast(attrs, [:user_id, :app_id, :scopes, :valid_until]) @@ -58,7 +58,7 @@ defp add_lifetime(changeset) do put_change(changeset, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan)) end - @spec use_changeset(Authtorizatiton.t(), map()) :: Changeset.t() + @spec use_changeset(Authorization.t(), map()) :: Ecto.Changeset.t() def use_changeset(%Authorization{} = auth, params) do auth |> cast(params, [:used]) @@ -66,7 +66,7 @@ def use_changeset(%Authorization{} = auth, params) do end @spec use_token(Authorization.t()) :: - {:ok, Authorization.t()} | {:error, Changeset.t()} | {:error, String.t()} + {:ok, Authorization.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()} def use_token(%Authorization{used: false, valid_until: valid_until} = auth) do if NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) < 0 do Repo.update(use_changeset(auth, %{used: true})) diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index c1fb4f378..2bbe5d5fa 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -310,7 +310,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} after_token_exchange(conn, %{token: token}) else _error -> - handle_token_exchange_error(conn, :invalid_credentails) + handle_token_exchange_error(conn, :invalid_credentials) end end diff --git a/lib/pleroma/web/o_auth/token.ex b/lib/pleroma/web/o_auth/token.ex index 26de7bb10..a5ad2e909 100644 --- a/lib/pleroma/web/o_auth/token.ex +++ b/lib/pleroma/web/o_auth/token.ex @@ -56,7 +56,8 @@ def get_by_refresh_token(%App{id: app_id} = _app, token) do |> Repo.find_resource() end - @spec exchange_token(App.t(), Authorization.t()) :: {:ok, Token.t()} | {:error, Changeset.t()} + @spec exchange_token(App.t(), Authorization.t()) :: + {:ok, Token.t()} | {:error, Ecto.Changeset.t()} def exchange_token(app, auth) do with {:ok, auth} <- Authorization.use_token(auth), true <- auth.app_id == app.id do @@ -95,7 +96,7 @@ defp put_valid_until(changeset, attrs) do |> validate_required([:valid_until]) end - @spec create(App.t(), User.t(), map()) :: {:ok, Token} | {:error, Changeset.t()} + @spec create(App.t(), User.t(), map()) :: {:ok, Token} | {:error, Ecto.Changeset.t()} def create(%App{} = app, %User{} = user, attrs \\ %{}) do with {:ok, token} <- do_create(app, user, attrs) do if Pleroma.Config.get([:oauth2, :clean_expired_tokens]) do @@ -137,9 +138,9 @@ def get_user_tokens(%User{id: user_id}) do |> Repo.all() end - def is_expired?(%__MODULE__{valid_until: valid_until}) do + def expired?(%__MODULE__{valid_until: valid_until}) do NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 end - def is_expired?(_), do: false + def expired?(_), do: false end diff --git a/lib/pleroma/web/o_auth/token/query.ex b/lib/pleroma/web/o_auth/token/query.ex index 4a4d2d3ef..6853ec8dd 100644 --- a/lib/pleroma/web/o_auth/token/query.ex +++ b/lib/pleroma/web/o_auth/token/query.ex @@ -9,10 +9,10 @@ defmodule Pleroma.Web.OAuth.Token.Query do import Ecto.Query, only: [from: 2] - @type query :: Ecto.Queryable.t() | Token.t() - alias Pleroma.Web.OAuth.Token + @type query :: Ecto.Queryable.t() | Token.t() + @spec get_by_refresh_token(query, String.t()) :: query def get_by_refresh_token(query \\ Token, refresh_token) do from(q in query, where: q.refresh_token == ^refresh_token) diff --git a/lib/pleroma/web/o_status/o_status_controller.ex b/lib/pleroma/web/o_status/o_status_controller.ex index ea4994bd0..4f2cf02c3 100644 --- a/lib/pleroma/web/o_status/o_status_controller.ex +++ b/lib/pleroma/web/o_status/o_status_controller.ex @@ -37,7 +37,7 @@ def object(conn, _params) do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)} do + {_, true} <- {:public?, Visibility.public?(activity)} do redirect(conn, to: "/notice/#{activity.id}") else reason when reason in [{:public?, false}, {:activity, nil}] -> @@ -56,7 +56,7 @@ def activity(%{assigns: %{format: format}} = conn, _params) def activity(conn, _params) do with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)} do + {_, true} <- {:public?, Visibility.public?(activity)} do redirect(conn, to: "/notice/#{activity.id}") else reason when reason in [{:public?, false}, {:activity, nil}] -> @@ -69,7 +69,7 @@ def activity(conn, _params) do def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id_with_object(id)}, - {_, true} <- {:public?, Visibility.is_public?(activity)}, + {_, true} <- {:public?, Visibility.public?(activity)}, %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do cond do format in ["json", "activity+json"] -> @@ -106,7 +106,7 @@ def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do # Returns an HTML embedded